mirror of
https://github.com/unraid/api.git
synced 2026-01-06 00:30:22 -06:00
Compare commits
29 Commits
feat/cpu-s
...
v4.24.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed9a5c5ff9 | ||
|
|
d8b166e4b6 | ||
|
|
8b862ecef5 | ||
|
|
16913627de | ||
|
|
6b2f331941 | ||
|
|
8f02d96464 | ||
|
|
caff5a78ba | ||
|
|
810be7a679 | ||
|
|
1d9ce0aa3d | ||
|
|
9714b21c5c | ||
|
|
44b4d77d80 | ||
|
|
3f5039c342 | ||
|
|
1d2c6701ce | ||
|
|
0ee09aefbb | ||
|
|
c60a51dc1b | ||
|
|
c4fbf698b4 | ||
|
|
00faa8f9d9 | ||
|
|
45d9d65c13 | ||
|
|
771014b005 | ||
|
|
31a255c928 | ||
|
|
167857a323 | ||
|
|
b80988aaab | ||
|
|
fe4a6451f1 | ||
|
|
9a86c615da | ||
|
|
25ff8992a5 | ||
|
|
45fb53d040 | ||
|
|
c855caa9b2 | ||
|
|
ba4a43aec8 | ||
|
|
c4ca761dfc |
23
.github/workflows/build-plugin.yml
vendored
23
.github/workflows/build-plugin.yml
vendored
@@ -51,21 +51,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get API Version
|
||||
id: vars
|
||||
@@ -76,14 +71,6 @@ jobs:
|
||||
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
|
||||
echo "API_VERSION=${API_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ${{ github.workspace }}
|
||||
|
||||
82
.github/workflows/create-docusaurus-pr.yml
vendored
82
.github/workflows/create-docusaurus-pr.yml
vendored
@@ -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/\([^)]*\))||g' {} \;
|
||||
find docs-repo/docs/API -name "*.md" -type f -exec sed -i 's|!\[\([^]]*\)\](images/\([^)]*\))||g' {} \;
|
||||
find docs-repo/docs/API -name "*.md" -type f -exec sed -i 's|!\[\([^]]*\)\](../images/\([^)]*\))||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
|
||||
11
.github/workflows/deploy-storybook.yml
vendored
11
.github/workflows/deploy-storybook.yml
vendored
@@ -22,16 +22,17 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
with:
|
||||
|
||||
96
.github/workflows/main.yml
vendored
96
.github/workflows/main.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
@@ -23,10 +27,16 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
@@ -34,25 +44,6 @@ jobs:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system php-cli
|
||||
version: 1.0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: PNPM Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -175,29 +166,16 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
@@ -228,7 +206,7 @@ jobs:
|
||||
id: buildnumber
|
||||
uses: onyxmueller/build-tag-number@v1
|
||||
with:
|
||||
token: ${{secrets.github_token}}
|
||||
token: ${{secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN}}
|
||||
prefix: ${{steps.vars.outputs.PACKAGE_LOCK_VERSION}}
|
||||
|
||||
- name: Build
|
||||
@@ -252,29 +230,16 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
@@ -318,29 +283,16 @@ jobs:
|
||||
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
|
||||
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: PNPM Install
|
||||
run: |
|
||||
|
||||
@@ -29,11 +29,6 @@ jobs:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Set Timezone
|
||||
uses: szenius/set-timezone@v2.0
|
||||
with:
|
||||
timezoneLinux: "America/Los_Angeles"
|
||||
|
||||
- name: Set PR number
|
||||
id: pr_number
|
||||
run: |
|
||||
|
||||
30
.github/workflows/release-production.yml
vendored
30
.github/workflows/release-production.yml
vendored
@@ -28,9 +28,9 @@ jobs:
|
||||
with:
|
||||
latest: true
|
||||
prerelease: false
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '22.18.0'
|
||||
node-version: 22.19.0
|
||||
- run: |
|
||||
cat << 'EOF' > release-notes.txt
|
||||
${{ steps.release-info.outputs.body }}
|
||||
@@ -125,15 +125,21 @@ jobs:
|
||||
--content-encoding none \
|
||||
--acl public-read
|
||||
|
||||
- name: Actions for Discord
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.PUBLIC_DISCORD_RELEASE_ENDPOINT }}
|
||||
- name: Discord Webhook Notification
|
||||
uses: tsickert/discord-webhook@v7.0.0
|
||||
with:
|
||||
args: |
|
||||
🚀 **Unraid API Release ${{ inputs.version }}**
|
||||
|
||||
View Release: https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }}
|
||||
|
||||
**Changelog:**
|
||||
webhook-url: ${{ secrets.PUBLIC_DISCORD_RELEASE_ENDPOINT }}
|
||||
username: "Unraid API Bot"
|
||||
avatar-url: "https://craftassets.unraid.net/uploads/logos/un-mark-gradient.png"
|
||||
embed-title: "🚀 Unraid API ${{ inputs.version }} Released!"
|
||||
embed-url: "https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }}"
|
||||
embed-description: |
|
||||
A new version of Unraid API has been released!
|
||||
|
||||
**Version:** `${{ inputs.version }}`
|
||||
**Release Page:** [View on GitHub](https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }})
|
||||
|
||||
**📋 Changelog:**
|
||||
${{ steps.release-info.outputs.body }}
|
||||
embed-color: 16734296
|
||||
embed-footer-text: "Unraid API • Automated Release"
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.22.0"}
|
||||
{".":"4.24.0"}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
--header-background-color: #1c1b1b;
|
||||
--header-gradient-start: rgba(28, 27, 27, 0);
|
||||
--header-gradient-end: rgba(28, 27, 27, 0.7);
|
||||
--ui-border-muted: hsl(240 5% 20%);
|
||||
--color-border: #383735;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #1c1b1b;
|
||||
@@ -28,7 +27,6 @@
|
||||
--header-background-color: #f2f2f2;
|
||||
--header-gradient-start: rgba(242, 242, 242, 0);
|
||||
--header-gradient-end: rgba(242, 242, 242, 0.7);
|
||||
--ui-border-muted: hsl(240 5.9% 90%);
|
||||
--color-border: #e0e0e0;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #f2f2f2;
|
||||
@@ -43,7 +41,6 @@
|
||||
--header-background-color: #1c1b1b;
|
||||
--header-gradient-start: rgba(28, 27, 27, 0);
|
||||
--header-gradient-end: rgba(28, 27, 27, 0.7);
|
||||
--ui-border-muted: hsl(240 5% 25%);
|
||||
--color-border: #383735;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #383735;
|
||||
@@ -58,7 +55,6 @@
|
||||
--header-background-color: #f2f2f2;
|
||||
--header-gradient-start: rgba(242, 242, 242, 0);
|
||||
--header-gradient-end: rgba(242, 242, 242, 0.7);
|
||||
--ui-border-muted: hsl(210 40% 80%);
|
||||
--color-border: #5a8bb8;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #e7f2f8;
|
||||
@@ -68,7 +64,6 @@
|
||||
|
||||
/* Dark Mode Overrides */
|
||||
.dark {
|
||||
--ui-border-muted: hsl(240 5% 20%);
|
||||
--color-border: #383735;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** pin dependency conventional-changelog-conventionalcommits to 9.1.0 ([#1697](https://github.com/unraid/api/issues/1697)) ([9a86c61](https://github.com/unraid/api/commit/9a86c615da2e975f568922fa012cc29b3f9cde0e))
|
||||
* **deps:** update dependency filenamify to v7 ([#1703](https://github.com/unraid/api/issues/1703)) ([b80988a](https://github.com/unraid/api/commit/b80988aaabebc4b8dbf2bf31f0764bf2f28e1575))
|
||||
* **deps:** update graphqlcodegenerator monorepo (major) ([#1689](https://github.com/unraid/api/issues/1689)) ([ba4a43a](https://github.com/unraid/api/commit/ba4a43aec863fc30c47dd17370d74daed7f84703))
|
||||
* false positive on verify_install script being external shell ([#1704](https://github.com/unraid/api/issues/1704)) ([31a255c](https://github.com/unraid/api/commit/31a255c9281b29df983d0f5d0475cd5a69790a48))
|
||||
* improve vue mount speed by 10x ([c855caa](https://github.com/unraid/api/commit/c855caa9b2d4d63bead1a992f5c583e00b9ba843))
|
||||
|
||||
## [4.22.1](https://github.com/unraid/api/compare/v4.22.0...v4.22.1) (2025-09-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* set input color in SSO field rather than inside of the main.css ([01d353f](https://github.com/unraid/api/commit/01d353fa08a3df688b37a495a204605138f7f71d))
|
||||
|
||||
## [4.22.0](https://github.com/unraid/api/compare/v4.21.0...v4.22.0) (2025-09-12)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const config: CodegenConfig = {
|
||||
URL: 'URL',
|
||||
Port: 'number',
|
||||
UUID: 'string',
|
||||
BigInt: 'number',
|
||||
},
|
||||
scalarSchemas: {
|
||||
URL: 'z.instanceof(URL)',
|
||||
@@ -24,6 +25,7 @@ const config: CodegenConfig = {
|
||||
JSON: 'z.record(z.string(), z.any())',
|
||||
Port: 'z.number()',
|
||||
UUID: 'z.string()',
|
||||
BigInt: 'z.number()',
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.22.0",
|
||||
"version": "4.22.2",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"label": "Unraid API",
|
||||
"position": 4
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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.
|
||||
1
api/docs/public/moved-to-docs-repo.md
Normal file
1
api/docs/public/moved-to-docs-repo.md
Normal file
@@ -0,0 +1 @@
|
||||
# All Content Here has been permanently moved to [Unraid Docs](https://github.com/unraid/docs)
|
||||
@@ -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 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
|
||||
|
||||

|
||||
*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
|
||||
|
||||

|
||||
*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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
:::
|
||||
@@ -1093,8 +1093,8 @@ type DockerContainer implements Node {
|
||||
created: Int!
|
||||
ports: [ContainerPort!]!
|
||||
|
||||
"""Total size of all the files in the container"""
|
||||
sizeRootFs: Int
|
||||
"""Total size of all files in the container (in bytes)"""
|
||||
sizeRootFs: BigInt
|
||||
labels: JSON
|
||||
state: ContainerState!
|
||||
status: String!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.22.0",
|
||||
"version": "4.24.0",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
@@ -56,7 +56,7 @@
|
||||
"@as-integrations/fastify": "2.1.1",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
"@fastify/helmet": "13.0.1",
|
||||
"@graphql-codegen/client-preset": "4.8.3",
|
||||
"@graphql-codegen/client-preset": "5.0.0",
|
||||
"@graphql-tools/load-files": "7.0.1",
|
||||
"@graphql-tools/merge": "9.1.1",
|
||||
"@graphql-tools/schema": "10.0.25",
|
||||
@@ -103,7 +103,7 @@
|
||||
"execa": "9.6.0",
|
||||
"exit-hook": "4.0.0",
|
||||
"fastify": "5.5.0",
|
||||
"filenamify": "6.0.0",
|
||||
"filenamify": "7.0.0",
|
||||
"fs-extra": "11.3.1",
|
||||
"glob": "11.0.3",
|
||||
"global-agent": "3.0.0",
|
||||
@@ -156,14 +156,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.34.0",
|
||||
"@graphql-codegen/add": "5.0.3",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/fragment-matcher": "5.1.0",
|
||||
"@graphql-codegen/add": "6.0.0",
|
||||
"@graphql-codegen/cli": "6.0.0",
|
||||
"@graphql-codegen/fragment-matcher": "6.0.0",
|
||||
"@graphql-codegen/import-types-preset": "3.0.1",
|
||||
"@graphql-codegen/typed-document-node": "5.1.2",
|
||||
"@graphql-codegen/typescript": "4.1.6",
|
||||
"@graphql-codegen/typescript-operations": "4.6.1",
|
||||
"@graphql-codegen/typescript-resolvers": "4.5.1",
|
||||
"@graphql-codegen/typed-document-node": "6.0.0",
|
||||
"@graphql-codegen/typescript": "5.0.0",
|
||||
"@graphql-codegen/typescript-operations": "5.0.0",
|
||||
"@graphql-codegen/typescript-resolvers": "5.0.0",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
|
||||
"@nestjs/testing": "11.1.6",
|
||||
@@ -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",
|
||||
@@ -205,7 +205,7 @@
|
||||
"rollup-plugin-node-externals": "8.1.0",
|
||||
"supertest": "7.1.4",
|
||||
"tsx": "4.20.5",
|
||||
"type-fest": "4.41.0",
|
||||
"type-fest": "5.0.0",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "8.41.0",
|
||||
"unplugin-swc": "1.5.7",
|
||||
|
||||
178
api/src/__test__/core/utils/parsers/ini-boolean-parser.test.ts
Normal file
178
api/src/__test__/core/utils/parsers/ini-boolean-parser.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import pino from 'pino';
|
||||
import pretty from 'pino-pretty';
|
||||
|
||||
import { API_VERSION, LOG_LEVEL, LOG_TYPE, PATHS_LOGS_FILE, SUPPRESS_LOGS } from '@app/environment.js';
|
||||
import { API_VERSION, LOG_LEVEL, LOG_TYPE, SUPPRESS_LOGS } from '@app/environment.js';
|
||||
|
||||
export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
|
||||
|
||||
@@ -17,30 +17,27 @@ const nullDestination = pino.destination({
|
||||
|
||||
export const logDestination =
|
||||
process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination();
|
||||
const localFileDestination = pino.destination({
|
||||
dest: PATHS_LOGS_FILE,
|
||||
sync: true,
|
||||
});
|
||||
|
||||
// Since PM2 captures stdout and writes to the log file, we should not colorize stdout
|
||||
// to avoid ANSI escape codes in the log file
|
||||
const stream = SUPPRESS_LOGS
|
||||
? nullDestination
|
||||
: LOG_TYPE === 'pretty'
|
||||
? pretty({
|
||||
singleLine: true,
|
||||
hideObject: false,
|
||||
colorize: true,
|
||||
colorizeObjects: true,
|
||||
colorize: false, // No colors since PM2 writes stdout to file
|
||||
colorizeObjects: false,
|
||||
levelFirst: false,
|
||||
ignore: 'hostname,pid',
|
||||
destination: logDestination,
|
||||
translateTime: 'HH:mm:ss',
|
||||
customPrettifiers: {
|
||||
time: (timestamp: string | object) => `[${timestamp}`,
|
||||
level: (logLevel: string | object, key: string, log: any, extras: any) => {
|
||||
// Use labelColorized which preserves the colors
|
||||
const { labelColorized } = extras;
|
||||
level: (_logLevel: string | object, _key: string, log: any, extras: any) => {
|
||||
// Use label instead of labelColorized for non-colored output
|
||||
const { label } = extras;
|
||||
const context = log.context || log.logger || 'app';
|
||||
return `${labelColorized} ${context}]`;
|
||||
return `${label} ${context}]`;
|
||||
},
|
||||
},
|
||||
messageFormat: (log: any, messageKey: string) => {
|
||||
@@ -98,7 +95,7 @@ export const keyServerLogger = logger.child({ logger: 'key-server' });
|
||||
export const remoteAccessLogger = logger.child({ logger: 'remote-access' });
|
||||
export const remoteQueryLogger = logger.child({ logger: 'remote-query' });
|
||||
export const apiLogger = logger.child({ logger: 'api' });
|
||||
export const pluginLogger = logger.child({ logger: 'plugin', stream: localFileDestination });
|
||||
export const pluginLogger = logger.child({ logger: 'plugin' });
|
||||
|
||||
export const loggers = [
|
||||
internalLogger,
|
||||
|
||||
86
api/src/core/utils/parsers/ini-boolean-parser.ts
Normal file
86
api/src/core/utils/parsers/ini-boolean-parser.ts
Normal 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;
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export type Scalars = {
|
||||
Int: { input: number; output: number; }
|
||||
Float: { input: number; output: number; }
|
||||
/** The `BigInt` scalar type represents non-fractional signed whole numeric values. */
|
||||
BigInt: { input: any; output: any; }
|
||||
BigInt: { input: number; output: number; }
|
||||
/** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */
|
||||
DateTime: { input: string; output: string; }
|
||||
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
|
||||
@@ -711,8 +711,8 @@ export type DockerContainer = Node & {
|
||||
names: Array<Scalars['String']['output']>;
|
||||
networkSettings?: Maybe<Scalars['JSON']['output']>;
|
||||
ports: Array<ContainerPort>;
|
||||
/** Total size of all the files in the container */
|
||||
sizeRootFs?: Maybe<Scalars['Int']['output']>;
|
||||
/** Total size of all files in the container (in bytes) */
|
||||
sizeRootFs?: Maybe<Scalars['BigInt']['output']>;
|
||||
state: ContainerState;
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
@@ -35,7 +35,8 @@ export class RestartCommand extends CommandRunner {
|
||||
{ tag: 'PM2 Restart', raw: true, extendEnv: true, env },
|
||||
'restart',
|
||||
ECOSYSTEM_PATH,
|
||||
'--update-env'
|
||||
'--update-env',
|
||||
'--mini-list'
|
||||
);
|
||||
|
||||
if (stderr) {
|
||||
|
||||
@@ -33,7 +33,8 @@ export class StartCommand extends CommandRunner {
|
||||
{ tag: 'PM2 Start', raw: true, extendEnv: true, env },
|
||||
'start',
|
||||
ECOSYSTEM_PATH,
|
||||
'--update-env'
|
||||
'--update-env',
|
||||
'--mini-list'
|
||||
);
|
||||
if (stdout) {
|
||||
this.logger.log(stdout.toString());
|
||||
|
||||
@@ -8,6 +8,11 @@ export class StatusCommand extends CommandRunner {
|
||||
super();
|
||||
}
|
||||
async run(): Promise<void> {
|
||||
await this.pm2.run({ tag: 'PM2 Status', stdio: 'inherit', raw: true }, 'status', 'unraid-api');
|
||||
await this.pm2.run(
|
||||
{ tag: 'PM2 Status', stdio: 'inherit', raw: true },
|
||||
'status',
|
||||
'unraid-api',
|
||||
'--mini-list'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ export class StopCommand extends CommandRunner {
|
||||
{ tag: 'PM2 Delete', stdio: 'inherit' },
|
||||
'delete',
|
||||
ECOSYSTEM_PATH,
|
||||
'--no-autorestart'
|
||||
'--no-autorestart',
|
||||
'--mini-list'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { GraphQLJSON, GraphQLPort } from 'graphql-scalars';
|
||||
import { GraphQLBigInt, GraphQLJSON, GraphQLPort } from 'graphql-scalars';
|
||||
|
||||
export enum ContainerPortType {
|
||||
TCP = 'TCP',
|
||||
@@ -89,7 +89,10 @@ export class DockerContainer extends Node {
|
||||
@Field(() => [ContainerPort])
|
||||
ports!: ContainerPort[];
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Total size of all the files in the container' })
|
||||
@Field(() => GraphQLBigInt, {
|
||||
nullable: true,
|
||||
description: 'Total size of all files in the container (in bytes)',
|
||||
})
|
||||
sizeRootFs?: number;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
|
||||
@@ -8,6 +8,13 @@ import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers
|
||||
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
|
||||
import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.js';
|
||||
|
||||
vi.mock('@app/unraid-api/utils/graphql-field-helper.js', () => ({
|
||||
GraphQLFieldHelper: {
|
||||
isFieldRequested: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DockerResolver', () => {
|
||||
let resolver: DockerResolver;
|
||||
@@ -41,6 +48,9 @@ describe('DockerResolver', () => {
|
||||
|
||||
resolver = module.get<DockerResolver>(DockerResolver);
|
||||
dockerService = module.get<DockerService>(DockerService);
|
||||
|
||||
// Reset mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -80,9 +90,75 @@ describe('DockerResolver', () => {
|
||||
},
|
||||
];
|
||||
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
|
||||
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false);
|
||||
|
||||
const result = await resolver.containers(false);
|
||||
const mockInfo = {} as any;
|
||||
|
||||
const result = await resolver.containers(false, mockInfo);
|
||||
expect(result).toEqual(mockContainers);
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false });
|
||||
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs');
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: false });
|
||||
});
|
||||
|
||||
it('should request size when sizeRootFs field is requested', async () => {
|
||||
const mockContainers: DockerContainer[] = [
|
||||
{
|
||||
id: '1',
|
||||
autoStart: false,
|
||||
command: 'test',
|
||||
names: ['test-container'],
|
||||
created: 1234567890,
|
||||
image: 'test-image',
|
||||
imageId: 'test-image-id',
|
||||
ports: [],
|
||||
sizeRootFs: 1024000,
|
||||
state: ContainerState.EXITED,
|
||||
status: 'Exited',
|
||||
},
|
||||
];
|
||||
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
|
||||
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(true);
|
||||
|
||||
const mockInfo = {} as any;
|
||||
|
||||
const result = await resolver.containers(false, mockInfo);
|
||||
expect(result).toEqual(mockContainers);
|
||||
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs');
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: true });
|
||||
});
|
||||
|
||||
it('should request size when GraphQLFieldHelper indicates sizeRootFs is requested', async () => {
|
||||
const mockContainers: DockerContainer[] = [];
|
||||
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
|
||||
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(true);
|
||||
|
||||
const mockInfo = {} as any;
|
||||
|
||||
await resolver.containers(false, mockInfo);
|
||||
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs');
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: true });
|
||||
});
|
||||
|
||||
it('should not request size when GraphQLFieldHelper indicates sizeRootFs is not requested', async () => {
|
||||
const mockContainers: DockerContainer[] = [];
|
||||
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
|
||||
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false);
|
||||
|
||||
const mockInfo = {} as any;
|
||||
|
||||
await resolver.containers(false, mockInfo);
|
||||
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs');
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: false });
|
||||
});
|
||||
|
||||
it('should handle skipCache parameter', async () => {
|
||||
const mockContainers: DockerContainer[] = [];
|
||||
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
|
||||
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false);
|
||||
|
||||
const mockInfo = {} as any;
|
||||
|
||||
await resolver.containers(true, mockInfo);
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: true, size: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { Args, Info, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import type { GraphQLResolveInfo } from 'graphql';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
@@ -15,6 +16,7 @@ import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.ser
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
|
||||
import { DEFAULT_ORGANIZER_ROOT_ID } from '@app/unraid-api/organizer/organizer.js';
|
||||
import { ResolvedOrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
|
||||
import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.js';
|
||||
|
||||
@Resolver(() => Docker)
|
||||
export class DockerResolver {
|
||||
@@ -41,9 +43,11 @@ export class DockerResolver {
|
||||
})
|
||||
@ResolveField(() => [DockerContainer])
|
||||
public async containers(
|
||||
@Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean
|
||||
@Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean,
|
||||
@Info() info: GraphQLResolveInfo
|
||||
) {
|
||||
return this.dockerService.getContainers({ skipCache });
|
||||
const requestsSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRootFs');
|
||||
return this.dockerService.getContainers({ skipCache, size: requestsSize });
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
|
||||
@@ -109,6 +109,65 @@ describe('DockerService', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use separate cache keys for containers with and without size', async () => {
|
||||
const mockContainersWithoutSize = [
|
||||
{
|
||||
Id: 'abc123',
|
||||
Names: ['/test-container'],
|
||||
Image: 'test-image',
|
||||
ImageID: 'test-image-id',
|
||||
Command: 'test',
|
||||
Created: 1234567890,
|
||||
State: 'exited',
|
||||
Status: 'Exited',
|
||||
Ports: [],
|
||||
Labels: {},
|
||||
HostConfig: { NetworkMode: 'bridge' },
|
||||
NetworkSettings: {},
|
||||
Mounts: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockContainersWithSize = [
|
||||
{
|
||||
Id: 'abc123',
|
||||
Names: ['/test-container'],
|
||||
Image: 'test-image',
|
||||
ImageID: 'test-image-id',
|
||||
Command: 'test',
|
||||
Created: 1234567890,
|
||||
State: 'exited',
|
||||
Status: 'Exited',
|
||||
Ports: [],
|
||||
Labels: {},
|
||||
HostConfig: { NetworkMode: 'bridge' },
|
||||
NetworkSettings: {},
|
||||
Mounts: [],
|
||||
SizeRootFs: 1024000,
|
||||
},
|
||||
];
|
||||
|
||||
// First call without size
|
||||
mockListContainers.mockResolvedValue(mockContainersWithoutSize);
|
||||
mockCacheManager.get.mockResolvedValue(undefined);
|
||||
|
||||
await service.getContainers({ size: false });
|
||||
|
||||
expect(mockCacheManager.set).toHaveBeenCalledWith('docker_containers', expect.any(Array), 60000);
|
||||
|
||||
// Second call with size
|
||||
mockListContainers.mockResolvedValue(mockContainersWithSize);
|
||||
mockCacheManager.get.mockResolvedValue(undefined);
|
||||
|
||||
await service.getContainers({ size: true });
|
||||
|
||||
expect(mockCacheManager.set).toHaveBeenCalledWith(
|
||||
'docker_containers_with_size',
|
||||
expect.any(Array),
|
||||
60000
|
||||
);
|
||||
});
|
||||
|
||||
it('should get containers', async () => {
|
||||
const mockContainers = [
|
||||
{
|
||||
@@ -159,7 +218,7 @@ describe('DockerService', () => {
|
||||
|
||||
expect(mockListContainers).toHaveBeenCalledWith({
|
||||
all: true,
|
||||
size: true,
|
||||
size: false,
|
||||
});
|
||||
expect(mockCacheManager.set).toHaveBeenCalled(); // Ensure cache is set
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export class DockerService {
|
||||
private readonly logger = new Logger(DockerService.name);
|
||||
|
||||
public static readonly CONTAINER_CACHE_KEY = 'docker_containers';
|
||||
public static readonly CONTAINER_WITH_SIZE_CACHE_KEY = 'docker_containers_with_size';
|
||||
public static readonly NETWORK_CACHE_KEY = 'docker_networks';
|
||||
public static readonly CACHE_TTL_SECONDS = 60; // Cache for 60 seconds
|
||||
|
||||
@@ -71,6 +72,8 @@ export class DockerService {
|
||||
}
|
||||
|
||||
public transformContainer(container: Docker.ContainerInfo): DockerContainer {
|
||||
const sizeValue = (container as Docker.ContainerInfo & { SizeRootFs?: number }).SizeRootFs;
|
||||
|
||||
const transformed: DockerContainer = {
|
||||
id: container.Id,
|
||||
names: container.Names,
|
||||
@@ -86,7 +89,7 @@ export class DockerService {
|
||||
ContainerPortType[port.Type.toUpperCase() as keyof typeof ContainerPortType] ||
|
||||
ContainerPortType.TCP,
|
||||
})),
|
||||
sizeRootFs: undefined,
|
||||
sizeRootFs: sizeValue,
|
||||
labels: container.Labels ?? {},
|
||||
state:
|
||||
typeof container.State === 'string'
|
||||
@@ -109,21 +112,23 @@ export class DockerService {
|
||||
{
|
||||
skipCache = false,
|
||||
all = true,
|
||||
size = true,
|
||||
size = false,
|
||||
...listOptions
|
||||
}: Partial<ContainerListingOptions> = { skipCache: false }
|
||||
): Promise<DockerContainer[]> {
|
||||
const cacheKey = size
|
||||
? DockerService.CONTAINER_WITH_SIZE_CACHE_KEY
|
||||
: DockerService.CONTAINER_CACHE_KEY;
|
||||
|
||||
if (!skipCache) {
|
||||
const cachedContainers = await this.cacheManager.get<DockerContainer[]>(
|
||||
DockerService.CONTAINER_CACHE_KEY
|
||||
);
|
||||
const cachedContainers = await this.cacheManager.get<DockerContainer[]>(cacheKey);
|
||||
if (cachedContainers) {
|
||||
this.logger.debug('Using docker container cache');
|
||||
this.logger.debug(`Using docker container cache (${size ? 'with' : 'without'} size)`);
|
||||
return cachedContainers;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Updating docker container cache');
|
||||
this.logger.debug(`Updating docker container cache (${size ? 'with' : 'without'} size)`);
|
||||
const rawContainers =
|
||||
(await this.client
|
||||
.listContainers({
|
||||
@@ -136,11 +141,7 @@ export class DockerService {
|
||||
this.autoStarts = await this.getAutoStarts();
|
||||
const containers = rawContainers.map((container) => this.transformContainer(container));
|
||||
|
||||
await this.cacheManager.set(
|
||||
DockerService.CONTAINER_CACHE_KEY,
|
||||
containers,
|
||||
DockerService.CACHE_TTL_SECONDS * 1000
|
||||
);
|
||||
await this.cacheManager.set(cacheKey, containers, DockerService.CACHE_TTL_SECONDS * 1000);
|
||||
return containers;
|
||||
}
|
||||
|
||||
@@ -191,15 +192,18 @@ export class DockerService {
|
||||
}
|
||||
|
||||
public async clearContainerCache(): Promise<void> {
|
||||
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
|
||||
this.logger.debug('Invalidated container cache due to external event.');
|
||||
await Promise.all([
|
||||
this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY),
|
||||
this.cacheManager.del(DockerService.CONTAINER_WITH_SIZE_CACHE_KEY),
|
||||
]);
|
||||
this.logger.debug('Invalidated container caches due to external event.');
|
||||
}
|
||||
|
||||
public async start(id: string): Promise<DockerContainer> {
|
||||
const container = this.client.getContainer(id);
|
||||
await container.start();
|
||||
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
|
||||
this.logger.debug(`Invalidated container cache after starting ${id}`);
|
||||
await this.clearContainerCache();
|
||||
this.logger.debug(`Invalidated container caches after starting ${id}`);
|
||||
const containers = await this.getContainers({ skipCache: true });
|
||||
const updatedContainer = containers.find((c) => c.id === id);
|
||||
if (!updatedContainer) {
|
||||
@@ -213,8 +217,8 @@ export class DockerService {
|
||||
public async stop(id: string): Promise<DockerContainer> {
|
||||
const container = this.client.getContainer(id);
|
||||
await container.stop({ t: 10 });
|
||||
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
|
||||
this.logger.debug(`Invalidated container cache after stopping ${id}`);
|
||||
await this.clearContainerCache();
|
||||
this.logger.debug(`Invalidated container caches after stopping ${id}`);
|
||||
|
||||
let containers = await this.getContainers({ skipCache: true });
|
||||
let updatedContainer: DockerContainer | undefined;
|
||||
|
||||
@@ -29,16 +29,16 @@ export class CpuService {
|
||||
|
||||
return {
|
||||
id: 'info/cpu-load',
|
||||
percentTotal: Math.floor(loadData.currentLoad),
|
||||
percentTotal: loadData.currentLoad,
|
||||
cpus: loadData.cpus.map((cpu) => ({
|
||||
percentTotal: Math.floor(cpu.load),
|
||||
percentUser: Math.floor(cpu.loadUser),
|
||||
percentSystem: Math.floor(cpu.loadSystem),
|
||||
percentNice: Math.floor(cpu.loadNice),
|
||||
percentIdle: Math.floor(cpu.loadIdle),
|
||||
percentIrq: Math.floor(cpu.loadIrq),
|
||||
percentGuest: Math.floor(cpu.loadGuest || 0),
|
||||
percentSteal: Math.floor(cpu.loadSteal || 0),
|
||||
percentTotal: cpu.load,
|
||||
percentUser: cpu.loadUser,
|
||||
percentSystem: cpu.loadSystem,
|
||||
percentNice: cpu.loadNice,
|
||||
percentIdle: cpu.loadIdle,
|
||||
percentIrq: cpu.loadIrq,
|
||||
percentGuest: cpu.loadGuest || 0,
|
||||
percentSteal: cpu.loadSteal || 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
350
api/src/unraid-api/rest/rest.service.test.ts
Normal file
350
api/src/unraid-api/rest/rest.service.test.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import type { ReadStream, Stats } from 'node:fs';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat, writeFile } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { execa, ExecaError } from 'execa';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ApiReportData } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import {
|
||||
getBannerPathIfPresent,
|
||||
getCasePathIfPresent,
|
||||
} from '@app/core/utils/images/image-file-helpers.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('execa');
|
||||
vi.mock('@app/store/index.js');
|
||||
vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
|
||||
getBannerPathIfPresent: vi.fn(),
|
||||
getCasePathIfPresent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('RestService', () => {
|
||||
let service: RestService;
|
||||
let apiReportService: ApiReportService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RestService,
|
||||
{
|
||||
provide: ApiReportService,
|
||||
useValue: {
|
||||
generateReport: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RestService>(RestService);
|
||||
apiReportService = module.get<ApiReportService>(ApiReportService);
|
||||
});
|
||||
|
||||
describe('getLogs', () => {
|
||||
const mockLogPath = '/usr/local/emhttp/logs/unraid-api';
|
||||
const mockGraphqlApiLog = '/var/log/graphql-api.log';
|
||||
const mockZipPath = '/usr/local/emhttp/logs/unraid-api.tar.gz';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getters).paths = vi.fn().mockReturnValue({
|
||||
'log-base': mockLogPath,
|
||||
});
|
||||
// Mock saveApiReport to avoid side effects
|
||||
vi.spyOn(service as any, 'saveApiReport').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should create and return log archive successfully', async () => {
|
||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath || path === mockZipPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
vi.mocked(execa).mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
} as any);
|
||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||
|
||||
const result = await service.getLogs();
|
||||
|
||||
expect(execa).toHaveBeenCalledWith('tar', ['-czf', mockZipPath, mockLogPath], {
|
||||
timeout: 60000,
|
||||
reject: true,
|
||||
});
|
||||
expect(createReadStream).toHaveBeenCalledWith(mockZipPath);
|
||||
expect(result).toBe(mockStream);
|
||||
});
|
||||
|
||||
it('should include graphql-api.log when it exists', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath || path === mockGraphqlApiLog || path === mockZipPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
vi.mocked(execa).mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
} as any);
|
||||
vi.mocked(createReadStream).mockReturnValue(Readable.from([]) as ReadStream);
|
||||
|
||||
await service.getLogs();
|
||||
|
||||
expect(execa).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-czf', mockZipPath, mockLogPath, mockGraphqlApiLog],
|
||||
{
|
||||
timeout: 60000,
|
||||
reject: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle timeout errors with detailed message', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
|
||||
const timeoutError = new Error('Command timed out') as ExecaError;
|
||||
timeoutError.timedOut = true;
|
||||
timeoutError.command =
|
||||
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
|
||||
timeoutError.exitCode = undefined;
|
||||
timeoutError.stderr = '';
|
||||
timeoutError.stdout = '';
|
||||
|
||||
vi.mocked(execa).mockRejectedValue(timeoutError);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow('Tar command timed out after 60 seconds');
|
||||
});
|
||||
|
||||
it('should handle command failure with exit code and stderr', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
|
||||
const execError = new Error('Command failed') as ExecaError;
|
||||
execError.exitCode = 1;
|
||||
execError.command =
|
||||
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
|
||||
execError.stderr = 'tar: Cannot create archive';
|
||||
execError.stdout = '';
|
||||
execError.shortMessage = 'Command failed with exit code 1';
|
||||
|
||||
vi.mocked(execa).mockRejectedValue(execError);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow('Tar command failed with exit code 1');
|
||||
await expect(service.getLogs()).rejects.toThrow('tar: Cannot create archive');
|
||||
});
|
||||
|
||||
it('should handle case when tar succeeds but zip file is not created', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
// Zip file doesn't exist after tar command
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
vi.mocked(execa).mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
} as any);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow(
|
||||
'Failed to create log zip - tar file not found after successful command'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when log path does not exist', async () => {
|
||||
vi.mocked(stat).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow('No logs to download');
|
||||
});
|
||||
|
||||
it('should handle generic errors', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
|
||||
const genericError = new Error('Unexpected error');
|
||||
vi.mocked(execa).mockRejectedValue(genericError);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow(
|
||||
'Failed to create logs archive: Unexpected error'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors with stdout in addition to stderr', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
|
||||
const execError = new Error('Command failed') as ExecaError;
|
||||
execError.exitCode = 1;
|
||||
execError.command =
|
||||
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
|
||||
execError.stderr = 'tar: Error';
|
||||
execError.stdout = 'Processing archive...';
|
||||
execError.shortMessage = 'Command failed with exit code 1';
|
||||
|
||||
vi.mocked(execa).mockRejectedValue(execError);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow('Stdout: Processing archive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveApiReport', () => {
|
||||
it('should generate and save API report', async () => {
|
||||
const mockReport: ApiReportData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionStatus: { running: 'yes' },
|
||||
system: {
|
||||
name: 'Test Server',
|
||||
version: '6.12.0',
|
||||
machineId: 'test-machine-id',
|
||||
},
|
||||
connect: {
|
||||
installed: false,
|
||||
},
|
||||
config: {
|
||||
valid: true,
|
||||
},
|
||||
services: {
|
||||
cloud: null,
|
||||
minigraph: null,
|
||||
allServices: [],
|
||||
},
|
||||
};
|
||||
const mockPath = '/test/report.json';
|
||||
|
||||
vi.mocked(apiReportService.generateReport).mockResolvedValue(mockReport);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await service.saveApiReport(mockPath);
|
||||
|
||||
expect(apiReportService.generateReport).toHaveBeenCalled();
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
mockPath,
|
||||
JSON.stringify(mockReport, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when generating report', async () => {
|
||||
const mockPath = '/test/report.json';
|
||||
|
||||
vi.mocked(apiReportService.generateReport).mockRejectedValue(
|
||||
new Error('Report generation failed')
|
||||
);
|
||||
|
||||
// Should not throw, just log warning
|
||||
await expect(service.saveApiReport(mockPath)).resolves.toBeUndefined();
|
||||
expect(apiReportService.generateReport).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomizationPath', () => {
|
||||
it('should return banner path when type is banner', async () => {
|
||||
const mockBannerPath = '/path/to/banner.png';
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath);
|
||||
|
||||
const result = await service.getCustomizationPath('banner');
|
||||
|
||||
expect(getBannerPathIfPresent).toHaveBeenCalled();
|
||||
expect(result).toBe(mockBannerPath);
|
||||
});
|
||||
|
||||
it('should return case path when type is case', async () => {
|
||||
const mockCasePath = '/path/to/case.png';
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath);
|
||||
|
||||
const result = await service.getCustomizationPath('case');
|
||||
|
||||
expect(getCasePathIfPresent).toHaveBeenCalled();
|
||||
expect(result).toBe(mockCasePath);
|
||||
});
|
||||
|
||||
it('should return null when no banner found', async () => {
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
||||
|
||||
const result = await service.getCustomizationPath('banner');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no case found', async () => {
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
||||
|
||||
const result = await service.getCustomizationPath('case');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomizationStream', () => {
|
||||
it('should return read stream for banner', async () => {
|
||||
const mockPath = '/path/to/banner.png';
|
||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath);
|
||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||
|
||||
const result = await service.getCustomizationStream('banner');
|
||||
|
||||
expect(getBannerPathIfPresent).toHaveBeenCalled();
|
||||
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
||||
expect(result).toBe(mockStream);
|
||||
});
|
||||
|
||||
it('should return read stream for case', async () => {
|
||||
const mockPath = '/path/to/case.png';
|
||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath);
|
||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||
|
||||
const result = await service.getCustomizationStream('case');
|
||||
|
||||
expect(getCasePathIfPresent).toHaveBeenCalled();
|
||||
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
||||
expect(result).toBe(mockStream);
|
||||
});
|
||||
|
||||
it('should throw error when no banner found', async () => {
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
|
||||
});
|
||||
|
||||
it('should throw error when no case found', async () => {
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { createReadStream } from 'node:fs';
|
||||
import { stat, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ExecaError } from 'execa';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import {
|
||||
@@ -31,6 +32,8 @@ export class RestService {
|
||||
|
||||
async getLogs(): Promise<ReadStream> {
|
||||
const logPath = getters.paths()['log-base'];
|
||||
const graphqlApiLog = '/var/log/graphql-api.log';
|
||||
|
||||
try {
|
||||
await this.saveApiReport(join(logPath, 'report.json'));
|
||||
} catch (error) {
|
||||
@@ -41,16 +44,62 @@ export class RestService {
|
||||
const logPathExists = Boolean(await stat(logPath).catch(() => null));
|
||||
if (logPathExists) {
|
||||
try {
|
||||
await execa('tar', ['-czf', zipToWrite, logPath]);
|
||||
// Build tar command arguments
|
||||
const tarArgs = ['-czf', zipToWrite, logPath];
|
||||
|
||||
// Check if graphql-api.log exists and add it to the archive
|
||||
const graphqlLogExists = Boolean(await stat(graphqlApiLog).catch(() => null));
|
||||
if (graphqlLogExists) {
|
||||
tarArgs.push(graphqlApiLog);
|
||||
this.logger.debug('Including graphql-api.log in archive');
|
||||
}
|
||||
|
||||
// Execute tar with timeout and capture output
|
||||
await execa('tar', tarArgs, {
|
||||
timeout: 60000, // 60 seconds timeout for tar operation
|
||||
reject: true, // Throw on non-zero exit (default behavior)
|
||||
});
|
||||
|
||||
const tarFileExists = Boolean(await stat(zipToWrite).catch(() => null));
|
||||
|
||||
if (tarFileExists) {
|
||||
return createReadStream(zipToWrite);
|
||||
} else {
|
||||
throw new Error('Failed to create log zip');
|
||||
throw new Error(
|
||||
'Failed to create log zip - tar file not found after successful command'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Failed to create logs');
|
||||
// Build detailed error message with execa's built-in error info
|
||||
let errorMessage = 'Failed to create logs archive';
|
||||
|
||||
if (error && typeof error === 'object' && 'command' in error) {
|
||||
const execaError = error as ExecaError;
|
||||
|
||||
if (execaError.timedOut) {
|
||||
errorMessage = `Tar command timed out after 60 seconds. Command: ${execaError.command}`;
|
||||
} else if (execaError.exitCode !== undefined) {
|
||||
errorMessage = `Tar command failed with exit code ${execaError.exitCode}. Command: ${execaError.command}`;
|
||||
}
|
||||
|
||||
// Add stderr/stdout if available
|
||||
if (execaError.stderr) {
|
||||
errorMessage += `. Stderr: ${execaError.stderr}`;
|
||||
}
|
||||
if (execaError.stdout) {
|
||||
errorMessage += `. Stdout: ${execaError.stdout}`;
|
||||
}
|
||||
|
||||
// Include the short message from execa
|
||||
if (execaError.shortMessage) {
|
||||
errorMessage += `. Details: ${execaError.shortMessage}`;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage += `: ${error.message}`;
|
||||
}
|
||||
|
||||
this.logger.error(errorMessage, error);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
} else {
|
||||
throw new Error('No logs to download');
|
||||
|
||||
332
api/src/unraid-api/utils/graphql-field-helper.spec.ts
Normal file
332
api/src/unraid-api/utils/graphql-field-helper.spec.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { buildSchema, FieldNode, GraphQLResolveInfo, parse } from 'graphql';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.js';
|
||||
|
||||
describe('GraphQLFieldHelper', () => {
|
||||
const schema = buildSchema(`
|
||||
type User {
|
||||
id: String
|
||||
name: String
|
||||
email: String
|
||||
profile: Profile
|
||||
posts: [Post]
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
type Profile {
|
||||
avatar: String
|
||||
bio: String
|
||||
}
|
||||
|
||||
type Post {
|
||||
title: String
|
||||
content: String
|
||||
}
|
||||
|
||||
type Settings {
|
||||
theme: String
|
||||
language: String
|
||||
}
|
||||
|
||||
type Query {
|
||||
user: User
|
||||
users: [User]
|
||||
}
|
||||
`);
|
||||
|
||||
const createMockInfo = (query: string): GraphQLResolveInfo => {
|
||||
const document = parse(query);
|
||||
const operation = document.definitions[0] as any;
|
||||
const fieldNode = operation.selectionSet.selections[0] as FieldNode;
|
||||
|
||||
return {
|
||||
fieldName: fieldNode.name.value,
|
||||
fieldNodes: [fieldNode],
|
||||
returnType: schema.getType('User') as any,
|
||||
parentType: schema.getType('Query') as any,
|
||||
path: { prev: undefined, key: fieldNode.name.value, typename: 'Query' },
|
||||
schema,
|
||||
fragments: {},
|
||||
rootValue: {},
|
||||
operation,
|
||||
variableValues: {},
|
||||
} as GraphQLResolveInfo;
|
||||
};
|
||||
|
||||
describe('getRequestedFields', () => {
|
||||
it('should return flat fields structure', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const fields = GraphQLFieldHelper.getRequestedFields(mockInfo);
|
||||
|
||||
expect(fields).toEqual({
|
||||
id: {},
|
||||
name: {},
|
||||
email: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return nested fields structure', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
profile {
|
||||
avatar
|
||||
bio
|
||||
}
|
||||
settings {
|
||||
theme
|
||||
language
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const fields = GraphQLFieldHelper.getRequestedFields(mockInfo);
|
||||
|
||||
expect(fields).toEqual({
|
||||
id: {},
|
||||
profile: {
|
||||
avatar: {},
|
||||
bio: {},
|
||||
},
|
||||
settings: {
|
||||
theme: {},
|
||||
language: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFieldRequested', () => {
|
||||
it('should return true for requested top-level field', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'id')).toBe(true);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'name')).toBe(true);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'email')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-requested field', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'email')).toBe(false);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'profile')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested field paths', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'profile')).toBe(true);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'profile.avatar')).toBe(true);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'profile.bio')).toBe(false);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'settings')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequestedFieldsList', () => {
|
||||
it('should return list of top-level field names', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
email
|
||||
profile {
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const fieldsList = GraphQLFieldHelper.getRequestedFieldsList(mockInfo);
|
||||
|
||||
expect(fieldsList).toEqual(['id', 'name', 'email', 'profile']);
|
||||
});
|
||||
|
||||
it('should return empty array for no fields', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user
|
||||
}
|
||||
`);
|
||||
|
||||
const fieldsList = GraphQLFieldHelper.getRequestedFieldsList(mockInfo);
|
||||
|
||||
expect(fieldsList).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasNestedFields', () => {
|
||||
it('should return true when field has nested selections', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
avatar
|
||||
bio
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.hasNestedFields(mockInfo, 'profile')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when field has no nested selections', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.hasNestedFields(mockInfo, 'id')).toBe(false);
|
||||
expect(GraphQLFieldHelper.hasNestedFields(mockInfo, 'name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-existent field', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.hasNestedFields(mockInfo, 'profile')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNestedFields', () => {
|
||||
it('should return nested fields object', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
avatar
|
||||
bio
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const nestedFields = GraphQLFieldHelper.getNestedFields(mockInfo, 'profile');
|
||||
|
||||
expect(nestedFields).toEqual({
|
||||
avatar: {},
|
||||
bio: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for field without nested selections', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.getNestedFields(mockInfo, 'id')).toBeNull();
|
||||
expect(GraphQLFieldHelper.getNestedFields(mockInfo, 'name')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-existent field', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.getNestedFields(mockInfo, 'profile')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldFetchRelation', () => {
|
||||
it('should return true when relation is requested with nested fields', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
avatar
|
||||
}
|
||||
posts {
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'profile')).toBe(true);
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'posts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when relation has no nested fields', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'id')).toBe(false);
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when relation is not requested', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'profile')).toBe(false);
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'posts')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
api/src/unraid-api/utils/graphql-field-helper.ts
Normal file
63
api/src/unraid-api/utils/graphql-field-helper.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { GraphQLResolveInfo } from 'graphql';
|
||||
import graphqlFields from 'graphql-fields';
|
||||
|
||||
export interface RequestedFields {
|
||||
[key: string]: RequestedFields | {};
|
||||
}
|
||||
|
||||
export interface GraphQLFieldOptions {
|
||||
processArguments?: boolean;
|
||||
excludedFields?: string[];
|
||||
}
|
||||
|
||||
export class GraphQLFieldHelper {
|
||||
static getRequestedFields(info: GraphQLResolveInfo, options?: GraphQLFieldOptions): RequestedFields {
|
||||
return graphqlFields(info, {}, options);
|
||||
}
|
||||
|
||||
static isFieldRequested(info: GraphQLResolveInfo, fieldPath: string): boolean {
|
||||
const fields = this.getRequestedFields(info);
|
||||
const pathParts = fieldPath.split('.');
|
||||
|
||||
let current: RequestedFields | {} = fields;
|
||||
for (const part of pathParts) {
|
||||
if (!(part in current)) {
|
||||
return false;
|
||||
}
|
||||
current = current[part as keyof typeof current] as RequestedFields | {};
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static getRequestedFieldsList(info: GraphQLResolveInfo): string[] {
|
||||
const fields = this.getRequestedFields(info);
|
||||
return Object.keys(fields);
|
||||
}
|
||||
|
||||
static hasNestedFields(info: GraphQLResolveInfo, fieldName: string): boolean {
|
||||
const fields = this.getRequestedFields(info);
|
||||
const field = fields[fieldName];
|
||||
return field !== undefined && Object.keys(field).length > 0;
|
||||
}
|
||||
|
||||
static getNestedFields(info: GraphQLResolveInfo, fieldName: string): RequestedFields | null {
|
||||
const fields = this.getRequestedFields(info);
|
||||
const field = fields[fieldName];
|
||||
|
||||
if (!field || typeof field !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// graphql-fields returns {} for fields without nested selections
|
||||
if (Object.keys(field).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return field as RequestedFields;
|
||||
}
|
||||
|
||||
static shouldFetchRelation(info: GraphQLResolveInfo, relationName: string): boolean {
|
||||
return this.isFieldRequested(info, relationName) && this.hasNestedFields(info, relationName);
|
||||
}
|
||||
}
|
||||
12
package.json
12
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "unraid-monorepo",
|
||||
"private": true,
|
||||
"version": "4.22.0",
|
||||
"version": "4.24.0",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",
|
||||
@@ -63,8 +63,14 @@
|
||||
"pre-commit": "pnpm lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,vue}": [
|
||||
"pnpm lint:fix"
|
||||
"api/**/*.{js,ts}": [
|
||||
"pnpm --filter api lint:fix"
|
||||
],
|
||||
"web/**/*.{js,ts,tsx,vue}": [
|
||||
"pnpm --filter web lint:fix"
|
||||
],
|
||||
"unraid-ui/**/*.{js,ts,tsx,vue}": [
|
||||
"pnpm --filter @unraid/ui lint:fix"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.15.0"
|
||||
|
||||
@@ -17,6 +17,7 @@ const config: CodegenConfig = {
|
||||
URL: 'URL',
|
||||
Port: 'number',
|
||||
UUID: 'string',
|
||||
BigInt: 'number',
|
||||
},
|
||||
scalarSchemas: {
|
||||
URL: 'z.instanceof(URL)',
|
||||
@@ -24,6 +25,7 @@ const config: CodegenConfig = {
|
||||
JSON: 'z.record(z.string(), z.any())',
|
||||
Port: 'z.number()',
|
||||
UUID: 'z.string()',
|
||||
BigInt: 'z.number()',
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"devDependencies": {
|
||||
"@apollo/client": "3.14.0",
|
||||
"@faker-js/faker": "10.0.0",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/cli": "6.0.0",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
|
||||
"@jsonforms/core": "3.6.0",
|
||||
@@ -60,7 +60,7 @@
|
||||
"prettier": "3.6.2",
|
||||
"rimraf": "6.0.1",
|
||||
"rxjs": "7.8.2",
|
||||
"type-fest": "4.41.0",
|
||||
"type-fest": "5.0.0",
|
||||
"typescript": "5.9.2",
|
||||
"undici": "7.15.0",
|
||||
"vitest": "3.2.4",
|
||||
|
||||
@@ -731,10 +731,17 @@ export type RemoteGraphQlEventFragmentFragment = { __typename?: 'RemoteGraphQLEv
|
||||
export type EventsSubscriptionVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type EventsSubscription = { __typename?: 'Subscription', events?: Array<{ __typename: 'ClientConnectedEvent', connectedEvent: EventType, connectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } } | { __typename: 'ClientDisconnectedEvent', disconnectedEvent: EventType, disconnectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } } | { __typename: 'ClientPingEvent' } | { __typename: 'RemoteAccessEvent' } | (
|
||||
{ __typename: 'RemoteGraphQLEvent' }
|
||||
& { ' $fragmentRefs'?: { 'RemoteGraphQlEventFragmentFragment': RemoteGraphQlEventFragmentFragment } }
|
||||
) | { __typename: 'UpdateEvent' }> | null };
|
||||
export type EventsSubscription = { __typename?: 'Subscription', events?: Array<
|
||||
| { __typename: 'ClientConnectedEvent', connectedEvent: EventType, connectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } }
|
||||
| { __typename: 'ClientDisconnectedEvent', disconnectedEvent: EventType, disconnectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } }
|
||||
| { __typename: 'ClientPingEvent' }
|
||||
| { __typename: 'RemoteAccessEvent' }
|
||||
| (
|
||||
{ __typename: 'RemoteGraphQLEvent' }
|
||||
& { ' $fragmentRefs'?: { 'RemoteGraphQlEventFragmentFragment': RemoteGraphQlEventFragmentFragment } }
|
||||
)
|
||||
| { __typename: 'UpdateEvent' }
|
||||
> | null };
|
||||
|
||||
export type SendRemoteGraphQlResponseMutationVariables = Exact<{
|
||||
input: RemoteGraphQlServerInput;
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"nest-authz": "2.17.0",
|
||||
"pify": "6.1.0",
|
||||
"rimraf": "6.0.1",
|
||||
"type-fest": "4.41.0",
|
||||
"type-fest": "5.0.0",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "3.2.4",
|
||||
"ws": "8.18.3"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@unraid/connect-plugin",
|
||||
"version": "4.22.0",
|
||||
"version": "4.24.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
"conventional-changelog": "7.1.1",
|
||||
"conventional-changelog-conventionalcommits": "^9.1.0",
|
||||
"conventional-changelog-conventionalcommits": "9.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"glob": "11.0.3",
|
||||
"html-sloppy-escaper": "0.1.0",
|
||||
@@ -33,8 +33,9 @@
|
||||
"env:validate": "test -f .env || (echo 'Error: .env file missing. Run npm run env:init first' && exit 1)",
|
||||
"env:clean": "rm -f .env",
|
||||
"// Testing": "",
|
||||
"test": "vitest && pnpm run test:extractor",
|
||||
"test:extractor": "bash ./tests/test-extractor.sh"
|
||||
"test": "vitest && pnpm run test:extractor && pnpm run test:shell-detection",
|
||||
"test:extractor": "bash ./tests/test-extractor.sh",
|
||||
"test:shell-detection": "bash ./tests/test-shell-detection.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
"http-server": "14.1.1",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
Menu="ManagementAccess:99"
|
||||
Title="Unraid API Status"
|
||||
Icon="icon-u-globe"
|
||||
Tag="globe"
|
||||
---
|
||||
<!-- API Status Manager -->
|
||||
<unraid-api-status-manager></unraid-api-status-manager>
|
||||
|
||||
<!-- end unraid-api section -->
|
||||
@@ -1,5 +1,5 @@
|
||||
Menu="ManagementAccess:100"
|
||||
Title="Unraid API"
|
||||
Title="Unraid API Settings"
|
||||
Icon="icon-u-globe"
|
||||
Tag="globe"
|
||||
---
|
||||
@@ -596,8 +596,10 @@ $(function() {
|
||||
_(Unraid API extra origins)_:
|
||||
_(Connect Remote Access)_:
|
||||
_(GraphQL API Developer Sandbox)_:
|
||||
_(OIDC Configuration)_:
|
||||
|
||||
</div>
|
||||
|
||||
<!-- start unraid-api section -->
|
||||
<unraid-connect-settings></unraid-connect-settings>
|
||||
<!-- end unraid-api section -->
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ $validCommands = [
|
||||
'start',
|
||||
'restart',
|
||||
'stop',
|
||||
'status',
|
||||
'report',
|
||||
'wanip'
|
||||
];
|
||||
@@ -68,7 +69,12 @@ switch ($command) {
|
||||
response_complete(200, array('result' => $output), $output);
|
||||
break;
|
||||
case 'restart':
|
||||
exec('unraid-api restart 2>/dev/null', $output, $retval);
|
||||
exec('/etc/rc.d/rc.unraid-api restart 2>&1', $output, $retval);
|
||||
$output = implode(PHP_EOL, $output);
|
||||
response_complete(200, array('success' => ($retval === 0), 'result' => $output, 'error' => ($retval !== 0 ? $output : null)), $output);
|
||||
break;
|
||||
case 'status':
|
||||
exec('unraid-api status 2>&1', $output, $retval);
|
||||
$output = implode(PHP_EOL, $output);
|
||||
response_complete(200, array('result' => $output), $output);
|
||||
break;
|
||||
|
||||
@@ -6,11 +6,19 @@
|
||||
check_shell() {
|
||||
# This script runs with #!/bin/bash shebang
|
||||
# On Unraid, users may configure bash to load other shells through .bashrc
|
||||
# We check if the current process ($$) is actually bash, not another shell
|
||||
# Using $$ is correct here - we need to detect if THIS process is running the expected bash
|
||||
# We need to check if the interpreter running this script is actually bash
|
||||
# Use readlink on /proc to find the actual interpreter, not the script name
|
||||
local current_shell
|
||||
current_shell=$(ps -o comm= -p $$)
|
||||
|
||||
|
||||
# Get the actual interpreter from /proc
|
||||
if [ -e "/proc/$$/exe" ]; then
|
||||
current_shell=$(readlink "/proc/$$/exe")
|
||||
else
|
||||
# Fallback to checking the current process if /proc isn't available
|
||||
# Note: This may return the script name on some systems
|
||||
current_shell=$(ps -o comm= -p $$)
|
||||
fi
|
||||
|
||||
# Remove any path and get just the shell name
|
||||
current_shell=$(basename "$current_shell")
|
||||
|
||||
|
||||
159
plugin/tests/test-shell-detection.sh
Executable file
159
plugin/tests/test-shell-detection.sh
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/bin/bash
|
||||
# Test script for shell detection logic in verify_install.sh
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERIFY_SCRIPT="$SCRIPT_DIR/../source/dynamix.unraid.net/usr/local/share/dynamix.unraid.net/install/scripts/verify_install.sh"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Test counter
|
||||
TESTS_RUN=0
|
||||
TESTS_PASSED=0
|
||||
|
||||
# Helper function to run a test
|
||||
run_test() {
|
||||
local test_name="$1"
|
||||
local test_cmd="$2"
|
||||
local expected_result="$3"
|
||||
|
||||
TESTS_RUN=$((TESTS_RUN + 1))
|
||||
|
||||
echo -n "Testing: $test_name ... "
|
||||
|
||||
# Run the test and capture exit code
|
||||
set +e
|
||||
output=$($test_cmd 2>&1)
|
||||
result=$?
|
||||
set -e
|
||||
|
||||
if [ "$result" -eq "$expected_result" ]; then
|
||||
echo -e "${GREEN}PASS${NC}"
|
||||
TESTS_PASSED=$((TESTS_PASSED + 1))
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}"
|
||||
echo " Expected exit code: $expected_result, Got: $result"
|
||||
echo " Output: $output"
|
||||
fi
|
||||
}
|
||||
|
||||
# Extract just the check_shell function from verify_install.sh
|
||||
extract_check_shell() {
|
||||
cat << 'EOF'
|
||||
#!/bin/bash
|
||||
check_shell() {
|
||||
# This script runs with #!/bin/bash shebang
|
||||
# On Unraid, users may configure bash to load other shells through .bashrc
|
||||
# We need to check if the interpreter running this script is actually bash
|
||||
# Use readlink on /proc to find the actual interpreter, not the script name
|
||||
local current_shell
|
||||
|
||||
# Get the actual interpreter from /proc
|
||||
if [ -e "/proc/$$/exe" ]; then
|
||||
current_shell=$(readlink "/proc/$$/exe")
|
||||
else
|
||||
# Fallback to checking the current process if /proc isn't available
|
||||
# Note: This may return the script name on some systems
|
||||
current_shell=$(ps -o comm= -p $$)
|
||||
fi
|
||||
|
||||
# Remove any path and get just the shell name
|
||||
current_shell=$(basename "$current_shell")
|
||||
|
||||
if [[ "$current_shell" != "bash" ]]; then
|
||||
echo "Unsupported shell detected: $current_shell" >&2
|
||||
echo "Unraid scripts require bash but your system is configured to use $current_shell for scripts." >&2
|
||||
echo "This can cause infinite loops or unexpected behavior when Unraid scripts execute." >&2
|
||||
echo "Please configure $current_shell to only activate for interactive shells." >&2
|
||||
echo "Add this check to your ~/.bashrc or /etc/profile before starting $current_shell:" >&2
|
||||
echo " [[ \$- == *i* ]] && exec $current_shell" >&2
|
||||
echo "This ensures $current_shell only starts for interactive sessions, not scripts." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
check_shell
|
||||
EOF
|
||||
}
|
||||
|
||||
echo "=== Shell Detection Tests ==="
|
||||
echo
|
||||
|
||||
# Test 1: Running with bash should succeed
|
||||
echo "Test 1: Direct bash execution"
|
||||
TEMP_SCRIPT=$(mktemp)
|
||||
extract_check_shell > "$TEMP_SCRIPT"
|
||||
chmod +x "$TEMP_SCRIPT"
|
||||
run_test "Bash interpreter (should pass)" "bash $TEMP_SCRIPT" 0
|
||||
rm -f "$TEMP_SCRIPT"
|
||||
|
||||
# Test 2: Check that the actual verify_install.sh script works with bash
|
||||
echo "Test 2: Verify install script with bash"
|
||||
if [ -f "$VERIFY_SCRIPT" ]; then
|
||||
# Create a modified version that only runs check_shell
|
||||
TEMP_VERIFY=$(mktemp)
|
||||
sed -n '1,/^check_shell$/p' "$VERIFY_SCRIPT" > "$TEMP_VERIFY"
|
||||
echo "exit 0" >> "$TEMP_VERIFY"
|
||||
chmod +x "$TEMP_VERIFY"
|
||||
run_test "Verify install script shell check" "bash $TEMP_VERIFY" 0
|
||||
rm -f "$TEMP_VERIFY"
|
||||
else
|
||||
echo -e "${YELLOW}SKIP${NC} - verify_install.sh not found"
|
||||
fi
|
||||
|
||||
# Test 3: Simulate non-bash shell (if available)
|
||||
echo "Test 3: Non-bash shell simulation"
|
||||
if command -v sh >/dev/null 2>&1 && [ "$(readlink -f "$(command -v sh)")" != "$(readlink -f "$(command -v bash)")" ]; then
|
||||
TEMP_SCRIPT=$(mktemp)
|
||||
# Create a test that will fail if sh is detected
|
||||
cat << 'EOF' > "$TEMP_SCRIPT"
|
||||
#!/bin/sh
|
||||
# This simulates what would happen if a non-bash shell was detected
|
||||
current_shell=$(basename "$(readlink -f /proc/$$/exe 2>/dev/null || echo sh)")
|
||||
if [ "$current_shell" != "bash" ]; then
|
||||
echo "Detected non-bash shell: $current_shell" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$TEMP_SCRIPT"
|
||||
run_test "Non-bash shell detection" "sh $TEMP_SCRIPT" 1
|
||||
rm -f "$TEMP_SCRIPT"
|
||||
else
|
||||
echo -e "${YELLOW}SKIP${NC} - sh not available or is symlinked to bash"
|
||||
fi
|
||||
|
||||
# Test 4: Check /proc availability (informational only, not a failure)
|
||||
echo "Test 4: /proc filesystem check"
|
||||
if [ -e "/proc/$$/exe" ]; then
|
||||
echo -e "${GREEN}INFO${NC} - /proc filesystem is available"
|
||||
else
|
||||
echo -e "${YELLOW}INFO${NC} - /proc filesystem not available, fallback to ps will be used"
|
||||
fi
|
||||
|
||||
# Test 5: Verify the script name is not detected as shell
|
||||
echo "Test 5: Script name not detected as shell"
|
||||
TEMP_SCRIPT=$(mktemp -t verify_install.XXXXXX)
|
||||
extract_check_shell > "$TEMP_SCRIPT"
|
||||
chmod +x "$TEMP_SCRIPT"
|
||||
# This should pass because it's still bash, even though the script is named verify_install
|
||||
run_test "Script named verify_install (should still pass)" "bash $TEMP_SCRIPT" 0
|
||||
rm -f "$TEMP_SCRIPT"
|
||||
|
||||
echo
|
||||
echo "=== Test Summary ==="
|
||||
echo "Tests run: $TESTS_RUN"
|
||||
echo "Tests passed: $TESTS_PASSED"
|
||||
echo "Tests failed: $((TESTS_RUN - TESTS_PASSED))"
|
||||
|
||||
if [ "$TESTS_PASSED" -eq "$TESTS_RUN" ]; then
|
||||
echo -e "${GREEN}All tests passed!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}Some tests failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
690
pnpm-lock.yaml
generated
690
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,7 @@ eslint.configs.recommended, ...tseslint.configs.recommended, // TypeScript Files
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
...commonLanguageOptions,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
@@ -128,6 +129,7 @@ eslint.configs.recommended, ...tseslint.configs.recommended, // TypeScript Files
|
||||
parserOptions: {
|
||||
...commonLanguageOptions,
|
||||
parser: tseslint.parser,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/ui",
|
||||
"version": "4.22.0",
|
||||
"version": "4.24.0",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -32,7 +32,7 @@ const { teleportTarget } = useTeleport();
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-50 min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-muted z-[103] min-w-32 overflow-hidden rounded-lg border p-1 shadow-md',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
77
unraid-ui/src/composables/useTeleport.test.ts
Normal file
77
unraid-ui/src/composables/useTeleport.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import useTeleport from '@/composables/useTeleport';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
describe('useTeleport', () => {
|
||||
beforeEach(() => {
|
||||
// Clear the DOM before each test
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up virtual container if it exists
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
if (virtualContainer) {
|
||||
virtualContainer.remove();
|
||||
}
|
||||
// Reset the module to clear the virtualModalContainer variable
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should return teleportTarget ref with correct value', () => {
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('#unraid-api-modals-virtual');
|
||||
});
|
||||
|
||||
it('should create virtual container element on mount with correct properties', () => {
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
// Initially, virtual container should not exist
|
||||
expect(document.getElementById('unraid-api-modals-virtual')).toBeNull();
|
||||
|
||||
// Mount the component
|
||||
mount(TestComponent);
|
||||
|
||||
// After mount, virtual container should be created with correct properties
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
expect(virtualContainer).toBeTruthy();
|
||||
expect(virtualContainer?.className).toBe('unapi');
|
||||
expect(virtualContainer?.style.position).toBe('relative');
|
||||
expect(virtualContainer?.style.zIndex).toBe('999999');
|
||||
expect(virtualContainer?.parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('should reuse existing virtual container within same test', () => {
|
||||
// Manually create the container first
|
||||
const manualContainer = document.createElement('div');
|
||||
manualContainer.id = 'unraid-api-modals-virtual';
|
||||
manualContainer.className = 'unapi';
|
||||
manualContainer.style.position = 'relative';
|
||||
manualContainer.style.zIndex = '999999';
|
||||
document.body.appendChild(manualContainer);
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
// Mount component - should not create a new container
|
||||
mount(TestComponent);
|
||||
|
||||
// Should still have only one container
|
||||
const containers = document.querySelectorAll('#unraid-api-modals-virtual');
|
||||
expect(containers.length).toBe(1);
|
||||
expect(containers[0]).toBe(manualContainer);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,24 @@
|
||||
import { ensureTeleportContainer } from '@/helpers/ensure-teleport-container';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
let virtualModalContainer: HTMLDivElement | null = null;
|
||||
|
||||
const ensureVirtualContainer = () => {
|
||||
if (!virtualModalContainer) {
|
||||
virtualModalContainer = document.createElement('div');
|
||||
virtualModalContainer.id = 'unraid-api-modals-virtual';
|
||||
virtualModalContainer.className = 'unapi';
|
||||
virtualModalContainer.style.position = 'relative';
|
||||
virtualModalContainer.style.zIndex = '999999';
|
||||
document.body.appendChild(virtualModalContainer);
|
||||
}
|
||||
return virtualModalContainer;
|
||||
};
|
||||
|
||||
const useTeleport = () => {
|
||||
const teleportTarget = ref<string | HTMLElement>('body');
|
||||
const teleportTarget = ref<string>('#unraid-api-modals-virtual');
|
||||
|
||||
onMounted(() => {
|
||||
const container = ensureTeleportContainer();
|
||||
teleportTarget.value = container;
|
||||
ensureVirtualContainer();
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createAjv } from '@jsonforms/core';
|
||||
import type { Ajv } from 'ajv';
|
||||
import type Ajv from 'ajv';
|
||||
import addErrors from 'ajv-errors';
|
||||
|
||||
export interface JsonFormsConfig {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Ensures the teleport container exists in the DOM.
|
||||
* This is used by both the standalone mount script and unraid-ui components
|
||||
* to ensure modals and other teleported content have a target.
|
||||
*/
|
||||
export function ensureTeleportContainer(): HTMLElement {
|
||||
const containerId = 'unraid-teleport-container';
|
||||
|
||||
// Check if container already exists
|
||||
let container = document.getElementById(containerId);
|
||||
|
||||
// If it doesn't exist, create it
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = containerId;
|
||||
container.style.position = 'relative';
|
||||
container.classList.add('unapi');
|
||||
container.style.zIndex = '999999'; // Very high z-index to ensure it's always on top
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
@@ -15,6 +15,3 @@ export * from '@/lib/utils';
|
||||
export { default as useTeleport } from '@/composables/useTeleport';
|
||||
export { useToast } from '@/composables/useToast';
|
||||
export type { ToastInstance } from '@/composables/useToast';
|
||||
|
||||
// Helpers
|
||||
export { ensureTeleportContainer } from '@/helpers/ensure-teleport-container';
|
||||
|
||||
@@ -51,10 +51,6 @@
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.copy.vue",
|
||||
"**/*copy.vue",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.tsx"
|
||||
"**/*copy.vue"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,17 @@ export default function createConfig() {
|
||||
dts({
|
||||
insertTypesEntry: true,
|
||||
include: ['src/**/*.ts', 'src/**/*.vue'],
|
||||
exclude: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.spec.ts',
|
||||
'src/**/*.test.tsx',
|
||||
'src/**/*.spec.tsx',
|
||||
'src/**/*.test.vue',
|
||||
'src/**/*.spec.vue',
|
||||
'src/**/*.stories.*',
|
||||
'src/**/*.stories.{ts,tsx,vue}',
|
||||
'src/**/__tests__/**',
|
||||
],
|
||||
outDir: 'dist',
|
||||
rollupTypes: true,
|
||||
copyDtsFiles: true,
|
||||
@@ -31,8 +42,6 @@ export default function createConfig() {
|
||||
external: [
|
||||
'vue',
|
||||
'tailwindcss',
|
||||
'ajv',
|
||||
'ajv-errors',
|
||||
...(process.env.npm_lifecycle_script?.includes('storybook') ? [/^storybook\//] : []),
|
||||
],
|
||||
input: {
|
||||
@@ -77,6 +86,9 @@ export default function createConfig() {
|
||||
'@/theme': resolve(__dirname, './src/theme'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['ajv', 'ajv-errors'],
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
|
||||
@@ -156,4 +156,28 @@ describe('HeaderOsVersion', () => {
|
||||
|
||||
expect(findUpdateStatusComponent()).toBeNull();
|
||||
});
|
||||
|
||||
it('removes logo class from logo wrapper on mount', async () => {
|
||||
// Create a mock logo element
|
||||
const logoElement = document.createElement('div');
|
||||
logoElement.classList.add('logo');
|
||||
document.body.appendChild(logoElement);
|
||||
|
||||
// Mount component
|
||||
const newWrapper = mount(HeaderOsVersion, {
|
||||
global: {
|
||||
plugins: [testingPinia],
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for nextTick to allow onMounted to complete
|
||||
await nextTick();
|
||||
await nextTick(); // Double nextTick since onMounted uses nextTick internally
|
||||
|
||||
expect(logoElement.classList.contains('logo')).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
newWrapper.unmount();
|
||||
document.body.removeChild(logoElement);
|
||||
});
|
||||
});
|
||||
|
||||
232
web/__test__/components/Modals.test.ts
Normal file
232
web/__test__/components/Modals.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { nextTick } from 'vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { VueWrapper } from '@vue/test-utils';
|
||||
import type { Pinia } from 'pinia';
|
||||
|
||||
import Modals from '~/components/Modals.standalone.vue';
|
||||
import { useCallbackActionsStore } from '~/store/callbackActions';
|
||||
import { useTrialStore } from '~/store/trial';
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('~/components/Activation/ActivationModal.vue', () => ({
|
||||
default: {
|
||||
name: 'ActivationModal',
|
||||
props: ['t'],
|
||||
template: '<div>ActivationModal</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('~/components/UpdateOs/ChangelogModal.vue', () => ({
|
||||
default: {
|
||||
name: 'UpdateOsChangelogModal',
|
||||
props: ['t', 'open'],
|
||||
template: '<div v-if="open">ChangelogModal</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('~/components/UpdateOs/CheckUpdateResponseModal.vue', () => ({
|
||||
default: {
|
||||
name: 'UpdateOsCheckUpdateResponseModal',
|
||||
props: ['t', 'open'],
|
||||
template: '<div v-if="open">CheckUpdateResponseModal</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('~/components/UserProfile/CallbackFeedback.vue', () => ({
|
||||
default: {
|
||||
name: 'UpcCallbackFeedback',
|
||||
props: ['t', 'open'],
|
||||
template: '<div v-if="open">CallbackFeedback</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('~/components/UserProfile/Trial.vue', () => ({
|
||||
default: {
|
||||
name: 'UpcTrial',
|
||||
props: ['t', 'open'],
|
||||
template: '<div v-if="open">Trial</div>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Modals.standalone.vue', () => {
|
||||
let wrapper: VueWrapper;
|
||||
let pinia: Pinia;
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
wrapper = mount(Modals, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render modals container with correct id and ref', () => {
|
||||
const modalsDiv = wrapper.find('#modals');
|
||||
expect(modalsDiv.exists()).toBe(true);
|
||||
expect(modalsDiv.attributes('class')).toContain('relative');
|
||||
expect(modalsDiv.attributes('class')).toContain('z-[999999]');
|
||||
});
|
||||
|
||||
it('should render all modal components', () => {
|
||||
expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'UpcTrial' }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).exists()).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass correct props to CallbackFeedback based on store state', async () => {
|
||||
const callbackStore = useCallbackActionsStore();
|
||||
callbackStore.callbackStatus = 'loading';
|
||||
|
||||
await nextTick();
|
||||
|
||||
const callbackFeedback = wrapper.findComponent({ name: 'UpcCallbackFeedback' });
|
||||
expect(callbackFeedback.props('open')).toBe(true);
|
||||
|
||||
callbackStore.callbackStatus = 'ready';
|
||||
await nextTick();
|
||||
expect(callbackFeedback.props('open')).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass correct props to Trial modal based on store state', async () => {
|
||||
const trialStore = useTrialStore();
|
||||
// trialModalVisible is computed based on trialStatus
|
||||
trialStore.trialStatus = 'trialStart';
|
||||
|
||||
await nextTick();
|
||||
|
||||
const trialModal = wrapper.findComponent({ name: 'UpcTrial' });
|
||||
expect(trialModal.props('open')).toBe(true);
|
||||
|
||||
trialStore.trialStatus = 'ready';
|
||||
await nextTick();
|
||||
expect(trialModal.props('open')).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass correct props to UpdateOs modal based on store state', async () => {
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
updateOsStore.setModalOpen(true);
|
||||
|
||||
await nextTick();
|
||||
|
||||
const updateOsModal = wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' });
|
||||
expect(updateOsModal.props('open')).toBe(true);
|
||||
|
||||
updateOsStore.setModalOpen(false);
|
||||
await nextTick();
|
||||
expect(updateOsModal.props('open')).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass correct props to Changelog modal based on store state', async () => {
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
// changelogModalVisible is computed based on releaseForUpdate
|
||||
updateOsStore.setReleaseForUpdate({
|
||||
version: '6.13.0',
|
||||
name: 'Unraid 6.13.0',
|
||||
date: '2024-01-01',
|
||||
isNewer: true,
|
||||
isEligible: true,
|
||||
changelog: null,
|
||||
sha256: null,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
const changelogModal = wrapper.findComponent({ name: 'UpdateOsChangelogModal' });
|
||||
expect(changelogModal.props('open')).toBe(true);
|
||||
|
||||
updateOsStore.setReleaseForUpdate(null);
|
||||
await nextTick();
|
||||
expect(changelogModal.props('open')).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass translation function to all modals', () => {
|
||||
const components = [
|
||||
'UpcCallbackFeedback',
|
||||
'UpcTrial',
|
||||
'UpdateOsCheckUpdateResponseModal',
|
||||
'UpdateOsChangelogModal',
|
||||
'ActivationModal',
|
||||
];
|
||||
|
||||
components.forEach((componentName) => {
|
||||
const component = wrapper.findComponent({ name: componentName });
|
||||
expect(component.props('t')).toBeDefined();
|
||||
expect(typeof component.props('t')).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
it('should use computed properties for reactive store access', async () => {
|
||||
// Test that computed properties react to store changes
|
||||
const callbackStore = useCallbackActionsStore();
|
||||
const trialStore = useTrialStore();
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
|
||||
// Initially all should be closed/default
|
||||
expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).props('open')).toBe(false);
|
||||
expect(wrapper.findComponent({ name: 'UpcTrial' }).props('open')).toBe(false);
|
||||
expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).props('open')).toBe(
|
||||
false
|
||||
);
|
||||
expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).props('open')).toBe(false);
|
||||
|
||||
// Update all stores using proper methods
|
||||
callbackStore.callbackStatus = 'loading';
|
||||
trialStore.trialStatus = 'trialStart';
|
||||
updateOsStore.setModalOpen(true);
|
||||
updateOsStore.setReleaseForUpdate({
|
||||
version: '6.13.0',
|
||||
name: 'Unraid 6.13.0',
|
||||
date: '2024-01-01',
|
||||
isNewer: true,
|
||||
isEligible: true,
|
||||
changelog: null,
|
||||
sha256: null,
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
// All should be open now
|
||||
expect(wrapper.findComponent({ name: 'UpcCallbackFeedback' }).props('open')).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'UpcTrial' }).props('open')).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'UpdateOsCheckUpdateResponseModal' }).props('open')).toBe(true);
|
||||
expect(wrapper.findComponent({ name: 'UpdateOsChangelogModal' }).props('open')).toBe(true);
|
||||
});
|
||||
|
||||
it('should render modals container even when all modals are closed', () => {
|
||||
const callbackStore = useCallbackActionsStore();
|
||||
const trialStore = useTrialStore();
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
|
||||
// Set all modals to closed state
|
||||
callbackStore.callbackStatus = 'ready';
|
||||
trialStore.trialStatus = 'ready';
|
||||
updateOsStore.setModalOpen(false);
|
||||
updateOsStore.setReleaseForUpdate(null);
|
||||
|
||||
const modalsDiv = wrapper.find('#modals');
|
||||
expect(modalsDiv.exists()).toBe(true);
|
||||
// Container should still exist
|
||||
expect(wrapper.findComponent({ name: 'ActivationModal' }).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
184
web/__test__/components/Wrapper/component-registry.test.ts
Normal file
184
web/__test__/components/Wrapper/component-registry.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock Vue's defineAsyncComponent
|
||||
vi.mock('vue', () => ({
|
||||
defineAsyncComponent: vi.fn((loader) => ({ loader, __asyncComponent: true })),
|
||||
}));
|
||||
|
||||
// Mock CSS imports
|
||||
vi.mock('~/assets/main.css', () => ({}));
|
||||
vi.mock('@unraid/ui/styles', () => ({}));
|
||||
|
||||
// Mock all component imports
|
||||
vi.mock('@/components/HeaderOsVersion.standalone.vue', () => ({ default: 'HeaderOsVersion' }));
|
||||
vi.mock('@/components/UserProfile.standalone.vue', () => ({ default: 'UserProfile' }));
|
||||
vi.mock('../Auth.standalone.vue', () => ({ default: 'Auth' }));
|
||||
vi.mock('../ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: 'ConnectSettings' }));
|
||||
vi.mock('../DownloadApiLogs.standalone.vue', () => ({ default: 'DownloadApiLogs' }));
|
||||
vi.mock('@/components/Modals.standalone.vue', () => ({ default: 'Modals' }));
|
||||
vi.mock('../Registration.standalone.vue', () => ({ default: 'Registration' }));
|
||||
vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' }));
|
||||
vi.mock('../CallbackHandler.standalone.vue', () => ({ default: 'CallbackHandler' }));
|
||||
vi.mock('../Logs/LogViewer.standalone.vue', () => ({ default: 'LogViewer' }));
|
||||
vi.mock('../SsoButton.standalone.vue', () => ({ default: 'SsoButton' }));
|
||||
vi.mock('../Activation/WelcomeModal.standalone.vue', () => ({ default: 'WelcomeModal' }));
|
||||
vi.mock('../UpdateOs.standalone.vue', () => ({ default: 'UpdateOs' }));
|
||||
vi.mock('../DowngradeOs.standalone.vue', () => ({ default: 'DowngradeOs' }));
|
||||
vi.mock('../DevSettings.vue', () => ({ default: 'DevSettings' }));
|
||||
vi.mock('../ApiKeyPage.standalone.vue', () => ({ default: 'ApiKeyPage' }));
|
||||
vi.mock('../ApiKeyAuthorize.standalone.vue', () => ({ default: 'ApiKeyAuthorize' }));
|
||||
vi.mock('../DevModalTest.standalone.vue', () => ({ default: 'DevModalTest' }));
|
||||
vi.mock('../LayoutViews/Detail/DetailTest.standalone.vue', () => ({ default: 'DetailTest' }));
|
||||
vi.mock('@/components/ThemeSwitcher.standalone.vue', () => ({ default: 'ThemeSwitcher' }));
|
||||
vi.mock('../ColorSwitcher.standalone.vue', () => ({ default: 'ColorSwitcher' }));
|
||||
vi.mock('@/components/UnraidToaster.vue', () => ({ default: 'UnraidToaster' }));
|
||||
vi.mock('../UpdateOs/TestUpdateModal.standalone.vue', () => ({ default: 'TestUpdateModal' }));
|
||||
vi.mock('../TestThemeSwitcher.standalone.vue', () => ({ default: 'TestThemeSwitcher' }));
|
||||
|
||||
describe('component-registry', () => {
|
||||
it('should export ComponentMapping type', async () => {
|
||||
const module = await import('~/components/Wrapper/component-registry');
|
||||
expect(module).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export componentMappings array', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
expect(Array.isArray(componentMappings)).toBe(true);
|
||||
expect(componentMappings.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have required properties for each component mapping', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
componentMappings.forEach((mapping) => {
|
||||
expect(mapping).toHaveProperty('selector');
|
||||
expect(mapping).toHaveProperty('appId');
|
||||
expect(mapping).toHaveProperty('component');
|
||||
|
||||
// Check selector is string or array
|
||||
expect(typeof mapping.selector === 'string' || Array.isArray(mapping.selector)).toBe(true);
|
||||
|
||||
// Check appId is string
|
||||
expect(typeof mapping.appId).toBe('string');
|
||||
|
||||
// Check component exists and is an object
|
||||
expect(mapping.component).toBeDefined();
|
||||
expect(typeof mapping.component).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have priority components listed first', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
// Priority components should be first
|
||||
expect(componentMappings[0].appId).toBe('header-os-version');
|
||||
expect(componentMappings[1].appId).toBe('user-profile');
|
||||
});
|
||||
|
||||
it('should support multiple selectors for modals', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
const modalsMapping = componentMappings.find((m) => m.appId === 'modals');
|
||||
expect(Array.isArray(modalsMapping?.selector)).toBe(true);
|
||||
expect(modalsMapping?.selector).toContain('unraid-modals');
|
||||
expect(modalsMapping?.selector).toContain('#modals');
|
||||
expect(modalsMapping?.selector).toContain('modals-direct');
|
||||
});
|
||||
|
||||
it('should support multiple selectors for api key components', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
const apiKeyMapping = componentMappings.find((m) => m.appId === 'apikey-page');
|
||||
expect(Array.isArray(apiKeyMapping?.selector)).toBe(true);
|
||||
expect(apiKeyMapping?.selector).toContain('unraid-apikey-page');
|
||||
expect(apiKeyMapping?.selector).toContain('unraid-api-key-manager');
|
||||
});
|
||||
|
||||
it('should support multiple selectors for toaster', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
const toasterMapping = componentMappings.find((m) => m.appId === 'toaster');
|
||||
expect(Array.isArray(toasterMapping?.selector)).toBe(true);
|
||||
expect(toasterMapping?.selector).toContain('unraid-toaster');
|
||||
expect(toasterMapping?.selector).toContain('uui-toaster');
|
||||
});
|
||||
|
||||
it('should have unique appIds', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
const appIds = componentMappings.map((m) => m.appId);
|
||||
const uniqueAppIds = new Set(appIds);
|
||||
expect(appIds.length).toBe(uniqueAppIds.size);
|
||||
});
|
||||
|
||||
it('should define all components as async components', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
componentMappings.forEach((mapping) => {
|
||||
expect(mapping.component).toBeDefined();
|
||||
expect(typeof mapping.component).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
it('should have at least the core component mappings', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
// Just ensure we have a reasonable number of components, not an exact count
|
||||
expect(componentMappings.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('should include all expected components', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
const expectedAppIds = [
|
||||
'header-os-version',
|
||||
'user-profile',
|
||||
'auth',
|
||||
'connect-settings',
|
||||
'download-api-logs',
|
||||
'modals',
|
||||
'registration',
|
||||
'wan-ip-check',
|
||||
'callback-handler',
|
||||
'log-viewer',
|
||||
'sso-button',
|
||||
'welcome-modal',
|
||||
'update-os',
|
||||
'downgrade-os',
|
||||
'dev-settings',
|
||||
'apikey-page',
|
||||
'apikey-authorize',
|
||||
'dev-modal-test',
|
||||
'detail-test',
|
||||
'theme-switcher',
|
||||
'color-switcher',
|
||||
'toaster',
|
||||
'test-update-modal',
|
||||
'test-theme-switcher',
|
||||
];
|
||||
|
||||
const actualAppIds = componentMappings.map((m) => m.appId);
|
||||
expectedAppIds.forEach((appId) => {
|
||||
expect(actualAppIds).toContain(appId);
|
||||
});
|
||||
});
|
||||
|
||||
it('should properly format selectors', async () => {
|
||||
const { componentMappings } = await import('~/components/Wrapper/component-registry');
|
||||
|
||||
componentMappings.forEach((mapping) => {
|
||||
if (typeof mapping.selector === 'string') {
|
||||
// Single selectors should be non-empty strings
|
||||
expect(mapping.selector.length).toBeGreaterThan(0);
|
||||
} else if (Array.isArray(mapping.selector)) {
|
||||
// Array selectors should have at least one item
|
||||
expect(mapping.selector.length).toBeGreaterThan(0);
|
||||
// Each selector in array should be non-empty string
|
||||
mapping.selector.forEach((sel) => {
|
||||
expect(typeof sel).toBe('string');
|
||||
expect(sel.length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,31 +2,33 @@ import { defineComponent, h } from 'vue';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ComponentMapping } from '~/components/Wrapper/component-registry';
|
||||
import type { MockInstance } from 'vitest';
|
||||
import type { App as VueApp } from 'vue';
|
||||
|
||||
// Extend HTMLElement to include Vue's internal properties (matching the source file)
|
||||
interface HTMLElementWithVue extends HTMLElement {
|
||||
__vueParentComponent?: {
|
||||
appContext?: {
|
||||
app?: VueApp;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// We'll manually mock createApp only in specific tests that need it
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue')>('vue');
|
||||
return {
|
||||
...actual,
|
||||
};
|
||||
});
|
||||
|
||||
const mockEnsureTeleportContainer = vi.fn();
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
ensureTeleportContainer: mockEnsureTeleportContainer,
|
||||
// Mock @nuxt/ui components
|
||||
vi.mock('@nuxt/ui/components/App.vue', () => ({
|
||||
default: defineComponent({
|
||||
name: 'UApp',
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'u-app' }, slots.default?.());
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@nuxt/ui/vue-plugin', () => ({
|
||||
default: {
|
||||
install: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock component registry
|
||||
const mockComponentMappings: ComponentMapping[] = [];
|
||||
vi.mock('~/components/Wrapper/component-registry', () => ({
|
||||
componentMappings: mockComponentMappings,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
const mockI18n = {
|
||||
global: {},
|
||||
install: vi.fn(),
|
||||
@@ -60,26 +62,22 @@ vi.mock('~/helpers/i18n-utils', () => ({
|
||||
createHtmlEntityDecoder: vi.fn(() => (str: string) => str),
|
||||
}));
|
||||
|
||||
// CSS is now bundled separately by Vite, no inline imports
|
||||
|
||||
describe('mount-engine', () => {
|
||||
let mountVueApp: typeof import('~/components/Wrapper/mount-engine').mountVueApp;
|
||||
let unmountVueApp: typeof import('~/components/Wrapper/mount-engine').unmountVueApp;
|
||||
let getMountedApp: typeof import('~/components/Wrapper/mount-engine').getMountedApp;
|
||||
let autoMountComponent: typeof import('~/components/Wrapper/mount-engine').autoMountComponent;
|
||||
let mountUnifiedApp: typeof import('~/components/Wrapper/mount-engine').mountUnifiedApp;
|
||||
let autoMountAllComponents: typeof import('~/components/Wrapper/mount-engine').autoMountAllComponents;
|
||||
let TestComponent: ReturnType<typeof defineComponent>;
|
||||
let consoleWarnSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
let consoleInfoSpy: MockInstance;
|
||||
let consoleDebugSpy: MockInstance;
|
||||
let testContainer: HTMLDivElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear component mappings
|
||||
mockComponentMappings.length = 0;
|
||||
|
||||
// Import fresh module
|
||||
vi.resetModules();
|
||||
const module = await import('~/components/Wrapper/mount-engine');
|
||||
mountVueApp = module.mountVueApp;
|
||||
unmountVueApp = module.unmountVueApp;
|
||||
getMountedApp = module.getMountedApp;
|
||||
autoMountComponent = module.autoMountComponent;
|
||||
mountUnifiedApp = module.mountUnifiedApp;
|
||||
autoMountAllComponents = module.autoMountAllComponents;
|
||||
|
||||
TestComponent = defineComponent({
|
||||
name: 'TestComponent',
|
||||
@@ -96,526 +94,314 @@ describe('mount-engine', () => {
|
||||
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
testContainer = document.createElement('div');
|
||||
testContainer.id = 'test-container';
|
||||
document.body.appendChild(testContainer);
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Clear mounted apps from previous tests
|
||||
if (window.mountedApps) {
|
||||
window.mountedApps.clear();
|
||||
}
|
||||
// Clean up DOM
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
if (window.mountedApps) {
|
||||
window.mountedApps.clear();
|
||||
}
|
||||
// Clean up global references
|
||||
// Clean up any window references if needed
|
||||
});
|
||||
|
||||
describe('mountVueApp', () => {
|
||||
it('should mount a Vue app to a single element', () => {
|
||||
describe('mountUnifiedApp', () => {
|
||||
it('should create and mount a unified app with shared context', async () => {
|
||||
// Add a component mapping
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.id = 'test-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
mockComponentMappings.push({
|
||||
selector: '#test-app',
|
||||
appId: 'test-app',
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
const app = mountUnifiedApp();
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
expect(element.textContent).toBe('Hello');
|
||||
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockI18n.install).toHaveBeenCalled();
|
||||
expect(mockGlobalPinia.install).toHaveBeenCalled();
|
||||
|
||||
it('should mount with custom props', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
props: { message: 'Custom Message' },
|
||||
// Wait for async component to render
|
||||
await vi.waitFor(() => {
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.textContent).toBe('Custom Message');
|
||||
// Check that component was rendered
|
||||
expect(element.textContent).toContain('Hello');
|
||||
expect(element.getAttribute('data-vue-mounted')).toBe('true');
|
||||
expect(element.classList.contains('unapi')).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse props from element attributes', () => {
|
||||
it('should parse props from element attributes', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.id = 'test-app';
|
||||
element.setAttribute('message', 'Attribute Message');
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
mockComponentMappings.push({
|
||||
selector: '#test-app',
|
||||
appId: 'test-app',
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.textContent).toBe('Attribute Message');
|
||||
mountUnifiedApp();
|
||||
|
||||
// Wait for async component to render
|
||||
await vi.waitFor(() => {
|
||||
expect(element.textContent).toContain('Attribute Message');
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse JSON props from attributes', () => {
|
||||
it('should handle JSON props from attributes', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.id = 'test-app';
|
||||
element.setAttribute('message', '{"text": "JSON Message"}');
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
mockComponentMappings.push({
|
||||
selector: '#test-app',
|
||||
appId: 'test-app',
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
mountUnifiedApp();
|
||||
|
||||
// The component receives the parsed JSON object
|
||||
expect(element.getAttribute('message')).toBe('{"text": "JSON Message"}');
|
||||
});
|
||||
|
||||
it('should handle HTML-encoded JSON in attributes', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.id = 'test-app';
|
||||
element.setAttribute('message', '{"text": "Encoded"}');
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
mockComponentMappings.push({
|
||||
selector: '#test-app',
|
||||
appId: 'test-app',
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
mountUnifiedApp();
|
||||
|
||||
expect(element.getAttribute('message')).toBe('{"text": "Encoded"}');
|
||||
});
|
||||
|
||||
it('should mount to multiple elements', () => {
|
||||
it('should handle multiple selector aliases', async () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.className = 'multi-mount';
|
||||
element1.id = 'app1';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
const element2 = document.createElement('div');
|
||||
element2.className = 'multi-mount';
|
||||
element2.className = 'app-alt';
|
||||
document.body.appendChild(element2);
|
||||
|
||||
const app = mountVueApp({
|
||||
// Component with multiple selector aliases - only first match mounts
|
||||
mockComponentMappings.push({
|
||||
selector: ['#app1', '.app-alt'],
|
||||
appId: 'multi-selector',
|
||||
component: TestComponent,
|
||||
selector: '.multi-mount',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element1.querySelector('.test-component')).toBeTruthy();
|
||||
expect(element2.querySelector('.test-component')).toBeTruthy();
|
||||
mountUnifiedApp();
|
||||
|
||||
// Wait for async component to render
|
||||
await vi.waitFor(() => {
|
||||
expect(element1.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Only the first matching element should be mounted
|
||||
expect(element1.getAttribute('data-vue-mounted')).toBe('true');
|
||||
|
||||
// Second element should not be mounted (first match wins)
|
||||
expect(element2.querySelector('.test-component')).toBeFalsy();
|
||||
expect(element2.getAttribute('data-vue-mounted')).toBeNull();
|
||||
});
|
||||
|
||||
it('should use shadow root when specified', () => {
|
||||
it('should handle async component loaders', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'shadow-app';
|
||||
element.id = 'async-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
mockComponentMappings.push({
|
||||
selector: '#async-app',
|
||||
appId: 'async-app',
|
||||
component: TestComponent,
|
||||
selector: '#shadow-app',
|
||||
useShadowRoot: true,
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(element.shadowRoot).toBeTruthy();
|
||||
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
|
||||
expect(element.shadowRoot?.querySelector('.test-component')).toBeTruthy();
|
||||
mountUnifiedApp();
|
||||
|
||||
// Wait for component to mount
|
||||
await vi.waitFor(() => {
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should clean up existing Vue attributes', () => {
|
||||
it('should skip already mounted elements', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.id = 'already-mounted';
|
||||
element.setAttribute('data-vue-mounted', 'true');
|
||||
element.setAttribute('data-v-app', '');
|
||||
element.setAttribute('data-server-rendered', 'true');
|
||||
element.setAttribute('data-v-123', '');
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
mockComponentMappings.push({
|
||||
selector: '#already-mounted',
|
||||
appId: 'already-mounted',
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Element #app has Vue attributes but no content, cleaning up'
|
||||
);
|
||||
mountUnifiedApp();
|
||||
|
||||
// Should not mount to already mounted element
|
||||
expect(element.querySelector('.test-component')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle elements with problematic child nodes', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
element.appendChild(document.createTextNode(' '));
|
||||
element.appendChild(document.createComment('test comment'));
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Cleaning up problematic nodes in #app before mounting'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when no elements found', () => {
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
it('should handle missing elements gracefully', () => {
|
||||
mockComponentMappings.push({
|
||||
selector: '#non-existent',
|
||||
appId: 'non-existent',
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
expect(app).toBeNull();
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] No elements found for any selector: #non-existent'
|
||||
const app = mountUnifiedApp();
|
||||
|
||||
// Should still create the app successfully
|
||||
expect(app).toBeTruthy();
|
||||
// No errors should be thrown
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should error on invalid component mapping', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'invalid-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Invalid mapping - no component
|
||||
mockComponentMappings.push({
|
||||
selector: '#invalid-app',
|
||||
appId: 'invalid-app',
|
||||
} as ComponentMapping);
|
||||
|
||||
mountUnifiedApp();
|
||||
|
||||
// Should log error for missing component
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[UnifiedMount] No component defined for invalid-app'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add unapi class to mounted elements', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
expect(element.classList.contains('unapi')).toBe(true);
|
||||
expect(element.getAttribute('data-vue-mounted')).toBe('true');
|
||||
});
|
||||
|
||||
it('should skip disconnected elements during multi-mount', () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.className = 'multi';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
const element2 = document.createElement('div');
|
||||
element2.className = 'multi';
|
||||
// This element is NOT added to the document
|
||||
|
||||
// Create a third element and manually add it to element1 to simulate DOM issues
|
||||
const orphanedChild = document.createElement('span');
|
||||
element1.appendChild(orphanedChild);
|
||||
// Now remove element1 from DOM temporarily to trigger the warning
|
||||
element1.remove();
|
||||
|
||||
// Add element1 back
|
||||
document.body.appendChild(element1);
|
||||
|
||||
// Create elements matching the selector
|
||||
document.body.innerHTML = '';
|
||||
const validElement = document.createElement('div');
|
||||
validElement.className = 'multi';
|
||||
document.body.appendChild(validElement);
|
||||
|
||||
const disconnectedElement = document.createElement('div');
|
||||
disconnectedElement.className = 'multi';
|
||||
const container = document.createElement('div');
|
||||
container.appendChild(disconnectedElement);
|
||||
// Now disconnectedElement has a parent but that parent is not in the document
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '.multi',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
// The app should mount only to the connected element
|
||||
expect(validElement.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unmountVueApp', () => {
|
||||
it('should unmount a mounted app', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(getMountedApp('test-app')).toBe(app);
|
||||
|
||||
const result = unmountVueApp('test-app');
|
||||
expect(result).toBe(true);
|
||||
expect(getMountedApp('test-app')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clean up data attributes on unmount', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
expect(element.getAttribute('data-vue-mounted')).toBe('true');
|
||||
expect(element.classList.contains('unapi')).toBe(true);
|
||||
|
||||
unmountVueApp('test-app');
|
||||
|
||||
// Component should not be rendered without a valid component
|
||||
expect(element.querySelector('.test-component')).toBeFalsy();
|
||||
expect(element.getAttribute('data-vue-mounted')).toBeNull();
|
||||
});
|
||||
|
||||
it('should unmount cloned apps', () => {
|
||||
it('should create hidden root element if not exists', () => {
|
||||
mountUnifiedApp();
|
||||
|
||||
const rootElement = document.getElementById('unraid-unified-root');
|
||||
expect(rootElement).toBeTruthy();
|
||||
expect(rootElement?.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('should reuse existing root element', () => {
|
||||
// Create root element first
|
||||
const existingRoot = document.createElement('div');
|
||||
existingRoot.id = 'unraid-unified-root';
|
||||
document.body.appendChild(existingRoot);
|
||||
|
||||
mountUnifiedApp();
|
||||
|
||||
const rootElement = document.getElementById('unraid-unified-root');
|
||||
expect(rootElement).toBe(existingRoot);
|
||||
});
|
||||
|
||||
it('should wrap components in UApp for Nuxt UI support', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'wrapped-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mockComponentMappings.push({
|
||||
selector: '#wrapped-app',
|
||||
appId: 'wrapped-app',
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
|
||||
// Wait for async component to render
|
||||
await vi.waitFor(() => {
|
||||
expect(element.querySelector('.u-app')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Check that UApp wrapper is present
|
||||
expect(element.querySelector('.u-app .test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should share app context across all components', async () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.className = 'multi';
|
||||
element1.id = 'app1';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
const element2 = document.createElement('div');
|
||||
element2.className = 'multi';
|
||||
element2.id = 'app2';
|
||||
document.body.appendChild(element2);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '.multi',
|
||||
appId: 'multi-app',
|
||||
});
|
||||
|
||||
const result = unmountVueApp('multi-app');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove shadow root containers', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'shadow-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#shadow-app',
|
||||
appId: 'shadow-app',
|
||||
useShadowRoot: true,
|
||||
});
|
||||
|
||||
expect(element.shadowRoot?.querySelector('#app')).toBeTruthy();
|
||||
|
||||
unmountVueApp('shadow-app');
|
||||
|
||||
expect(element.shadowRoot?.querySelector('#app')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should warn when unmounting non-existent app', () => {
|
||||
const result = unmountVueApp('non-existent');
|
||||
expect(result).toBe(false);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith('[VueMountApp] No app found with id: non-existent');
|
||||
});
|
||||
|
||||
it('should handle unmount errors gracefully', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
// Force an error by corrupting the app
|
||||
if (app) {
|
||||
(app as { unmount: () => void }).unmount = () => {
|
||||
throw new Error('Unmount error');
|
||||
};
|
||||
}
|
||||
|
||||
const result = unmountVueApp('test-app');
|
||||
expect(result).toBe(true);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Error unmounting app test-app:',
|
||||
expect.any(Error)
|
||||
mockComponentMappings.push(
|
||||
{
|
||||
selector: '#app1',
|
||||
appId: 'app1',
|
||||
component: TestComponent,
|
||||
},
|
||||
{
|
||||
selector: '#app2',
|
||||
appId: 'app2',
|
||||
component: TestComponent,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMountedApp', () => {
|
||||
it('should return mounted app by id', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
mountUnifiedApp();
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
// Wait for async components to render
|
||||
await vi.waitFor(() => {
|
||||
expect(element1.querySelector('.test-component')).toBeTruthy();
|
||||
expect(element2.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(getMountedApp('test-app')).toBe(app);
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent app', () => {
|
||||
expect(getMountedApp('non-existent')).toBeUndefined();
|
||||
// Only one Pinia instance should be installed
|
||||
expect(mockGlobalPinia.install).toHaveBeenCalledTimes(1);
|
||||
// Only one i18n instance should be installed
|
||||
expect(mockI18n.install).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoMountComponent', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should auto-mount when DOM is ready', async () => {
|
||||
describe('autoMountAllComponents', () => {
|
||||
it('should call mountUnifiedApp', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'auto-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#auto-app');
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should wait for DOMContentLoaded if document is loading', async () => {
|
||||
Object.defineProperty(document, 'readyState', {
|
||||
value: 'loading',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.id = 'auto-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#auto-app');
|
||||
|
||||
expect(element.querySelector('.test-component')).toBeFalsy();
|
||||
|
||||
Object.defineProperty(document, 'readyState', {
|
||||
value: 'complete',
|
||||
writable: true,
|
||||
});
|
||||
|
||||
document.dispatchEvent(new Event('DOMContentLoaded'));
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should skip auto-mount for already mounted modals', async () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.id = 'modals';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
mountVueApp({
|
||||
mockComponentMappings.push({
|
||||
selector: '#auto-app',
|
||||
appId: 'auto-app',
|
||||
component: TestComponent,
|
||||
selector: '#modals',
|
||||
appId: 'modals',
|
||||
});
|
||||
|
||||
autoMountComponent(TestComponent, '#modals');
|
||||
await vi.runAllTimersAsync();
|
||||
autoMountAllComponents();
|
||||
|
||||
expect(consoleDebugSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Component already mounted as modals for selector #modals, returning existing instance'
|
||||
);
|
||||
});
|
||||
|
||||
it('should mount immediately for all selectors', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'unraid-connect-settings';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#unraid-connect-settings');
|
||||
|
||||
// Component should mount immediately without delay
|
||||
await vi.runAllTimersAsync();
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should mount even when element is hidden', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'hidden-app';
|
||||
element.style.display = 'none';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#hidden-app');
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Hidden elements should still mount successfully
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('No valid DOM elements found')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle nextSibling errors with retry', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'error-app';
|
||||
element.setAttribute('data-vue-mounted', 'true');
|
||||
element.setAttribute('data-v-app', '');
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Simulate the element having Vue instance references which cause nextSibling errors
|
||||
const mockVueInstance = { appContext: { app: {} as VueApp } };
|
||||
(element as HTMLElementWithVue).__vueParentComponent = mockVueInstance;
|
||||
|
||||
// Add an invalid child that will trigger cleanup
|
||||
const textNode = document.createTextNode(' ');
|
||||
element.appendChild(textNode);
|
||||
|
||||
autoMountComponent(TestComponent, '#error-app');
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Should detect and clean up existing Vue state
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'[VueMountApp] Element #error-app has Vue attributes but no content, cleaning up'
|
||||
)
|
||||
);
|
||||
|
||||
// Should successfully mount after cleanup
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should pass options to mountVueApp', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'options-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#options-app', {
|
||||
props: { message: 'Auto Mount Message' },
|
||||
useShadowRoot: true,
|
||||
// Wait for async component to render
|
||||
await vi.waitFor(() => {
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(element.shadowRoot).toBeTruthy();
|
||||
expect(element.shadowRoot?.textContent).toContain('Auto Mount Message');
|
||||
});
|
||||
});
|
||||
|
||||
describe('i18n setup', () => {
|
||||
it('should setup i18n with default locale', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'i18n-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#i18n-app',
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
expect(mockI18n.install).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -627,14 +413,7 @@ describe('mount-engine', () => {
|
||||
JSON.stringify(localeData)
|
||||
);
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.id = 'i18n-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#i18n-app',
|
||||
});
|
||||
mountUnifiedApp();
|
||||
|
||||
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
|
||||
});
|
||||
@@ -642,14 +421,7 @@ describe('mount-engine', () => {
|
||||
it('should handle locale data parsing errors', () => {
|
||||
(window as unknown as Record<string, unknown>).LOCALE_DATA = 'invalid json';
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.id = 'i18n-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#i18n-app',
|
||||
});
|
||||
mountUnifiedApp();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] error parsing messages',
|
||||
@@ -660,65 +432,29 @@ describe('mount-engine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('error recovery', () => {
|
||||
it('should attempt recovery from nextSibling error', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.id = 'recovery-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
// Create a mock Vue app that throws on first mount attempt
|
||||
let mountAttempt = 0;
|
||||
const mockApp = {
|
||||
use: vi.fn().mockReturnThis(),
|
||||
provide: vi.fn().mockReturnThis(),
|
||||
mount: vi.fn().mockImplementation(() => {
|
||||
mountAttempt++;
|
||||
if (mountAttempt === 1) {
|
||||
const error = new TypeError('Cannot read property nextSibling of null');
|
||||
throw error;
|
||||
}
|
||||
return mockApp;
|
||||
}),
|
||||
unmount: vi.fn(),
|
||||
version: '3.0.0',
|
||||
config: { globalProperties: {} },
|
||||
};
|
||||
|
||||
// Mock createApp using module mock
|
||||
const vueModule = await import('vue');
|
||||
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#recovery-app',
|
||||
appId: 'recovery-app',
|
||||
});
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Attempting recovery from nextSibling error for #recovery-app'
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Successfully recovered from nextSibling error for #recovery-app'
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('global exposure', () => {
|
||||
it('should expose mountedApps globally', () => {
|
||||
expect(window.mountedApps).toBeDefined();
|
||||
expect(window.mountedApps).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it('should expose globalPinia globally', () => {
|
||||
expect(window.globalPinia).toBeDefined();
|
||||
expect(window.globalPinia).toBe(mockGlobalPinia);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance debugging', () => {
|
||||
it('should not log timing by default', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'perf-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mockComponentMappings.push({
|
||||
selector: '#perf-app',
|
||||
appId: 'perf-app',
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
|
||||
// Should not log timing information when PERF_DEBUG is false
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('[UnifiedMount] Mounted'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,16 +57,12 @@ vi.mock('~/components/UnraidToaster.vue', () => ({
|
||||
}));
|
||||
|
||||
// Mock mount-engine module
|
||||
const mockAutoMountComponent = vi.fn();
|
||||
const mockAutoMountAllComponents = vi.fn();
|
||||
const mockMountVueApp = vi.fn();
|
||||
const mockGetMountedApp = vi.fn();
|
||||
const mockMountUnifiedApp = vi.fn();
|
||||
|
||||
vi.mock('~/components/Wrapper/mount-engine', () => ({
|
||||
autoMountComponent: mockAutoMountComponent,
|
||||
autoMountAllComponents: mockAutoMountAllComponents,
|
||||
mountVueApp: mockMountVueApp,
|
||||
getMountedApp: mockGetMountedApp,
|
||||
mountUnifiedApp: mockMountUnifiedApp,
|
||||
}));
|
||||
|
||||
// Mock theme initializer
|
||||
@@ -104,10 +100,7 @@ vi.mock('graphql', () => ({
|
||||
}));
|
||||
|
||||
// Mock @unraid/ui
|
||||
const mockEnsureTeleportContainer = vi.fn();
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
ensureTeleportContainer: mockEnsureTeleportContainer,
|
||||
}));
|
||||
vi.mock('@unraid/ui', () => ({}));
|
||||
|
||||
describe('component-registry', () => {
|
||||
beforeEach(() => {
|
||||
@@ -151,10 +144,12 @@ describe('component-registry', () => {
|
||||
expect(mockInitializeTheme).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ensure teleport container exists', async () => {
|
||||
it('should mount unified app with components', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
|
||||
// The unified app architecture no longer requires teleport container setup per component
|
||||
// Instead it uses a unified approach
|
||||
expect(mockAutoMountAllComponents).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,26 +183,29 @@ describe('component-registry', () => {
|
||||
it('should expose utility functions globally', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
expect(window.mountVueApp).toBe(mockMountVueApp);
|
||||
expect(window.getMountedApp).toBe(mockGetMountedApp);
|
||||
expect(window.autoMountComponent).toBe(mockAutoMountComponent);
|
||||
// With unified app architecture, these are exposed instead:
|
||||
expect(window.apolloClient).toBe(mockApolloClient);
|
||||
expect(window.gql).toBe(mockParse);
|
||||
expect(window.graphqlParse).toBe(mockParse);
|
||||
// The unified app itself is exposed via window.__unifiedApp after mounting
|
||||
});
|
||||
|
||||
it('should expose mountVueApp function globally', async () => {
|
||||
it('should not expose legacy mount functions', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
// Check that mountVueApp is exposed
|
||||
expect(typeof window.mountVueApp).toBe('function');
|
||||
|
||||
// Note: Dynamic mount functions are no longer created automatically
|
||||
// They would be created via mountVueApp calls
|
||||
// These functions are no longer exposed in the unified app architecture
|
||||
expect(window.mountVueApp).toBeUndefined();
|
||||
expect(window.getMountedApp).toBeUndefined();
|
||||
expect(window.autoMountComponent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expose autoMountComponent function globally', async () => {
|
||||
it('should expose apollo client and graphql utilities', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
// Check that autoMountComponent is exposed
|
||||
expect(typeof window.autoMountComponent).toBe('function');
|
||||
// Check that Apollo client and GraphQL utilities are exposed
|
||||
expect(window.apolloClient).toBeDefined();
|
||||
expect(typeof window.gql).toBe('function');
|
||||
expect(typeof window.graphqlParse).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ const config: CodegenConfig = {
|
||||
Port: 'number',
|
||||
UUID: 'string',
|
||||
PrefixedID: 'string',
|
||||
BigInt: 'number',
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
|
||||
3
web/components.d.ts
vendored
3
web/components.d.ts
vendored
@@ -16,6 +16,8 @@ declare module 'vue' {
|
||||
ApiKeyCreate: typeof import('./src/components/ApiKey/ApiKeyCreate.vue')['default']
|
||||
ApiKeyManager: typeof import('./src/components/ApiKey/ApiKeyManager.vue')['default']
|
||||
'ApiKeyPage.standalone': typeof import('./src/components/ApiKeyPage.standalone.vue')['default']
|
||||
ApiStatus: typeof import('./src/components/ApiStatus/ApiStatus.vue')['default']
|
||||
'ApiStatus.standalone': typeof import('./src/components/ApiStatus/ApiStatus.standalone.vue')['default']
|
||||
'Auth.standalone': typeof import('./src/components/Auth.standalone.vue')['default']
|
||||
Avatar: typeof import('./src/components/Brand/Avatar.vue')['default']
|
||||
Beta: typeof import('./src/components/UserProfile/Beta.vue')['default']
|
||||
@@ -34,7 +36,6 @@ declare module 'vue' {
|
||||
ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default']
|
||||
'ConnectSettings.standalone': typeof import('./src/components/ConnectSettings/ConnectSettings.standalone.vue')['default']
|
||||
Console: typeof import('./src/components/Docker/Console.vue')['default']
|
||||
'CpuStats.standalone': typeof import('./src/components/CpuStats/CpuStats.standalone.vue')['default']
|
||||
Detail: typeof import('./src/components/LayoutViews/Detail/Detail.vue')['default']
|
||||
DetailContentHeader: typeof import('./src/components/LayoutViews/Detail/DetailContentHeader.vue')['default']
|
||||
DetailLeftNavigation: typeof import('./src/components/LayoutViews/Detail/DetailLeftNavigation.vue')['default']
|
||||
|
||||
@@ -103,6 +103,7 @@ export default [
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
...commonLanguageOptions,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
@@ -128,6 +129,7 @@ export default [
|
||||
parserOptions: {
|
||||
...commonLanguageOptions,
|
||||
parser: tseslint.parser,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/web",
|
||||
"version": "4.22.0",
|
||||
"version": "4.24.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -38,9 +38,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.34.0",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/client-preset": "4.8.3",
|
||||
"@graphql-codegen/introspection": "4.0.3",
|
||||
"@graphql-codegen/cli": "6.0.0",
|
||||
"@graphql-codegen/client-preset": "5.0.0",
|
||||
"@graphql-codegen/introspection": "5.0.0",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
|
||||
"@pinia/testing": "1.0.2",
|
||||
@@ -105,7 +105,6 @@
|
||||
"@vueuse/integrations": "13.8.0",
|
||||
"ajv": "8.17.1",
|
||||
"ansi_up": "6.0.6",
|
||||
"chart.js": "^4.5.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"crypto-js": "4.2.0",
|
||||
@@ -124,7 +123,6 @@
|
||||
"postcss-import": "16.1.1",
|
||||
"semver": "7.7.2",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-i18n": "11.1.11",
|
||||
"vue-router": "4.5.1",
|
||||
"vue-web-component-wrapper": "1.7.7",
|
||||
|
||||
356
web/public/test-pages/component-mounting.html
Normal file
356
web/public/test-pages/component-mounting.html
Normal file
@@ -0,0 +1,356 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Component Mounting Test - Unraid Component Test</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.test-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h2 {
|
||||
color: #666;
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
.status.loading {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.status.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.status.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.mount-target {
|
||||
padding: 20px;
|
||||
background: #fafafa;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 4px;
|
||||
min-height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
.mount-target::before {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
background: white;
|
||||
padding: 0 5px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.debug-info {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.multiple-mounts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.test-button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.test-button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.test-button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Teleport target for dropdowns and modals -->
|
||||
<div id="teleports"></div>
|
||||
|
||||
<!-- Mount point for Modals component -->
|
||||
<unraid-modals></unraid-modals>
|
||||
|
||||
<div class="container">
|
||||
<h1>🧪 Standalone Vue Apps Test Page</h1>
|
||||
<div id="status" class="status loading">Loading...</div>
|
||||
|
||||
<!-- Test Section 1: Single Mount -->
|
||||
<div class="test-section">
|
||||
<h2>Test 1: Single Component Mount</h2>
|
||||
<p>Testing single instance of HeaderOsVersion component</p>
|
||||
<div class="mount-target" data-label="HeaderOsVersion Mount">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Section 2: Multiple Mounts -->
|
||||
<div class="test-section">
|
||||
<h2>Test 2: Multiple Component Mounts (Shared Pinia Store)</h2>
|
||||
<p>Testing that multiple instances share the same Pinia store</p>
|
||||
<div class="multiple-mounts">
|
||||
<div class="mount-target" data-label="Instance 1">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
<div class="mount-target" data-label="Instance 2">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
<div class="mount-target" data-label="Instance 3">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Section 3: Dynamic Mount -->
|
||||
<div class="test-section">
|
||||
<h2>Test 3: Dynamic Component Creation</h2>
|
||||
<p>Test dynamically adding components after page load</p>
|
||||
<button class="test-button" id="addComponent">Add New Component</button>
|
||||
<button class="test-button" id="removeComponent">Remove Last Component</button>
|
||||
<button class="test-button" id="remountAll">Remount All</button>
|
||||
<div id="dynamicContainer" style="margin-top: 20px;">
|
||||
<!-- Dynamic components will be added here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Section 4: Modal Testing -->
|
||||
<div class="test-section">
|
||||
<h2>Test 4: Modal Components</h2>
|
||||
<p>Test modal functionality</p>
|
||||
<button class="test-button" onclick="testTrialModal()">Open Trial Modal</button>
|
||||
<button class="test-button" onclick="testUpdateModal()">Open Update Modal</button>
|
||||
<button class="test-button" onclick="testApiKeyModal()">Open API Key Modal</button>
|
||||
<div style="margin-top: 10px;">
|
||||
<small>Note: Modals require proper store state to display</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Info -->
|
||||
<div class="test-section">
|
||||
<h2>Debug Information</h2>
|
||||
<div class="debug-info" id="debugInfo">
|
||||
Waiting for initialization...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration for local testing -->
|
||||
<script>
|
||||
// Set GraphQL endpoint - handled by Vite proxy in dev mode
|
||||
window.GRAPHQL_ENDPOINT = window.location.port === '3000' ? '/graphql' : 'http://localhost:3001/graphql';
|
||||
|
||||
// Mock webGui path for images
|
||||
window.__WEBGUI_PATH__ = '';
|
||||
|
||||
// Add some debug logging
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const status = document.getElementById('status');
|
||||
const debugInfo = document.getElementById('debugInfo');
|
||||
|
||||
// Log when scripts are loaded
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeName === 'SCRIPT') {
|
||||
console.log('Script loaded:', node.src || 'inline');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.head, { childList: true });
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
// Check for Vue app mounting
|
||||
let checkInterval = setInterval(() => {
|
||||
const mountedElements = document.querySelectorAll('[data-vue-mounted="true"]');
|
||||
let totalComponents = document.querySelectorAll('unraid-header-os-version, unraid-modals').length;
|
||||
let mountedCount = mountedElements.length;
|
||||
|
||||
if (mountedCount > 0) {
|
||||
status.className = 'status success';
|
||||
status.textContent = `✅ Successfully mounted ${mountedCount} component(s)`;
|
||||
|
||||
// Update debug info
|
||||
debugInfo.textContent = `
|
||||
Components Found: ${totalComponents}
|
||||
Components Mounted: ${mountedCount}
|
||||
Unified Vue App: ${window.__unifiedApp ? 'Initialized' : 'Not found'}
|
||||
Mounted Components: ${window.__mountedComponents ? window.__mountedComponents.length : 0}
|
||||
Pinia Store: ${window.globalPinia ? 'Initialized' : 'Not found'}
|
||||
GraphQL Endpoint: ${window.GRAPHQL_ENDPOINT || 'Not configured'}
|
||||
`.trim();
|
||||
|
||||
clearInterval(checkInterval);
|
||||
|
||||
// Log to test console if available
|
||||
if (window.testLog) {
|
||||
window.testLog(`Mounted ${mountedCount} components successfully`, 'success');
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
if (status.className === 'status loading') {
|
||||
status.className = 'status error';
|
||||
status.textContent = '❌ Failed to mount components (timeout)';
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
// Dynamic component controls
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
let dynamicCount = 0;
|
||||
const dynamicContainer = document.getElementById('dynamicContainer');
|
||||
|
||||
document.getElementById('addComponent').addEventListener('click', () => {
|
||||
dynamicCount++;
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'mount-target';
|
||||
wrapper.setAttribute('data-label', `Dynamic Instance ${dynamicCount}`);
|
||||
wrapper.style.marginBottom = '10px';
|
||||
|
||||
// Create the custom element
|
||||
const element = document.createElement('unraid-header-os-version');
|
||||
wrapper.appendChild(element);
|
||||
dynamicContainer.appendChild(wrapper);
|
||||
|
||||
// The unified mount system doesn't support dynamic addition after initial mount
|
||||
// For now, we'll just add the element and note it won't be mounted until reload
|
||||
console.log('Note: Dynamic components require page reload to mount with the unified app system');
|
||||
|
||||
// Show a message that reload is needed
|
||||
if (!wrapper.querySelector('.reload-note')) {
|
||||
const note = document.createElement('div');
|
||||
note.className = 'reload-note';
|
||||
note.style.cssText = 'color: #666; font-size: 12px; margin-top: 10px;';
|
||||
note.textContent = 'Reload page to mount this component';
|
||||
wrapper.appendChild(note);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('removeComponent').addEventListener('click', () => {
|
||||
const lastChild = dynamicContainer.lastElementChild;
|
||||
if (lastChild) {
|
||||
// If component was mounted, unmount it properly
|
||||
const mountedElement = lastChild.querySelector('[data-vue-mounted="true"]');
|
||||
if (mountedElement && window.__mountedComponents) {
|
||||
const componentIndex = window.__mountedComponents.findIndex(c => c.element === mountedElement);
|
||||
if (componentIndex !== -1) {
|
||||
window.__mountedComponents[componentIndex].unmount();
|
||||
window.__mountedComponents.splice(componentIndex, 1);
|
||||
}
|
||||
}
|
||||
dynamicContainer.removeChild(lastChild);
|
||||
dynamicCount = Math.max(0, dynamicCount - 1);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('remountAll').addEventListener('click', () => {
|
||||
console.log('Remounting all components...');
|
||||
// The unified app requires a full reload to remount
|
||||
location.reload();
|
||||
});
|
||||
});
|
||||
|
||||
// Modal test functions
|
||||
window.testTrialModal = function() {
|
||||
console.log('Testing trial modal...');
|
||||
if (window.globalPinia) {
|
||||
const trialStore = window.globalPinia._s.get('trial');
|
||||
if (trialStore) {
|
||||
trialStore.trialModalVisible = true;
|
||||
console.log('Trial modal triggered');
|
||||
} else {
|
||||
console.error('Trial store not found');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.testUpdateModal = function() {
|
||||
console.log('Testing update modal...');
|
||||
if (window.globalPinia) {
|
||||
const updateStore = window.globalPinia._s.get('updateOs');
|
||||
if (updateStore) {
|
||||
updateStore.updateOsModalVisible = true;
|
||||
console.log('Update modal triggered');
|
||||
} else {
|
||||
console.error('Update store not found');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.testApiKeyModal = function() {
|
||||
console.log('Testing API key modal...');
|
||||
if (window.globalPinia) {
|
||||
const apiKeyStore = window.globalPinia._s.get('apiKey');
|
||||
if (apiKeyStore) {
|
||||
apiKeyStore.showCreateModal = true;
|
||||
console.log('API key modal triggered');
|
||||
} else {
|
||||
console.error('API key store not found');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Load shared header and manifest resources -->
|
||||
<script src="/test-pages/shared-header.js"></script>
|
||||
<script src="/test-pages/load-manifest.js"></script>
|
||||
<script src="/test-pages/test-server-state.js"></script>
|
||||
|
||||
<script>
|
||||
// Initialize page
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.initializeSharedHeader) {
|
||||
window.initializeSharedHeader('Component Mounting Test');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -103,13 +103,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>CPU Statistics</h2>
|
||||
<div class="component-mount" data-component="unraid-cpu-stats">
|
||||
<unraid-cpu-stats></unraid-cpu-stats>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Theme Settings</h2>
|
||||
<div class="component-mount" data-component="unraid-theme-switcher">
|
||||
|
||||
@@ -185,7 +185,15 @@
|
||||
<h3>Update Modal Testing <span class="badge new">NEW</span></h3>
|
||||
<p>Test various update scenarios including expired licenses, renewals, and auth requirements</p>
|
||||
</div>
|
||||
<a href="/test-update-modal.html">Open →</a>
|
||||
<a href="/test-pages/update-modal.html">Open →</a>
|
||||
</div>
|
||||
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>Component Mounting Test</h3>
|
||||
<p>Test single and multiple component mounting with shared Pinia store and dynamic creation</p>
|
||||
</div>
|
||||
<a href="/test-pages/component-mounting.html">Open →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
62
web/public/test-pages/update-modal.html
Normal file
62
web/public/test-pages/update-modal.html
Normal file
@@ -0,0 +1,62 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Update Modal Test - Unraid Component Test</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
html {
|
||||
font-size: 10px;
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.header {
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.back-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-bottom: 10px;
|
||||
display: inline-block;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.back-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<a href="/test-pages/" class="back-link">← Back to Test Pages</a>
|
||||
<h1>🧪 Update Modal Test Scenarios</h1>
|
||||
</div>
|
||||
<div>
|
||||
<unraid-test-theme-switcher></unraid-test-theme-switcher>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mount the test component -->
|
||||
<unraid-test-update-modal></unraid-test-update-modal>
|
||||
|
||||
<!-- Mount the modals component which includes the changelog modal -->
|
||||
<unraid-modals></unraid-modals>
|
||||
|
||||
<!-- Load the manifest and inject resources -->
|
||||
<script src="/test-pages/load-manifest.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -40,7 +40,7 @@ if [ "$has_standalone" = true ]; then
|
||||
rsync -avz --delete -e "ssh" "$standalone_directory" "root@${server_name}:/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone/"
|
||||
standalone_exit_code=$?
|
||||
# If standalone rsync failed, update exit_code
|
||||
if [ $standalone_exit_code -ne 0 ]; then
|
||||
if [ "$standalone_exit_code" -ne 0 ]; then
|
||||
exit_code=$standalone_exit_code
|
||||
fi
|
||||
fi
|
||||
@@ -49,7 +49,9 @@ fi
|
||||
update_auth_request() {
|
||||
local server_name="$1"
|
||||
# SSH into server and update auth-request.php
|
||||
ssh "root@${server_name}" bash -s << 'EOF'
|
||||
ssh "root@${server_name}" /bin/bash -s << 'EOF'
|
||||
set -euo pipefail
|
||||
set -o errtrace
|
||||
AUTH_REQUEST_FILE='/usr/local/emhttp/auth-request.php'
|
||||
UNRAID_COMPS_DIR='/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/'
|
||||
|
||||
@@ -76,8 +78,9 @@ update_auth_request() {
|
||||
# Clean up any existing temp file
|
||||
rm -f "$TEMP_FILE"
|
||||
|
||||
# Process the file through both stages using a pipeline
|
||||
# Process the file through both stages
|
||||
# First remove existing web component entries, then add new ones
|
||||
# Use a simpler approach without relying on PIPESTATUS
|
||||
awk '
|
||||
BEGIN { in_array = 0 }
|
||||
/\$arrWhitelist\s*=\s*\[/ {
|
||||
@@ -93,19 +96,40 @@ update_auth_request() {
|
||||
!in_array || !/\/plugins\/dynamix\.my\.servers\/unraid-components\/.*\.(m?js|css)/ {
|
||||
print $0
|
||||
}
|
||||
' "$AUTH_REQUEST_FILE" | \
|
||||
' "$AUTH_REQUEST_FILE" > "$TEMP_FILE.stage1" || {
|
||||
echo "Failed to process $AUTH_REQUEST_FILE (stage 1)" >&2
|
||||
rm -f "$TEMP_FILE.stage1"
|
||||
exit 1
|
||||
}
|
||||
|
||||
awk -v files_to_add="$(printf '%s\n' "${FILES_TO_ADD[@]}" | sed "s/'/\\\\'/g" | sort -u | awk '{printf " \047%s\047,\n", $0}')" '
|
||||
/\$arrWhitelist\s*=\s*\[/ {
|
||||
/\$arrWhitelist[[:space:]]*=[[:space:]]*\[/ {
|
||||
print $0
|
||||
print files_to_add
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' > "$TEMP_FILE"
|
||||
|
||||
# Check pipeline succeeded and temp file is non-empty
|
||||
if [ ${PIPESTATUS[0]} -ne 0 ] || [ ${PIPESTATUS[1]} -ne 0 ] || [ ! -s "$TEMP_FILE" ]; then
|
||||
echo "Failed to process $AUTH_REQUEST_FILE" >&2
|
||||
' "$TEMP_FILE.stage1" > "$TEMP_FILE" || {
|
||||
echo "Failed to process $AUTH_REQUEST_FILE (stage 2)" >&2
|
||||
rm -f "$TEMP_FILE.stage1" "$TEMP_FILE"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Clean up intermediate file
|
||||
rm -f "$TEMP_FILE.stage1"
|
||||
|
||||
# Verify whitelist entries were actually injected
|
||||
if [ ${#FILES_TO_ADD[@]} -gt 0 ]; then
|
||||
if ! grep -qF "${FILES_TO_ADD[0]}" "$TEMP_FILE"; then
|
||||
echo "Failed to inject whitelist entries" >&2
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check temp file is non-empty
|
||||
if [ ! -s "$TEMP_FILE" ]; then
|
||||
echo "Failed to process $AUTH_REQUEST_FILE - empty result" >&2
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
@@ -136,7 +160,7 @@ update_auth_request "$server_name"
|
||||
auth_request_exit_code=$?
|
||||
|
||||
# If auth request update failed, update exit_code
|
||||
if [ $auth_request_exit_code -ne 0 ]; then
|
||||
if [ "$auth_request_exit_code" -ne 0 ]; then
|
||||
exit_code=$auth_request_exit_code
|
||||
fi
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
.unapi p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: unset;
|
||||
}
|
||||
|
||||
/* Reset UL styles to prevent default browser styling */
|
||||
@@ -143,6 +144,13 @@
|
||||
|
||||
/* Note: Tailwind utilities will apply globally but should be used with .unapi prefix in HTML */
|
||||
|
||||
/* Ensure unraid-modals container has extremely high z-index */
|
||||
unraid-modals.unapi {
|
||||
position: relative;
|
||||
z-index: 999999;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* Style for Unraid progress frame */
|
||||
iframe#progressFrame {
|
||||
background-color: var(--background-color);
|
||||
|
||||
5
web/src/components/ApiStatus/ApiStatus.standalone.vue
Normal file
5
web/src/components/ApiStatus/ApiStatus.standalone.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ApiStatus from '@/components/ApiStatus/ApiStatus.vue';
|
||||
|
||||
export default ApiStatus;
|
||||
</script>
|
||||
139
web/src/components/ApiStatus/ApiStatus.vue
Normal file
139
web/src/components/ApiStatus/ApiStatus.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { WebguiUnraidApiCommand } from '~/composables/services/webgui';
|
||||
import { useServerStore } from '~/store/server';
|
||||
|
||||
const serverStore = useServerStore();
|
||||
|
||||
const apiStatus = ref<string>('');
|
||||
const isRunning = ref<boolean>(false);
|
||||
const isLoading = ref<boolean>(false);
|
||||
const isRestarting = ref<boolean>(false);
|
||||
const statusMessage = ref<string>('');
|
||||
const messageType = ref<'success' | 'error' | 'info' | ''>('');
|
||||
|
||||
const checkStatus = async () => {
|
||||
isLoading.value = true;
|
||||
statusMessage.value = '';
|
||||
try {
|
||||
const response = await WebguiUnraidApiCommand({
|
||||
csrf_token: serverStore.csrf,
|
||||
command: 'status',
|
||||
});
|
||||
|
||||
if (response?.result) {
|
||||
apiStatus.value = response.result;
|
||||
isRunning.value =
|
||||
response.result.includes('running') ||
|
||||
response.result.includes('active') ||
|
||||
response.result.includes('status : online');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get API status:', error);
|
||||
apiStatus.value = 'Error fetching status';
|
||||
isRunning.value = false;
|
||||
statusMessage.value = 'Failed to fetch API status';
|
||||
messageType.value = 'error';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restartApi = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to restart the Unraid API service? This will temporarily interrupt API connections.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
isRestarting.value = true;
|
||||
statusMessage.value = 'Restarting API service...';
|
||||
messageType.value = 'info';
|
||||
|
||||
try {
|
||||
const response = await WebguiUnraidApiCommand({
|
||||
csrf_token: serverStore.csrf,
|
||||
command: 'restart',
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
statusMessage.value = 'API service restart initiated. Please wait a few seconds.';
|
||||
messageType.value = 'success';
|
||||
setTimeout(() => {
|
||||
checkStatus();
|
||||
}, 3000);
|
||||
} else {
|
||||
statusMessage.value = response?.error || 'Failed to restart API service';
|
||||
messageType.value = 'error';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restart API:', error);
|
||||
statusMessage.value = 'Failed to restart API service';
|
||||
messageType.value = 'error';
|
||||
} finally {
|
||||
isRestarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-muted border-muted my-4 rounded-lg border p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2 text-lg font-semibold">API Service Status</h3>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="font-medium">Status:</span>
|
||||
<span :class="['font-semibold', isRunning ? 'text-green-500' : 'text-orange-500']">
|
||||
{{ isLoading ? 'Loading...' : isRunning ? 'Running' : 'Not Running' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4">
|
||||
<pre
|
||||
class="max-h-52 overflow-y-auto rounded bg-black p-4 font-mono text-xs break-words whitespace-pre-wrap text-white"
|
||||
>{{ apiStatus }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
:class="[
|
||||
'my-4 rounded px-4 py-3 text-sm',
|
||||
messageType === 'success' && 'bg-green-500 text-white',
|
||||
messageType === 'error' && 'bg-red-500 text-white',
|
||||
messageType === 'info' && 'bg-blue-500 text-white',
|
||||
]"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-4">
|
||||
<button
|
||||
@click="checkStatus"
|
||||
:disabled="isLoading"
|
||||
class="bg-secondary hover:bg-secondary/80 text-secondary-foreground rounded px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{{ isLoading ? 'Refreshing...' : 'Refresh Status' }}
|
||||
</button>
|
||||
<button
|
||||
@click="restartApi"
|
||||
:disabled="isRestarting"
|
||||
class="bg-destructive hover:bg-destructive/90 text-destructive-foreground rounded px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{{ isRestarting ? 'Restarting...' : 'Restart API' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-muted mt-6 border-t pt-4">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
View the current status of the Unraid API service and restart if needed. Use this to debug API
|
||||
connection issues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,308 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import { useQuery, useSubscription } from '@vue/apollo-composable';
|
||||
import { GET_CPU_INFO, CPU_METRICS_SUBSCRIPTION } from './cpu-stats.query';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
type ChartOptions,
|
||||
type ChartData
|
||||
} from 'chart.js';
|
||||
import { Button, Select } from '@unraid/ui';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
const showDetails = ref(true);
|
||||
const cpuHistory = ref<number[]>([]);
|
||||
|
||||
// History duration options
|
||||
type HistoryDuration = '10s' | '30s' | '1m' | '2m' | '5m';
|
||||
|
||||
const historyDuration = ref<HistoryDuration>('30s');
|
||||
|
||||
const historyConfigs: Record<HistoryDuration, { points: number; interval: number }> = {
|
||||
'10s': { points: 60, interval: 167 }, // ~6 fps
|
||||
'30s': { points: 60, interval: 500 }, // 2 fps
|
||||
'1m': { points: 60, interval: 1000 }, // 1 fps
|
||||
'2m': { points: 60, interval: 2000 }, // 0.5 fps
|
||||
'5m': { points: 60, interval: 5000 }, // 0.2 fps
|
||||
};
|
||||
|
||||
const historyOptions = [
|
||||
{ value: '10s', label: '10 seconds' },
|
||||
{ value: '30s', label: '30 seconds' },
|
||||
{ value: '1m', label: '1 minute' },
|
||||
{ value: '2m', label: '2 minutes' },
|
||||
{ value: '5m', label: '5 minutes' },
|
||||
];
|
||||
|
||||
const currentHistoryConfig = computed(() =>
|
||||
historyConfigs[historyDuration.value] || historyConfigs['30s']
|
||||
);
|
||||
|
||||
const { result: cpuInfoResult } = useQuery(GET_CPU_INFO);
|
||||
const { result: cpuMetricsResult } = useSubscription(CPU_METRICS_SUBSCRIPTION);
|
||||
|
||||
const cpuInfo = computed(() => cpuInfoResult.value?.info?.cpu);
|
||||
const cpuMetrics = computed(() => cpuMetricsResult.value?.systemMetricsCpu);
|
||||
|
||||
const cpuBrand = computed(() => {
|
||||
if (!cpuInfo.value) return 'Loading...';
|
||||
const brand = cpuInfo.value.brand || cpuInfo.value.model || 'Unknown CPU';
|
||||
return brand;
|
||||
});
|
||||
|
||||
const overallLoad = computed(() => {
|
||||
if (!cpuMetrics.value) return 0;
|
||||
return Math.floor(cpuMetrics.value.percentTotal);
|
||||
});
|
||||
|
||||
const cpuCores = computed(() => {
|
||||
if (!cpuMetrics.value?.cpus) return [];
|
||||
return cpuMetrics.value.cpus.map((cpu, index) => ({
|
||||
index: index * 2, // Assuming HT, so multiply by 2
|
||||
htIndex: index * 2 + 1,
|
||||
percent: Math.floor(cpu.percentTotal),
|
||||
percentUser: Math.floor(cpu.percentUser),
|
||||
percentSystem: Math.floor(cpu.percentSystem),
|
||||
}));
|
||||
});
|
||||
|
||||
// Keep chart data simple - just the last 60 data points
|
||||
const chartDataRef = shallowRef<ChartData<'line'>>({
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'CPU Usage %',
|
||||
data: [],
|
||||
borderColor: '#ff8c2e',
|
||||
backgroundColor: 'rgba(255, 140, 46, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
}]
|
||||
});
|
||||
|
||||
// Update chart data without triggering computed re-evaluation
|
||||
const updateChartData = () => {
|
||||
// Create simple numeric labels (no timestamps, just indices)
|
||||
const labels = Array.from({ length: cpuHistory.value.length }, (_, i) => '');
|
||||
|
||||
// Update the ref value directly
|
||||
chartDataRef.value = {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'CPU Usage %',
|
||||
data: [...cpuHistory.value], // Clone to prevent reactivity issues
|
||||
borderColor: '#ff8c2e',
|
||||
backgroundColor: 'rgba(255, 140, 46, 0.1)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0, // Disable hover points for performance
|
||||
}]
|
||||
};
|
||||
};
|
||||
|
||||
const chartData = computed(() => chartDataRef.value);
|
||||
|
||||
const chartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: 0 // Disable all animations for performance
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false // Disable tooltips for performance
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
display: false // Hide x-axis completely for performance
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
grid: {
|
||||
color: 'rgb(229, 231, 235)'
|
||||
},
|
||||
ticks: {
|
||||
stepSize: 25,
|
||||
color: 'rgb(107, 114, 128)',
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
callback: (value) => `${value}%`
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let updateInterval: NodeJS.Timeout | null = null;
|
||||
let tickInterval: NodeJS.Timeout | null = null;
|
||||
let lastKnownValue = 0;
|
||||
|
||||
// Update with actual data from subscription
|
||||
const updateFromMetrics = () => {
|
||||
if (cpuMetrics.value) {
|
||||
lastKnownValue = Math.floor(cpuMetrics.value.percentTotal);
|
||||
}
|
||||
};
|
||||
|
||||
// Tick the chart forward with the last known value
|
||||
const tick = () => {
|
||||
// Always push a value (either new or repeated last known)
|
||||
cpuHistory.value.push(lastKnownValue);
|
||||
|
||||
// Keep only the configured number of data points
|
||||
if (cpuHistory.value.length > currentHistoryConfig.value.points) {
|
||||
cpuHistory.value.shift();
|
||||
}
|
||||
|
||||
// Update chart data
|
||||
updateChartData();
|
||||
};
|
||||
|
||||
// Watch for actual metric changes
|
||||
watch(cpuMetrics, updateFromMetrics, { immediate: true });
|
||||
|
||||
// Restart ticker when duration changes
|
||||
const restartTicker = () => {
|
||||
if (tickInterval) {
|
||||
clearInterval(tickInterval);
|
||||
}
|
||||
|
||||
// Clear history when changing duration for clean transition
|
||||
cpuHistory.value = [];
|
||||
|
||||
// Start new ticker with appropriate interval
|
||||
tickInterval = setInterval(tick, currentHistoryConfig.value.interval);
|
||||
};
|
||||
|
||||
watch(historyDuration, restartTicker);
|
||||
|
||||
onMounted(() => {
|
||||
// Start ticker with initial interval
|
||||
tickInterval = setInterval(tick, currentHistoryConfig.value.interval);
|
||||
tick(); // Initial tick
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (updateInterval) {
|
||||
clearInterval(updateInterval);
|
||||
}
|
||||
if (tickInterval) {
|
||||
clearInterval(tickInterval);
|
||||
}
|
||||
});
|
||||
|
||||
const toggleDetails = () => {
|
||||
showDetails.value = !showDetails.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background rounded-md border-2 border-muted shadow-md p-4">
|
||||
<div class="space-y-4">
|
||||
<!-- Header Section -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-foreground">Processor</h3>
|
||||
<div class="text-sm text-muted-foreground mt-1">{{ cpuBrand }}</div>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div class="text-sm">
|
||||
<span class="text-foreground">Overall Load: </span>
|
||||
<span class="font-semibold" style="color: var(--color-orange, #ff8c2f)">{{ overallLoad }}%</span>
|
||||
</div>
|
||||
<Button
|
||||
@click="toggleDetails"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{{ showDetails ? 'Hide details' : 'Show details' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CPU Cores Details -->
|
||||
<Transition name="slide-fade">
|
||||
<div v-if="showDetails" class="bg-muted/30 rounded-md p-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<div
|
||||
v-for="core in cpuCores"
|
||||
:key="core.index"
|
||||
class="bg-background rounded border border-border p-2"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
CPU {{ core.index }} - HT {{ core.htIndex }}
|
||||
</div>
|
||||
<div class="text-sm font-semibold mt-1" style="color: var(--color-orange, #ff8c2f)">{{ core.percent }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Chart Section -->
|
||||
<div class="border-t border-border pt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-sm font-semibold text-foreground">CPU Usage</h4>
|
||||
<Select
|
||||
v-model="historyDuration"
|
||||
:items="historyOptions"
|
||||
placeholder="Duration"
|
||||
class="w-32"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="chartData.labels && chartData.labels.length > 0" class="h-40">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
<div v-else class="h-40 flex items-center justify-center text-muted-foreground text-sm">
|
||||
Collecting data...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Smooth transition for details panel */
|
||||
.slide-fade-enter-active,
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-fade-leave-to {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +0,0 @@
|
||||
import { graphql } from '~/composables/gql/gql';
|
||||
|
||||
export const GET_CPU_INFO = graphql(/* GraphQL */ `
|
||||
query GetCpuInfo {
|
||||
info {
|
||||
cpu {
|
||||
id
|
||||
manufacturer
|
||||
brand
|
||||
vendor
|
||||
family
|
||||
model
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const CPU_METRICS_SUBSCRIPTION = graphql(/* GraphQL */ `
|
||||
subscription CpuMetrics {
|
||||
systemMetricsCpu {
|
||||
id
|
||||
percentTotal
|
||||
cpus {
|
||||
percentTotal
|
||||
percentUser
|
||||
percentSystem
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { useLazyQuery } from '@vue/apollo-composable';
|
||||
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
@@ -34,11 +34,15 @@ import { useUpdateOsActionsStore } from '~/store/updateOsActions';
|
||||
const { t } = useI18n();
|
||||
const { copyWithNotification } = useClipboardWithToast();
|
||||
|
||||
// Defer logo cleanup to avoid blocking mount
|
||||
onMounted(() => {
|
||||
const logoWrapper = document.querySelector('.logo');
|
||||
logoWrapper?.classList.remove('logo');
|
||||
nextTick(() => {
|
||||
const logoWrapper = document.querySelector('.logo');
|
||||
logoWrapper?.classList.remove('logo');
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize all stores - they're needed for the UI
|
||||
const serverStore = useServerStore();
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
const updateOsActionsStore = useUpdateOsActionsStore();
|
||||
@@ -48,10 +52,19 @@ const { osVersion, rebootType, stateDataError } = storeToRefs(serverStore);
|
||||
const { available, availableWithRenewal } = storeToRefs(updateOsStore);
|
||||
const { rebootTypeText } = storeToRefs(updateOsActionsStore);
|
||||
|
||||
// Query for version information
|
||||
const { result: versionsResult } = useQuery(INFO_VERSIONS_QUERY, null, {
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
// Use lazy query and only load when dropdown is opened
|
||||
const { load: loadVersions, result: versionsResult } = useLazyQuery(INFO_VERSIONS_QUERY);
|
||||
|
||||
// Track if we've loaded the versions yet
|
||||
const hasLoadedVersions = ref(false);
|
||||
|
||||
// Load version data only when dropdown is opened
|
||||
const handleDropdownOpen = (open: boolean) => {
|
||||
if (open && !hasLoadedVersions.value) {
|
||||
hasLoadedVersions.value = true;
|
||||
loadVersions();
|
||||
}
|
||||
};
|
||||
|
||||
// Use versions endpoint as primary source, fallback to store
|
||||
const displayOsVersion = computed(
|
||||
@@ -174,7 +187,7 @@ const updateOsStatus = computed(() => {
|
||||
</a>
|
||||
|
||||
<div class="mt-2 flex flex-wrap justify-start gap-2">
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuRoot @update:open="handleDropdownOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="link"
|
||||
|
||||
@@ -26,7 +26,7 @@ const changelogModalVisible = computed(() => updateOsStore.changelogModalVisible
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="modals" ref="modals" class="relative z-99999">
|
||||
<div id="modals" ref="modals" class="relative z-[999999]">
|
||||
<UpcCallbackFeedback :t="t" :open="callbackStatus !== 'ready'" />
|
||||
<UpcTrial :t="t" :open="trialModalVisible" />
|
||||
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />
|
||||
|
||||
@@ -333,19 +333,22 @@ const showUpdateEligibility = computed(() => {
|
||||
</div>
|
||||
|
||||
<template v-if="updateOsStatus === 'confirming' && !stateDataError">
|
||||
<div class="my-4 flex flex-col gap-y-2 text-center">
|
||||
<div class="my-4 flex flex-col gap-y-2">
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<p class="text-lg">
|
||||
<p class="text-center text-lg">
|
||||
{{ t('Current Version: Unraid {0}', [osVersion]) }}
|
||||
</p>
|
||||
|
||||
<ChevronDoubleDownIcon class="mx-auto h-8 w-8 animate-pulse fill-current opacity-50" />
|
||||
|
||||
<p class="text-lg">
|
||||
<p class="text-center text-lg">
|
||||
{{ t('New Version: {0}', [callbackUpdateRelease?.name]) }}
|
||||
</p>
|
||||
|
||||
<p v-if="!callbackUpdateRelease?.version?.includes('+')" class="text-sm italic opacity-75">
|
||||
<p
|
||||
v-if="!callbackUpdateRelease?.version?.includes('+')"
|
||||
class="text-center text-sm italic opacity-75"
|
||||
>
|
||||
{{
|
||||
callbackTypeDowngrade
|
||||
? t('This downgrade will require a reboot')
|
||||
|
||||
@@ -13,14 +13,14 @@ import UpcUptimeExpire from '~/components/UserProfile/UptimeExpire.vue';
|
||||
cn(
|
||||
'text-header-text-secondary leading-tight font-semibold',
|
||||
'flex flex-col items-end justify-end gap-y-0.5',
|
||||
'xs:flex-row xs:items-baseline xs:gap-x-2 xs:gap-y-0',
|
||||
'xs:!flex-row xs:items-baseline xs:gap-x-2 xs:gap-y-0',
|
||||
'text-xs',
|
||||
$attrs.class as ClassValue
|
||||
)
|
||||
"
|
||||
>
|
||||
<UpcUptimeExpire :as="'span'" :short-text="true" class="text-xs" />
|
||||
<span class="xs:inline hidden">•</span>
|
||||
<span class="xs:!inline hidden">•</span>
|
||||
<UpcServerState class="text-xs" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,27 +3,7 @@
|
||||
|
||||
import { provideApolloClient } from '@vue/apollo-composable';
|
||||
|
||||
// Copy the ensureTeleportContainer function to avoid importing from @unraid/ui
|
||||
// which causes ESM/CommonJS issues with ajv-errors
|
||||
function ensureTeleportContainer(): HTMLElement {
|
||||
const containerId = 'unraid-teleport-container';
|
||||
let container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = containerId;
|
||||
container.style.position = 'relative';
|
||||
container.classList.add('unapi');
|
||||
container.style.zIndex = '999999';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
import {
|
||||
autoMountAllComponents,
|
||||
autoMountComponent,
|
||||
getMountedApp,
|
||||
mountVueApp,
|
||||
} from '@/components/Wrapper/mount-engine';
|
||||
import { autoMountAllComponents } from '@/components/Wrapper/mount-engine';
|
||||
import { client as apolloClient } from '~/helpers/create-apollo-client';
|
||||
import { parse } from 'graphql';
|
||||
|
||||
@@ -36,10 +16,6 @@ function initializeGlobalDependencies() {
|
||||
// Provide Apollo client globally for all components
|
||||
provideApolloClient(apolloClient);
|
||||
|
||||
// Pre-create the teleport container to avoid mounting issues
|
||||
// This ensures the container exists before any components try to teleport to it
|
||||
ensureTeleportContainer();
|
||||
|
||||
// Initialize theme once per page load
|
||||
// This loads theme from GraphQL and applies Tailwind v4 classes
|
||||
initializeTheme().catch((error: unknown) => {
|
||||
@@ -47,10 +23,6 @@ function initializeGlobalDependencies() {
|
||||
});
|
||||
|
||||
// Expose utility functions on window for debugging/external use
|
||||
window.mountVueApp = mountVueApp;
|
||||
window.getMountedApp = getMountedApp;
|
||||
window.autoMountComponent = autoMountComponent;
|
||||
|
||||
// Expose Apollo client on window for global access
|
||||
window.apolloClient = apolloClient;
|
||||
|
||||
|
||||
@@ -2,160 +2,148 @@
|
||||
// This module defines all web components and their mappings
|
||||
// Actual mounting is handled by mount-engine.ts
|
||||
|
||||
import type { Component } from 'vue';
|
||||
|
||||
// Import CSS for bundling - this ensures Tailwind styles are included
|
||||
import '~/assets/main.css';
|
||||
// Import @unraid/ui styles which includes vue-sonner styles
|
||||
import '@unraid/ui/styles';
|
||||
|
||||
// Static imports for critical components that are always present
|
||||
// These are included in the main bundle for faster initial render
|
||||
import HeaderOsVersionCe from '@/components/HeaderOsVersion.standalone.vue';
|
||||
import ModalsCe from '@/components/Modals.standalone.vue';
|
||||
import ThemeSwitcherCe from '@/components/ThemeSwitcher.standalone.vue';
|
||||
import UnraidToaster from '@/components/UnraidToaster.vue';
|
||||
import UserProfileCe from '@/components/UserProfile.standalone.vue';
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
// Type for Vue component module
|
||||
type VueComponentModule = { default: object } | object;
|
||||
import type { Component } from 'vue';
|
||||
|
||||
// Type for component mappings
|
||||
export type ComponentMapping = {
|
||||
selector: string | string[]; // Can be a single selector or array of selector aliases
|
||||
appId: string;
|
||||
} & (
|
||||
| { component: Component } // Static import
|
||||
| { loader: () => Promise<VueComponentModule> } // Dynamic import
|
||||
);
|
||||
component: Component; // The async component
|
||||
};
|
||||
|
||||
// Define component mappings
|
||||
// Critical components use static imports (already loaded)
|
||||
// Page-specific components use dynamic imports (lazy loaded)
|
||||
// Define component mappings - all components use async loading for consistency
|
||||
// Priority components (header, user profile) are listed first for faster mounting
|
||||
export const componentMappings: ComponentMapping[] = [
|
||||
{
|
||||
loader: () => import('../Auth.standalone.vue'),
|
||||
selector: 'unraid-auth',
|
||||
appId: 'auth',
|
||||
},
|
||||
{
|
||||
loader: () => import('../ConnectSettings/ConnectSettings.standalone.vue'),
|
||||
selector: 'unraid-connect-settings',
|
||||
appId: 'connect-settings',
|
||||
},
|
||||
{
|
||||
loader: () => import('../DownloadApiLogs.standalone.vue'),
|
||||
selector: 'unraid-download-api-logs',
|
||||
appId: 'download-api-logs',
|
||||
},
|
||||
{
|
||||
component: HeaderOsVersionCe, // Static import - always present in header
|
||||
component: defineAsyncComponent(() => import('@/components/HeaderOsVersion.standalone.vue')),
|
||||
selector: 'unraid-header-os-version',
|
||||
appId: 'header-os-version',
|
||||
},
|
||||
{
|
||||
component: ModalsCe, // Static import - global modals
|
||||
selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors
|
||||
appId: 'modals',
|
||||
},
|
||||
{
|
||||
component: UserProfileCe, // Static import - always present in header
|
||||
component: defineAsyncComponent(() => import('@/components/UserProfile.standalone.vue')),
|
||||
selector: 'unraid-user-profile',
|
||||
appId: 'user-profile',
|
||||
},
|
||||
{
|
||||
loader: () => import('../Registration.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../Auth.standalone.vue')),
|
||||
selector: 'unraid-auth',
|
||||
appId: 'auth',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('../ConnectSettings/ConnectSettings.standalone.vue')),
|
||||
selector: 'unraid-connect-settings',
|
||||
appId: 'connect-settings',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('../DownloadApiLogs.standalone.vue')),
|
||||
selector: 'unraid-download-api-logs',
|
||||
appId: 'download-api-logs',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('@/components/Modals.standalone.vue')),
|
||||
selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors
|
||||
appId: 'modals',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('../Registration.standalone.vue')),
|
||||
selector: 'unraid-registration',
|
||||
appId: 'registration',
|
||||
},
|
||||
{
|
||||
loader: () => import('../WanIpCheck.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../WanIpCheck.standalone.vue')),
|
||||
selector: 'unraid-wan-ip-check',
|
||||
appId: 'wan-ip-check',
|
||||
},
|
||||
{
|
||||
loader: () => import('../CallbackHandler.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../CallbackHandler.standalone.vue')),
|
||||
selector: 'unraid-callback-handler',
|
||||
appId: 'callback-handler',
|
||||
},
|
||||
{
|
||||
loader: () => import('../Logs/LogViewer.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../Logs/LogViewer.standalone.vue')),
|
||||
selector: 'unraid-log-viewer',
|
||||
appId: 'log-viewer',
|
||||
},
|
||||
{
|
||||
loader: () => import('../SsoButton.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../SsoButton.standalone.vue')),
|
||||
selector: 'unraid-sso-button',
|
||||
appId: 'sso-button',
|
||||
},
|
||||
{
|
||||
loader: () => import('../Activation/WelcomeModal.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../Activation/WelcomeModal.standalone.vue')),
|
||||
selector: 'unraid-welcome-modal',
|
||||
appId: 'welcome-modal',
|
||||
},
|
||||
{
|
||||
loader: () => import('../UpdateOs.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../UpdateOs.standalone.vue')),
|
||||
selector: 'unraid-update-os',
|
||||
appId: 'update-os',
|
||||
},
|
||||
{
|
||||
loader: () => import('../DowngradeOs.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../DowngradeOs.standalone.vue')),
|
||||
selector: 'unraid-downgrade-os',
|
||||
appId: 'downgrade-os',
|
||||
},
|
||||
{
|
||||
loader: () => import('../DevSettings.vue'),
|
||||
component: defineAsyncComponent(() => import('../DevSettings.vue')),
|
||||
selector: 'unraid-dev-settings',
|
||||
appId: 'dev-settings',
|
||||
},
|
||||
{
|
||||
loader: () => import('../ApiKeyPage.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../ApiKeyPage.standalone.vue')),
|
||||
selector: ['unraid-apikey-page', 'unraid-api-key-manager'],
|
||||
appId: 'apikey-page',
|
||||
},
|
||||
{
|
||||
loader: () => import('../ApiKeyAuthorize.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../ApiKeyAuthorize.standalone.vue')),
|
||||
selector: 'unraid-apikey-authorize',
|
||||
appId: 'apikey-authorize',
|
||||
},
|
||||
{
|
||||
loader: () => import('../DevModalTest.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../DevModalTest.standalone.vue')),
|
||||
selector: 'unraid-dev-modal-test',
|
||||
appId: 'dev-modal-test',
|
||||
},
|
||||
{
|
||||
loader: () => import('../LayoutViews/Detail/DetailTest.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../LayoutViews/Detail/DetailTest.standalone.vue')),
|
||||
selector: 'unraid-detail-test',
|
||||
appId: 'detail-test',
|
||||
},
|
||||
{
|
||||
component: ThemeSwitcherCe, // Static import - theme switcher
|
||||
component: defineAsyncComponent(() => import('@/components/ThemeSwitcher.standalone.vue')),
|
||||
selector: 'unraid-theme-switcher',
|
||||
appId: 'theme-switcher',
|
||||
},
|
||||
{
|
||||
loader: () => import('../ColorSwitcher.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../ColorSwitcher.standalone.vue')),
|
||||
selector: 'unraid-color-switcher',
|
||||
appId: 'color-switcher',
|
||||
},
|
||||
{
|
||||
component: UnraidToaster, // Static import - toaster styles need to be in main bundle
|
||||
component: defineAsyncComponent(() => import('@/components/UnraidToaster.vue')),
|
||||
selector: ['unraid-toaster', 'uui-toaster'],
|
||||
appId: 'toaster',
|
||||
},
|
||||
{
|
||||
loader: () => import('../UpdateOs/TestUpdateModal.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../UpdateOs/TestUpdateModal.standalone.vue')),
|
||||
selector: 'unraid-test-update-modal',
|
||||
appId: 'test-update-modal',
|
||||
},
|
||||
{
|
||||
loader: () => import('../TestThemeSwitcher.standalone.vue'),
|
||||
component: defineAsyncComponent(() => import('../TestThemeSwitcher.standalone.vue')),
|
||||
selector: 'unraid-test-theme-switcher',
|
||||
appId: 'test-theme-switcher',
|
||||
},
|
||||
{
|
||||
loader: () => import('../CpuStats/CpuStats.standalone.vue'),
|
||||
selector: 'unraid-cpu-stats',
|
||||
appId: 'cpu-stats',
|
||||
component: defineAsyncComponent(() => import('../ApiStatus/ApiStatus.standalone.vue')),
|
||||
selector: 'unraid-api-status-manager',
|
||||
appId: 'api-status-manager',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,51 +1,30 @@
|
||||
import { createApp, defineAsyncComponent, h } from 'vue';
|
||||
import { createApp, createVNode, h, render } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { DefaultApolloClient } from '@vue/apollo-composable';
|
||||
import UApp from '@nuxt/ui/components/App.vue';
|
||||
import ui from '@nuxt/ui/vue-plugin';
|
||||
|
||||
import { ensureTeleportContainer } from '@unraid/ui';
|
||||
// Import component registry (only imported here to avoid ordering issues)
|
||||
import { componentMappings } from '@/components/Wrapper/component-registry';
|
||||
import { client } from '~/helpers/create-apollo-client';
|
||||
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
|
||||
import en_US from '~/locales/en_US.json';
|
||||
|
||||
import type { Component, App as VueApp } from 'vue';
|
||||
|
||||
// Import Pinia for use in Vue apps
|
||||
import { globalPinia } from '~/store/globalPinia';
|
||||
|
||||
// Ensure Apollo client is singleton
|
||||
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
|
||||
|
||||
// Global store for mounted apps
|
||||
const mountedApps = new Map<string, VueApp>();
|
||||
const mountedAppClones = new Map<string, VueApp[]>();
|
||||
const mountedAppContainers = new Map<string, HTMLElement[]>();
|
||||
|
||||
// Registry to track selector aliases - maps each selector to its canonical appId
|
||||
const selectorRegistry = new Map<string, string>(); // shadow-root containers for cleanup
|
||||
|
||||
// Extend HTMLElement to include Vue's internal properties
|
||||
interface HTMLElementWithVue extends HTMLElement {
|
||||
__vueParentComponent?: {
|
||||
appContext?: {
|
||||
app?: VueApp;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Expose globally for debugging
|
||||
declare global {
|
||||
interface Window {
|
||||
mountedApps: Map<string, VueApp>;
|
||||
globalPinia: typeof globalPinia;
|
||||
LOCALE_DATA?: string;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.mountedApps = mountedApps;
|
||||
window.globalPinia = globalPinia;
|
||||
}
|
||||
|
||||
@@ -57,7 +36,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));
|
||||
@@ -81,31 +60,28 @@ function setupI18n() {
|
||||
});
|
||||
}
|
||||
|
||||
export interface MountOptions {
|
||||
component: Component;
|
||||
selector: string | string[]; // Can be a single selector or array of selector aliases
|
||||
appId?: string;
|
||||
useShadowRoot?: boolean;
|
||||
props?: Record<string, unknown>;
|
||||
skipRecovery?: boolean; // Internal flag to prevent recursive recovery attempts
|
||||
waitForElement?: boolean; // If true, poll for element existence before mounting
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -127,565 +103,123 @@ function parsePropsFromElement(element: Element): Record<string, unknown> {
|
||||
return props;
|
||||
}
|
||||
|
||||
export function mountVueApp(options: MountOptions): VueApp | null {
|
||||
const {
|
||||
component,
|
||||
selector,
|
||||
appId,
|
||||
useShadowRoot = false,
|
||||
props = {},
|
||||
skipRecovery = false,
|
||||
waitForElement = false,
|
||||
} = options;
|
||||
|
||||
// Normalize selector to array
|
||||
const selectors = Array.isArray(selector) ? selector : [selector];
|
||||
|
||||
// Generate appId from first selector if not provided
|
||||
const canonicalAppId = appId || selectors[0];
|
||||
|
||||
// Check if any of the selectors are already registered (singleton check)
|
||||
for (const sel of selectors) {
|
||||
if (selectorRegistry.has(sel)) {
|
||||
const existingAppId = selectorRegistry.get(sel)!;
|
||||
if (mountedApps.has(existingAppId)) {
|
||||
console.debug(
|
||||
`[VueMountApp] Component already mounted as ${existingAppId} for selector ${sel}, returning existing instance`
|
||||
);
|
||||
return mountedApps.get(existingAppId)!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if app is already mounted by its ID
|
||||
if (mountedApps.has(canonicalAppId)) {
|
||||
console.warn(`[VueMountApp] App ${canonicalAppId} is already mounted`);
|
||||
return mountedApps.get(canonicalAppId)!;
|
||||
}
|
||||
|
||||
// If waitForElement is true, poll for element existence
|
||||
if (waitForElement) {
|
||||
const tryMount = () => {
|
||||
// Check if any of the selectors have elements
|
||||
for (const sel of selectors) {
|
||||
const elements = document.querySelectorAll(sel);
|
||||
if (elements.length > 0) {
|
||||
try {
|
||||
// Element found, mount immediately with this selector
|
||||
mountVueApp({ ...options, selector: sel, waitForElement: false });
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Failed to mount ${appId || sel} during async mount:`, error);
|
||||
// Don't retry this component to avoid infinite loops
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No elements found, try again later
|
||||
setTimeout(tryMount, 100);
|
||||
};
|
||||
|
||||
// Start polling when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', tryMount);
|
||||
} else {
|
||||
tryMount();
|
||||
}
|
||||
return null; // Return null for async mounting
|
||||
}
|
||||
|
||||
// Find the first selector that has elements in the DOM
|
||||
let activeSelector: string | null = null;
|
||||
for (const sel of selectors) {
|
||||
if (document.querySelector(sel)) {
|
||||
activeSelector = sel;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeSelector) {
|
||||
console.warn(`[VueMountApp] No elements found for any selector: ${selectors.join(', ')}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Register all selectors as aliases for this app
|
||||
for (const sel of selectors) {
|
||||
selectorRegistry.set(sel, canonicalAppId);
|
||||
}
|
||||
|
||||
// Check if any elements matching the selector already have Vue apps mounted
|
||||
const potentialTargets = document.querySelectorAll(activeSelector);
|
||||
for (const target of potentialTargets) {
|
||||
const element = target as HTMLElementWithVue;
|
||||
const hasVueAttributes =
|
||||
element.hasAttribute('data-vue-mounted') ||
|
||||
element.hasAttribute('data-v-app') ||
|
||||
element.hasAttribute('data-server-rendered');
|
||||
|
||||
if (hasVueAttributes || element.__vueParentComponent) {
|
||||
// Check if the existing Vue component is actually working (has content)
|
||||
const hasContent = element.innerHTML.trim().length > 0 || element.children.length > 0;
|
||||
|
||||
if (hasContent) {
|
||||
console.info(
|
||||
`[VueMountApp] Element ${selector} already has working Vue component, skipping remount`
|
||||
);
|
||||
// Return the existing app if we can find it
|
||||
const existingApp = mountedApps.get(canonicalAppId);
|
||||
if (existingApp) {
|
||||
return existingApp;
|
||||
}
|
||||
// If we can't find the app reference but component is working, return null (success)
|
||||
return null;
|
||||
}
|
||||
|
||||
console.warn(`[VueMountApp] Element ${selector} has Vue attributes but no content, cleaning up`);
|
||||
|
||||
try {
|
||||
// DO NOT attempt to unmount existing Vue instances - this causes the nextSibling error
|
||||
// Instead, just clear the DOM state and let Vue handle the cleanup naturally
|
||||
|
||||
// Remove all Vue-related attributes
|
||||
element.removeAttribute('data-vue-mounted');
|
||||
element.removeAttribute('data-v-app');
|
||||
element.removeAttribute('data-server-rendered');
|
||||
|
||||
// Remove any Vue-injected attributes
|
||||
Array.from(element.attributes).forEach((attr) => {
|
||||
if (attr.name.startsWith('data-v-')) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear the element content to ensure fresh state
|
||||
element.innerHTML = '';
|
||||
|
||||
// Remove the __vueParentComponent reference without calling unmount
|
||||
delete element.__vueParentComponent;
|
||||
|
||||
console.info(
|
||||
`[VueMountApp] Cleared Vue state from ${activeSelector} without unmounting (prevents nextSibling errors)`
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error cleaning up existing Vue instance:`, error);
|
||||
// Force clear everything if normal cleanup fails
|
||||
element.innerHTML = '';
|
||||
element.removeAttribute('data-vue-mounted');
|
||||
element.removeAttribute('data-v-app');
|
||||
element.removeAttribute('data-server-rendered');
|
||||
|
||||
// Remove all data-v-* attributes
|
||||
Array.from(element.attributes).forEach((attr) => {
|
||||
if (attr.name.startsWith('data-v-')) {
|
||||
element.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all mount targets
|
||||
const targets = document.querySelectorAll(activeSelector);
|
||||
if (targets.length === 0) {
|
||||
console.warn(`[VueMountApp] No elements found for selector: ${activeSelector}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure teleport container exists before mounting
|
||||
ensureTeleportContainer();
|
||||
|
||||
// For the first target, parse props from HTML attributes
|
||||
const firstTarget = targets[0];
|
||||
const parsedProps = { ...parsePropsFromElement(firstTarget), ...props };
|
||||
|
||||
// Create the Vue app wrapped with UApp for proper Nuxt UI functionality
|
||||
// Create and mount unified app with shared context
|
||||
export function mountUnifiedApp() {
|
||||
// Create a minimal app just for context sharing
|
||||
const app = createApp({
|
||||
name: 'StandaloneAppWrapper',
|
||||
setup() {
|
||||
// Delay component creation until setup to ensure app context is ready
|
||||
return () =>
|
||||
h(
|
||||
UApp,
|
||||
{},
|
||||
{
|
||||
default: () => h(component, parsedProps),
|
||||
}
|
||||
);
|
||||
},
|
||||
name: 'UnifiedContextApp',
|
||||
render: () => h('div', 'Context Provider'),
|
||||
});
|
||||
|
||||
// Setup i18n
|
||||
// Setup everything once
|
||||
const i18n = setupI18n();
|
||||
app.use(i18n);
|
||||
|
||||
// Use the shared Pinia instance - this makes it available in the app context
|
||||
app.use(globalPinia);
|
||||
|
||||
// Nuxt UI plugin
|
||||
app.use(ui);
|
||||
|
||||
// Provide Apollo client
|
||||
app.provide(DefaultApolloClient, apolloClient);
|
||||
|
||||
// UI config removed - not available
|
||||
// Mount the app to establish context
|
||||
let rootElement = document.getElementById('unraid-unified-root');
|
||||
if (!rootElement) {
|
||||
rootElement = document.createElement('div');
|
||||
rootElement.id = 'unraid-unified-root';
|
||||
rootElement.style.display = 'none';
|
||||
document.body.appendChild(rootElement);
|
||||
}
|
||||
app.mount(rootElement);
|
||||
|
||||
// Mount to all targets
|
||||
const clones: VueApp[] = [];
|
||||
const containers: HTMLElement[] = [];
|
||||
targets.forEach((target, index) => {
|
||||
const mountTarget = target as HTMLElement;
|
||||
// Now render components to their locations using the shared context
|
||||
const mountedComponents: Array<{ element: HTMLElement; unmount: () => void }> = [];
|
||||
|
||||
// Comprehensive DOM validation
|
||||
if (!mountTarget.isConnected || !mountTarget.parentNode || !document.contains(mountTarget)) {
|
||||
console.warn(`[VueMountApp] Mount target not properly connected to DOM for ${appId}, skipping`);
|
||||
// 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 selectors = Array.isArray(mapping.selector) ? mapping.selector : [mapping.selector];
|
||||
selectors.forEach((sel) => selectorToMapping.set(sel, mapping));
|
||||
});
|
||||
|
||||
// Query all selectors at once
|
||||
const allSelectors = Array.from(selectorToMapping.keys()).join(',');
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Special handling for PHP-generated pages that might have whitespace/comment nodes
|
||||
if (mountTarget.childNodes.length > 0) {
|
||||
let hasProblematicNodes = false;
|
||||
const nodesToRemove: Node[] = [];
|
||||
// Parse props from element
|
||||
const props = parsePropsFromElement(element);
|
||||
|
||||
Array.from(mountTarget.childNodes).forEach((node) => {
|
||||
// Check for orphaned nodes
|
||||
if (node.parentNode !== mountTarget) {
|
||||
hasProblematicNodes = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for empty text nodes or comments that could cause fragment issues
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim() === '') {
|
||||
nodesToRemove.push(node);
|
||||
hasProblematicNodes = true;
|
||||
} else if (node.nodeType === Node.COMMENT_NODE) {
|
||||
nodesToRemove.push(node);
|
||||
hasProblematicNodes = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasProblematicNodes) {
|
||||
console.warn(`[VueMountApp] Cleaning up problematic nodes in ${selector} before mounting`);
|
||||
|
||||
// Remove problematic nodes
|
||||
nodesToRemove.forEach((node) => {
|
||||
try {
|
||||
if (node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
// Wrap component in UApp for Nuxt UI support
|
||||
const wrappedComponent = {
|
||||
name: `${appId}-wrapper`,
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
UApp,
|
||||
{},
|
||||
{
|
||||
default: () => h(component, props),
|
||||
}
|
||||
} catch (_e) {
|
||||
// If removal fails, clear the entire content
|
||||
mountTarget.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// If we still have orphaned nodes after cleanup, clear everything
|
||||
const remainingInvalidChildren = Array.from(mountTarget.childNodes).filter((node) => {
|
||||
return node.parentNode !== mountTarget;
|
||||
});
|
||||
|
||||
if (remainingInvalidChildren.length > 0) {
|
||||
console.warn(
|
||||
`[VueMountApp] Clearing all content due to remaining orphaned nodes in ${selector}`
|
||||
);
|
||||
mountTarget.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Add unapi class for minimal styling and mark as mounted
|
||||
mountTarget.classList.add('unapi');
|
||||
mountTarget.setAttribute('data-vue-mounted', 'true');
|
||||
// Create vnode with shared app context
|
||||
const vnode = createVNode(wrappedComponent);
|
||||
vnode.appContext = app._context; // Share the app context
|
||||
|
||||
if (useShadowRoot) {
|
||||
// Create shadow root if needed
|
||||
if (!mountTarget.shadowRoot) {
|
||||
mountTarget.attachShadow({ mode: 'open' });
|
||||
}
|
||||
// Clear the element and render the component into it
|
||||
element.replaceChildren();
|
||||
render(vnode, element);
|
||||
|
||||
// Create mount container in shadow root
|
||||
const container = document.createElement('div');
|
||||
container.id = 'app';
|
||||
container.setAttribute('data-app-id', canonicalAppId);
|
||||
mountTarget.shadowRoot!.appendChild(container);
|
||||
containers.push(container);
|
||||
// Mark as mounted
|
||||
element.setAttribute('data-vue-mounted', 'true');
|
||||
element.classList.add('unapi');
|
||||
|
||||
// For the first target, use the main app, otherwise create clones
|
||||
if (index === 0) {
|
||||
try {
|
||||
app.mount(container);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting main app to shadow root ${selector}:`, error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
||||
const clonedApp = createApp({
|
||||
name: 'StandaloneAppWrapperClone',
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
UApp,
|
||||
{},
|
||||
{
|
||||
default: () => h(component, targetProps),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
clonedApp.use(i18n);
|
||||
clonedApp.use(globalPinia);
|
||||
clonedApp.use(ui);
|
||||
clonedApp.provide(DefaultApolloClient, apolloClient);
|
||||
|
||||
try {
|
||||
clonedApp.mount(container);
|
||||
clones.push(clonedApp);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting cloned app to shadow root ${selector}:`, error);
|
||||
// Don't call unmount since mount failed - just let the app be garbage collected
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct mount without shadow root
|
||||
|
||||
// For multiple targets, we need to create separate app instances
|
||||
// but they'll share the same Pinia store
|
||||
if (index === 0) {
|
||||
// First target, use the main app
|
||||
try {
|
||||
// Final validation before mounting
|
||||
if (!mountTarget.isConnected || !document.contains(mountTarget)) {
|
||||
throw new Error(`Mount target disconnected before mounting: ${selector}`);
|
||||
}
|
||||
|
||||
app.mount(mountTarget);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting main app to ${selector}:`, error);
|
||||
|
||||
// Special handling for nextSibling error - attempt recovery (only if not already retrying)
|
||||
if (!skipRecovery && error instanceof TypeError && error.message.includes('nextSibling')) {
|
||||
console.warn(`[VueMountApp] Attempting recovery from nextSibling error for ${selector}`);
|
||||
|
||||
// Remove the problematic data attribute that might be causing issues
|
||||
mountTarget.removeAttribute('data-vue-mounted');
|
||||
|
||||
// Try mounting immediately
|
||||
try {
|
||||
// Ensure element is still valid
|
||||
if (mountTarget.isConnected && document.contains(mountTarget)) {
|
||||
app.mount(mountTarget);
|
||||
mountTarget.setAttribute('data-vue-mounted', 'true');
|
||||
console.info(
|
||||
`[VueMountApp] Successfully recovered from nextSibling error for ${selector}`
|
||||
);
|
||||
} else {
|
||||
console.error(`[VueMountApp] Recovery failed - element no longer in DOM: ${selector}`);
|
||||
}
|
||||
} catch (retryError) {
|
||||
console.error(`[VueMountApp] Recovery attempt failed for ${selector}:`, retryError);
|
||||
}
|
||||
|
||||
// Return without throwing to allow other elements to mount
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't throw error - just return null to allow other components to mount
|
||||
// The error has already been logged
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Additional targets, create cloned apps with their own props
|
||||
const targetProps = { ...parsePropsFromElement(mountTarget), ...props };
|
||||
const clonedApp = createApp({
|
||||
name: 'StandaloneAppWrapperClone',
|
||||
setup() {
|
||||
return () =>
|
||||
h(
|
||||
UApp,
|
||||
{},
|
||||
{
|
||||
default: () => h(component, targetProps),
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
clonedApp.use(i18n);
|
||||
clonedApp.use(globalPinia); // Shared Pinia instance
|
||||
clonedApp.use(ui);
|
||||
clonedApp.provide(DefaultApolloClient, apolloClient);
|
||||
|
||||
try {
|
||||
clonedApp.mount(mountTarget);
|
||||
clones.push(clonedApp);
|
||||
} catch (error) {
|
||||
console.error(`[VueMountApp] Error mounting cloned app to ${selector}:`, error);
|
||||
// Don't call unmount since mount failed - just let the app be garbage collected
|
||||
}
|
||||
}
|
||||
}
|
||||
// Store for cleanup
|
||||
mountedComponents.push({
|
||||
element,
|
||||
unmount: () => render(null, element),
|
||||
});
|
||||
});
|
||||
|
||||
// Store the app reference
|
||||
mountedApps.set(canonicalAppId, app);
|
||||
if (clones.length) mountedAppClones.set(canonicalAppId, clones);
|
||||
if (containers.length) mountedAppContainers.set(canonicalAppId, containers);
|
||||
console.debug(`[UnifiedMount] Mounted ${mountedComponents.length} components`);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export function unmountVueApp(appId: string): boolean {
|
||||
const app = mountedApps.get(appId);
|
||||
if (!app) {
|
||||
console.warn(`[VueMountApp] No app found with id: ${appId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up selector registry - remove all selectors that point to this appId
|
||||
for (const [selector, registeredAppId] of selectorRegistry.entries()) {
|
||||
if (registeredAppId === appId) {
|
||||
selectorRegistry.delete(selector);
|
||||
}
|
||||
}
|
||||
|
||||
// Unmount clones first with error handling
|
||||
const clones = mountedAppClones.get(appId) ?? [];
|
||||
for (const c of clones) {
|
||||
try {
|
||||
c.unmount();
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error unmounting clone for ${appId}:`, error);
|
||||
}
|
||||
}
|
||||
mountedAppClones.delete(appId);
|
||||
|
||||
// Remove shadow containers with error handling
|
||||
const containers = mountedAppContainers.get(appId) ?? [];
|
||||
for (const el of containers) {
|
||||
try {
|
||||
el.remove();
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error removing container for ${appId}:`, error);
|
||||
}
|
||||
}
|
||||
mountedAppContainers.delete(appId);
|
||||
|
||||
// Unmount main app with error handling
|
||||
try {
|
||||
app.unmount();
|
||||
|
||||
// Clean up data attributes from mounted elements
|
||||
const elements = document.querySelectorAll(`[data-vue-mounted="true"]`);
|
||||
elements.forEach((el) => {
|
||||
if (el.classList.contains('unapi')) {
|
||||
el.removeAttribute('data-vue-mounted');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`[VueMountApp] Error unmounting app ${appId}:`, error);
|
||||
}
|
||||
|
||||
mountedApps.delete(appId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getMountedApp(appId: string): VueApp | undefined {
|
||||
return mountedApps.get(appId);
|
||||
}
|
||||
|
||||
// Auto-mount function that waits for DOM elements to be available
|
||||
export function autoMountComponent(
|
||||
componentOrMapping: Component | { component?: Component; loader?: () => Promise<VueComponentModule> },
|
||||
selector: string | string[],
|
||||
options?: Partial<MountOptions>
|
||||
) {
|
||||
let component: Component;
|
||||
|
||||
// Handle different input types
|
||||
if ('component' in componentOrMapping && componentOrMapping.component) {
|
||||
// Direct component from mapping
|
||||
component = componentOrMapping.component;
|
||||
} else if ('loader' in componentOrMapping && componentOrMapping.loader) {
|
||||
// Async loader from mapping - create async component
|
||||
component = createAsyncComponent(componentOrMapping.loader);
|
||||
} else if (
|
||||
typeof componentOrMapping === 'object' &&
|
||||
!('component' in componentOrMapping) &&
|
||||
!('loader' in componentOrMapping)
|
||||
) {
|
||||
// Direct component passed
|
||||
component = componentOrMapping as Component;
|
||||
} else {
|
||||
console.error('[autoMountComponent] Invalid component or mapping provided');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to mountVueApp with waitForElement option
|
||||
mountVueApp({
|
||||
component,
|
||||
selector,
|
||||
...options,
|
||||
waitForElement: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Type for Vue component module
|
||||
type VueComponentModule = { default: object } | object;
|
||||
|
||||
// Helper to create async components with consistent error handling
|
||||
export function createAsyncComponent(loader: () => Promise<VueComponentModule>) {
|
||||
return defineAsyncComponent({
|
||||
loader: async () => {
|
||||
const module = await loader();
|
||||
return 'default' in module ? module.default : module;
|
||||
},
|
||||
loadingComponent: undefined,
|
||||
errorComponent: undefined,
|
||||
delay: 0,
|
||||
timeout: 5000, // 5 second timeout
|
||||
onError(error, _retry, fail) {
|
||||
console.error('[AsyncComponent] Failed to load component:', error);
|
||||
fail();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-mount all registered components from component-registry
|
||||
// Replace the old autoMountAllComponents with the new unified approach
|
||||
export function autoMountAllComponents() {
|
||||
console.log('[AutoMountAll] Starting auto-mount for', componentMappings.length, 'components');
|
||||
|
||||
componentMappings.forEach((mapping) => {
|
||||
const { selector, appId } = mapping;
|
||||
|
||||
// Normalize selector to array for consistent handling
|
||||
const selectors = Array.isArray(selector) ? selector : [selector];
|
||||
|
||||
// Check if any of the selectors have elements in the DOM
|
||||
const hasElements = selectors.some((sel) => {
|
||||
const found = document.querySelector(sel);
|
||||
if (found) {
|
||||
console.log(`[AutoMountAll] Found element for selector: ${sel}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Only proceed if at least one selector has elements
|
||||
if (hasElements) {
|
||||
console.log(`[AutoMountAll] Mounting component: ${appId}`);
|
||||
try {
|
||||
// Pass the mapping directly to autoMountComponent
|
||||
// Let mount-engine handle component vs loader logic
|
||||
autoMountComponent(mapping, selector, {
|
||||
appId,
|
||||
useShadowRoot: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[AutoMountAll] Failed to mount ${appId}:`, error);
|
||||
// Continue with next component
|
||||
}
|
||||
} else {
|
||||
console.log(`[AutoMountAll] No elements found for: ${selectors.join(', ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[AutoMountAll] Auto-mount complete');
|
||||
return mountUnifiedApp();
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ type Documents = {
|
||||
"\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n": typeof types.GetPermissionsForRolesDocument,
|
||||
"\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": typeof types.UnifiedDocument,
|
||||
"\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": typeof types.UpdateConnectSettingsDocument,
|
||||
"\n query GetCpuInfo {\n info {\n cpu {\n id\n manufacturer\n brand\n vendor\n family\n model\n }\n }\n }\n": typeof types.GetCpuInfoDocument,
|
||||
"\n subscription CpuMetrics {\n systemMetricsCpu {\n id\n percentTotal\n cpus {\n percentTotal\n percentUser\n percentSystem\n }\n }\n }\n": typeof types.CpuMetricsDocument,
|
||||
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument,
|
||||
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": typeof types.LogFileContentDocument,
|
||||
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": typeof types.LogFileSubscriptionDocument,
|
||||
@@ -75,8 +73,6 @@ const documents: Documents = {
|
||||
"\n query GetPermissionsForRoles($roles: [Role!]!) {\n getPermissionsForRoles(roles: $roles) {\n resource\n actions\n }\n }\n": types.GetPermissionsForRolesDocument,
|
||||
"\n query Unified {\n settings {\n unified {\n id\n dataSchema\n uiSchema\n values\n }\n }\n }\n": types.UnifiedDocument,
|
||||
"\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n": types.UpdateConnectSettingsDocument,
|
||||
"\n query GetCpuInfo {\n info {\n cpu {\n id\n manufacturer\n brand\n vendor\n family\n model\n }\n }\n }\n": types.GetCpuInfoDocument,
|
||||
"\n subscription CpuMetrics {\n systemMetricsCpu {\n id\n percentTotal\n cpus {\n percentTotal\n percentUser\n percentSystem\n }\n }\n }\n": types.CpuMetricsDocument,
|
||||
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument,
|
||||
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": types.LogFileContentDocument,
|
||||
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": types.LogFileSubscriptionDocument,
|
||||
@@ -178,14 +174,6 @@ export function graphql(source: "\n query Unified {\n settings {\n unif
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n"): (typeof documents)["\n mutation UpdateConnectSettings($input: JSON!) {\n updateSettings(input: $input) {\n restartRequired\n values\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query GetCpuInfo {\n info {\n cpu {\n id\n manufacturer\n brand\n vendor\n family\n model\n }\n }\n }\n"): (typeof documents)["\n query GetCpuInfo {\n info {\n cpu {\n id\n manufacturer\n brand\n vendor\n family\n model\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n subscription CpuMetrics {\n systemMetricsCpu {\n id\n percentTotal\n cpus {\n percentTotal\n percentUser\n percentSystem\n }\n }\n }\n"): (typeof documents)["\n subscription CpuMetrics {\n systemMetricsCpu {\n id\n percentTotal\n cpus {\n percentTotal\n percentUser\n percentSystem\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ export type Scalars = {
|
||||
Int: { input: number; output: number; }
|
||||
Float: { input: number; output: number; }
|
||||
/** The `BigInt` scalar type represents non-fractional signed whole numeric values. */
|
||||
BigInt: { input: any; output: any; }
|
||||
BigInt: { input: number; output: number; }
|
||||
/** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */
|
||||
DateTime: { input: string; output: string; }
|
||||
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
|
||||
@@ -711,8 +711,8 @@ export type DockerContainer = Node & {
|
||||
names: Array<Scalars['String']['output']>;
|
||||
networkSettings?: Maybe<Scalars['JSON']['output']>;
|
||||
ports: Array<ContainerPort>;
|
||||
/** Total size of all the files in the container */
|
||||
sizeRootFs?: Maybe<Scalars['Int']['output']>;
|
||||
/** Total size of all files in the container (in bytes) */
|
||||
sizeRootFs?: Maybe<Scalars['BigInt']['output']>;
|
||||
state: ContainerState;
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
@@ -2662,16 +2662,6 @@ export type UpdateConnectSettingsMutationVariables = Exact<{
|
||||
|
||||
export type UpdateConnectSettingsMutation = { __typename?: 'Mutation', updateSettings: { __typename?: 'UpdateSettingsResponse', restartRequired: boolean, values: any } };
|
||||
|
||||
export type GetCpuInfoQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetCpuInfoQuery = { __typename?: 'Query', info: { __typename?: 'Info', cpu: { __typename?: 'InfoCpu', id: string, manufacturer?: string | null, brand?: string | null, vendor?: string | null, family?: string | null, model?: string | null } } };
|
||||
|
||||
export type CpuMetricsSubscriptionVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type CpuMetricsSubscription = { __typename?: 'Subscription', systemMetricsCpu: { __typename?: 'CpuUtilization', id: string, percentTotal: number, cpus: Array<{ __typename?: 'CpuLoad', percentTotal: number, percentUser: number, percentSystem: number }> } };
|
||||
|
||||
export type LogFilesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -2870,8 +2860,6 @@ export const PreviewEffectivePermissionsDocument = {"kind":"Document","definitio
|
||||
export const GetPermissionsForRolesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetPermissionsForRoles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"roles"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Role"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getPermissionsForRoles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"roles"},"value":{"kind":"Variable","name":{"kind":"Name","value":"roles"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<GetPermissionsForRolesQuery, GetPermissionsForRolesQueryVariables>;
|
||||
export const UnifiedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unified"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]}}]} as unknown as DocumentNode<UnifiedQuery, UnifiedQueryVariables>;
|
||||
export const UpdateConnectSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateConnectSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"restartRequired"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]} as unknown as DocumentNode<UpdateConnectSettingsMutation, UpdateConnectSettingsMutationVariables>;
|
||||
export const GetCpuInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCpuInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cpu"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"brand"}},{"kind":"Field","name":{"kind":"Name","value":"vendor"}},{"kind":"Field","name":{"kind":"Name","value":"family"}},{"kind":"Field","name":{"kind":"Name","value":"model"}}]}}]}}]}}]} as unknown as DocumentNode<GetCpuInfoQuery, GetCpuInfoQueryVariables>;
|
||||
export const CpuMetricsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"CpuMetrics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"systemMetricsCpu"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"percentTotal"}},{"kind":"Field","name":{"kind":"Name","value":"cpus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"percentTotal"}},{"kind":"Field","name":{"kind":"Name","value":"percentUser"}},{"kind":"Field","name":{"kind":"Name","value":"percentSystem"}}]}}]}}]}}]} as unknown as DocumentNode<CpuMetricsSubscription, CpuMetricsSubscriptionVariables>;
|
||||
export const LogFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"modifiedAt"}}]}}]}}]} as unknown as DocumentNode<LogFilesQuery, LogFilesQueryVariables>;
|
||||
export const LogFileContentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFileContent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lines"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"lines"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lines"}}},{"kind":"Argument","name":{"kind":"Name","value":"startLine"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}},{"kind":"Field","name":{"kind":"Name","value":"startLine"}}]}}]}}]} as unknown as DocumentNode<LogFileContentQuery, LogFileContentQueryVariables>;
|
||||
export const LogFileSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"LogFileSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}}]}}]}}]} as unknown as DocumentNode<LogFileSubscriptionSubscription, LogFileSubscriptionSubscriptionVariables>;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./fragment-masking";
|
||||
export * from "./gql";
|
||||
export * from './fragment-masking';
|
||||
export * from './gql';
|
||||
|
||||
@@ -35,7 +35,7 @@ export const WebguiState = request.url('/plugins/dynamix.my.servers/data/server-
|
||||
*/
|
||||
export interface WebguiUnraidApiCommandPayload {
|
||||
csrf_token: string;
|
||||
command: 'report' | 'restart' | 'start';
|
||||
command: 'report' | 'restart' | 'start' | 'status';
|
||||
param1?: '-v' | '-vv';
|
||||
}
|
||||
export const WebguiUnraidApiCommand = async (payload: WebguiUnraidApiCommandPayload) => {
|
||||
|
||||
4
web/src/helpers/globals.d.ts
vendored
4
web/src/helpers/globals.d.ts
vendored
@@ -1,5 +1,9 @@
|
||||
declare global {
|
||||
var csrf_token: string;
|
||||
interface Window {
|
||||
__unifiedApp?: unknown;
|
||||
__mountedComponents?: Array<{ element: HTMLElement; unmount: () => void }>;
|
||||
}
|
||||
}
|
||||
|
||||
// an export or import statement is required to make this file a module
|
||||
|
||||
4
web/types/window.d.ts
vendored
4
web/types/window.d.ts
vendored
@@ -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;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user