mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
41 Commits
v4.26.2
...
4.29.1-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73135b8328 | ||
|
|
e42d619b6d | ||
|
|
560db880cc | ||
|
|
d6055f102b | ||
|
|
d099e7521d | ||
|
|
bb9b539732 | ||
|
|
0e44e73bf7 | ||
|
|
277ac42046 | ||
|
|
e1e3ea7eb6 | ||
|
|
8b155d1f1c | ||
|
|
d13a1f6174 | ||
|
|
e243ae836e | ||
|
|
01a63fd86b | ||
|
|
df78608457 | ||
|
|
ca3bee4ad5 | ||
|
|
024ae69343 | ||
|
|
99ce88bfdc | ||
|
|
73b2ce360c | ||
|
|
d6e29395c8 | ||
|
|
317e0fa307 | ||
|
|
331c913329 | ||
|
|
abf3461348 | ||
|
|
079a09ec90 | ||
|
|
e4223ab5a1 | ||
|
|
6f54206a4a | ||
|
|
e35bcc72f1 | ||
|
|
74df938e45 | ||
|
|
51f025b105 | ||
|
|
23a71207dd | ||
|
|
832e9d04f2 | ||
|
|
31af99e52f | ||
|
|
933cefa020 | ||
|
|
375dcd0598 | ||
|
|
64875edbba | ||
|
|
330e81a484 | ||
|
|
b8f0fdf8d2 | ||
|
|
36c104915e | ||
|
|
dc9a036c73 | ||
|
|
c71b0487ad | ||
|
|
e7340431a5 | ||
|
|
e4a9b8291b |
@@ -241,4 +241,3 @@ const pinia = createTestingPinia({
|
||||
- Set initial state for focused testing
|
||||
- Test computed properties by accessing them directly
|
||||
- Verify state changes by updating the store
|
||||
|
||||
|
||||
27
.github/workflows/build-artifacts.yml
vendored
27
.github/workflows/build-artifacts.yml
vendored
@@ -32,13 +32,13 @@ jobs:
|
||||
name: Build API
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build_number: ${{ steps.buildnumber.outputs.build_number }}
|
||||
build_number: ${{ steps.buildnumber.outputs.build_number || steps.fallback_buildnumber.outputs.build_number }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
fetch-depth: 0
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
@@ -81,18 +81,25 @@ jobs:
|
||||
|
||||
- name: Generate build number
|
||||
id: buildnumber
|
||||
if: github.repository == 'unraid/api'
|
||||
continue-on-error: true
|
||||
uses: onyxmueller/build-tag-number@v1
|
||||
with:
|
||||
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN || github.token }}
|
||||
prefix: ${{ inputs.version_override || steps.vars.outputs.PACKAGE_LOCK_VERSION }}
|
||||
|
||||
- name: Generate fallback build number
|
||||
id: fallback_buildnumber
|
||||
if: steps.buildnumber.outcome != 'success'
|
||||
run: echo "build_number=${GITHUB_RUN_NUMBER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
pnpm run build:release
|
||||
tar -czf deploy/unraid-api.tgz -C deploy/pack/ .
|
||||
|
||||
- name: Upload tgz to Github artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unraid-api
|
||||
path: ${{ github.workspace }}/api/deploy/unraid-api.tgz
|
||||
@@ -105,7 +112,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
@@ -115,7 +122,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
@@ -138,7 +145,7 @@ jobs:
|
||||
run: pnpm run build:wc
|
||||
|
||||
- name: Upload Artifact to Github
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unraid-wc-ui
|
||||
path: unraid-ui/dist-wc/
|
||||
@@ -151,7 +158,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
@@ -169,7 +176,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
@@ -194,7 +201,7 @@ jobs:
|
||||
run: pnpm run build
|
||||
|
||||
- name: Upload build to Github artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unraid-wc-rich
|
||||
path: web/dist
|
||||
|
||||
28
.github/workflows/build-plugin.yml
vendored
28
.github/workflows/build-plugin.yml
vendored
@@ -56,7 +56,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
@@ -78,7 +78,21 @@ jobs:
|
||||
GIT_SHA=$(git rev-parse --short HEAD)
|
||||
IS_TAGGED=$(git describe --tags --abbrev=0 --exact-match || echo '')
|
||||
PACKAGE_LOCK_VERSION=$(jq -r '.version' package.json)
|
||||
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
|
||||
|
||||
# For release builds, trust the release tag version to avoid stale checkouts
|
||||
if [ "${{ inputs.RELEASE_CREATED }}" = "true" ] && [ -n "${{ inputs.RELEASE_TAG }}" ]; then
|
||||
TAG_VERSION="${{ inputs.RELEASE_TAG }}"
|
||||
TAG_VERSION="${TAG_VERSION#v}" # trim leading v if present
|
||||
|
||||
if [ "$TAG_VERSION" != "$PACKAGE_LOCK_VERSION" ]; then
|
||||
echo "::warning::Release tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_LOCK_VERSION). Using tag version for TXZ naming."
|
||||
fi
|
||||
|
||||
API_VERSION="$TAG_VERSION"
|
||||
else
|
||||
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
|
||||
fi
|
||||
|
||||
echo "API_VERSION=${API_VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -87,19 +101,19 @@ jobs:
|
||||
pnpm install --frozen-lockfile --filter @unraid/connect-plugin
|
||||
|
||||
- name: Download Unraid UI Components
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: unraid-wc-ui
|
||||
path: ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/uui
|
||||
merge-multiple: true
|
||||
- name: Download Unraid Web Components
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: unraid-wc-rich
|
||||
path: ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone
|
||||
merge-multiple: true
|
||||
- name: Download Unraid API
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: unraid-api
|
||||
path: ${{ github.workspace }}/plugin/api/
|
||||
@@ -128,7 +142,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload to GHA
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unraid-plugin-${{ github.run_id }}-${{ inputs.RELEASE_TAG }}
|
||||
path: plugin/deploy/
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -24,17 +24,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/codeql-config.yml
|
||||
queries: +security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
4
.github/workflows/deploy-storybook.yml
vendored
4
.github/workflows/deploy-storybook.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
name: Deploy Storybook
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
4
.github/workflows/generate-release-notes.yml
vendored
4
.github/workflows/generate-release-notes.yml
vendored
@@ -31,14 +31,14 @@ jobs:
|
||||
release_notes: ${{ steps.generate_notes.outputs.release_notes }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.target_commitish || github.ref }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
6
.github/workflows/manual-release.yml
vendored
6
.github/workflows/manual-release.yml
vendored
@@ -31,14 +31,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.target_commitish || github.ref }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
release_notes: ${{ needs.generate-release-notes.outputs.release_notes }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.target_commitish || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/publish-schema.yml
vendored
2
.github/workflows/publish-schema.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Apollo Rover CLI
|
||||
run: |
|
||||
|
||||
2
.github/workflows/release-production.yml
vendored
2
.github/workflows/release-production.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
with:
|
||||
latest: true
|
||||
prerelease: false
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.19.0
|
||||
- run: |
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.26.2"}
|
||||
{".":"4.29.1"}
|
||||
|
||||
@@ -63,15 +63,6 @@
|
||||
*/
|
||||
|
||||
.unapi {
|
||||
--color-alpha: #1c1b1b;
|
||||
--color-beta: #f2f2f2;
|
||||
--color-gamma: #999999;
|
||||
--color-gamma-opaque: rgba(153, 153, 153, 0.5);
|
||||
--color-customgradient-start: rgba(242, 242, 242, 0);
|
||||
--color-customgradient-end: rgba(242, 242, 242, 0.85);
|
||||
--shadow-beta: 0 25px 50px -12px rgba(242, 242, 242, 0.15);
|
||||
--ring-offset-shadow: 0 0 var(--color-beta);
|
||||
--ring-shadow: 0 0 var(--color-beta);
|
||||
}
|
||||
|
||||
.unapi button:not(:disabled),
|
||||
|
||||
@@ -6,92 +6,63 @@
|
||||
|
||||
/* Default/White Theme */
|
||||
.Theme--white {
|
||||
--header-text-primary: #ffffff;
|
||||
--header-text-secondary: #999999;
|
||||
--header-background-color: #1c1b1b;
|
||||
--header-gradient-start: rgba(28, 27, 27, 0);
|
||||
--header-gradient-end: rgba(28, 27, 27, 0.7);
|
||||
--color-border: #383735;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #1c1b1b;
|
||||
--color-gamma: #ffffff;
|
||||
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
|
||||
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
|
||||
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
|
||||
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
|
||||
--ring-offset-shadow: 0 0 var(--color-beta);
|
||||
--ring-shadow: 0 0 var(--color-beta);
|
||||
}
|
||||
|
||||
/* Black Theme */
|
||||
.Theme--black,
|
||||
.Theme--black.dark {
|
||||
--header-text-primary: #1c1b1b;
|
||||
--header-text-secondary: #999999;
|
||||
--header-background-color: #f2f2f2;
|
||||
--header-gradient-start: rgba(242, 242, 242, 0);
|
||||
--header-gradient-end: rgba(242, 242, 242, 0.7);
|
||||
--color-border: #e0e0e0;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #f2f2f2;
|
||||
--color-gamma: #1c1b1b;
|
||||
--color-gamma-opaque: rgba(28, 27, 27, 0.3);
|
||||
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
|
||||
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
|
||||
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
|
||||
--ring-offset-shadow: 0 0 var(--color-beta);
|
||||
--ring-shadow: 0 0 var(--color-beta);
|
||||
}
|
||||
|
||||
/* Gray Theme */
|
||||
.Theme--gray {
|
||||
--header-text-primary: #ffffff;
|
||||
--header-text-secondary: #999999;
|
||||
--header-background-color: #1c1b1b;
|
||||
--header-gradient-start: rgba(28, 27, 27, 0);
|
||||
--header-gradient-end: rgba(28, 27, 27, 0.7);
|
||||
.Theme--gray,
|
||||
.Theme--gray.dark {
|
||||
--color-border: #383735;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #383735;
|
||||
--color-gamma: #ffffff;
|
||||
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
|
||||
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
|
||||
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
|
||||
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
|
||||
--ring-offset-shadow: 0 0 var(--color-beta);
|
||||
--ring-shadow: 0 0 var(--color-beta);
|
||||
}
|
||||
|
||||
/* Azure Theme */
|
||||
.Theme--azure {
|
||||
--header-text-primary: #1c1b1b;
|
||||
--header-text-secondary: #999999;
|
||||
--header-background-color: #f2f2f2;
|
||||
--header-gradient-start: rgba(242, 242, 242, 0);
|
||||
--header-gradient-end: rgba(242, 242, 242, 0.7);
|
||||
--color-border: #5a8bb8;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #e7f2f8;
|
||||
--color-gamma: #336699;
|
||||
--color-gamma-opaque: rgba(51, 102, 153, 0.3);
|
||||
--color-header-gradient-start: color-mix(in srgb, var(--header-background-color) 0%, transparent);
|
||||
--color-header-gradient-end: color-mix(in srgb, var(--header-background-color) 100%, transparent);
|
||||
--shadow-beta: 0 25px 50px -12px color-mix(in srgb, var(--color-beta) 15%, transparent);
|
||||
--ring-offset-shadow: 0 0 var(--color-beta);
|
||||
--ring-shadow: 0 0 var(--color-beta);
|
||||
}
|
||||
|
||||
/* Dark Mode Overrides */
|
||||
.dark {
|
||||
--color-border: #383735;
|
||||
}
|
||||
|
||||
/*
|
||||
* Dynamic color variables for user overrides from GraphQL
|
||||
* These are set via JavaScript and override the theme defaults
|
||||
* Using :root with class for higher specificity to override theme classes
|
||||
*/
|
||||
:root.has-custom-header-text {
|
||||
--header-text-primary: var(--custom-header-text-primary);
|
||||
--color-header-text-primary: var(--custom-header-text-primary);
|
||||
}
|
||||
|
||||
:root.has-custom-header-meta {
|
||||
--header-text-secondary: var(--custom-header-text-secondary);
|
||||
--color-header-text-secondary: var(--custom-header-text-secondary);
|
||||
}
|
||||
|
||||
:root.has-custom-header-bg,
|
||||
.has-custom-header-bg.Theme--black,
|
||||
.has-custom-header-bg.Theme--black.dark,
|
||||
.has-custom-header-bg.Theme--white,
|
||||
.has-custom-header-bg.Theme--white.dark,
|
||||
.has-custom-header-bg.Theme--gray,
|
||||
.has-custom-header-bg.Theme--azure {
|
||||
--header-background-color: var(--custom-header-background-color);
|
||||
--color-header-background: var(--custom-header-background-color);
|
||||
--header-gradient-start: var(--custom-header-gradient-start);
|
||||
--header-gradient-end: var(--custom-header-gradient-end);
|
||||
--color-header-gradient-start: var(--custom-header-gradient-start);
|
||||
--color-header-gradient-end: var(--custom-header-gradient-end);
|
||||
}
|
||||
@@ -1,5 +1,85 @@
|
||||
# Changelog
|
||||
|
||||
## [4.29.1](https://github.com/unraid/api/compare/v4.29.0...v4.29.1) (2025-12-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* revert replace docker overview table with web component (7.3+) ([#1853](https://github.com/unraid/api/issues/1853)) ([560db88](https://github.com/unraid/api/commit/560db880cc138324f9ff8753f7209b683a84c045))
|
||||
|
||||
## [4.29.0](https://github.com/unraid/api/compare/v4.28.2...v4.29.0) (2025-12-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* replace docker overview table with web component (7.3+) ([#1764](https://github.com/unraid/api/issues/1764)) ([277ac42](https://github.com/unraid/api/commit/277ac420464379e7ee6739c4530271caf7717503))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle race condition between guid loading and license check ([#1847](https://github.com/unraid/api/issues/1847)) ([8b155d1](https://github.com/unraid/api/commit/8b155d1f1c99bb19efbc9614e000d852e9f0c12d))
|
||||
* resolve issue with "Continue" button when updating ([#1852](https://github.com/unraid/api/issues/1852)) ([d099e75](https://github.com/unraid/api/commit/d099e7521d2062bb9cf84f340e46b169dd2492c5))
|
||||
* update myservers config references to connect config references ([#1810](https://github.com/unraid/api/issues/1810)) ([e1e3ea7](https://github.com/unraid/api/commit/e1e3ea7eb68cc6840f67a8aec937fd3740e75b28))
|
||||
|
||||
## [4.28.2](https://github.com/unraid/api/compare/v4.28.1...v4.28.2) (2025-12-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** timeout on startup on 7.0 and 6.12 ([#1844](https://github.com/unraid/api/issues/1844)) ([e243ae8](https://github.com/unraid/api/commit/e243ae836ec1a7fde37dceeb106cc693b20ec82b))
|
||||
|
||||
## [4.28.1](https://github.com/unraid/api/compare/v4.28.0...v4.28.1) (2025-12-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* empty commit to release as 4.28.1 ([df78608](https://github.com/unraid/api/commit/df786084572eefb82e086c15939b50cc08b9db10))
|
||||
|
||||
## [4.28.0](https://github.com/unraid/api/compare/v4.27.2...v4.28.0) (2025-12-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* when cancelling OS upgrade, delete any plugin files that were d… ([#1823](https://github.com/unraid/api/issues/1823)) ([74df938](https://github.com/unraid/api/commit/74df938e450def2ee3e2864d4b928f53a68e9eb8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* change keyfile watcher to poll instead of inotify on FAT32 ([#1820](https://github.com/unraid/api/issues/1820)) ([23a7120](https://github.com/unraid/api/commit/23a71207ddde221867562b722f4e65a5fc4dd744))
|
||||
* enhance dark mode support in theme handling ([#1808](https://github.com/unraid/api/issues/1808)) ([d6e2939](https://github.com/unraid/api/commit/d6e29395c8a8b0215d4f5945775de7fa358d06ec))
|
||||
* improve API startup reliability with timeout budget tracking ([#1824](https://github.com/unraid/api/issues/1824)) ([51f025b](https://github.com/unraid/api/commit/51f025b105487b178048afaabf46b260c4a7f9c1))
|
||||
* PHP Warnings in Management Settings ([#1805](https://github.com/unraid/api/issues/1805)) ([832e9d0](https://github.com/unraid/api/commit/832e9d04f207d3ec612c98500a2ffc86659264e5))
|
||||
* **plg:** explicitly stop an existing api before installation ([#1841](https://github.com/unraid/api/issues/1841)) ([99ce88b](https://github.com/unraid/api/commit/99ce88bfdc0a7f020c42f2fe0c6a0f4e32ac8f5a))
|
||||
* update @unraid/shared-callbacks to version 3.0.0 ([#1831](https://github.com/unraid/api/issues/1831)) ([73b2ce3](https://github.com/unraid/api/commit/73b2ce360c66cd9bedc138a5f8306af04b6bde77))
|
||||
* **ups:** convert estimatedRuntime from minutes to seconds ([#1822](https://github.com/unraid/api/issues/1822)) ([024ae69](https://github.com/unraid/api/commit/024ae69343bad5a3cbc19f80e357082e9b2efc1e))
|
||||
|
||||
## [4.27.2](https://github.com/unraid/api/compare/v4.27.1...v4.27.2) (2025-11-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* issue with header flashing + issue with trial date ([64875ed](https://github.com/unraid/api/commit/64875edbba786a0d1ba0113c9e9a3d38594eafcc))
|
||||
|
||||
## [4.27.1](https://github.com/unraid/api/compare/v4.27.0...v4.27.1) (2025-11-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* missing translations for expiring trials ([#1800](https://github.com/unraid/api/issues/1800)) ([36c1049](https://github.com/unraid/api/commit/36c104915ece203a3cac9e1a13e0c325e536a839))
|
||||
* resolve header flash when background color is set ([#1796](https://github.com/unraid/api/issues/1796)) ([dc9a036](https://github.com/unraid/api/commit/dc9a036c73d8ba110029364e0d044dc24c7d0dfa))
|
||||
|
||||
## [4.27.0](https://github.com/unraid/api/compare/v4.26.2...v4.27.0) (2025-11-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* remove Unraid API log download functionality ([#1793](https://github.com/unraid/api/issues/1793)) ([e4a9b82](https://github.com/unraid/api/commit/e4a9b8291b049752a9ff59b17ff50cf464fe0535))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* auto-uninstallation of connect api plugin ([#1791](https://github.com/unraid/api/issues/1791)) ([e734043](https://github.com/unraid/api/commit/e7340431a58821ec1b4f5d1b452fba6613b01fa5))
|
||||
|
||||
## [4.26.2](https://github.com/unraid/api/compare/v4.26.1...v4.26.2) (2025-11-19)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.25.3",
|
||||
"version": "4.27.2",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -62,15 +62,18 @@ To build all packages in the monorepo:
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Watch Mode Building
|
||||
### Plugin Building (Docker Required)
|
||||
|
||||
For continuous building during development:
|
||||
The plugin build requires Docker. This command automatically builds all dependencies (API, web) before starting Docker:
|
||||
|
||||
```bash
|
||||
pnpm build:watch
|
||||
cd plugin
|
||||
pnpm run docker:build-and-run
|
||||
# Then inside the container:
|
||||
pnpm build
|
||||
```
|
||||
|
||||
This is useful when you want to see your changes reflected without manually rebuilding. This will also allow you to install a local plugin to test your changes.
|
||||
This serves the plugin at `http://YOUR_IP:5858/` for installation on your Unraid server.
|
||||
|
||||
### Package-Specific Building
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"cwd": "/usr/local/unraid-api",
|
||||
"exec_mode": "fork",
|
||||
"wait_ready": true,
|
||||
"listen_timeout": 15000,
|
||||
"listen_timeout": 30000,
|
||||
"max_restarts": 10,
|
||||
"min_uptime": 10000,
|
||||
"watch": false,
|
||||
|
||||
@@ -944,6 +944,23 @@ input UpdateApiKeyInput {
|
||||
permissions: [AddPermissionInput!]
|
||||
}
|
||||
|
||||
"""Customization related mutations"""
|
||||
type CustomizationMutations {
|
||||
"""Update the UI theme (writes dynamix.cfg)"""
|
||||
setTheme(
|
||||
"""Theme to apply"""
|
||||
theme: ThemeName!
|
||||
): Theme!
|
||||
}
|
||||
|
||||
"""The theme name"""
|
||||
enum ThemeName {
|
||||
azure
|
||||
black
|
||||
gray
|
||||
white
|
||||
}
|
||||
|
||||
"""
|
||||
Parity check related mutations, WIP, response types and functionaliy will change
|
||||
"""
|
||||
@@ -1042,14 +1059,6 @@ type Theme {
|
||||
headerSecondaryTextColor: String
|
||||
}
|
||||
|
||||
"""The theme name"""
|
||||
enum ThemeName {
|
||||
azure
|
||||
black
|
||||
gray
|
||||
white
|
||||
}
|
||||
|
||||
type ExplicitStatusItem {
|
||||
name: String!
|
||||
updateStatus: UpdateStatus!
|
||||
@@ -2449,6 +2458,7 @@ type Mutation {
|
||||
vm: VmMutations!
|
||||
parityCheck: ParityCheckMutations!
|
||||
apiKey: ApiKeyMutations!
|
||||
customization: CustomizationMutations!
|
||||
rclone: RCloneMutations!
|
||||
createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1!
|
||||
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.26.2",
|
||||
"version": "4.29.1",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
|
||||
@@ -83,6 +83,10 @@ try {
|
||||
if (parsedPackageJson.dependencies?.[dep]) {
|
||||
delete parsedPackageJson.dependencies[dep];
|
||||
}
|
||||
// Also strip from peerDependencies (npm doesn't understand workspace: protocol)
|
||||
if (parsedPackageJson.peerDependencies?.[dep]) {
|
||||
delete parsedPackageJson.peerDependencies[dep];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
151
api/src/__test__/store/watch/registration-watch.test.ts
Normal file
151
api/src/__test__/store/watch/registration-watch.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { StateFileKey } from '@app/store/types.js';
|
||||
import { RegistrationType } from '@app/unraid-api/graph/resolvers/registration/registration.model.js';
|
||||
|
||||
// Mock the store module
|
||||
vi.mock('@app/store/index.js', () => ({
|
||||
store: {
|
||||
dispatch: vi.fn(),
|
||||
},
|
||||
getters: {
|
||||
emhttp: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the emhttp module
|
||||
vi.mock('@app/store/modules/emhttp.js', () => ({
|
||||
loadSingleStateFile: vi.fn((key) => ({ type: 'emhttp/load-single-state-file', payload: key })),
|
||||
}));
|
||||
|
||||
// Mock the registration module
|
||||
vi.mock('@app/store/modules/registration.js', () => ({
|
||||
loadRegistrationKey: vi.fn(() => ({ type: 'registration/load-registration-key' })),
|
||||
}));
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('@app/core/log.js', () => ({
|
||||
keyServerLogger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('reloadVarIniWithRetry', () => {
|
||||
let store: { dispatch: ReturnType<typeof vi.fn> };
|
||||
let getters: { emhttp: ReturnType<typeof vi.fn> };
|
||||
let loadSingleStateFile: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const storeModule = await import('@app/store/index.js');
|
||||
const emhttpModule = await import('@app/store/modules/emhttp.js');
|
||||
|
||||
store = storeModule.store as unknown as typeof store;
|
||||
getters = storeModule.getters as unknown as typeof getters;
|
||||
loadSingleStateFile = emhttpModule.loadSingleStateFile as unknown as typeof loadSingleStateFile;
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns early when registration state changes on first retry', async () => {
|
||||
// Initial state is TRIAL
|
||||
getters.emhttp
|
||||
.mockReturnValueOnce({ var: { regTy: RegistrationType.TRIAL } }) // First call (beforeState)
|
||||
.mockReturnValueOnce({ var: { regTy: RegistrationType.UNLEASHED } }); // After first reload
|
||||
|
||||
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
|
||||
|
||||
const promise = reloadVarIniWithRetry();
|
||||
|
||||
// Advance past the first delay (500ms)
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await promise;
|
||||
|
||||
// Should only dispatch once since state changed
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(loadSingleStateFile).toHaveBeenCalledWith(StateFileKey.var);
|
||||
});
|
||||
|
||||
it('retries up to maxRetries when state does not change', async () => {
|
||||
// State never changes
|
||||
getters.emhttp.mockReturnValue({ var: { regTy: RegistrationType.TRIAL } });
|
||||
|
||||
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
|
||||
|
||||
const promise = reloadVarIniWithRetry(3);
|
||||
|
||||
// Advance through all retries: 500ms, 1000ms, 2000ms
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await promise;
|
||||
|
||||
// Should dispatch 3 times (maxRetries)
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('stops retrying when state changes on second attempt', async () => {
|
||||
getters.emhttp
|
||||
.mockReturnValueOnce({ var: { regTy: RegistrationType.TRIAL } }) // beforeState
|
||||
.mockReturnValueOnce({ var: { regTy: RegistrationType.TRIAL } }) // After first reload (no change)
|
||||
.mockReturnValueOnce({ var: { regTy: RegistrationType.UNLEASHED } }); // After second reload (changed!)
|
||||
|
||||
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
|
||||
|
||||
const promise = reloadVarIniWithRetry(3);
|
||||
|
||||
// First retry
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
// Second retry
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await promise;
|
||||
|
||||
// Should dispatch twice - stopped after state changed
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles undefined regTy gracefully', async () => {
|
||||
getters.emhttp.mockReturnValue({ var: {} });
|
||||
|
||||
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
|
||||
|
||||
const promise = reloadVarIniWithRetry(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await promise;
|
||||
|
||||
// Should still dispatch even with undefined regTy
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses exponential backoff delays', async () => {
|
||||
getters.emhttp.mockReturnValue({ var: { regTy: RegistrationType.TRIAL } });
|
||||
|
||||
const { reloadVarIniWithRetry } = await import('@app/store/watch/registration-watch.js');
|
||||
|
||||
const promise = reloadVarIniWithRetry(3);
|
||||
|
||||
// At 0ms, no dispatch yet
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(0);
|
||||
|
||||
// At 500ms, first dispatch
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// At 1500ms (500 + 1000), second dispatch
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(2);
|
||||
|
||||
// At 3500ms (500 + 1000 + 2000), third dispatch
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(store.dispatch).toHaveBeenCalledTimes(3);
|
||||
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
12
api/src/connect-plugin-cleanup.ts
Normal file
12
api/src/connect-plugin-cleanup.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Local filesystem and env checks stay synchronous so we can branch at module load.
|
||||
* @returns True if the Connect Unraid plugin is installed, false otherwise.
|
||||
*/
|
||||
export const isConnectPluginInstalled = () => {
|
||||
if (process.env.SKIP_CONNECT_PLUGIN_CHECK === 'true') {
|
||||
return true;
|
||||
}
|
||||
return existsSync('/boot/config/plugins/dynamix.unraid.net.plg');
|
||||
};
|
||||
231
api/src/core/utils/misc/__test__/timeout-budget.test.ts
Normal file
231
api/src/core/utils/misc/__test__/timeout-budget.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TimeoutBudget } from '@app/core/utils/misc/timeout-budget.js';
|
||||
|
||||
describe('TimeoutBudget', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('initializes with the given budget', () => {
|
||||
const budget = new TimeoutBudget(10000);
|
||||
expect(budget.remaining()).toBe(10000);
|
||||
expect(budget.elapsed()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remaining', () => {
|
||||
it('returns full budget immediately after construction', () => {
|
||||
const budget = new TimeoutBudget(5000);
|
||||
expect(budget.remaining()).toBe(5000);
|
||||
});
|
||||
|
||||
it('decreases as time passes', () => {
|
||||
const budget = new TimeoutBudget(5000);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(budget.remaining()).toBe(4000);
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(budget.remaining()).toBe(2000);
|
||||
});
|
||||
|
||||
it('never returns negative values', () => {
|
||||
const budget = new TimeoutBudget(1000);
|
||||
|
||||
vi.advanceTimersByTime(5000); // Well past the budget
|
||||
expect(budget.remaining()).toBe(0);
|
||||
});
|
||||
|
||||
it('returns zero when budget is exactly exhausted', () => {
|
||||
const budget = new TimeoutBudget(1000);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(budget.remaining()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('elapsed', () => {
|
||||
it('returns zero immediately after construction', () => {
|
||||
const budget = new TimeoutBudget(5000);
|
||||
expect(budget.elapsed()).toBe(0);
|
||||
});
|
||||
|
||||
it('increases as time passes', () => {
|
||||
const budget = new TimeoutBudget(5000);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(budget.elapsed()).toBe(1000);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(budget.elapsed()).toBe(1500);
|
||||
});
|
||||
|
||||
it('continues increasing past the budget limit', () => {
|
||||
const budget = new TimeoutBudget(1000);
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(budget.elapsed()).toBe(2000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeout', () => {
|
||||
it('returns maxMs when plenty of budget remains', () => {
|
||||
const budget = new TimeoutBudget(10000);
|
||||
expect(budget.getTimeout(2000)).toBe(2000);
|
||||
});
|
||||
|
||||
it('returns maxMs when budget minus reserve is sufficient', () => {
|
||||
const budget = new TimeoutBudget(10000);
|
||||
expect(budget.getTimeout(2000, 5000)).toBe(2000);
|
||||
});
|
||||
|
||||
it('caps timeout to available budget minus reserve', () => {
|
||||
const budget = new TimeoutBudget(10000);
|
||||
vi.advanceTimersByTime(5000); // 5000ms remaining
|
||||
|
||||
// Want 2000ms but reserve 4000ms, only 1000ms available
|
||||
expect(budget.getTimeout(2000, 4000)).toBe(1000);
|
||||
});
|
||||
|
||||
it('caps timeout to remaining budget when no reserve', () => {
|
||||
const budget = new TimeoutBudget(1000);
|
||||
vi.advanceTimersByTime(800); // 200ms remaining
|
||||
|
||||
expect(budget.getTimeout(500)).toBe(200);
|
||||
});
|
||||
|
||||
it('returns minimum of 100ms even when budget is exhausted', () => {
|
||||
const budget = new TimeoutBudget(1000);
|
||||
vi.advanceTimersByTime(2000); // Budget exhausted
|
||||
|
||||
expect(budget.getTimeout(500)).toBe(100);
|
||||
});
|
||||
|
||||
it('returns minimum of 100ms when reserve exceeds remaining', () => {
|
||||
const budget = new TimeoutBudget(5000);
|
||||
vi.advanceTimersByTime(4000); // 1000ms remaining
|
||||
|
||||
// Reserve 2000ms but only 1000ms remaining
|
||||
expect(budget.getTimeout(500, 2000)).toBe(100);
|
||||
});
|
||||
|
||||
it('uses default reserve of 0 when not specified', () => {
|
||||
const budget = new TimeoutBudget(1000);
|
||||
vi.advanceTimersByTime(500); // 500ms remaining
|
||||
|
||||
expect(budget.getTimeout(1000)).toBe(500); // Capped to remaining
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTimeFor', () => {
|
||||
it('returns true when enough time remains', () => {
|
||||
const budget = new TimeoutBudget(5000);
|
||||
expect(budget.hasTimeFor(3000)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when exactly enough time remains', () => {
|
||||
const budget = new TimeoutBudget(5000);
|
||||
expect(budget.hasTimeFor(5000)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when not enough time remains', () => {
|
||||
const budget = new TimeoutBudget(5000);
|
||||
expect(budget.hasTimeFor(6000)).toBe(false);
|
||||
});
|
||||
|
||||
it('accounts for elapsed time', () => {
|
||||
const budget = new TimeoutBudget(5000);
|
||||
vi.advanceTimersByTime(3000); // 2000ms remaining
|
||||
|
||||
expect(budget.hasTimeFor(2000)).toBe(true);
|
||||
expect(budget.hasTimeFor(3000)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when budget is exhausted', () => {
|
||||
const budget = new TimeoutBudget(1000);
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
expect(budget.hasTimeFor(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for zero required time', () => {
|
||||
const budget = new TimeoutBudget(1000);
|
||||
vi.advanceTimersByTime(2000); // Budget exhausted
|
||||
|
||||
expect(budget.hasTimeFor(0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('simulates a typical startup sequence', () => {
|
||||
const budget = new TimeoutBudget(13000); // 13 second budget
|
||||
const BOOTSTRAP_RESERVE = 8000;
|
||||
const MAX_OP_TIMEOUT = 2000;
|
||||
|
||||
// First operation - should get full 2000ms
|
||||
const op1Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
|
||||
expect(op1Timeout).toBe(2000);
|
||||
|
||||
// Simulate operation taking 500ms
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
// Second operation - still have plenty of budget
|
||||
const op2Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
|
||||
expect(op2Timeout).toBe(2000);
|
||||
|
||||
// Simulate operation taking 1000ms
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// Third operation
|
||||
const op3Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
|
||||
expect(op3Timeout).toBe(2000);
|
||||
|
||||
// Simulate slow operation taking 2000ms
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
// Now 3500ms elapsed, 9500ms remaining
|
||||
// After reserve, only 1500ms available - less than max
|
||||
const op4Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
|
||||
expect(op4Timeout).toBe(1500);
|
||||
|
||||
// Simulate operation completing
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// Bootstrap phase - use all remaining time
|
||||
const bootstrapTimeout = budget.remaining();
|
||||
expect(bootstrapTimeout).toBe(8500);
|
||||
expect(budget.hasTimeFor(8000)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles worst-case scenario where all operations timeout', () => {
|
||||
const budget = new TimeoutBudget(13000);
|
||||
const BOOTSTRAP_RESERVE = 8000;
|
||||
const MAX_OP_TIMEOUT = 2000;
|
||||
|
||||
// Each operation times out at its limit
|
||||
// Available for operations: 13000 - 8000 = 5000ms
|
||||
|
||||
// Op 1: gets 2000ms, times out
|
||||
budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
// Op 2: gets 2000ms, times out
|
||||
budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
// Op 3: only 1000ms available (5000 - 4000), times out
|
||||
const op3Timeout = budget.getTimeout(MAX_OP_TIMEOUT, BOOTSTRAP_RESERVE);
|
||||
expect(op3Timeout).toBe(1000);
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// Bootstrap: should still have 8000ms
|
||||
expect(budget.remaining()).toBe(8000);
|
||||
});
|
||||
});
|
||||
});
|
||||
65
api/src/core/utils/misc/__test__/with-timeout.test.ts
Normal file
65
api/src/core/utils/misc/__test__/with-timeout.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { withTimeout } from '@app/core/utils/misc/with-timeout.js';
|
||||
|
||||
describe('withTimeout', () => {
|
||||
it('resolves when promise completes before timeout', async () => {
|
||||
const promise = Promise.resolve('success');
|
||||
const result = await withTimeout(promise, 1000, 'testOp');
|
||||
expect(result).toBe('success');
|
||||
});
|
||||
|
||||
it('resolves with correct value for delayed promise within timeout', async () => {
|
||||
const promise = new Promise<number>((resolve) => setTimeout(() => resolve(42), 50));
|
||||
const result = await withTimeout(promise, 1000, 'testOp');
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('rejects when promise takes longer than timeout', async () => {
|
||||
const promise = new Promise<string>((resolve) => setTimeout(() => resolve('late'), 500));
|
||||
await expect(withTimeout(promise, 50, 'slowOp')).rejects.toThrow('slowOp timed out after 50ms');
|
||||
});
|
||||
|
||||
it('includes operation name in timeout error message', async () => {
|
||||
const promise = new Promise<void>(() => {}); // Never resolves
|
||||
await expect(withTimeout(promise, 10, 'myCustomOperation')).rejects.toThrow(
|
||||
'myCustomOperation timed out after 10ms'
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates rejection from the original promise', async () => {
|
||||
const promise = Promise.reject(new Error('original error'));
|
||||
await expect(withTimeout(promise, 1000, 'testOp')).rejects.toThrow('original error');
|
||||
});
|
||||
|
||||
it('resolves immediately for already-resolved promises', async () => {
|
||||
const promise = Promise.resolve('immediate');
|
||||
const start = Date.now();
|
||||
const result = await withTimeout(promise, 1000, 'testOp');
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe('immediate');
|
||||
expect(elapsed).toBeLessThan(50); // Should be nearly instant
|
||||
});
|
||||
|
||||
it('works with zero timeout (immediately times out for pending promises)', async () => {
|
||||
const promise = new Promise<void>(() => {}); // Never resolves
|
||||
await expect(withTimeout(promise, 0, 'zeroTimeout')).rejects.toThrow(
|
||||
'zeroTimeout timed out after 0ms'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the type of the resolved value', async () => {
|
||||
interface TestType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
const testObj: TestType = { id: 1, name: 'test' };
|
||||
const promise = Promise.resolve(testObj);
|
||||
|
||||
const result = await withTimeout(promise, 1000, 'testOp');
|
||||
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.name).toBe('test');
|
||||
});
|
||||
});
|
||||
70
api/src/core/utils/misc/timeout-budget.ts
Normal file
70
api/src/core/utils/misc/timeout-budget.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Tracks remaining time budget to ensure we don't exceed external timeouts (e.g., PM2's listen_timeout).
|
||||
*
|
||||
* This class helps coordinate multiple async operations by:
|
||||
* - Tracking elapsed time from construction
|
||||
* - Calculating dynamic timeouts based on remaining budget
|
||||
* - Reserving time for critical operations (like server bootstrap)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const budget = new TimeoutBudget(15000); // 15 second total budget
|
||||
*
|
||||
* // Each operation gets a timeout capped by remaining budget
|
||||
* await withTimeout(loadConfig(), budget.getTimeout(2000, 8000), 'loadConfig');
|
||||
* await withTimeout(loadState(), budget.getTimeout(2000, 8000), 'loadState');
|
||||
*
|
||||
* // Bootstrap gets all remaining time
|
||||
* await withTimeout(bootstrap(), budget.remaining(), 'bootstrap');
|
||||
*
|
||||
* console.log(`Completed in ${budget.elapsed()}ms`);
|
||||
* ```
|
||||
*/
|
||||
export class TimeoutBudget {
|
||||
private startTime: number;
|
||||
private budgetMs: number;
|
||||
|
||||
/**
|
||||
* Creates a new startup budget tracker.
|
||||
* @param budgetMs Total time budget in milliseconds
|
||||
*/
|
||||
constructor(budgetMs: number) {
|
||||
this.startTime = Date.now();
|
||||
this.budgetMs = budgetMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns remaining time in milliseconds.
|
||||
* Never returns negative values.
|
||||
*/
|
||||
remaining(): number {
|
||||
return Math.max(0, this.budgetMs - (Date.now() - this.startTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns elapsed time in milliseconds since construction.
|
||||
*/
|
||||
elapsed(): number {
|
||||
return Date.now() - this.startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns timeout for an operation, capped by remaining budget.
|
||||
*
|
||||
* @param maxMs Maximum timeout for this operation
|
||||
* @param reserveMs Time to reserve for future operations (e.g., server bootstrap)
|
||||
* @returns Timeout in milliseconds (minimum 100ms to avoid instant failures)
|
||||
*/
|
||||
getTimeout(maxMs: number, reserveMs: number = 0): number {
|
||||
const available = this.remaining() - reserveMs;
|
||||
return Math.max(100, Math.min(maxMs, available));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there's enough time remaining for an operation.
|
||||
* @param requiredMs Time required in milliseconds
|
||||
*/
|
||||
hasTimeFor(requiredMs: number): boolean {
|
||||
return this.remaining() >= requiredMs;
|
||||
}
|
||||
}
|
||||
25
api/src/core/utils/misc/with-timeout.ts
Normal file
25
api/src/core/utils/misc/with-timeout.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Wraps a promise with a timeout to prevent hangs.
|
||||
* If the operation takes longer than timeoutMs, it rejects with a timeout error.
|
||||
*
|
||||
* @param promise The promise to wrap with a timeout
|
||||
* @param timeoutMs Maximum time in milliseconds before timing out
|
||||
* @param operationName Name of the operation for the error message
|
||||
* @returns The result of the promise if it completes in time
|
||||
* @throws Error if the operation times out
|
||||
*/
|
||||
export const withTimeout = <T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
operationName: string
|
||||
): Promise<T> => {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs
|
||||
)
|
||||
),
|
||||
]);
|
||||
};
|
||||
106
api/src/index.ts
106
api/src/index.ts
@@ -15,6 +15,8 @@ import { WebSocket } from 'ws';
|
||||
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
|
||||
import { TimeoutBudget } from '@app/core/utils/misc/timeout-budget.js';
|
||||
import { withTimeout } from '@app/core/utils/misc/with-timeout.js';
|
||||
import { getServerIdentifier } from '@app/core/utils/server-identifier.js';
|
||||
import { environment, PATHS_CONFIG_MODULES, PORT } from '@app/environment.js';
|
||||
import * as envVars from '@app/environment.js';
|
||||
@@ -28,13 +30,23 @@ import { StateManager } from '@app/store/watch/state-watch.js';
|
||||
|
||||
let server: NestFastifyApplication<RawServerDefault> | null = null;
|
||||
|
||||
// PM2 listen_timeout is 15 seconds (ecosystem.config.json)
|
||||
// We use 13 seconds as our total budget to ensure our timeout triggers before PM2 kills us
|
||||
const TOTAL_STARTUP_BUDGET_MS = 30_000;
|
||||
// Reserve time for the NestJS bootstrap (the most critical and time-consuming operation)
|
||||
const BOOTSTRAP_RESERVED_MS = 20_000;
|
||||
// Maximum time for any single pre-bootstrap operation
|
||||
const MAX_OPERATION_TIMEOUT_MS = 5_000;
|
||||
|
||||
const unlinkUnixPort = () => {
|
||||
if (isNaN(parseInt(PORT, 10))) {
|
||||
if (fileExistsSync(PORT)) unlinkSync(PORT);
|
||||
}
|
||||
};
|
||||
|
||||
export const viteNodeApp = async () => {
|
||||
export const viteNodeApp = async (): Promise<NestFastifyApplication<RawServerDefault>> => {
|
||||
const budget = new TimeoutBudget(TOTAL_STARTUP_BUDGET_MS);
|
||||
|
||||
try {
|
||||
await import('json-bigint-patch');
|
||||
environment.IS_MAIN_PROCESS = true;
|
||||
@@ -42,15 +54,15 @@ export const viteNodeApp = async () => {
|
||||
/**------------------------------------------------------------------------
|
||||
* Attaching getServerIdentifier to globalThis
|
||||
|
||||
* getServerIdentifier is tightly coupled to the deprecated redux store,
|
||||
* getServerIdentifier is tightly coupled to the deprecated redux store,
|
||||
* which we don't want to share with other packages or plugins.
|
||||
*
|
||||
*
|
||||
* At the same time, we need to use it in @unraid/shared as a building block,
|
||||
* where it's used & available outside of NestJS's DI context.
|
||||
*
|
||||
* Attaching to globalThis is a temporary solution to avoid refactoring
|
||||
*
|
||||
* Attaching to globalThis is a temporary solution to avoid refactoring
|
||||
* config sync & management outside of NestJS's DI context.
|
||||
*
|
||||
*
|
||||
* Plugin authors should import getServerIdentifier from @unraid/shared instead,
|
||||
* to avoid breaking changes to their code.
|
||||
*------------------------------------------------------------------------**/
|
||||
@@ -58,7 +70,18 @@ export const viteNodeApp = async () => {
|
||||
logger.info('ENV %o', envVars);
|
||||
logger.info('PATHS %o', store.getState().paths);
|
||||
|
||||
await mkdir(PATHS_CONFIG_MODULES, { recursive: true });
|
||||
// Note: we use logger.info for checkpoints instead of a lower log level
|
||||
// to ensure emission during an unraid server's boot,
|
||||
// where the log level will be set to INFO by default.
|
||||
|
||||
// Create config directory
|
||||
try {
|
||||
await mkdir(PATHS_CONFIG_MODULES, { recursive: true });
|
||||
logger.info('Config directory ready');
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to create config directory');
|
||||
throw error;
|
||||
}
|
||||
|
||||
const cacheable = new CacheableLookup();
|
||||
|
||||
@@ -68,29 +91,73 @@ export const viteNodeApp = async () => {
|
||||
cacheable.install(https.globalAgent);
|
||||
|
||||
// Load emhttp state into store
|
||||
await store.dispatch(loadStateFiles());
|
||||
try {
|
||||
const timeout = budget.getTimeout(MAX_OPERATION_TIMEOUT_MS, BOOTSTRAP_RESERVED_MS);
|
||||
await withTimeout(store.dispatch(loadStateFiles()), timeout, 'loadStateFiles');
|
||||
logger.info('Emhttp state loaded');
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to load emhttp state files');
|
||||
logger.warn('Continuing with default state');
|
||||
}
|
||||
|
||||
// Load initial registration key into store
|
||||
await store.dispatch(loadRegistrationKey());
|
||||
try {
|
||||
const timeout = budget.getTimeout(MAX_OPERATION_TIMEOUT_MS, BOOTSTRAP_RESERVED_MS);
|
||||
await withTimeout(store.dispatch(loadRegistrationKey()), timeout, 'loadRegistrationKey');
|
||||
logger.info('Registration key loaded');
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to load registration key');
|
||||
logger.warn('Continuing without registration key');
|
||||
}
|
||||
|
||||
// Load my dynamix config file into store
|
||||
loadDynamixConfig();
|
||||
try {
|
||||
loadDynamixConfig();
|
||||
logger.info('Dynamix config loaded');
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to load dynamix config');
|
||||
logger.warn('Continuing with default dynamix config');
|
||||
}
|
||||
|
||||
// Start listening to file updates
|
||||
StateManager.getInstance();
|
||||
try {
|
||||
StateManager.getInstance();
|
||||
logger.info('State manager initialized');
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to initialize state manager');
|
||||
logger.warn('Continuing without state watching');
|
||||
}
|
||||
|
||||
// Start listening to key file changes
|
||||
setupRegistrationKeyWatch();
|
||||
try {
|
||||
setupRegistrationKeyWatch();
|
||||
logger.info('Registration key watch active');
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to setup registration key watch');
|
||||
logger.warn('Continuing without key file watching');
|
||||
}
|
||||
|
||||
// If port is unix socket, delete old socket before starting http server
|
||||
unlinkUnixPort();
|
||||
|
||||
startMiddlewareListeners();
|
||||
|
||||
// Start webserver
|
||||
const { bootstrapNestServer } = await import('@app/unraid-api/main.js');
|
||||
|
||||
server = await bootstrapNestServer();
|
||||
// Start webserver - use all remaining budget
|
||||
try {
|
||||
const bootstrapTimeout = budget.remaining();
|
||||
if (bootstrapTimeout < 1000) {
|
||||
logger.warn(
|
||||
`Insufficient startup budget remaining (${bootstrapTimeout}ms) for NestJS bootstrap`
|
||||
);
|
||||
}
|
||||
logger.info('Bootstrapping NestJS server (budget: %dms)...', bootstrapTimeout);
|
||||
const { bootstrapNestServer } = await import('@app/unraid-api/main.js');
|
||||
server = await withTimeout(bootstrapNestServer(), bootstrapTimeout, 'bootstrapNestServer');
|
||||
logger.info('Startup complete in %dms', budget.elapsed());
|
||||
} catch (error) {
|
||||
logger.error(error, 'Failed to start NestJS server');
|
||||
throw error; // This is critical - must rethrow to trigger graceful exit
|
||||
}
|
||||
|
||||
asyncExitHook(
|
||||
async (signal) => {
|
||||
@@ -103,8 +170,10 @@ export const viteNodeApp = async () => {
|
||||
|
||||
gracefulExit();
|
||||
},
|
||||
{ wait: 9999 }
|
||||
{ wait: 10_000 }
|
||||
);
|
||||
|
||||
return server;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(error, 'API-ERROR');
|
||||
@@ -115,8 +184,9 @@ export const viteNodeApp = async () => {
|
||||
await server?.close?.();
|
||||
}
|
||||
shutdownApiEvent();
|
||||
// Kill application
|
||||
// Kill application - gracefulExit calls process.exit but TS doesn't know it never returns
|
||||
gracefulExit(1);
|
||||
throw new Error('Unreachable');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
import { watch } from 'chokidar';
|
||||
|
||||
import { CHOKIDAR_USEPOLLING } from '@app/environment.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { keyServerLogger } from '@app/core/log.js';
|
||||
import { getters, store } from '@app/store/index.js';
|
||||
import { loadSingleStateFile } from '@app/store/modules/emhttp.js';
|
||||
import { loadRegistrationKey } from '@app/store/modules/registration.js';
|
||||
import { StateFileKey } from '@app/store/types.js';
|
||||
|
||||
/**
|
||||
* Reloads var.ini with retry logic to handle timing issues with emhttpd.
|
||||
* When a key file changes, emhttpd needs time to process it and update var.ini.
|
||||
* This function retries loading var.ini until the registration state changes
|
||||
* or max retries are exhausted.
|
||||
*/
|
||||
export const reloadVarIniWithRetry = async (maxRetries = 3): Promise<void> => {
|
||||
const beforeState = getters.emhttp().var?.regTy;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const delay = 500 * Math.pow(2, attempt); // 500ms, 1s, 2s
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
await store.dispatch(loadSingleStateFile(StateFileKey.var));
|
||||
|
||||
const afterState = getters.emhttp().var?.regTy;
|
||||
if (beforeState !== afterState) {
|
||||
keyServerLogger.info('Registration state updated: %s -> %s', beforeState, afterState);
|
||||
return;
|
||||
}
|
||||
keyServerLogger.debug('Retry %d: var.ini regTy still %s', attempt + 1, afterState);
|
||||
}
|
||||
keyServerLogger.debug('var.ini regTy unchanged after %d retries (may be expected)', maxRetries);
|
||||
};
|
||||
|
||||
export const setupRegistrationKeyWatch = () => {
|
||||
// IMPORTANT: /boot/config is on FAT32 flash drive which does NOT support inotify
|
||||
// Must use polling to detect file changes on FAT32 filesystems
|
||||
watch('/boot/config', {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
ignored: (path: string) => !path.endsWith('.key'),
|
||||
usePolling: CHOKIDAR_USEPOLLING === true,
|
||||
}).on('all', async () => {
|
||||
// Load updated key into store
|
||||
usePolling: true, // Required for FAT32 - inotify doesn't work
|
||||
interval: 5000, // Poll every 5 seconds (balance between responsiveness and CPU usage)
|
||||
}).on('all', async (event, path) => {
|
||||
keyServerLogger.info('Key file %s: %s', event, path);
|
||||
|
||||
await store.dispatch(loadRegistrationKey());
|
||||
|
||||
// Reload var.ini to get updated registration metadata from emhttpd
|
||||
await reloadVarIniWithRetry();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -10,7 +11,11 @@ describe('Module Dependencies Integration', () => {
|
||||
let module;
|
||||
try {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [CacheModule.register({ isGlobal: true }), RestModule],
|
||||
imports: [
|
||||
ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
|
||||
CacheModule.register({ isGlobal: true }),
|
||||
RestModule,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
expect(module).toBeDefined();
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ApiConfig } from '@unraid/shared/services/api-config.js';
|
||||
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
|
||||
import { csvStringToArray } from '@unraid/shared/util/data.js';
|
||||
|
||||
import { isConnectPluginInstalled } from '@app/connect-plugin-cleanup.js';
|
||||
import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js';
|
||||
|
||||
export { type ApiConfig };
|
||||
@@ -29,6 +30,13 @@ export const loadApiConfig = async () => {
|
||||
const apiHandler = new ApiConfigPersistence(new ConfigService()).getFileHandler();
|
||||
|
||||
const diskConfig: Partial<ApiConfig> = await apiHandler.loadConfig();
|
||||
// Hack: cleanup stale connect plugin entry if necessary
|
||||
if (!isConnectPluginInstalled()) {
|
||||
diskConfig.plugins = diskConfig.plugins?.filter(
|
||||
(plugin) => plugin !== 'unraid-api-plugin-connect'
|
||||
);
|
||||
await apiHandler.writeConfigFile(diskConfig as ApiConfig);
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CustomizationMutationsResolver } from '@app/unraid-api/graph/resolvers/customization/customization.mutations.resolver.js';
|
||||
import { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js';
|
||||
import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [CustomizationService, CustomizationResolver],
|
||||
providers: [CustomizationService, CustomizationResolver, CustomizationMutationsResolver],
|
||||
exports: [CustomizationService],
|
||||
})
|
||||
export class CustomizationModule {}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { CustomizationService } from '@app/unraid-api/graph/resolvers/customization/customization.service.js';
|
||||
import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js';
|
||||
import { CustomizationMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
|
||||
|
||||
@Resolver(() => CustomizationMutations)
|
||||
export class CustomizationMutationsResolver {
|
||||
constructor(private readonly customizationService: CustomizationService) {}
|
||||
|
||||
@ResolveField(() => Theme, { description: 'Update the UI theme (writes dynamix.cfg)' })
|
||||
@UsePermissions({
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.CUSTOMIZATIONS,
|
||||
})
|
||||
async setTheme(
|
||||
@Args('theme', { type: () => ThemeName, description: 'Theme to apply' })
|
||||
theme: ThemeName
|
||||
): Promise<Theme> {
|
||||
return this.customizationService.setTheme(theme);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,9 @@ import * as ini from 'ini';
|
||||
|
||||
import { emcmd } from '@app/core/utils/clients/emcmd.js';
|
||||
import { fileExists } from '@app/core/utils/files/file-exists.js';
|
||||
import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js';
|
||||
import { getters, store } from '@app/store/index.js';
|
||||
import { updateDynamixConfig } from '@app/store/modules/dynamix.js';
|
||||
import {
|
||||
ActivationCode,
|
||||
PublicPartnerInfo,
|
||||
@@ -466,4 +468,16 @@ export class CustomizationService implements OnModuleInit {
|
||||
showHeaderDescription: descriptionShow === 'yes',
|
||||
};
|
||||
}
|
||||
|
||||
public async setTheme(theme: ThemeName): Promise<Theme> {
|
||||
this.logger.log(`Updating theme to ${theme}`);
|
||||
await this.updateCfgFile(this.configFile, 'display', { theme });
|
||||
|
||||
// Refresh in-memory store so subsequent reads get the new theme without a restart
|
||||
const paths = getters.paths();
|
||||
const updatedConfig = loadDynamixConfigFromDiskSync(paths['dynamix-config']);
|
||||
store.dispatch(updateDynamixConfig(updatedConfig));
|
||||
|
||||
return this.getTheme();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ export class VmMutations {}
|
||||
})
|
||||
export class ApiKeyMutations {}
|
||||
|
||||
@ObjectType({
|
||||
description: 'Customization related mutations',
|
||||
})
|
||||
export class CustomizationMutations {}
|
||||
|
||||
@ObjectType({
|
||||
description: 'Parity check related mutations, WIP, response types and functionaliy will change',
|
||||
})
|
||||
@@ -54,6 +59,9 @@ export class RootMutations {
|
||||
@Field(() => ApiKeyMutations, { description: 'API Key related mutations' })
|
||||
apiKey: ApiKeyMutations = new ApiKeyMutations();
|
||||
|
||||
@Field(() => CustomizationMutations, { description: 'Customization related mutations' })
|
||||
customization: CustomizationMutations = new CustomizationMutations();
|
||||
|
||||
@Field(() => ParityCheckMutations, { description: 'Parity check related mutations' })
|
||||
parityCheck: ParityCheckMutations = new ParityCheckMutations();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Mutation, Resolver } from '@nestjs/graphql';
|
||||
import {
|
||||
ApiKeyMutations,
|
||||
ArrayMutations,
|
||||
CustomizationMutations,
|
||||
DockerMutations,
|
||||
ParityCheckMutations,
|
||||
RCloneMutations,
|
||||
@@ -37,6 +38,11 @@ export class RootMutationsResolver {
|
||||
return new ApiKeyMutations();
|
||||
}
|
||||
|
||||
@Mutation(() => CustomizationMutations, { name: 'customization' })
|
||||
customization(): CustomizationMutations {
|
||||
return new CustomizationMutations();
|
||||
}
|
||||
|
||||
@Mutation(() => RCloneMutations, { name: 'rclone' })
|
||||
rclone(): RCloneMutations {
|
||||
return new RCloneMutations();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { UserSettingsModule } from '@unraid/shared/services/user-settings.js';
|
||||
|
||||
@@ -7,7 +8,7 @@ import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/
|
||||
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [UserSettingsModule, forwardRef(() => OidcClientModule)],
|
||||
imports: [ConfigModule, UserSettingsModule, forwardRef(() => OidcClientModule)],
|
||||
providers: [OidcConfigPersistence, OidcValidationService],
|
||||
exports: [OidcConfigPersistence, OidcValidationService],
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('UPSResolver', () => {
|
||||
MODEL: 'Test UPS',
|
||||
STATUS: 'Online',
|
||||
BCHARGE: '100',
|
||||
TIMELEFT: '3600',
|
||||
TIMELEFT: '60', // 60 minutes (apcupsd format)
|
||||
LINEV: '120.5',
|
||||
OUTPUTV: '120.5',
|
||||
LOADPCT: '25',
|
||||
|
||||
@@ -21,7 +21,8 @@ export class UPSResolver {
|
||||
status: upsData.STATUS || 'Online',
|
||||
battery: {
|
||||
chargeLevel: parseInt(upsData.BCHARGE || '100', 10),
|
||||
estimatedRuntime: parseInt(upsData.TIMELEFT || '3600', 10),
|
||||
// Convert TIMELEFT from minutes (apcupsd format) to seconds
|
||||
estimatedRuntime: Math.round(parseFloat(upsData.TIMELEFT || '60') * 60),
|
||||
health: 'Good',
|
||||
},
|
||||
power: {
|
||||
|
||||
@@ -21,9 +21,19 @@ describe('PluginManagementService', () => {
|
||||
if (key === 'api.plugins') {
|
||||
return configStore ?? defaultValue ?? [];
|
||||
}
|
||||
if (key === 'api') {
|
||||
return { plugins: configStore ?? defaultValue ?? [] };
|
||||
}
|
||||
return defaultValue;
|
||||
}),
|
||||
set: vi.fn((key: string, value: unknown) => {
|
||||
if (key === 'api' && typeof value === 'object' && value !== null) {
|
||||
// @ts-expect-error - value is an object
|
||||
if (Array.isArray(value.plugins)) {
|
||||
// @ts-expect-error - value is an object
|
||||
configStore = [...value.plugins];
|
||||
}
|
||||
}
|
||||
if (key === 'api.plugins' && Array.isArray(value)) {
|
||||
configStore = [...value];
|
||||
}
|
||||
|
||||
@@ -56,8 +56,7 @@ export class PluginManagementService {
|
||||
}
|
||||
pluginSet.add(plugin);
|
||||
});
|
||||
// @ts-expect-error - This is a valid config key
|
||||
this.configService.set<string[]>('api.plugins', Array.from(pluginSet));
|
||||
this.updatePluginsConfig(Array.from(pluginSet));
|
||||
return added;
|
||||
}
|
||||
|
||||
@@ -71,11 +70,15 @@ export class PluginManagementService {
|
||||
const pluginSet = new Set(this.plugins);
|
||||
const removed = plugins.filter((plugin) => pluginSet.delete(plugin));
|
||||
const pluginsArray = Array.from(pluginSet);
|
||||
// @ts-expect-error - This is a valid config key
|
||||
this.configService.set('api.plugins', pluginsArray);
|
||||
this.updatePluginsConfig(pluginsArray);
|
||||
return removed;
|
||||
}
|
||||
|
||||
private updatePluginsConfig(plugins: string[]) {
|
||||
const apiConfig = this.configService.get<ApiConfig>('api');
|
||||
this.configService.set('api', { ...apiConfig, plugins });
|
||||
}
|
||||
|
||||
/**
|
||||
* Install bundle / unbundled plugins using npm or direct with the config.
|
||||
*
|
||||
|
||||
@@ -91,13 +91,9 @@ export class PluginService {
|
||||
return name;
|
||||
})
|
||||
);
|
||||
const { peerDependencies } = getPackageJson();
|
||||
// All api plugins must be installed as peer dependencies of the unraid-api package
|
||||
if (!peerDependencies) {
|
||||
PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.');
|
||||
return [];
|
||||
}
|
||||
const pluginTuples = Object.entries(peerDependencies).filter(
|
||||
const { peerDependencies = {}, dependencies = {} } = getPackageJson();
|
||||
const allDependencies = { ...peerDependencies, ...dependencies };
|
||||
const pluginTuples = Object.entries(allDependencies).filter(
|
||||
(entry): entry is [string, string] => {
|
||||
const [pkgName, version] = entry;
|
||||
return pluginNames.has(pkgName) && typeof version === 'string';
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('@app/store/index.js', () => ({
|
||||
getters: {
|
||||
paths: vi.fn(() => ({
|
||||
'log-base': '/tmp/logs',
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: vi.fn().mockResolvedValue({ stdout: 'mocked output' }),
|
||||
}));
|
||||
|
||||
describe('RestService Dependencies', () => {
|
||||
it('should resolve ApiReportService dependency successfully', async () => {
|
||||
const mockApiReportService = {
|
||||
generateReport: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }),
|
||||
};
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
RestService,
|
||||
{
|
||||
provide: ApiReportService,
|
||||
useValue: mockApiReportService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const restService = module.get<RestService>(RestService);
|
||||
expect(restService).toBeDefined();
|
||||
expect(restService).toBeInstanceOf(RestService);
|
||||
|
||||
await module.close();
|
||||
});
|
||||
|
||||
it('should fail gracefully when ApiReportService is missing', async () => {
|
||||
// This test ensures we get a clear error when dependencies are missing
|
||||
await expect(
|
||||
Test.createTestingModule({
|
||||
providers: [RestService],
|
||||
}).compile()
|
||||
).rejects.toThrow(/ApiReportService/);
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { CacheModule } from '@nestjs/cache-manager';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||
|
||||
// Mock external dependencies that cause issues in tests
|
||||
vi.mock('@app/store/index.js', () => ({
|
||||
store: {
|
||||
getState: vi.fn(() => ({
|
||||
paths: {
|
||||
'log-base': '/tmp/logs',
|
||||
'auth-keys': '/tmp/auth-keys',
|
||||
config: '/tmp/config',
|
||||
},
|
||||
emhttp: {},
|
||||
dynamix: { notify: { path: '/tmp/notifications' } },
|
||||
registration: {},
|
||||
})),
|
||||
subscribe: vi.fn(() => vi.fn()), // Return unsubscribe function
|
||||
},
|
||||
getters: {
|
||||
paths: vi.fn(() => ({
|
||||
'log-base': '/tmp/logs',
|
||||
'auth-keys': '/tmp/auth-keys',
|
||||
config: '/tmp/config',
|
||||
})),
|
||||
dynamix: vi.fn(() => ({ notify: { path: '/tmp/notifications' } })),
|
||||
emhttp: vi.fn(() => ({})),
|
||||
registration: vi.fn(() => ({})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/core/log.js', () => ({
|
||||
levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'],
|
||||
apiLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
pluginLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('execa', () => ({
|
||||
execa: vi.fn().mockResolvedValue({ stdout: 'mocked output' }),
|
||||
}));
|
||||
|
||||
describe('RestModule Integration', () => {
|
||||
it('should compile with RestService having access to ApiReportService', async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [CacheModule.register({ isGlobal: true }), RestModule],
|
||||
})
|
||||
// Override services that have complex dependencies for testing
|
||||
.overrideProvider(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
||||
.useValue({ getClient: vi.fn() })
|
||||
.overrideProvider(LogService)
|
||||
.useValue({ error: vi.fn(), debug: vi.fn() })
|
||||
.compile();
|
||||
|
||||
const restService = module.get<RestService>(RestService);
|
||||
const apiReportService = module.get<ApiReportService>(ApiReportService);
|
||||
|
||||
expect(restService).toBeDefined();
|
||||
expect(apiReportService).toBeDefined();
|
||||
|
||||
// Verify RestService has the injected ApiReportService
|
||||
expect(restService['apiReportService']).toBeDefined();
|
||||
|
||||
await module.close();
|
||||
}, 10000);
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||
|
||||
const mockWriteFile = vi.fn();
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
writeFile: (...args: any[]) => mockWriteFile(...args),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock ApiReportService
|
||||
const mockApiReportService = {
|
||||
generateReport: vi.fn(),
|
||||
};
|
||||
|
||||
describe('RestService', () => {
|
||||
let restService: RestService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [RestService, { provide: ApiReportService, useValue: mockApiReportService }],
|
||||
}).compile();
|
||||
|
||||
restService = module.get<RestService>(RestService);
|
||||
|
||||
// Clear mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('saveApiReport', () => {
|
||||
it('should generate report using ApiReportService and save to file', async () => {
|
||||
const mockReport = {
|
||||
timestamp: '2023-01-01T00:00:00.000Z',
|
||||
connectionStatus: {
|
||||
running: 'yes' as const,
|
||||
},
|
||||
system: {
|
||||
id: 'test-uuid',
|
||||
name: 'Test Server',
|
||||
version: '6.12.0',
|
||||
machineId: 'REDACTED',
|
||||
manufacturer: 'Test Manufacturer',
|
||||
model: 'Test Model',
|
||||
},
|
||||
connect: {
|
||||
installed: true,
|
||||
dynamicRemoteAccess: {
|
||||
enabledType: 'STATIC',
|
||||
runningType: 'STATIC',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
config: {
|
||||
valid: true,
|
||||
error: null,
|
||||
},
|
||||
services: {
|
||||
cloud: { name: 'cloud', online: true },
|
||||
minigraph: { name: 'minigraph', online: false },
|
||||
allServices: [],
|
||||
},
|
||||
remote: {
|
||||
apikey: 'REDACTED',
|
||||
localApiKey: 'REDACTED',
|
||||
accesstoken: 'REDACTED',
|
||||
idtoken: 'REDACTED',
|
||||
refreshtoken: 'REDACTED',
|
||||
ssoSubIds: 'REDACTED',
|
||||
allowedOrigins: 'REDACTED',
|
||||
email: 'REDACTED',
|
||||
},
|
||||
};
|
||||
|
||||
const reportPath = '/tmp/test-report.json';
|
||||
mockApiReportService.generateReport.mockResolvedValue(mockReport);
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
|
||||
await restService.saveApiReport(reportPath);
|
||||
|
||||
// Verify ApiReportService was called (defaults to API running)
|
||||
expect(mockApiReportService.generateReport).toHaveBeenCalledWith();
|
||||
|
||||
// Verify file was written with correct content
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
reportPath,
|
||||
JSON.stringify(mockReport, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ApiReportService errors gracefully', async () => {
|
||||
const reportPath = '/tmp/test-report.json';
|
||||
const error = new Error('Report generation failed');
|
||||
mockApiReportService.generateReport.mockRejectedValue(error);
|
||||
|
||||
// Should not throw error
|
||||
await restService.saveApiReport(reportPath);
|
||||
|
||||
// Verify ApiReportService was called
|
||||
expect(mockApiReportService.generateReport).toHaveBeenCalled();
|
||||
|
||||
// Verify file write was not called due to error
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle file write errors gracefully', async () => {
|
||||
const mockReport = {
|
||||
timestamp: '2023-01-01T00:00:00.000Z',
|
||||
system: { name: 'Test' },
|
||||
};
|
||||
|
||||
const reportPath = '/tmp/test-report.json';
|
||||
mockApiReportService.generateReport.mockResolvedValue(mockReport);
|
||||
mockWriteFile.mockRejectedValue(new Error('File write failed'));
|
||||
|
||||
// Should not throw error
|
||||
await restService.saveApiReport(reportPath);
|
||||
|
||||
// Verify both service and file operations were attempted
|
||||
expect(mockApiReportService.generateReport).toHaveBeenCalled();
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
reportPath,
|
||||
JSON.stringify(mockReport, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,6 @@ describe('RestController', () => {
|
||||
{
|
||||
provide: RestService,
|
||||
useValue: {
|
||||
getLogs: vi.fn(),
|
||||
getCustomizationStream: vi.fn(),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,21 +29,6 @@ export class RestController {
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
@Get('/graphql/api/logs')
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.LOGS,
|
||||
})
|
||||
async getLogs(@Res() res: FastifyReply) {
|
||||
try {
|
||||
const logStream = await this.restService.getLogs();
|
||||
return res.status(200).type('application/x-gtar').send(logStream);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(error);
|
||||
return res.status(500).send(`Error: Failed to get logs`);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('/graphql/api/customizations/:type')
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js';
|
||||
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
|
||||
import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js';
|
||||
import { RestController } from '@app/unraid-api/rest/rest.controller.js';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [RCloneModule, CliServicesModule, SsoModule],
|
||||
imports: [RCloneModule, SsoModule],
|
||||
controllers: [RestController],
|
||||
providers: [RestService],
|
||||
})
|
||||
|
||||
@@ -1,33 +1,88 @@
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import type { ReadStream } from 'node:fs';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import {
|
||||
getBannerPathIfPresent,
|
||||
getCasePathIfPresent,
|
||||
} from '@app/core/utils/images/image-file-helpers.js';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
|
||||
getBannerPathIfPresent: vi.fn(),
|
||||
getCasePathIfPresent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('RestService', () => {
|
||||
let service: RestService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockApiReportService = {
|
||||
generateReport: vi.fn(),
|
||||
};
|
||||
vi.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RestService,
|
||||
{
|
||||
provide: ApiReportService,
|
||||
useValue: mockApiReportService,
|
||||
},
|
||||
],
|
||||
providers: [RestService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RestService>(RestService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
describe('getCustomizationPath', () => {
|
||||
it('returns banner path when present', async () => {
|
||||
const mockBannerPath = '/path/to/banner.png';
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath);
|
||||
|
||||
await expect(service.getCustomizationPath('banner')).resolves.toBe(mockBannerPath);
|
||||
});
|
||||
|
||||
it('returns case path when present', async () => {
|
||||
const mockCasePath = '/path/to/case.png';
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath);
|
||||
|
||||
await expect(service.getCustomizationPath('case')).resolves.toBe(mockCasePath);
|
||||
});
|
||||
|
||||
it('returns null when no path is available', async () => {
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCustomizationPath('banner')).resolves.toBeNull();
|
||||
await expect(service.getCustomizationPath('case')).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomizationStream', () => {
|
||||
it('returns read stream for banner', async () => {
|
||||
const mockPath = '/path/to/banner.png';
|
||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath);
|
||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||
|
||||
await expect(service.getCustomizationStream('banner')).resolves.toBe(mockStream);
|
||||
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
||||
});
|
||||
|
||||
it('returns read stream for case', async () => {
|
||||
const mockPath = '/path/to/case.png';
|
||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath);
|
||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||
|
||||
await expect(service.getCustomizationStream('case')).resolves.toBe(mockStream);
|
||||
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
||||
});
|
||||
|
||||
it('throws when no customization is available', async () => {
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
|
||||
await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import type { ReadStream, Stats } from 'node:fs';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat, writeFile } from 'node:fs/promises';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { execa, ExecaError } from 'execa';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ApiReportData } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import {
|
||||
getBannerPathIfPresent,
|
||||
getCasePathIfPresent,
|
||||
} from '@app/core/utils/images/image-file-helpers.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('execa');
|
||||
vi.mock('@app/store/index.js');
|
||||
vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
|
||||
getBannerPathIfPresent: vi.fn(),
|
||||
getCasePathIfPresent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('RestService', () => {
|
||||
let service: RestService;
|
||||
let apiReportService: ApiReportService;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RestService,
|
||||
{
|
||||
provide: ApiReportService,
|
||||
useValue: {
|
||||
generateReport: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RestService>(RestService);
|
||||
apiReportService = module.get<ApiReportService>(ApiReportService);
|
||||
});
|
||||
|
||||
describe('getLogs', () => {
|
||||
const mockLogPath = '/usr/local/emhttp/logs/unraid-api';
|
||||
const mockGraphqlApiLog = '/var/log/graphql-api.log';
|
||||
const mockZipPath = '/usr/local/emhttp/logs/unraid-api.tar.gz';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getters).paths = vi.fn().mockReturnValue({
|
||||
'log-base': mockLogPath,
|
||||
});
|
||||
// Mock saveApiReport to avoid side effects
|
||||
vi.spyOn(service as any, 'saveApiReport').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should create and return log archive successfully', async () => {
|
||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath || path === mockZipPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
vi.mocked(execa).mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
} as any);
|
||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||
|
||||
const result = await service.getLogs();
|
||||
|
||||
expect(execa).toHaveBeenCalledWith('tar', ['-czf', mockZipPath, mockLogPath], {
|
||||
timeout: 60000,
|
||||
reject: true,
|
||||
});
|
||||
expect(createReadStream).toHaveBeenCalledWith(mockZipPath);
|
||||
expect(result).toBe(mockStream);
|
||||
});
|
||||
|
||||
it('should include graphql-api.log when it exists', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath || path === mockGraphqlApiLog || path === mockZipPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
vi.mocked(execa).mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
} as any);
|
||||
vi.mocked(createReadStream).mockReturnValue(Readable.from([]) as ReadStream);
|
||||
|
||||
await service.getLogs();
|
||||
|
||||
expect(execa).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-czf', mockZipPath, mockLogPath, mockGraphqlApiLog],
|
||||
{
|
||||
timeout: 60000,
|
||||
reject: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle timeout errors with detailed message', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
|
||||
const timeoutError = new Error('Command timed out') as ExecaError;
|
||||
timeoutError.timedOut = true;
|
||||
timeoutError.command =
|
||||
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
|
||||
timeoutError.exitCode = undefined;
|
||||
timeoutError.stderr = '';
|
||||
timeoutError.stdout = '';
|
||||
|
||||
vi.mocked(execa).mockRejectedValue(timeoutError);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow('Tar command timed out after 60 seconds');
|
||||
});
|
||||
|
||||
it('should handle command failure with exit code and stderr', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
|
||||
const execError = new Error('Command failed') as ExecaError;
|
||||
execError.exitCode = 1;
|
||||
execError.command =
|
||||
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
|
||||
execError.stderr = 'tar: Cannot create archive';
|
||||
execError.stdout = '';
|
||||
execError.shortMessage = 'Command failed with exit code 1';
|
||||
|
||||
vi.mocked(execa).mockRejectedValue(execError);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow('Tar command failed with exit code 1');
|
||||
await expect(service.getLogs()).rejects.toThrow('tar: Cannot create archive');
|
||||
});
|
||||
|
||||
it('should handle case when tar succeeds but zip file is not created', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
// Zip file doesn't exist after tar command
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
vi.mocked(execa).mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
} as any);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow(
|
||||
'Failed to create log zip - tar file not found after successful command'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when log path does not exist', async () => {
|
||||
vi.mocked(stat).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow('No logs to download');
|
||||
});
|
||||
|
||||
it('should handle generic errors', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
|
||||
const genericError = new Error('Unexpected error');
|
||||
vi.mocked(execa).mockRejectedValue(genericError);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow(
|
||||
'Failed to create logs archive: Unexpected error'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors with stdout in addition to stderr', async () => {
|
||||
vi.mocked(stat).mockImplementation((path) => {
|
||||
if (path === mockLogPath) {
|
||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
||||
}
|
||||
return Promise.reject(new Error('File not found'));
|
||||
});
|
||||
|
||||
const execError = new Error('Command failed') as ExecaError;
|
||||
execError.exitCode = 1;
|
||||
execError.command =
|
||||
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
|
||||
execError.stderr = 'tar: Error';
|
||||
execError.stdout = 'Processing archive...';
|
||||
execError.shortMessage = 'Command failed with exit code 1';
|
||||
|
||||
vi.mocked(execa).mockRejectedValue(execError);
|
||||
|
||||
await expect(service.getLogs()).rejects.toThrow('Stdout: Processing archive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveApiReport', () => {
|
||||
it('should generate and save API report', async () => {
|
||||
const mockReport: ApiReportData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionStatus: { running: 'yes' },
|
||||
system: {
|
||||
name: 'Test Server',
|
||||
version: '6.12.0',
|
||||
machineId: 'test-machine-id',
|
||||
},
|
||||
connect: {
|
||||
installed: false,
|
||||
},
|
||||
config: {
|
||||
valid: true,
|
||||
},
|
||||
services: {
|
||||
cloud: null,
|
||||
minigraph: null,
|
||||
allServices: [],
|
||||
},
|
||||
};
|
||||
const mockPath = '/test/report.json';
|
||||
|
||||
vi.mocked(apiReportService.generateReport).mockResolvedValue(mockReport);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await service.saveApiReport(mockPath);
|
||||
|
||||
expect(apiReportService.generateReport).toHaveBeenCalled();
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
mockPath,
|
||||
JSON.stringify(mockReport, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when generating report', async () => {
|
||||
const mockPath = '/test/report.json';
|
||||
|
||||
vi.mocked(apiReportService.generateReport).mockRejectedValue(
|
||||
new Error('Report generation failed')
|
||||
);
|
||||
|
||||
// Should not throw, just log warning
|
||||
await expect(service.saveApiReport(mockPath)).resolves.toBeUndefined();
|
||||
expect(apiReportService.generateReport).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomizationPath', () => {
|
||||
it('should return banner path when type is banner', async () => {
|
||||
const mockBannerPath = '/path/to/banner.png';
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath);
|
||||
|
||||
const result = await service.getCustomizationPath('banner');
|
||||
|
||||
expect(getBannerPathIfPresent).toHaveBeenCalled();
|
||||
expect(result).toBe(mockBannerPath);
|
||||
});
|
||||
|
||||
it('should return case path when type is case', async () => {
|
||||
const mockCasePath = '/path/to/case.png';
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath);
|
||||
|
||||
const result = await service.getCustomizationPath('case');
|
||||
|
||||
expect(getCasePathIfPresent).toHaveBeenCalled();
|
||||
expect(result).toBe(mockCasePath);
|
||||
});
|
||||
|
||||
it('should return null when no banner found', async () => {
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
||||
|
||||
const result = await service.getCustomizationPath('banner');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no case found', async () => {
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
||||
|
||||
const result = await service.getCustomizationPath('case');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomizationStream', () => {
|
||||
it('should return read stream for banner', async () => {
|
||||
const mockPath = '/path/to/banner.png';
|
||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath);
|
||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||
|
||||
const result = await service.getCustomizationStream('banner');
|
||||
|
||||
expect(getBannerPathIfPresent).toHaveBeenCalled();
|
||||
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
||||
expect(result).toBe(mockStream);
|
||||
});
|
||||
|
||||
it('should return read stream for case', async () => {
|
||||
const mockPath = '/path/to/case.png';
|
||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath);
|
||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||
|
||||
const result = await service.getCustomizationStream('case');
|
||||
|
||||
expect(getCasePathIfPresent).toHaveBeenCalled();
|
||||
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
||||
expect(result).toBe(mockStream);
|
||||
});
|
||||
|
||||
it('should throw error when no banner found', async () => {
|
||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
|
||||
});
|
||||
|
||||
it('should throw error when no case found', async () => {
|
||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,111 +1,14 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ReadStream } from 'node:fs';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { ExecaError } from 'execa';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import {
|
||||
getBannerPathIfPresent,
|
||||
getCasePathIfPresent,
|
||||
} from '@app/core/utils/images/image-file-helpers.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class RestService {
|
||||
protected logger = new Logger(RestService.name);
|
||||
|
||||
constructor(private readonly apiReportService: ApiReportService) {}
|
||||
|
||||
async saveApiReport(pathToReport: string) {
|
||||
try {
|
||||
const apiReport = await this.apiReportService.generateReport();
|
||||
this.logger.debug('Report object %o', apiReport);
|
||||
await writeFile(pathToReport, JSON.stringify(apiReport, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
this.logger.warn('Could not generate report for zip with error %o', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getLogs(): Promise<ReadStream> {
|
||||
const logPath = getters.paths()['log-base'];
|
||||
const graphqlApiLog = '/var/log/graphql-api.log';
|
||||
|
||||
try {
|
||||
await this.saveApiReport(join(logPath, 'report.json'));
|
||||
} catch (error) {
|
||||
this.logger.warn('Could not generate report for zip with error %o', error);
|
||||
}
|
||||
const zipToWrite = join(logPath, '../unraid-api.tar.gz');
|
||||
|
||||
const logPathExists = Boolean(await stat(logPath).catch(() => null));
|
||||
if (logPathExists) {
|
||||
try {
|
||||
// Build tar command arguments
|
||||
const tarArgs = ['-czf', zipToWrite, logPath];
|
||||
|
||||
// Check if graphql-api.log exists and add it to the archive
|
||||
const graphqlLogExists = Boolean(await stat(graphqlApiLog).catch(() => null));
|
||||
if (graphqlLogExists) {
|
||||
tarArgs.push(graphqlApiLog);
|
||||
this.logger.debug('Including graphql-api.log in archive');
|
||||
}
|
||||
|
||||
// Execute tar with timeout and capture output
|
||||
await execa('tar', tarArgs, {
|
||||
timeout: 60000, // 60 seconds timeout for tar operation
|
||||
reject: true, // Throw on non-zero exit (default behavior)
|
||||
});
|
||||
|
||||
const tarFileExists = Boolean(await stat(zipToWrite).catch(() => null));
|
||||
|
||||
if (tarFileExists) {
|
||||
return createReadStream(zipToWrite);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Failed to create log zip - tar file not found after successful command'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Build detailed error message with execa's built-in error info
|
||||
let errorMessage = 'Failed to create logs archive';
|
||||
|
||||
if (error && typeof error === 'object' && 'command' in error) {
|
||||
const execaError = error as ExecaError;
|
||||
|
||||
if (execaError.timedOut) {
|
||||
errorMessage = `Tar command timed out after 60 seconds. Command: ${execaError.command}`;
|
||||
} else if (execaError.exitCode !== undefined) {
|
||||
errorMessage = `Tar command failed with exit code ${execaError.exitCode}. Command: ${execaError.command}`;
|
||||
}
|
||||
|
||||
// Add stderr/stdout if available
|
||||
if (execaError.stderr) {
|
||||
errorMessage += `. Stderr: ${execaError.stderr}`;
|
||||
}
|
||||
if (execaError.stdout) {
|
||||
errorMessage += `. Stdout: ${execaError.stdout}`;
|
||||
}
|
||||
|
||||
// Include the short message from execa
|
||||
if (execaError.shortMessage) {
|
||||
errorMessage += `. Details: ${execaError.shortMessage}`;
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage += `: ${error.message}`;
|
||||
}
|
||||
|
||||
this.logger.error(errorMessage, error);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
} else {
|
||||
throw new Error('No logs to download');
|
||||
}
|
||||
}
|
||||
|
||||
async getCustomizationPath(type: 'banner' | 'case'): Promise<string | null> {
|
||||
switch (type) {
|
||||
case 'banner':
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "unraid-monorepo",
|
||||
"private": true,
|
||||
"version": "4.26.2",
|
||||
"version": "4.29.1",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",
|
||||
"codegen": "pnpm -r codegen",
|
||||
"i18n:extract": "pnpm --filter @unraid/api i18n:extract && pnpm --filter @unraid/web i18n:extract",
|
||||
"dev": "pnpm -r dev",
|
||||
"dev": "pnpm -r --parallel dev",
|
||||
"unraid:deploy": "pnpm -r unraid:deploy",
|
||||
"test": "pnpm -r test",
|
||||
"test:watch": "pnpm -r --parallel test:watch",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { Inject, Logger, Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { ConnectConfigPersister } from './config/config.persistence.js';
|
||||
import { configFeature } from './config/connect.config.js';
|
||||
@@ -30,64 +27,4 @@ class ConnectPluginModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback module keeps the export shape intact but only warns operators.
|
||||
* This makes `ApiModule` safe to import even when the plugin is absent.
|
||||
*/
|
||||
@Module({})
|
||||
export class DisabledConnectPluginModule {
|
||||
logger = new Logger(DisabledConnectPluginModule.name);
|
||||
async onModuleInit() {
|
||||
const removalCommand = 'unraid-api plugins remove -b unraid-api-plugin-connect';
|
||||
|
||||
this.logger.warn(
|
||||
'Connect plugin is not installed, but is listed as an API plugin. Attempting `%s` automatically.',
|
||||
removalCommand
|
||||
);
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execa('unraid-api', [
|
||||
'plugins',
|
||||
'remove',
|
||||
'-b',
|
||||
'unraid-api-plugin-connect',
|
||||
]);
|
||||
|
||||
if (stdout?.trim()) {
|
||||
this.logger.debug(stdout.trim());
|
||||
}
|
||||
|
||||
if (stderr?.trim()) {
|
||||
this.logger.debug(stderr.trim());
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
'Successfully completed `%s` to prune the stale connect plugin entry.',
|
||||
removalCommand
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error while removing stale connect plugin entry.';
|
||||
this.logger.error('Failed to run `%s`: %s', removalCommand, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local filesystem and env checks stay synchronous so we can branch at module load.
|
||||
*/
|
||||
const isConnectPluginInstalled = () => {
|
||||
if (process.env.SKIP_CONNECT_PLUGIN_CHECK === 'true') {
|
||||
return true;
|
||||
}
|
||||
return existsSync('/boot/config/plugins/dynamix.unraid.net.plg');
|
||||
};
|
||||
|
||||
/**
|
||||
* Downstream code always imports `ApiModule`. We swap the implementation based on availability,
|
||||
* avoiding dynamic module plumbing while keeping the DI graph predictable.
|
||||
* Set `SKIP_CONNECT_PLUGIN_CHECK=true` in development to force the connected path.
|
||||
*/
|
||||
export const ApiModule = isConnectPluginInstalled() ? ConnectPluginModule : DisabledConnectPluginModule;
|
||||
export const ApiModule = ConnectPluginModule;
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsc --project tsconfig.build.json",
|
||||
"build": "pnpm clean && tsc --project tsconfig.build.json",
|
||||
"clean": "rimraf dist",
|
||||
"prepare": "npm run build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
||||
@@ -4,50 +4,32 @@ Tool for building and testing Unraid plugins locally as well as packaging them f
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Watch for Changes
|
||||
### 1. Build the Plugin
|
||||
|
||||
The watch command will automatically sync changes from the API, UI components, and web app into the plugin source:
|
||||
|
||||
```bash
|
||||
# Start watching all components
|
||||
pnpm run watch:all
|
||||
|
||||
# Or run individual watchers:
|
||||
pnpm run api:watch # Watch API changes
|
||||
pnpm run ui:watch # Watch Unraid UI component changes
|
||||
pnpm run wc:watch # Watch web component changes
|
||||
```
|
||||
|
||||
This will copy:
|
||||
|
||||
- API files to `./source/dynamix.unraid.net/usr/local/unraid-api`
|
||||
- UI components to `./source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components`
|
||||
- Web components to the same UI directory
|
||||
|
||||
### 2. Build the Plugin
|
||||
> **Note:** Building the plugin requires Docker.
|
||||
|
||||
Once your changes are ready, build the plugin package:
|
||||
|
||||
```bash
|
||||
# Build using Docker - on non-Linux systems
|
||||
# Start Docker container (builds dependencies automatically)
|
||||
pnpm run docker:build-and-run
|
||||
|
||||
# Or build with the build script
|
||||
pnpm run build:validate
|
||||
# Inside the container, build the plugin
|
||||
pnpm build
|
||||
```
|
||||
|
||||
This will create the plugin files in `./deploy/release/`
|
||||
This will:
|
||||
|
||||
### 3. Serve and Install
|
||||
1. Build the API release (`api/deploy/release/`)
|
||||
2. Build the web standalone components (`web/dist/`)
|
||||
3. Start Docker container with HTTP server on port 5858
|
||||
4. Build the plugin package (when you run `pnpm build`)
|
||||
|
||||
Start a local HTTP server to serve the plugin files:
|
||||
The plugin files will be created in `./deploy/` and served automatically.
|
||||
|
||||
```bash
|
||||
# Serve the plugin files
|
||||
pnpm run http-server
|
||||
```
|
||||
### 2. Install on Unraid
|
||||
|
||||
Then install the plugin on your Unraid development machine by visiting:
|
||||
Install the plugin on your Unraid development machine by visiting:
|
||||
|
||||
`http://SERVER_NAME.local/Plugins`
|
||||
|
||||
@@ -59,8 +41,7 @@ Replace `SERVER_NAME` with your development machine's hostname.
|
||||
|
||||
## Development Tips
|
||||
|
||||
- Run watchers in a separate terminal while developing
|
||||
- The http-server includes CORS headers for local development
|
||||
- The HTTP server includes CORS headers for local development
|
||||
- Check the Unraid system log for plugin installation issues
|
||||
|
||||
## Environment Setup
|
||||
@@ -81,22 +62,10 @@ Replace `SERVER_NAME` with your development machine's hostname.
|
||||
|
||||
### Build Commands
|
||||
|
||||
- `build` - Build the plugin package
|
||||
- `build:validate` - Build with environment validation
|
||||
- `build` - Build the plugin package (run inside Docker container)
|
||||
- `docker:build` - Build the Docker container
|
||||
- `docker:run` - Run the builder in Docker
|
||||
- `docker:build-and-run` - Build and run in Docker
|
||||
|
||||
### Watch Commands
|
||||
|
||||
- `watch:all` - Watch all component changes
|
||||
- `api:watch` - Watch API changes
|
||||
- `ui:watch` - Watch UI component changes
|
||||
- `wc:watch` - Watch web component changes
|
||||
|
||||
### Server Commands
|
||||
|
||||
- `http-server` - Serve the plugin files locally
|
||||
- `docker:build-and-run` - Build dependencies and start Docker container
|
||||
|
||||
### Environment Commands
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/connect-plugin",
|
||||
"version": "4.26.2",
|
||||
"version": "4.29.1",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
|
||||
@@ -206,6 +206,7 @@ FILES_TO_BACKUP=(
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php"
|
||||
"/usr/local/emhttp/update.htm"
|
||||
"/usr/local/emhttp/redirect.htm"
|
||||
"/usr/local/emhttp/logging.htm"
|
||||
"/etc/nginx/nginx.conf"
|
||||
"/etc/rc.d/rc.nginx"
|
||||
@@ -349,6 +350,7 @@ exit 0
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php"
|
||||
"/usr/local/emhttp/update.htm"
|
||||
"/usr/local/emhttp/redirect.htm"
|
||||
"/usr/local/emhttp/logging.htm"
|
||||
"/etc/nginx/nginx.conf"
|
||||
"/etc/rc.d/rc.nginx"
|
||||
@@ -478,6 +480,12 @@ if [ "$SKIP_API_INSTALL" = false ]; then
|
||||
fi
|
||||
done
|
||||
|
||||
# Stop the API service before mutating /usr/local/unraid-api to avoid upgrade races
|
||||
if [ -x "/etc/rc.d/rc.unraid-api" ]; then
|
||||
echo "Stopping Unraid API service before upgrade..."
|
||||
/etc/rc.d/rc.unraid-api stop || echo "Warning: Failed to stop Unraid API service"
|
||||
fi
|
||||
|
||||
# Remove existing node_modules directory
|
||||
echo "Cleaning up existing node_modules directory..."
|
||||
if [ -d "/usr/local/unraid-api/node_modules" ]; then
|
||||
|
||||
@@ -33,6 +33,23 @@ if [ ! -d "$WEB_DIST_DIR" ]; then
|
||||
mkdir -p "$WEB_DIST_DIR"
|
||||
fi
|
||||
|
||||
# Build dependencies before starting Docker (always rebuild to prevent staleness)
|
||||
echo "Building dependencies..."
|
||||
|
||||
echo "Building API release..."
|
||||
if ! (cd .. && pnpm --filter @unraid/api build:release); then
|
||||
echo "Error: API build failed. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Building web standalone..."
|
||||
if ! (cd .. && pnpm --filter @unraid/web build); then
|
||||
echo "Error: Web build failed. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Dependencies built successfully."
|
||||
|
||||
# Stop any running plugin-builder container first
|
||||
echo "Stopping any running plugin-builder containers..."
|
||||
docker ps -q --filter "name=${CONTAINER_NAME}" | xargs -r docker stop
|
||||
|
||||
@@ -15,12 +15,29 @@ Tag="globe"
|
||||
*/
|
||||
require_once "$docroot/plugins/dynamix.my.servers/include/state.php";
|
||||
require_once "$docroot/plugins/dynamix.my.servers/include/api-config.php";
|
||||
require_once "$docroot/plugins/dynamix.my.servers/include/connect-config.php";
|
||||
require_once "$docroot/webGui/include/Wrappers.php";
|
||||
$serverState = new ServerState();
|
||||
|
||||
$keyfile = $serverState->keyfileBase64;
|
||||
|
||||
$myServersFlashCfg = $serverState->myServersFlashCfg;
|
||||
$connectConfig = ConnectConfig::getConfig();
|
||||
$legacyRemoteCfg = $serverState->myServersFlashCfg['remote'] ?? [];
|
||||
|
||||
$remoteDynamicRemoteAccessType = $connectConfig['dynamicRemoteAccessType'] ?? ($legacyRemoteCfg['dynamicRemoteAccessType'] ?? null);
|
||||
$remoteWanAccessRaw = $connectConfig['wanaccess'] ?? ($legacyRemoteCfg['wanaccess'] ?? null);
|
||||
$remoteUpnpEnabledRaw = $connectConfig['upnpEnabled'] ?? ($legacyRemoteCfg['upnpEnabled'] ?? null);
|
||||
$remoteWanPortRaw = $connectConfig['wanport'] ?? ($legacyRemoteCfg['wanport'] ?? null);
|
||||
|
||||
$wanaccessEnabled = filter_var($remoteWanAccessRaw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($wanaccessEnabled === null) {
|
||||
$wanaccessEnabled = false;
|
||||
}
|
||||
$upnpEnabled = filter_var($remoteUpnpEnabledRaw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($upnpEnabled === null) {
|
||||
$upnpEnabled = false;
|
||||
}
|
||||
$remoteWanPort = is_numeric($remoteWanPortRaw) ? (int)$remoteWanPortRaw : 0;
|
||||
|
||||
$showT2Fa = (file_exists('/boot/config/plugins/dynamix.my.servers/showT2Fa'));
|
||||
|
||||
@@ -37,9 +54,7 @@ $passwd_result = exec('/usr/bin/passwd --status root');
|
||||
$boolWebUIAuth = $isRegistered && (($passwd_result !== false) && (substr($passwd_result, 0, 6) == 'root P'));
|
||||
|
||||
// Helper to determine the current value for the remote access input
|
||||
$dynamicRemoteAccessType = $myServersFlashCfg['remote']['dynamicRemoteAccessType'];
|
||||
$upnpEnabled = $myServersFlashCfg['remote']['upnpEnabled'] === 'yes';
|
||||
$wanaccessEnabled = $myServersFlashCfg['remote']['wanaccess'] === 'yes';
|
||||
$dynamicRemoteAccessType = $remoteDynamicRemoteAccessType ?? null;
|
||||
|
||||
$currentRemoteAccessValue = 'OFF';
|
||||
if ($dynamicRemoteAccessType === 'STATIC') {
|
||||
@@ -59,6 +74,12 @@ if ($dynamicRemoteAccessType === 'STATIC') {
|
||||
$enableRemoteT2fa = $showT2Fa && $currentRemoteAccessValue !== 'OFF' && $hasMyUnraidNetCert;
|
||||
$enableLocalT2fa = $showT2Fa && $var['USE_SSL'] === 'auto' && $hasMyUnraidNetCert;
|
||||
$shade="shade-".($display['theme']??'unk');
|
||||
$wanAccessOriginal = $remoteWanAccessRaw;
|
||||
if (is_bool($wanAccessOriginal)) {
|
||||
$wanAccessOriginal = $wanAccessOriginal ? 'yes' : 'no';
|
||||
} elseif (!is_string($wanAccessOriginal)) {
|
||||
$wanAccessOriginal = '';
|
||||
}
|
||||
?>
|
||||
<style>
|
||||
div.shade-white{background-color:#ededed;margin-top:10px;padding:8px 0 3px 0}
|
||||
@@ -68,13 +89,18 @@ div.shade-gray{background-color:#121510;margin-top:10px;padding:8px 0 3px 0}
|
||||
</style>
|
||||
<script>
|
||||
const hasMyUnraidNetCert = <?=($hasMyUnraidNetCert ? 'true' : 'false')?>;
|
||||
const wanAccessOrg = "<?=$myServersFlashCfg['remote']['wanaccess']?>";
|
||||
const wanAccessOrg = "<?=$wanAccessOriginal?>";
|
||||
|
||||
function registerServer(button) {
|
||||
|
||||
const $remoteAccessInput = $('#remoteAccess');
|
||||
const $remoteAccessManualPort = $('#wanport');
|
||||
|
||||
const parsePort = (val) => {
|
||||
const parsed = parseInt(val, 10);
|
||||
return isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
let computedRemoteAccessConfig = null;
|
||||
switch ($remoteAccessInput.val()) {
|
||||
case 'ALWAYS_MANUAL':
|
||||
@@ -119,19 +145,64 @@ function registerServer(button) {
|
||||
break;
|
||||
}
|
||||
|
||||
const enableLocalT2fa = <?=($enableLocalT2fa ? 'true' : 'false')?>;
|
||||
const enableRemoteT2fa = $remoteAccessInput.val() !== 'OFF' && hasMyUnraidNetCert;
|
||||
const enableLocalT2fa = <?=($enableLocalT2fa ? 'true' : 'false')?>;
|
||||
const enableRemoteT2fa = $remoteAccessInput.val() !== 'OFF' && hasMyUnraidNetCert;
|
||||
|
||||
var postobj = {
|
||||
"#cfg": "/boot/config/plugins/dynamix.my.servers/myservers.cfg",
|
||||
...(computedRemoteAccessConfig ? computedRemoteAccessConfig : {}),
|
||||
// only allow 'yes' value when fields are enabled
|
||||
"local_2Fa": enableLocalT2fa ? $('#local2fa').val() : 'no',
|
||||
"remote_2Fa": enableRemoteT2fa ? $('#remote2fa').val() : 'no',
|
||||
};
|
||||
const postobj = {
|
||||
"#cfg": "/boot/config/plugins/dynamix.my.servers/myservers.cfg",
|
||||
...(computedRemoteAccessConfig ? computedRemoteAccessConfig : {}),
|
||||
// only allow 'yes' value when fields are enabled
|
||||
"local_2Fa": enableLocalT2fa ? $('#local2fa').val() : 'no',
|
||||
"remote_2Fa": enableRemoteT2fa ? $('#remote2fa').val() : 'no',
|
||||
};
|
||||
|
||||
$(button).prop("disabled", true).html("_(Applying)_ <i class=\"fa fa-spinner fa-spin\" aria-hidden=\"true\"></i>");
|
||||
$.post('/webGui/include/Dispatcher.php', postobj, function(data2) {
|
||||
const buildConnectSettingsInput = () => {
|
||||
const selection = $remoteAccessInput.val();
|
||||
switch (selection) {
|
||||
case 'ALWAYS_MANUAL':
|
||||
return { accessType: 'ALWAYS', forwardType: 'STATIC', port: parsePort($remoteAccessManualPort.val()) };
|
||||
case 'ALWAYS_UPNP':
|
||||
return { accessType: 'ALWAYS', forwardType: 'UPNP', port: null };
|
||||
case 'DYNAMIC_UPNP':
|
||||
return { accessType: 'DYNAMIC', forwardType: 'UPNP', port: null };
|
||||
case 'DYNAMIC_MANUAL':
|
||||
return { accessType: 'DYNAMIC', forwardType: 'STATIC', port: parsePort($remoteAccessManualPort.val()) };
|
||||
default:
|
||||
return { accessType: 'DISABLED', forwardType: 'STATIC', port: null };
|
||||
}
|
||||
};
|
||||
|
||||
const $button = $(button);
|
||||
const originalLabel = $button.html();
|
||||
$button.prop("disabled", true).html("_(Applying)_ <i class=\"fa fa-spinner fa-spin\" aria-hidden=\"true\"></i>");
|
||||
const saveLegacyConfig = new Promise((resolve, reject) => {
|
||||
$.post('/webGui/include/Dispatcher.php', postobj).done(resolve).fail(reject);
|
||||
});
|
||||
|
||||
const apolloClient = window.apolloClient;
|
||||
const gql = window.gql || window.graphqlParse;
|
||||
|
||||
const mutations = [saveLegacyConfig];
|
||||
if (apolloClient && gql) {
|
||||
const updateConnectSettingsMutation = gql(`
|
||||
mutation UpdateConnectSettings($input: ConnectSettingsInput!) {
|
||||
updateApiSettings(input: $input) {
|
||||
accessType
|
||||
forwardType
|
||||
port
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
mutations.push(
|
||||
apolloClient.mutate({
|
||||
mutation: updateConnectSettingsMutation,
|
||||
variables: { input: buildConnectSettingsInput() },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Promise.all(mutations).then(function() {
|
||||
<?if(!$isRegistered):?>
|
||||
swal({
|
||||
title: "",
|
||||
@@ -150,7 +221,22 @@ function registerServer(button) {
|
||||
button.form.submit();
|
||||
}, delay);
|
||||
<?endif?>
|
||||
});
|
||||
}).catch(function(error) {
|
||||
let message = "_(Sorry, an error occurred)_";
|
||||
if (error && error.responseJSON && error.responseJSON.error) {
|
||||
message = error.responseJSON.error;
|
||||
} else if (error && error.message) {
|
||||
message = error.message;
|
||||
}
|
||||
$button.prop("disabled", false).html(originalLabel);
|
||||
swal({
|
||||
title: "Oops",
|
||||
text: message,
|
||||
type: "error",
|
||||
html: true,
|
||||
confirmButtonText: "_(Ok)_"
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -196,7 +282,7 @@ function dnsCheckServer(button) {
|
||||
} else {
|
||||
swal({
|
||||
title: "Oops",
|
||||
text: "<?=sprintf(_("The Unraid server is unreachable from outside your network. Be sure you have configured your router to forward port") . " <strong style='font-weight: bold'>%u/TCP</strong> " . _("to the Unraid server at") . " <strong style='font-weight: bold'>%s</strong> " . _("port") . " <strong style='font-weight: bold'>%u</strong>", $myServersFlashCfg['remote']['wanport'], htmlspecialchars($eth0['IPADDR:0']??''), $var['PORTSSL']??443)?>",
|
||||
text: "<?=sprintf(_("The Unraid server is unreachable from outside your network. Be sure you have configured your router to forward port") . " <strong style='font-weight: bold'>%u/TCP</strong> " . _("to the Unraid server at") . " <strong style='font-weight: bold'>%s</strong> " . _("port") . " <strong style='font-weight: bold'>%u</strong>", $remoteWanPort, htmlspecialchars($eth0['IPADDR:0']??''), $var['PORTSSL']??443)?>",
|
||||
type: "error",
|
||||
html: true,
|
||||
confirmButtonText: "_(Ok)_"
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<?php
|
||||
$docroot = $docroot ?? $_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp';
|
||||
if (!class_exists('ThemeHelper')) {
|
||||
$themeHelperPath = $docroot . '/plugins/dynamix/include/ThemeHelper.php';
|
||||
if (is_readable($themeHelperPath)) {
|
||||
require_once $themeHelperPath;
|
||||
}
|
||||
}
|
||||
|
||||
class WebComponentsExtractor
|
||||
{
|
||||
@@ -38,7 +44,12 @@ class WebComponentsExtractor
|
||||
public function getManifestContents(string $manifestPath): array
|
||||
{
|
||||
$contents = @file_get_contents($manifestPath);
|
||||
return $contents ? json_decode($contents, true) : [];
|
||||
if (!$contents) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($contents, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function processManifestFiles(): string
|
||||
@@ -148,22 +159,190 @@ class WebComponentsExtractor
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function normalizeHex(?string $color): ?string
|
||||
{
|
||||
if (!is_string($color) || trim($color) === '') {
|
||||
return null;
|
||||
}
|
||||
$color = trim($color);
|
||||
if ($color[0] !== '#') {
|
||||
$color = '#' . ltrim($color, '#');
|
||||
}
|
||||
$hex = substr($color, 1);
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
if (!ctype_xdigit($hex) || strlen($hex) !== 6) {
|
||||
return null;
|
||||
}
|
||||
return '#' . strtolower($hex);
|
||||
}
|
||||
|
||||
private function hexToRgba(string $hex, float $alpha): string
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
if (strlen($hex) === 3) {
|
||||
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
|
||||
}
|
||||
$r = hexdec(substr($hex, 0, 2));
|
||||
$g = hexdec(substr($hex, 2, 2));
|
||||
$b = hexdec(substr($hex, 4, 2));
|
||||
return sprintf('rgba(%d, %d, %d, %.3f)', $r, $g, $b, max(0, min(1, $alpha)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to build CSS variables from PHP display data (server-rendered settings).
|
||||
*
|
||||
* @return array{vars: array<string,string>, classes: string[], diagnostics: array}|null
|
||||
*/
|
||||
private function getDisplayThemeVars(): ?array
|
||||
{
|
||||
if (!isset($GLOBALS['display']) || !is_array($GLOBALS['display'])) {
|
||||
return null;
|
||||
}
|
||||
$display = $GLOBALS['display'];
|
||||
$vars = [];
|
||||
|
||||
$textPrimary = $this->normalizeHex($display['header'] ?? null);
|
||||
if ($textPrimary) {
|
||||
$vars['--header-text-primary'] = $textPrimary;
|
||||
}
|
||||
|
||||
$textSecondary = $this->normalizeHex($display['headermetacolor'] ?? null);
|
||||
if ($textSecondary) {
|
||||
$vars['--header-text-secondary'] = $textSecondary;
|
||||
}
|
||||
|
||||
$theme = strtolower(trim($display['theme'] ?? ''));
|
||||
$darkThemes = ['gray', 'black'];
|
||||
$isDarkMode = in_array($theme, $darkThemes, true);
|
||||
$vars['--theme-dark-mode'] = $isDarkMode ? '1' : '0';
|
||||
$vars['--theme-name'] = $theme ?: 'white';
|
||||
|
||||
if ($theme === 'white') {
|
||||
if (!$textPrimary) {
|
||||
$vars['--header-text-primary'] = 'var(--inverse-text-color, #ffffff)';
|
||||
}
|
||||
if (!$textSecondary) {
|
||||
$vars['--header-text-secondary'] = 'var(--alt-text-color, #999999)';
|
||||
}
|
||||
}
|
||||
|
||||
// Unraid WebGUI stores banner enablement as a non-empty `display['banner']` value
|
||||
// (typically the banner file name/path).
|
||||
$shouldShowBanner = !empty($display['banner']);
|
||||
$bgColor = $this->normalizeHex($display['background'] ?? null);
|
||||
if ($bgColor) {
|
||||
$vars['--header-background-color'] = $bgColor;
|
||||
// Only set gradient variables if banner image is enabled
|
||||
if ($shouldShowBanner) {
|
||||
$vars['--header-gradient-start'] = $this->hexToRgba($bgColor, 0);
|
||||
$vars['--header-gradient-end'] = $this->hexToRgba($bgColor, 1);
|
||||
}
|
||||
}
|
||||
|
||||
$shouldShowBannerGradient = ($display['showBannerGradient'] ?? '') === 'yes';
|
||||
if ($shouldShowBanner && $shouldShowBannerGradient) {
|
||||
// If the user didn't set a custom background color, prefer existing theme defaults instead of falling back to black.
|
||||
if (!isset($vars['--header-gradient-start'])) {
|
||||
$vars['--header-gradient-start'] = 'var(--color-header-gradient-start, rgba(242, 242, 242, 0))';
|
||||
}
|
||||
if (!isset($vars['--header-gradient-end'])) {
|
||||
$vars['--header-gradient-end'] = 'var(--color-header-gradient-end, rgba(242, 242, 242, 1))';
|
||||
}
|
||||
$start = $vars['--header-gradient-start'];
|
||||
$end = $vars['--header-gradient-end'];
|
||||
// Keep compatibility with older CSS that expects these names.
|
||||
$vars['--color-header-gradient-start'] = $start;
|
||||
$vars['--color-header-gradient-end'] = $end;
|
||||
$vars['--banner-gradient'] = sprintf(
|
||||
'linear-gradient(90deg, %s 0, %s var(--banner-gradient-stop, 30%%))',
|
||||
$start,
|
||||
$end
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($vars)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'vars' => $vars,
|
||||
'diagnostics' => [
|
||||
'theme' => $display['theme'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function renderThemeVars(array $cssVars, string $source, array $diagnostics = []): string
|
||||
{
|
||||
$cssRules = [];
|
||||
foreach ($cssVars as $key => $value) {
|
||||
if (!is_string($key) || !is_string($value) || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!preg_match('/^--[A-Za-z0-9_-]+$/', $key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeKey = htmlspecialchars($key, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$safeValue = str_replace('</style>', '<\/style>', $value);
|
||||
|
||||
$cssRules[] = sprintf(
|
||||
' %s: %s;',
|
||||
$safeKey,
|
||||
$safeValue
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($cssRules)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<style id="unraid-theme-css-vars">
|
||||
:root {
|
||||
' . implode("\n", $cssRules) . '
|
||||
}
|
||||
</style>';
|
||||
}
|
||||
|
||||
private function getThemeInitScript(): string
|
||||
{
|
||||
$displayTheme = $this->getDisplayThemeVars();
|
||||
if ($displayTheme) {
|
||||
return $this->renderThemeVars(
|
||||
$displayTheme['vars'],
|
||||
'display',
|
||||
$displayTheme['diagnostics'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private static bool $scriptsOutput = false;
|
||||
|
||||
public function getScriptTagHtml(): string
|
||||
{
|
||||
// Use a static flag to ensure scripts are only output once per request
|
||||
static $scriptsOutput = false;
|
||||
|
||||
if ($scriptsOutput) {
|
||||
if (self::$scriptsOutput) {
|
||||
return '<!-- Resources already loaded -->';
|
||||
}
|
||||
|
||||
try {
|
||||
$scriptsOutput = true;
|
||||
return $this->processManifestFiles();
|
||||
self::$scriptsOutput = true;
|
||||
$themeScript = $this->getThemeInitScript();
|
||||
$manifestScripts = $this->processManifestFiles();
|
||||
return $themeScript . "\n" . $manifestScripts;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Error in WebComponentsExtractor::getScriptTagHtml: " . $e->getMessage());
|
||||
$scriptsOutput = false; // Reset on error
|
||||
self::$scriptsOutput = false; // Reset on error
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
public static function resetScriptsOutput(): void
|
||||
{
|
||||
self::$scriptsOutput = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ class UnraidOsCheck
|
||||
* Note: CURLINFO_HEADER_OUT exposes request headers (not response headers) for curl_getinfo
|
||||
* @return string|false $out The fetched content
|
||||
*/
|
||||
private function safe_http_get_contents(string $url, array $opts = [], array &$getinfo = NULL) {
|
||||
private function safe_http_get_contents(string $url, array $opts = [], ?array &$getinfo = NULL) {
|
||||
// Use system http_get_contents if it exists
|
||||
if (function_exists('http_get_contents')) {
|
||||
return http_get_contents($url, $opts, $getinfo);
|
||||
|
||||
@@ -46,6 +46,11 @@ class UnraidUpdateCancel
|
||||
$readmeContent .= "Unraid OS by [Lime Technology, Inc.](https://lime-technology.com).\n";
|
||||
file_put_contents($readmeFile, $readmeContent);
|
||||
|
||||
// Delete plugin files that were downloaded during the OS upgrade
|
||||
if (is_dir("/boot/config/plugins-nextboot")) {
|
||||
shell_exec("rm -rf /boot/config/plugins-nextboot");
|
||||
}
|
||||
|
||||
return ['success' => true]; // Upgrade handled successfully
|
||||
} catch (\Throwable $th) {
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Redirect Page</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="text" style="text-align: center; margin-top: calc(100vh - 75%); display: none; font-family: sans-serif;">
|
||||
<h1>Redirecting...</h1>
|
||||
<h2><a id="redirectButton" href="/Main">Click here if you are not redirected automatically</a></h2>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function parseRedirectTarget(target) {
|
||||
if (target && target !== '/') {
|
||||
// Parse target and ensure it is a bare path with no query parameters.
|
||||
// This keeps us on the same origin and avoids arbitrary redirects.
|
||||
try {
|
||||
const url = new URL(target, window.location.origin);
|
||||
return url.pathname || '/Main';
|
||||
} catch (_e) {
|
||||
// If the target is malformed, fall back safely.
|
||||
return '/Main';
|
||||
}
|
||||
}
|
||||
return '/Main';
|
||||
}
|
||||
|
||||
function getRedirectUrl() {
|
||||
const search = new URLSearchParams(window.location.search);
|
||||
const rawHash = window.location.hash || '';
|
||||
const hashString = rawHash.charAt(0) === '#' ? rawHash.substring(1) : rawHash;
|
||||
|
||||
let hashData = '';
|
||||
if (hashString.startsWith('data=')) {
|
||||
hashData = hashString.slice('data='.length);
|
||||
}
|
||||
|
||||
const targetRoute = parseRedirectTarget(search.get('target'));
|
||||
const baseUrl = `${window.location.origin}${targetRoute}`;
|
||||
|
||||
// If the incoming URL already has a hash-based data payload, preserve it exactly.
|
||||
if (hashData) {
|
||||
return `${baseUrl}#data=${hashData}`;
|
||||
}
|
||||
|
||||
// Fallback: accept legacy ?data= input and convert it to hash-based data.
|
||||
const queryData = search.get('data');
|
||||
if (queryData) {
|
||||
const encoded = encodeURIComponent(queryData);
|
||||
return `${baseUrl}#data=${encoded}`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
function showText() {
|
||||
const textEl = document.getElementById('text');
|
||||
if (textEl) {
|
||||
textEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function startRedirect() {
|
||||
setTimeout(showText, 750);
|
||||
|
||||
const redirectUrl = getRedirectUrl();
|
||||
console.log('[redirect.htm] redirecting to:', redirectUrl);
|
||||
|
||||
const link = document.getElementById('redirectButton');
|
||||
if (link) {
|
||||
link.setAttribute('href', redirectUrl);
|
||||
}
|
||||
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', startRedirect);
|
||||
} else {
|
||||
startRedirect();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -101,6 +101,29 @@ class ExtractorTest {
|
||||
'file' => 'special\'file".css'
|
||||
]
|
||||
], JSON_PRETTY_PRINT));
|
||||
|
||||
// Create an invalid JSON manifest to ensure it is safely ignored
|
||||
file_put_contents($this->componentDir . '/other/invalid.manifest.json', '{ invalid json ');
|
||||
// Create an empty manifest file
|
||||
file_put_contents($this->componentDir . '/other/empty.manifest.json', '');
|
||||
|
||||
// Create a manifest with unsupported file types to ensure they are ignored
|
||||
file_put_contents($this->componentDir . '/other/unsupported.manifest.json', json_encode([
|
||||
'image-entry' => [
|
||||
'file' => 'logo.svg'
|
||||
],
|
||||
'font-entry' => [
|
||||
'file' => 'font.woff2'
|
||||
]
|
||||
], JSON_PRETTY_PRINT));
|
||||
|
||||
// Create a manifest with invalid CSS list entries (only strings should be emitted)
|
||||
file_put_contents($this->componentDir . '/other/css-list-invalid.manifest.json', json_encode([
|
||||
'css-list-test' => [
|
||||
'file' => 'css-list-test.js',
|
||||
'css' => ['ok.css', '', null, 0, false]
|
||||
]
|
||||
], JSON_PRETTY_PRINT));
|
||||
|
||||
// Copy and modify the extractor for testing
|
||||
$this->prepareExtractor();
|
||||
@@ -120,14 +143,28 @@ class ExtractorTest {
|
||||
file_put_contents($this->testDir . '/extractor.php', $extractorContent);
|
||||
}
|
||||
|
||||
private function getExtractorOutput() {
|
||||
private function getExtractorOutput($resetStatic = false) {
|
||||
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
|
||||
require_once $this->testDir . '/extractor.php';
|
||||
|
||||
if ($resetStatic) {
|
||||
$this->resetExtractor();
|
||||
}
|
||||
|
||||
$extractor = WebComponentsExtractor::getInstance();
|
||||
return $extractor->getScriptTagHtml();
|
||||
}
|
||||
|
||||
|
||||
private function getExtractorOutputWithDisplay(?array $display): string
|
||||
{
|
||||
if ($display === null) {
|
||||
unset($GLOBALS['display']);
|
||||
} else {
|
||||
$GLOBALS['display'] = $display;
|
||||
}
|
||||
return $this->getExtractorOutput(true);
|
||||
}
|
||||
|
||||
private function runTests() {
|
||||
echo "\n";
|
||||
echo "========================================\n";
|
||||
@@ -298,6 +335,32 @@ class ExtractorTest {
|
||||
"CSS from manifest has data-unraid attribute",
|
||||
preg_match('/<link[^>]+id="unraid-[^"]*-css-[^"]+"[^>]+data-unraid="1"/', $output) > 0
|
||||
);
|
||||
$this->test(
|
||||
"Ignores non-string/empty entries in css array",
|
||||
preg_match_all('/id="unraid-other-css-list-test-css-[^"]+"/', $output, $matches) === 1 &&
|
||||
isset($matches[0][0]) &&
|
||||
strpos($matches[0][0], 'id="unraid-other-css-list-test-css-ok-css"') !== false
|
||||
);
|
||||
|
||||
// Test: Manifest Format Robustness
|
||||
echo "\nTest: Manifest Format Robustness\n";
|
||||
echo "---------------------------------\n";
|
||||
$this->testManifestContentsRobustness();
|
||||
$this->test(
|
||||
"Does not generate tags for unsupported file extensions",
|
||||
strpos($output, 'logo.svg') === false &&
|
||||
strpos($output, 'font.woff2') === false
|
||||
);
|
||||
|
||||
// Test: CSS Variable Validation
|
||||
echo "\nTest: CSS Variable Validation\n";
|
||||
echo "------------------------------\n";
|
||||
$this->testCssVariableValidation();
|
||||
|
||||
// Test: Display Variations / Theme CSS Vars
|
||||
echo "\nTest: Display Variations\n";
|
||||
echo "-------------------------\n";
|
||||
$this->testDisplayVariations();
|
||||
|
||||
// Test: Duplicate Prevention
|
||||
echo "\nTest: Duplicate Prevention\n";
|
||||
@@ -317,6 +380,282 @@ class ExtractorTest {
|
||||
);
|
||||
}
|
||||
|
||||
private function testCssVariableValidation() {
|
||||
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
|
||||
require_once $this->testDir . '/extractor.php';
|
||||
|
||||
$extractor = WebComponentsExtractor::getInstance();
|
||||
$reflection = new ReflectionClass('WebComponentsExtractor');
|
||||
$method = $reflection->getMethod('renderThemeVars');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Test valid CSS variable names
|
||||
$validVars = [
|
||||
'--header-text-primary' => '#ffffff',
|
||||
'--header-text-secondary' => '#cccccc',
|
||||
'--header-background-color' => '#000000',
|
||||
'--test-var' => 'value',
|
||||
'--test_var' => 'value',
|
||||
'--test123' => 'value',
|
||||
'--A-Z_a-z0-9' => 'value',
|
||||
];
|
||||
$output = $method->invoke($extractor, $validVars, 'test');
|
||||
$this->test(
|
||||
"Accepts valid CSS variable names starting with --",
|
||||
strpos($output, '--header-text-primary') !== false &&
|
||||
strpos($output, '--test-var') !== false &&
|
||||
strpos($output, '--test_var') !== false &&
|
||||
strpos($output, '--test123') !== false
|
||||
);
|
||||
|
||||
// Test invalid CSS variable names (should be rejected)
|
||||
$invalidVars = [
|
||||
'not-a-var' => 'value',
|
||||
'-not-a-var' => 'value',
|
||||
'--var with spaces' => 'value',
|
||||
'--var<script>' => 'value',
|
||||
'--var"quote' => 'value',
|
||||
'--var\'quote' => 'value',
|
||||
'--var;injection' => 'value',
|
||||
'--var:colon' => 'value',
|
||||
'--var.value' => 'value',
|
||||
'--var/value' => 'value',
|
||||
'--var\\backslash' => 'value',
|
||||
'' => 'value',
|
||||
'--' => 'value',
|
||||
];
|
||||
$output = $method->invoke($extractor, $invalidVars, 'test');
|
||||
$this->test(
|
||||
"Rejects CSS variable names without -- prefix",
|
||||
strpos($output, 'not-a-var') === false &&
|
||||
strpos($output, '-not-a-var') === false
|
||||
);
|
||||
$this->test(
|
||||
"Rejects CSS variable names with spaces",
|
||||
strpos($output, 'var with spaces') === false
|
||||
);
|
||||
$this->test(
|
||||
"Rejects CSS variable names with script tags",
|
||||
strpos($output, '<script>') === false &&
|
||||
strpos($output, 'var<script>') === false
|
||||
);
|
||||
$this->test(
|
||||
"Rejects CSS variable names with quotes",
|
||||
strpos($output, 'var"quote') === false &&
|
||||
strpos($output, "var'quote") === false
|
||||
);
|
||||
$this->test(
|
||||
"Rejects CSS variable names with semicolons",
|
||||
strpos($output, 'var;injection') === false
|
||||
);
|
||||
$this->test(
|
||||
"Rejects CSS variable names with dots",
|
||||
strpos($output, 'var.value') === false
|
||||
);
|
||||
$this->test(
|
||||
"Rejects empty or minimal invalid keys",
|
||||
strpos($output, ': --;') === false
|
||||
);
|
||||
|
||||
// Test mixed valid and invalid (only valid should appear)
|
||||
$mixedVars = [
|
||||
'--valid-var' => 'value1',
|
||||
'invalid-var' => 'value2',
|
||||
'--another-valid' => 'value3',
|
||||
'--invalid<script>' => 'value4',
|
||||
];
|
||||
$output = $method->invoke($extractor, $mixedVars, 'test');
|
||||
$this->test(
|
||||
"Accepts valid variables and rejects invalid ones in mixed input",
|
||||
strpos($output, '--valid-var') !== false &&
|
||||
strpos($output, '--another-valid') !== false &&
|
||||
strpos($output, 'invalid-var') === false &&
|
||||
strpos($output, '<script>') === false
|
||||
);
|
||||
|
||||
// Test non-string keys (should be rejected)
|
||||
$nonStringKeys = [
|
||||
'--valid' => 'value',
|
||||
123 => 'value',
|
||||
true => 'value',
|
||||
null => 'value',
|
||||
];
|
||||
$output = $method->invoke($extractor, $nonStringKeys, 'test');
|
||||
$this->test(
|
||||
"Rejects non-string keys",
|
||||
strpos($output, '--valid') !== false &&
|
||||
strpos($output, '123') === false
|
||||
);
|
||||
}
|
||||
|
||||
private function testDisplayVariations(): void
|
||||
{
|
||||
// No $display => no theme CSS vars injected
|
||||
$output = $this->getExtractorOutputWithDisplay(null);
|
||||
$this->test(
|
||||
"No display data produces no theme CSS var style tag",
|
||||
strpos($output, 'id="unraid-theme-css-vars"') === false
|
||||
);
|
||||
|
||||
// Banner empty + gradient yes => gradient should be ignored (no banner image)
|
||||
$output = $this->getExtractorOutputWithDisplay([
|
||||
'theme' => 'azure',
|
||||
'banner' => '',
|
||||
'showBannerGradient' => 'yes',
|
||||
'background' => '112233',
|
||||
]);
|
||||
$this->test(
|
||||
"Banner disabled suppresses --banner-gradient",
|
||||
strpos($output, '--banner-gradient:') === false
|
||||
);
|
||||
$this->test(
|
||||
"Banner disabled suppresses header gradient start/end",
|
||||
strpos($output, '--header-gradient-start:') === false &&
|
||||
strpos($output, '--header-gradient-end:') === false
|
||||
);
|
||||
|
||||
// Banner enabled + gradient yes + valid background => gradient vars and banner gradient
|
||||
$output = $this->getExtractorOutputWithDisplay([
|
||||
'theme' => 'azure',
|
||||
'banner' => 'image',
|
||||
'showBannerGradient' => 'yes',
|
||||
'background' => '112233',
|
||||
]);
|
||||
$this->test(
|
||||
"Injects theme vars style tag",
|
||||
strpos($output, 'id="unraid-theme-css-vars"') !== false &&
|
||||
strpos($output, ':root {') !== false
|
||||
);
|
||||
$this->test(
|
||||
"Sets --theme-name from display theme",
|
||||
strpos($output, '--theme-name: azure;') !== false
|
||||
);
|
||||
$this->test(
|
||||
"Sets --theme-dark-mode for non-dark themes",
|
||||
strpos($output, '--theme-dark-mode: 0;') !== false
|
||||
);
|
||||
$this->test(
|
||||
"Normalizes and sets background color",
|
||||
strpos($output, '--header-background-color: #112233;') !== false
|
||||
);
|
||||
$this->test(
|
||||
"Derives header gradient start/end from background",
|
||||
strpos($output, '--header-gradient-start: rgba(17, 34, 51, 0.000);') !== false &&
|
||||
strpos($output, '--header-gradient-end: rgba(17, 34, 51, 1.000);') !== false
|
||||
);
|
||||
$this->test(
|
||||
"Emits --banner-gradient with banner stop variable",
|
||||
strpos($output, '--banner-gradient: linear-gradient(90deg,') !== false &&
|
||||
strpos($output, 'var(--banner-gradient-stop, 30%)') !== false
|
||||
);
|
||||
|
||||
// Banner enabled + gradient yes but no custom background => should use theme defaults (not black fallbacks)
|
||||
$output = $this->getExtractorOutputWithDisplay([
|
||||
'theme' => 'azure',
|
||||
'banner' => 'image',
|
||||
'showBannerGradient' => 'yes',
|
||||
]);
|
||||
$this->test(
|
||||
"No custom background uses theme defaults for gradient vars",
|
||||
strpos($output, '--header-gradient-start: var(--color-header-gradient-start') !== false &&
|
||||
strpos($output, '--header-gradient-end: var(--color-header-gradient-end') !== false
|
||||
);
|
||||
$this->test(
|
||||
"No custom background still emits --banner-gradient",
|
||||
strpos($output, '--banner-gradient: linear-gradient(90deg,') !== false
|
||||
);
|
||||
|
||||
// Banner enabled + gradient no => no --banner-gradient, but does set start/end for other CSS usage
|
||||
$output = $this->getExtractorOutputWithDisplay([
|
||||
'theme' => 'azure',
|
||||
'banner' => 'image',
|
||||
'showBannerGradient' => 'no',
|
||||
'background' => '112233',
|
||||
]);
|
||||
$this->test(
|
||||
"Gradient disabled suppresses --banner-gradient",
|
||||
strpos($output, '--banner-gradient:') === false
|
||||
);
|
||||
$this->test(
|
||||
"Banner enabled still emits header gradient start/end",
|
||||
strpos($output, '--header-gradient-start:') !== false &&
|
||||
strpos($output, '--header-gradient-end:') !== false
|
||||
);
|
||||
|
||||
// Dark themes set --theme-dark-mode = 1
|
||||
$output = $this->getExtractorOutputWithDisplay([
|
||||
'theme' => 'black',
|
||||
'banner' => 'image',
|
||||
'showBannerGradient' => 'yes',
|
||||
'background' => '112233',
|
||||
]);
|
||||
$this->test(
|
||||
"Dark theme sets --theme-dark-mode to 1",
|
||||
strpos($output, '--theme-dark-mode: 1;') !== false &&
|
||||
strpos($output, '--theme-name: black;') !== false
|
||||
);
|
||||
|
||||
// Hex normalization: 3-digit values expand and lower-case
|
||||
$output = $this->getExtractorOutputWithDisplay([
|
||||
'theme' => 'azure',
|
||||
'banner' => 'image',
|
||||
'showBannerGradient' => 'yes',
|
||||
'background' => 'aBc',
|
||||
'header' => 'FfF',
|
||||
'headermetacolor' => '#0F0',
|
||||
]);
|
||||
$this->test(
|
||||
"Normalizes 3-digit hex values",
|
||||
strpos($output, '--header-background-color: #aabbcc;') !== false &&
|
||||
strpos($output, '--header-text-primary: #ffffff;') !== false &&
|
||||
strpos($output, '--header-text-secondary: #00ff00;') !== false
|
||||
);
|
||||
|
||||
// Invalid background => should not emit background var
|
||||
$output = $this->getExtractorOutputWithDisplay([
|
||||
'theme' => 'azure',
|
||||
'banner' => 'image',
|
||||
'showBannerGradient' => 'yes',
|
||||
'background' => 'not-a-hex',
|
||||
]);
|
||||
$this->test(
|
||||
"Rejects invalid background color",
|
||||
strpos($output, '--header-background-color:') === false
|
||||
);
|
||||
}
|
||||
|
||||
private function testManifestContentsRobustness(): void
|
||||
{
|
||||
$_SERVER['DOCUMENT_ROOT'] = '/usr/local/emhttp';
|
||||
require_once $this->testDir . '/extractor.php';
|
||||
|
||||
$extractor = WebComponentsExtractor::getInstance();
|
||||
|
||||
$missing = $extractor->getManifestContents($this->componentDir . '/other/does-not-exist.manifest.json');
|
||||
$this->test(
|
||||
"Missing manifest returns an empty array",
|
||||
is_array($missing) && $missing === []
|
||||
);
|
||||
|
||||
$empty = $extractor->getManifestContents($this->componentDir . '/other/empty.manifest.json');
|
||||
$this->test(
|
||||
"Empty manifest returns an empty array",
|
||||
is_array($empty) && $empty === []
|
||||
);
|
||||
|
||||
$invalid = $extractor->getManifestContents($this->componentDir . '/other/invalid.manifest.json');
|
||||
$this->test(
|
||||
"Invalid JSON manifest returns an empty array",
|
||||
is_array($invalid) && $invalid === []
|
||||
);
|
||||
|
||||
$valid = $extractor->getManifestContents($this->componentDir . '/other/manifest.json');
|
||||
$this->test(
|
||||
"Valid manifest decodes to an array",
|
||||
is_array($valid) && isset($valid['app-entry']) && isset($valid['app-styles'])
|
||||
);
|
||||
}
|
||||
|
||||
private function test($name, $condition) {
|
||||
if ($condition) {
|
||||
echo " " . self::GREEN . "✓" . self::NC . " " . $name . "\n";
|
||||
@@ -352,6 +691,19 @@ class ExtractorTest {
|
||||
return preg_replace('/[^a-zA-Z0-9-]/', '-', $input);
|
||||
}
|
||||
|
||||
private function resetExtractor() {
|
||||
// Reset singleton instance
|
||||
if (class_exists('WebComponentsExtractor')) {
|
||||
$reflection = new ReflectionClass('WebComponentsExtractor');
|
||||
$instance = $reflection->getProperty('instance');
|
||||
$instance->setAccessible(true);
|
||||
$instance->setValue(null, null);
|
||||
|
||||
// Reset static flag
|
||||
WebComponentsExtractor::resetScriptsOutput();
|
||||
}
|
||||
}
|
||||
|
||||
private function reportResults() {
|
||||
echo "\n";
|
||||
echo "========================================\n";
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -1086,8 +1086,8 @@ importers:
|
||||
specifier: 4.0.0-alpha.0
|
||||
version: 4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
|
||||
'@unraid/shared-callbacks':
|
||||
specifier: 1.1.1
|
||||
version: 1.1.1(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0
|
||||
'@unraid/ui':
|
||||
specifier: link:../unraid-ui
|
||||
version: link:../unraid-ui
|
||||
@@ -5096,10 +5096,8 @@ packages:
|
||||
cpu: [x64, arm64]
|
||||
os: [linux, darwin]
|
||||
|
||||
'@unraid/shared-callbacks@1.1.1':
|
||||
resolution: {integrity: sha512-14x5HFBOIVfUpQFAAhcRqIvj3AIsOyx90BdShXtddW55kiVtg+dDfsnlzExSYWhb35C6gYKZ0Sm9ZhF/YamGzg==}
|
||||
peerDependencies:
|
||||
'@vueuse/core': ^10.9.0 || ^13.0.0
|
||||
'@unraid/shared-callbacks@3.0.0':
|
||||
resolution: {integrity: sha512-O4AN5nsmnwUQ1utYhG2wS9L2NAFn3eOg5YHKq9h9EUa3n8xQeUOzeM6UV2xBg9YJGuF3wQsaEpfj1GyX/MIAGw==}
|
||||
|
||||
'@unraid/tailwind-rem-to-rem@2.0.0':
|
||||
resolution: {integrity: sha512-zccpQx5fvEBkAB0JkRwwtyRrT9l26LsjkozLy44LGv0NdZGaxgscniIqJRM+OQj5pSpsWDzExebAtUKdE98Flg==}
|
||||
@@ -17140,9 +17138,8 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@unraid/shared-callbacks@1.1.1(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2)))':
|
||||
'@unraid/shared-callbacks@3.0.0':
|
||||
dependencies:
|
||||
'@vueuse/core': 13.8.0(vue@3.5.20(typescript@5.9.2))
|
||||
crypto-js: 4.2.0
|
||||
|
||||
'@unraid/tailwind-rem-to-rem@2.0.0(tailwindcss@4.1.12)':
|
||||
|
||||
45
readme.md
45
readme.md
@@ -210,22 +210,34 @@ Once you have your key pair, add your public SSH key to your Unraid server:
|
||||
|
||||
### Development Modes
|
||||
|
||||
The project supports two development modes:
|
||||
#### Mode 1: Local Plugin Build (Docker)
|
||||
|
||||
#### Mode 1: Build Watcher with Local Plugin
|
||||
|
||||
This mode builds the plugin continuously and serves it locally for installation on your Unraid server:
|
||||
Build and test a full plugin locally using Docker:
|
||||
|
||||
```sh
|
||||
# From the root directory (api/)
|
||||
pnpm build:watch
|
||||
cd plugin
|
||||
pnpm run docker:build-and-run
|
||||
# Then inside the container:
|
||||
pnpm build
|
||||
```
|
||||
|
||||
This command will output a local plugin URL that you can install on your Unraid server by navigating to Plugins → Install Plugin. Be aware it will take a *while* to build the first time.
|
||||
This builds all dependencies (API, web), starts a Docker container, and serves the plugin at `http://YOUR_IP:5858/`. Install it on your Unraid server via Plugins → Install Plugin.
|
||||
|
||||
#### Mode 2: Development Servers
|
||||
#### Mode 2: Direct Deployment
|
||||
|
||||
For active development with hot-reload:
|
||||
Deploy individual packages directly to an Unraid server for faster iteration:
|
||||
|
||||
```sh
|
||||
# Deploy API changes
|
||||
cd api && pnpm unraid:deploy <SERVER_IP>
|
||||
|
||||
# Deploy web changes
|
||||
cd web && pnpm unraid:deploy <SERVER_IP>
|
||||
```
|
||||
|
||||
#### Mode 3: Development Servers
|
||||
|
||||
For active development with hot-reload (no Unraid server needed):
|
||||
|
||||
```sh
|
||||
# From the root directory - runs all dev servers concurrently
|
||||
@@ -238,22 +250,11 @@ Or run individual development servers:
|
||||
# API server (GraphQL backend at http://localhost:3001)
|
||||
cd api && pnpm dev
|
||||
|
||||
# Web interface (Nuxt frontend at http://localhost:3000)
|
||||
# Web interface (Nuxt frontend at http://localhost:3000)
|
||||
cd web && pnpm dev
|
||||
```
|
||||
|
||||
### Building the Full Plugin
|
||||
|
||||
To build the complete plugin package (.plg file):
|
||||
|
||||
```sh
|
||||
# From the root directory (api/)
|
||||
pnpm build:plugin
|
||||
|
||||
# The plugin will be created in plugin/dynamix.unraid.net.plg
|
||||
```
|
||||
|
||||
To deploy the plugin to your Unraid server:
|
||||
### Deploying to Unraid
|
||||
|
||||
```sh
|
||||
# Replace SERVER_IP with your Unraid server's IP address
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/ui",
|
||||
"version": "4.26.2",
|
||||
"version": "4.29.1",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import useTeleport from '@/composables/useTeleport';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defineComponent } from 'vue';
|
||||
import { defineComponent, nextTick } from 'vue';
|
||||
|
||||
describe('useTeleport', () => {
|
||||
beforeEach(() => {
|
||||
// Reset modules before each test to ensure fresh state
|
||||
vi.resetModules();
|
||||
// Clear the DOM before each test
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -16,16 +20,19 @@ describe('useTeleport', () => {
|
||||
if (virtualContainer) {
|
||||
virtualContainer.remove();
|
||||
}
|
||||
// Reset the module to clear the virtualModalContainer variable
|
||||
vi.resetModules();
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||
});
|
||||
|
||||
it('should return teleportTarget ref with correct value', () => {
|
||||
it('should return teleportTarget ref with correct value', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('#unraid-api-modals-virtual');
|
||||
});
|
||||
|
||||
it('should create virtual container element on mount with correct properties', () => {
|
||||
it('should create virtual container element on mount with correct properties', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
@@ -39,6 +46,7 @@ describe('useTeleport', () => {
|
||||
|
||||
// Mount the component
|
||||
mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
// After mount, virtual container should be created with correct properties
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
@@ -49,7 +57,8 @@ describe('useTeleport', () => {
|
||||
expect(virtualContainer?.parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('should reuse existing virtual container within same test', () => {
|
||||
it('should reuse existing virtual container within same test', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
// Manually create the container first
|
||||
const manualContainer = document.createElement('div');
|
||||
manualContainer.id = 'unraid-api-modals-virtual';
|
||||
@@ -68,10 +77,128 @@ describe('useTeleport', () => {
|
||||
|
||||
// Mount component - should not create a new container
|
||||
mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
// Should still have only one container
|
||||
const containers = document.querySelectorAll('#unraid-api-modals-virtual');
|
||||
expect(containers.length).toBe(1);
|
||||
expect(containers[0]).toBe(manualContainer);
|
||||
});
|
||||
|
||||
it('should apply dark class when dark mode is active via CSS variable', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '1';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
const wrapper = mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
expect(virtualContainer).toBeTruthy();
|
||||
expect(virtualContainer?.classList.contains('dark')).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
getComputedStyleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not apply dark class when dark mode is inactive via CSS variable', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '0';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
const wrapper = mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
expect(virtualContainer).toBeTruthy();
|
||||
expect(virtualContainer?.classList.contains('dark')).toBe(false);
|
||||
|
||||
wrapper.unmount();
|
||||
getComputedStyleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should apply dark class when dark mode is active via documentElement class', async () => {
|
||||
const useTeleport = (await import('@/composables/useTeleport')).default;
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
const getComputedStyleSpy = vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
const wrapper = mount(TestComponent);
|
||||
await nextTick();
|
||||
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
expect(virtualContainer).toBeTruthy();
|
||||
expect(virtualContainer?.classList.contains('dark')).toBe(true);
|
||||
|
||||
wrapper.unmount();
|
||||
getComputedStyleSpy.mockRestore();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { isDarkModeActive } from '@/lib/utils';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
let virtualModalContainer: HTMLDivElement | null = null;
|
||||
|
||||
const ensureVirtualContainer = () => {
|
||||
if (!virtualModalContainer) {
|
||||
virtualModalContainer = document.createElement('div');
|
||||
virtualModalContainer.id = 'unraid-api-modals-virtual';
|
||||
virtualModalContainer.className = 'unapi';
|
||||
virtualModalContainer.style.position = 'relative';
|
||||
virtualModalContainer.style.zIndex = '999999';
|
||||
document.body.appendChild(virtualModalContainer);
|
||||
const existing = document.getElementById('unraid-api-modals-virtual');
|
||||
if (existing) {
|
||||
virtualModalContainer = existing as HTMLDivElement;
|
||||
} else {
|
||||
virtualModalContainer = document.createElement('div');
|
||||
virtualModalContainer.id = 'unraid-api-modals-virtual';
|
||||
virtualModalContainer.className = 'unapi';
|
||||
virtualModalContainer.style.position = 'relative';
|
||||
virtualModalContainer.style.zIndex = '999999';
|
||||
if (isDarkModeActive()) {
|
||||
virtualModalContainer.classList.add('dark');
|
||||
}
|
||||
document.body.appendChild(virtualModalContainer);
|
||||
}
|
||||
}
|
||||
return virtualModalContainer;
|
||||
};
|
||||
|
||||
193
unraid-ui/src/lib/utils.test.ts
Normal file
193
unraid-ui/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { isDarkModeActive } from '@/lib/utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('isDarkModeActive', () => {
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
const originalDocumentElement = document.documentElement;
|
||||
const originalBody = document.body;
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
|
||||
});
|
||||
|
||||
describe('CSS variable detection', () => {
|
||||
it('should return true when CSS variable is set to "1"', () => {
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '1';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when CSS variable is set to "0"', () => {
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '0';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CSS variable is explicitly "0" even if dark class exists', () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.body.classList.add('dark');
|
||||
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '0';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassList detection fallback', () => {
|
||||
it('should return true when documentElement has dark class and CSS variable is not set', () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when body has dark class and CSS variable is not set', () => {
|
||||
document.body.classList.add('dark');
|
||||
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when .unapi.dark element exists and CSS variable is not set', () => {
|
||||
const unapiElement = document.createElement('div');
|
||||
unapiElement.className = 'unapi dark';
|
||||
document.body.appendChild(unapiElement);
|
||||
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(true);
|
||||
|
||||
unapiElement.remove();
|
||||
});
|
||||
|
||||
it('should return false when no dark indicators are present', () => {
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
expect(isDarkModeActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSR/Node environment', () => {
|
||||
it('should return false when document is undefined', () => {
|
||||
const originalDocument = global.document;
|
||||
// @ts-expect-error - intentionally removing document for SSR test
|
||||
global.document = undefined;
|
||||
|
||||
expect(isDarkModeActive()).toBe(false);
|
||||
|
||||
global.document = originalDocument;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -54,3 +54,17 @@ export class Markdown {
|
||||
return Markdown.instance.parse(markdownContent);
|
||||
}
|
||||
}
|
||||
|
||||
export const isDarkModeActive = (): boolean => {
|
||||
if (typeof document === 'undefined') return false;
|
||||
|
||||
const cssVar = getComputedStyle(document.documentElement).getPropertyValue('--theme-dark-mode').trim();
|
||||
if (cssVar === '1') return true;
|
||||
if (cssVar === '0') return false;
|
||||
|
||||
if (document.documentElement.classList.contains('dark')) return true;
|
||||
if (document.body?.classList.contains('dark')) return true;
|
||||
if (document.querySelector('.unapi.dark')) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -2,3 +2,4 @@ auto-imports.d.ts
|
||||
components.d.ts
|
||||
composables/gql/
|
||||
src/composables/gql/
|
||||
dist/
|
||||
|
||||
@@ -22,6 +22,13 @@ vi.mock('@vue/apollo-composable', () => ({
|
||||
onResult: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
useLazyQuery: () => ({
|
||||
load: vi.fn(),
|
||||
result: ref(null),
|
||||
loading: ref(false),
|
||||
onResult: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Explicitly mock @unraid/ui to ensure we use the actual components
|
||||
@@ -54,6 +61,11 @@ describe('ColorSwitcher', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Set CSS variables for theme store
|
||||
document.documentElement.style.setProperty('--theme-dark-mode', '0');
|
||||
document.documentElement.style.setProperty('--banner-gradient', '');
|
||||
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
setActivePinia(pinia);
|
||||
themeStore = useThemeStore();
|
||||
@@ -69,8 +81,12 @@ describe('ColorSwitcher', () => {
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
document.body.removeChild(modalDiv);
|
||||
consoleWarnSpy.mockRestore();
|
||||
if (modalDiv && modalDiv.parentNode) {
|
||||
modalDiv.parentNode.removeChild(modalDiv);
|
||||
}
|
||||
if (consoleWarnSpy) {
|
||||
consoleWarnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders all form elements correctly', () => {
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* DownloadApiLogs Component Test Coverage
|
||||
*/
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { BrandButton } from '@unraid/ui';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
|
||||
import { createTestI18n, testTranslate } from '../utils/i18n';
|
||||
|
||||
vi.mock('~/helpers/urls', () => ({
|
||||
CONNECT_FORUMS: new URL('http://mock-forums.local'),
|
||||
CONTACT: new URL('http://mock-contact.local'),
|
||||
DISCORD: new URL('http://mock-discord.local'),
|
||||
WEBGUI_GRAPHQL: '/graphql',
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof import('vue-i18n');
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: testTranslate,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('DownloadApiLogs', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock global csrf_token
|
||||
globalThis.csrf_token = 'mock-csrf-token';
|
||||
});
|
||||
|
||||
it('provides a download button with the correct URL', () => {
|
||||
const wrapper = mount(DownloadApiLogs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
ArrowDownTrayIcon: true,
|
||||
ArrowTopRightOnSquareIcon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Expected download URL
|
||||
const expectedUrl = '/graphql/api/logs?csrf_token=mock-csrf-token';
|
||||
|
||||
// Find the download button
|
||||
const downloadButton = wrapper.findComponent(BrandButton);
|
||||
|
||||
// Verify download button exists and has correct attributes
|
||||
expect(downloadButton.exists()).toBe(true);
|
||||
expect(downloadButton.attributes('href')).toBe(expectedUrl);
|
||||
expect(downloadButton.attributes('download')).toBe('');
|
||||
expect(downloadButton.attributes('target')).toBe('_blank');
|
||||
expect(downloadButton.attributes('rel')).toBe('noopener noreferrer');
|
||||
expect(downloadButton.text()).toContain(testTranslate('downloadApiLogs.downloadUnraidApiLogs'));
|
||||
});
|
||||
|
||||
it('displays support links to documentation and help resources', () => {
|
||||
const wrapper = mount(DownloadApiLogs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
ArrowDownTrayIcon: true,
|
||||
ArrowTopRightOnSquareIcon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const links = wrapper.findAll('a');
|
||||
expect(links.length).toBe(4);
|
||||
|
||||
expect(links[1].attributes('href')).toBe('http://mock-forums.local/');
|
||||
expect(links[1].text()).toContain(testTranslate('downloadApiLogs.unraidConnectForums'));
|
||||
|
||||
expect(links[2].attributes('href')).toBe('http://mock-discord.local/');
|
||||
expect(links[2].text()).toContain(testTranslate('downloadApiLogs.unraidDiscord'));
|
||||
|
||||
expect(links[3].attributes('href')).toBe('http://mock-contact.local/');
|
||||
expect(links[3].text()).toContain(testTranslate('downloadApiLogs.unraidContactPage'));
|
||||
|
||||
links.slice(1).forEach((link) => {
|
||||
expect(link.attributes('target')).toBe('_blank');
|
||||
expect(link.attributes('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays instructions about log usage and privacy', () => {
|
||||
const wrapper = mount(DownloadApiLogs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
ArrowDownTrayIcon: true,
|
||||
ArrowTopRightOnSquareIcon: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const text = wrapper.text();
|
||||
|
||||
expect(text).toContain(testTranslate('downloadApiLogs.thePrimaryMethodOfSupportFor'));
|
||||
expect(text).toContain(testTranslate('downloadApiLogs.ifYouAreAskedToSupply'));
|
||||
expect(text).toContain(testTranslate('downloadApiLogs.theLogsMayContainSensitiveInformation'));
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { BrandButton } from '@unraid/ui';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ServerStateDataAction, ServerStateDataActionType } from '~/types/server';
|
||||
import type { ServerStateDataAction } from '~/types/server';
|
||||
|
||||
import KeyActions from '~/components/KeyActions.vue';
|
||||
import { createTestI18n } from '../utils/i18n';
|
||||
@@ -34,7 +34,7 @@ describe('KeyActions', () => {
|
||||
|
||||
it('renders buttons from props when actions prop is provided', () => {
|
||||
const actions: ServerStateDataAction[] = [
|
||||
{ name: 'purchase' as ServerStateDataActionType, text: 'Custom Action 1', click: vi.fn() },
|
||||
{ name: 'purchase', text: 'Custom Action 1', click: vi.fn() },
|
||||
];
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
@@ -68,9 +68,7 @@ describe('KeyActions', () => {
|
||||
|
||||
it('calls action click handler when button is clicked', async () => {
|
||||
const click = vi.fn();
|
||||
const actions: ServerStateDataAction[] = [
|
||||
{ name: 'purchase' as ServerStateDataActionType, text: 'Clickable Action', click },
|
||||
];
|
||||
const actions: ServerStateDataAction[] = [{ name: 'purchase', text: 'Clickable Action', click }];
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
@@ -89,7 +87,7 @@ describe('KeyActions', () => {
|
||||
const click = vi.fn();
|
||||
const actions: ServerStateDataAction[] = [
|
||||
{
|
||||
name: 'purchase' as ServerStateDataActionType,
|
||||
name: 'purchase',
|
||||
text: 'Disabled Action',
|
||||
disabled: true,
|
||||
click,
|
||||
@@ -111,9 +109,9 @@ describe('KeyActions', () => {
|
||||
|
||||
it('filters actions using filterBy prop', () => {
|
||||
const actions: ServerStateDataAction[] = [
|
||||
{ name: 'purchase' as ServerStateDataActionType, text: 'Action 1', click: vi.fn() },
|
||||
{ name: 'redeem' as ServerStateDataActionType, text: 'Action 2', click: vi.fn() },
|
||||
{ name: 'upgrade' as ServerStateDataActionType, text: 'Action 3', click: vi.fn() },
|
||||
{ name: 'purchase', text: 'Action 1', click: vi.fn() },
|
||||
{ name: 'redeem', text: 'Action 2', click: vi.fn() },
|
||||
{ name: 'upgrade', text: 'Action 3', click: vi.fn() },
|
||||
];
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
@@ -135,9 +133,9 @@ describe('KeyActions', () => {
|
||||
|
||||
it('filters out actions using filterOut prop', () => {
|
||||
const actions: ServerStateDataAction[] = [
|
||||
{ name: 'purchase' as ServerStateDataActionType, text: 'Action 1', click: vi.fn() },
|
||||
{ name: 'redeem' as ServerStateDataActionType, text: 'Action 2', click: vi.fn() },
|
||||
{ name: 'upgrade' as ServerStateDataActionType, text: 'Action 3', click: vi.fn() },
|
||||
{ name: 'purchase', text: 'Action 1', click: vi.fn() },
|
||||
{ name: 'redeem', text: 'Action 2', click: vi.fn() },
|
||||
{ name: 'upgrade', text: 'Action 3', click: vi.fn() },
|
||||
];
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
@@ -158,9 +156,7 @@ describe('KeyActions', () => {
|
||||
});
|
||||
|
||||
it('applies maxWidth styling when maxWidth prop is true', () => {
|
||||
const actions: ServerStateDataAction[] = [
|
||||
{ name: 'purchase' as ServerStateDataActionType, text: 'Action 1', click: vi.fn() },
|
||||
];
|
||||
const actions: ServerStateDataAction[] = [{ name: 'purchase', text: 'Action 1', click: vi.fn() }];
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
@@ -180,7 +176,7 @@ describe('KeyActions', () => {
|
||||
it('passes all required props to BrandButton component', () => {
|
||||
const actions: ServerStateDataAction[] = [
|
||||
{
|
||||
name: 'purchase' as ServerStateDataActionType,
|
||||
name: 'purchase',
|
||||
text: 'Test Action',
|
||||
title: 'Action Title',
|
||||
href: '/test-link',
|
||||
|
||||
@@ -13,7 +13,9 @@ import { createTestI18n } from '../utils/i18n';
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
PageContainer: { template: '<div><slot /></div>' },
|
||||
BrandLoading: { template: '<div data-testid="brand-loading-mock">Loading...</div>' },
|
||||
BrandButton: {
|
||||
template: '<button v-bind="$attrs" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
}));
|
||||
|
||||
const mockAccountStore = {
|
||||
@@ -97,7 +99,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
});
|
||||
|
||||
describe('Initial Rendering and onBeforeMount Logic', () => {
|
||||
it('shows loader and calls updateOs when path matches and rebootType is empty', async () => {
|
||||
it('shows account button and does not auto-redirect when path matches and rebootType is empty', async () => {
|
||||
window.location.pathname = '/Tools/Update';
|
||||
mockRebootType.value = '';
|
||||
|
||||
@@ -105,7 +107,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
// Rely on @unraid/ui mock for PageContainer & BrandLoading
|
||||
// Rely on @unraid/ui mock for PageContainer & BrandButton
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
|
||||
},
|
||||
@@ -114,17 +116,9 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
|
||||
await nextTick();
|
||||
|
||||
// When path matches and rebootType is empty, updateOs should be called
|
||||
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
|
||||
// Since v-show is used, both elements exist in DOM
|
||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
||||
// The loader should be visible when showLoader is true
|
||||
const loaderWrapper = wrapper.find('[data-testid="brand-loading-mock"]').element.parentElement;
|
||||
expect(loaderWrapper?.style.display).not.toBe('none');
|
||||
// The status should be hidden when showLoader is true
|
||||
const statusWrapper = wrapper.find('[data-testid="update-os-status"]').element.parentElement;
|
||||
expect(statusWrapper?.style.display).toBe('none');
|
||||
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
|
||||
expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows status and does not call updateOs when path does not match', async () => {
|
||||
@@ -145,8 +139,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
await nextTick();
|
||||
|
||||
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
|
||||
// Since v-show is used, both elements exist in DOM
|
||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -168,10 +161,30 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
await nextTick();
|
||||
|
||||
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
|
||||
// Since v-show is used, both elements exist in DOM
|
||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('navigates to account update when the button is clicked', async () => {
|
||||
window.location.pathname = '/Tools/Update';
|
||||
mockRebootType.value = '';
|
||||
|
||||
const wrapper = mount(UpdateOs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
await wrapper.find('[data-testid="update-os-account-button"]').trigger('click');
|
||||
|
||||
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering based on rebootType', () => {
|
||||
|
||||
@@ -35,6 +35,13 @@ vi.mock('@vueuse/core', () => ({
|
||||
isSupported: mockIsSupported,
|
||||
};
|
||||
},
|
||||
useLocalStorage: <T>(key: string, initialValue: T) => {
|
||||
const storage = new Map<string, T>();
|
||||
if (!storage.has(key)) {
|
||||
storage.set(key, initialValue);
|
||||
}
|
||||
return ref(storage.get(key) ?? initialValue);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
@@ -46,6 +53,18 @@ vi.mock('@unraid/ui', () => ({
|
||||
props: ['variant', 'size'],
|
||||
},
|
||||
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
|
||||
isDarkModeActive: vi.fn(() => {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--theme-dark-mode')
|
||||
.trim();
|
||||
if (cssVar === '1') return true;
|
||||
if (cssVar === '0') return false;
|
||||
if (document.documentElement.classList.contains('dark')) return true;
|
||||
if (document.body?.classList.contains('dark')) return true;
|
||||
if (document.querySelector('.unapi.dark')) return true;
|
||||
return false;
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockWatcher = vi.fn();
|
||||
@@ -175,26 +194,33 @@ describe('UserProfile.standalone.vue', () => {
|
||||
createSpy: vi.fn,
|
||||
initialState: {
|
||||
server: { ...initialServerData },
|
||||
theme: {
|
||||
theme: {
|
||||
name: 'default',
|
||||
banner: true,
|
||||
bannerGradient: true,
|
||||
descriptionShow: true,
|
||||
textColor: '',
|
||||
metaColor: '',
|
||||
bgColor: '',
|
||||
},
|
||||
bannerGradient: 'linear-gradient(to right, #ff0000, #0000ff)',
|
||||
},
|
||||
},
|
||||
stubActions: false,
|
||||
});
|
||||
setActivePinia(pinia);
|
||||
|
||||
serverStore = useServerStore();
|
||||
|
||||
// Set CSS variables directly on document element for theme store
|
||||
document.documentElement.style.setProperty('--theme-dark-mode', '0');
|
||||
document.documentElement.style.setProperty(
|
||||
'--banner-gradient',
|
||||
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) var(--banner-gradient-stop, 30%))'
|
||||
);
|
||||
|
||||
themeStore = useThemeStore();
|
||||
|
||||
// Set the theme using setTheme method
|
||||
themeStore.setTheme({
|
||||
name: 'white',
|
||||
banner: true,
|
||||
bannerGradient: true,
|
||||
descriptionShow: true,
|
||||
textColor: '',
|
||||
metaColor: '',
|
||||
bgColor: '',
|
||||
});
|
||||
|
||||
// Override the setServer method to prevent console logging
|
||||
vi.spyOn(serverStore, 'setServer').mockImplementation((server) => {
|
||||
Object.assign(serverStore, server);
|
||||
@@ -319,7 +345,7 @@ describe('UserProfile.standalone.vue', () => {
|
||||
expect(themeStore.theme?.descriptionShow).toBe(true);
|
||||
|
||||
serverStore.description = initialServerData.description!;
|
||||
themeStore.theme!.descriptionShow = true;
|
||||
themeStore.setTheme({ ...themeStore.theme, descriptionShow: true });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Look for the description in a span element with v-html directive
|
||||
@@ -327,14 +353,14 @@ describe('UserProfile.standalone.vue', () => {
|
||||
expect(descriptionElement.exists()).toBe(true);
|
||||
expect(descriptionElement.html()).toContain(initialServerData.description);
|
||||
|
||||
themeStore.theme!.descriptionShow = false;
|
||||
themeStore.setTheme({ ...themeStore.theme, descriptionShow: false });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// When descriptionShow is false, the element should not exist
|
||||
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
|
||||
expect(descriptionElement.exists()).toBe(false);
|
||||
|
||||
themeStore.theme!.descriptionShow = true;
|
||||
themeStore.setTheme({ ...themeStore.theme, descriptionShow: true });
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
|
||||
@@ -352,28 +378,34 @@ describe('UserProfile.standalone.vue', () => {
|
||||
});
|
||||
|
||||
it('conditionally renders banner based on theme store', async () => {
|
||||
const bannerSelector = 'div.absolute.z-0';
|
||||
const bannerSelector = '.unraid-banner-gradient-layer';
|
||||
|
||||
themeStore.theme = {
|
||||
...themeStore.theme!,
|
||||
themeStore.setTheme({
|
||||
...themeStore.theme,
|
||||
banner: true,
|
||||
bannerGradient: true,
|
||||
};
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(themeStore.bannerGradient).toContain('background-image: linear-gradient');
|
||||
expect(themeStore.bannerGradient).toBe(true);
|
||||
expect(wrapper.find(bannerSelector).exists()).toBe(true);
|
||||
|
||||
themeStore.theme!.bannerGradient = false;
|
||||
themeStore.setTheme({
|
||||
...themeStore.theme,
|
||||
bannerGradient: false,
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(themeStore.bannerGradient).toBeUndefined();
|
||||
expect(themeStore.bannerGradient).toBe(false);
|
||||
expect(wrapper.find(bannerSelector).exists()).toBe(false);
|
||||
|
||||
themeStore.theme!.bannerGradient = true;
|
||||
themeStore.setTheme({
|
||||
...themeStore.theme,
|
||||
bannerGradient: true,
|
||||
});
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(themeStore.bannerGradient).toContain('background-image: linear-gradient');
|
||||
expect(themeStore.bannerGradient).toBe(true);
|
||||
expect(wrapper.find(bannerSelector).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ vi.mock('@/components/HeaderOsVersion.standalone.vue', () => ({ default: 'Header
|
||||
vi.mock('@/components/UserProfile.standalone.vue', () => ({ default: 'UserProfile' }));
|
||||
vi.mock('../Auth.standalone.vue', () => ({ default: 'Auth' }));
|
||||
vi.mock('../ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: 'ConnectSettings' }));
|
||||
vi.mock('../DownloadApiLogs.standalone.vue', () => ({ default: 'DownloadApiLogs' }));
|
||||
vi.mock('@/components/Modals.standalone.vue', () => ({ default: 'Modals' }));
|
||||
vi.mock('../Registration.standalone.vue', () => ({ default: 'Registration' }));
|
||||
vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' }));
|
||||
@@ -135,7 +134,6 @@ describe('component-registry', () => {
|
||||
'user-profile',
|
||||
'auth',
|
||||
'connect-settings',
|
||||
'download-api-logs',
|
||||
'modals',
|
||||
'registration',
|
||||
'wan-ip-check',
|
||||
|
||||
@@ -21,6 +21,21 @@ vi.mock('@nuxt/ui/vue-plugin', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
isDarkModeActive: vi.fn(() => {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--theme-dark-mode')
|
||||
.trim();
|
||||
if (cssVar === '1') return true;
|
||||
if (cssVar === '0') return false;
|
||||
if (document.documentElement.classList.contains('dark')) return true;
|
||||
if (document.body?.classList.contains('dark')) return true;
|
||||
if (document.querySelector('.unapi.dark')) return true;
|
||||
return false;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock component registry
|
||||
const mockComponentMappings: ComponentMapping[] = [];
|
||||
vi.mock('~/components/Wrapper/component-registry', () => ({
|
||||
|
||||
@@ -16,9 +16,6 @@ vi.mock('~/components/Auth.standalone.vue', () => ({
|
||||
vi.mock('~/components/ConnectSettings/ConnectSettings.standalone.vue', () => ({
|
||||
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' },
|
||||
}));
|
||||
vi.mock('~/components/DownloadApiLogs.standalone.vue', () => ({
|
||||
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' },
|
||||
}));
|
||||
vi.mock('~/components/HeaderOsVersion.standalone.vue', () => ({
|
||||
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' },
|
||||
}));
|
||||
|
||||
58
web/__test__/composables/dateTime.test.ts
Normal file
58
web/__test__/composables/dateTime.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ServerDateTimeFormat } from '~/types/server';
|
||||
|
||||
import useDateTimeHelper from '~/composables/dateTime';
|
||||
import { testTranslate } from '../utils/i18n';
|
||||
|
||||
const formatDateWithComponent = (
|
||||
dateTimeFormat: ServerDateTimeFormat | undefined,
|
||||
hideMinutesSeconds: boolean,
|
||||
providedDateTime: number
|
||||
) => {
|
||||
const wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
const { outputDateTimeFormatted } = useDateTimeHelper(
|
||||
dateTimeFormat,
|
||||
testTranslate,
|
||||
hideMinutesSeconds,
|
||||
providedDateTime
|
||||
);
|
||||
return { outputDateTimeFormatted };
|
||||
},
|
||||
template: '<div />',
|
||||
})
|
||||
);
|
||||
|
||||
const output = (wrapper.vm as unknown as { outputDateTimeFormatted: string | { value: string } })
|
||||
.outputDateTimeFormatted;
|
||||
return typeof output === 'string' ? output : output.value;
|
||||
};
|
||||
|
||||
describe('useDateTimeHelper', () => {
|
||||
it('falls back to default date format when server format is empty', () => {
|
||||
const timestamp = new Date(2025, 0, 2, 3, 4, 5).getTime();
|
||||
const formatted = formatDateWithComponent({ date: '', time: '' }, true, timestamp);
|
||||
|
||||
expect(formatted).toBe(dayjs(timestamp).format('dddd, MMMM D, YYYY'));
|
||||
});
|
||||
|
||||
it('falls back to default date format when server format is unknown', () => {
|
||||
const timestamp = new Date(2025, 0, 2, 3, 4, 5).getTime();
|
||||
const formatted = formatDateWithComponent({ date: '%Q', time: '%Q' }, true, timestamp);
|
||||
|
||||
expect(formatted).toBe(dayjs(timestamp).format('dddd, MMMM D, YYYY'));
|
||||
});
|
||||
|
||||
it('falls back to default time format when server time format is unknown', () => {
|
||||
const timestamp = new Date(2025, 0, 2, 3, 4, 5).getTime();
|
||||
const formatted = formatDateWithComponent({ date: '%c', time: '%Q' }, false, timestamp);
|
||||
|
||||
expect(formatted).toBe(dayjs(timestamp).format('ddd, D MMMM YYYY hh:mma'));
|
||||
});
|
||||
});
|
||||
90
web/__test__/i18n/trial-translations.test.ts
Normal file
90
web/__test__/i18n/trial-translations.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createTestI18n } from '../utils/i18n';
|
||||
|
||||
describe('Trial Translation Keys', () => {
|
||||
it('should load all trial-related translation keys', () => {
|
||||
const i18n = createTestI18n();
|
||||
const { t } = i18n.global;
|
||||
|
||||
const trialKeys = [
|
||||
'registration.trialExpiration',
|
||||
'server.actions.extendTrial',
|
||||
'server.actions.startTrial',
|
||||
'server.state.trial.humanReadable',
|
||||
'server.state.trial.messageEligibleInsideRenewal',
|
||||
'server.state.trial.messageEligibleOutsideRenewal',
|
||||
'server.state.trial.messageIneligibleInsideRenewal',
|
||||
'server.state.trial.messageIneligibleOutsideRenewal',
|
||||
'server.state.trialExpired.heading',
|
||||
'server.state.trialExpired.humanReadable',
|
||||
'server.state.trialExpired.messageEligible',
|
||||
'server.state.trialExpired.messageIneligible',
|
||||
'userProfile.trial.trialKeyCreated',
|
||||
'userProfile.trial.trialKeyCreationFailed',
|
||||
'userProfile.trial.startingYourFreeDayTrial',
|
||||
'userProfile.trial.extendingYourFreeTrialByDays',
|
||||
'userProfile.trial.errorCreatiingATrialKeyPlease',
|
||||
'userProfile.trial.pleaseKeepThisWindowOpen',
|
||||
'userProfile.trial.pleaseWaitWhileThePageReloads',
|
||||
'userProfile.uptimeExpire.trialKeyExpired',
|
||||
'userProfile.uptimeExpire.trialKeyExpiredAt',
|
||||
'userProfile.uptimeExpire.trialKeyExpiresAt',
|
||||
'userProfile.uptimeExpire.trialKeyExpiresIn',
|
||||
'userProfile.callbackFeedback.calculatingTrialExpiration',
|
||||
'userProfile.callbackFeedback.installingExtendedTrial',
|
||||
'userProfile.callbackFeedback.yourFreeTrialKeyProvidesAll',
|
||||
'userProfile.callbackFeedback.yourTrialKeyHasBeenExtended',
|
||||
'userProfile.dropdownTrigger.trialExpiredSeeOptionsBelow',
|
||||
];
|
||||
|
||||
for (const key of trialKeys) {
|
||||
const translation = t(key);
|
||||
expect(translation).toBeTruthy();
|
||||
expect(translation).not.toBe(key);
|
||||
expect(typeof translation).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('should translate trial expiration keys with parameters', () => {
|
||||
const i18n = createTestI18n();
|
||||
const { t } = i18n.global;
|
||||
|
||||
const testDate = '2024-01-15 10:30:00';
|
||||
const testDuration = '5 days';
|
||||
|
||||
expect(t('userProfile.uptimeExpire.trialKeyExpired', [testDuration])).toContain(testDuration);
|
||||
expect(t('userProfile.uptimeExpire.trialKeyExpiredAt', [testDate])).toContain(testDate);
|
||||
expect(t('userProfile.uptimeExpire.trialKeyExpiresAt', [testDate])).toContain(testDate);
|
||||
expect(t('userProfile.uptimeExpire.trialKeyExpiresIn', [testDuration])).toContain(testDuration);
|
||||
});
|
||||
|
||||
it('should have all required trial state messages', () => {
|
||||
const i18n = createTestI18n();
|
||||
const { t } = i18n.global;
|
||||
|
||||
const stateMessages = [
|
||||
'server.state.trial.messageEligibleInsideRenewal',
|
||||
'server.state.trial.messageEligibleOutsideRenewal',
|
||||
'server.state.trial.messageIneligibleInsideRenewal',
|
||||
'server.state.trial.messageIneligibleOutsideRenewal',
|
||||
'server.state.trialExpired.messageEligible',
|
||||
'server.state.trialExpired.messageIneligible',
|
||||
];
|
||||
|
||||
for (const key of stateMessages) {
|
||||
const message = t(key);
|
||||
expect(message).toBeTruthy();
|
||||
expect(message.length).toBeGreaterThan(0);
|
||||
expect(message).toMatch(/<p>/);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have trial action translations', () => {
|
||||
const i18n = createTestI18n();
|
||||
const { t } = i18n.global;
|
||||
|
||||
expect(t('server.actions.extendTrial')).toBe('Extend Trial');
|
||||
expect(t('server.actions.startTrial')).toBe('Start Free 30 Day Trial');
|
||||
});
|
||||
});
|
||||
@@ -94,5 +94,17 @@ vi.mock('@unraid/ui', () => ({
|
||||
name: 'ResponsiveModalTitle',
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
isDarkModeActive: vi.fn(() => {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--theme-dark-mode')
|
||||
.trim();
|
||||
if (cssVar === '1') return true;
|
||||
if (cssVar === '0') return false;
|
||||
if (document.documentElement.classList.contains('dark')) return true;
|
||||
if (document.body?.classList.contains('dark')) return true;
|
||||
if (document.querySelector('.unapi.dark')) return true;
|
||||
return false;
|
||||
}),
|
||||
// Add other UI components as needed
|
||||
}));
|
||||
|
||||
@@ -14,6 +14,10 @@ import { useServerStore } from '~/store/server';
|
||||
|
||||
vi.mock('@unraid/shared-callbacks', () => ({}));
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
BrandLoading: {},
|
||||
}));
|
||||
|
||||
vi.mock('~/composables/services/keyServer', () => ({
|
||||
validateGuid: vi.fn(),
|
||||
}));
|
||||
@@ -62,7 +66,7 @@ describe('ReplaceRenew Store', () => {
|
||||
expect(store.replaceStatus).toBe('ready');
|
||||
});
|
||||
|
||||
it('should initialize with error state when guid is missing', () => {
|
||||
it('should initialize with ready state even when guid is missing', () => {
|
||||
vi.mocked(useServerStore).mockReturnValueOnce({
|
||||
guid: undefined,
|
||||
keyfile: mockKeyfile,
|
||||
@@ -72,7 +76,8 @@ describe('ReplaceRenew Store', () => {
|
||||
|
||||
const newStore = useReplaceRenewStore();
|
||||
|
||||
expect(newStore.replaceStatus).toBe('error');
|
||||
// Store now always initializes as 'ready' - errors are set when check() is called
|
||||
expect(newStore.replaceStatus).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,6 +143,18 @@ describe('ReplaceRenew Store', () => {
|
||||
expect(store.renewStatus).toBe('installing');
|
||||
});
|
||||
|
||||
it('should reset all states with reset action', () => {
|
||||
store.setReplaceStatus('error');
|
||||
store.keyLinkedStatus = 'error';
|
||||
store.error = { name: 'Error', message: 'Test error' };
|
||||
|
||||
store.reset();
|
||||
|
||||
expect(store.replaceStatus).toBe('ready');
|
||||
expect(store.keyLinkedStatus).toBe('ready');
|
||||
expect(store.error).toBeNull();
|
||||
});
|
||||
|
||||
describe('check action', () => {
|
||||
const mockResponse = {
|
||||
hasNewerKeyfile: false,
|
||||
@@ -326,8 +343,59 @@ describe('ReplaceRenew Store', () => {
|
||||
await store.check();
|
||||
|
||||
expect(store.replaceStatus).toBe('error');
|
||||
expect(store.keyLinkedStatus).toBe('error');
|
||||
expect(console.error).toHaveBeenCalledWith('[ReplaceCheck.check]', testError);
|
||||
expect(store.error).toEqual(testError);
|
||||
expect(store.error).toEqual({ name: 'Error', message: 'Test error' });
|
||||
});
|
||||
|
||||
it('should set error when guid is missing during check', async () => {
|
||||
vi.mocked(useServerStore).mockReturnValue({
|
||||
guid: '',
|
||||
keyfile: mockKeyfile,
|
||||
} as unknown as ReturnType<typeof useServerStore>);
|
||||
|
||||
setActivePinia(createPinia());
|
||||
const testStore = useReplaceRenewStore();
|
||||
|
||||
await testStore.check();
|
||||
|
||||
expect(testStore.replaceStatus).toBe('error');
|
||||
expect(testStore.keyLinkedStatus).toBe('error');
|
||||
expect(testStore.error?.message).toBe('Flash GUID required to check replacement status');
|
||||
});
|
||||
|
||||
it('should set error when keyfile is missing during check', async () => {
|
||||
vi.mocked(useServerStore).mockReturnValue({
|
||||
guid: mockGuid,
|
||||
keyfile: '',
|
||||
} as unknown as ReturnType<typeof useServerStore>);
|
||||
|
||||
setActivePinia(createPinia());
|
||||
const testStore = useReplaceRenewStore();
|
||||
|
||||
await testStore.check();
|
||||
|
||||
expect(testStore.replaceStatus).toBe('error');
|
||||
expect(testStore.keyLinkedStatus).toBe('error');
|
||||
expect(testStore.error?.message).toBe('Keyfile required to check replacement status');
|
||||
});
|
||||
|
||||
it('should provide descriptive error for 403 status', async () => {
|
||||
const error403 = { response: { status: 403 }, message: 'Forbidden' };
|
||||
vi.mocked(validateGuid).mockRejectedValueOnce(error403);
|
||||
|
||||
await store.check();
|
||||
|
||||
expect(store.error?.message).toBe('Access denied - license may be linked to another account');
|
||||
});
|
||||
|
||||
it('should provide descriptive error for 500+ status', async () => {
|
||||
const error500 = { response: { status: 500 }, message: 'Server Error' };
|
||||
vi.mocked(validateGuid).mockRejectedValueOnce(error500);
|
||||
|
||||
await store.check();
|
||||
|
||||
expect(store.error?.message).toBe('Key server temporarily unavailable - please try again later');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,37 +161,45 @@ const getStore = () => {
|
||||
},
|
||||
serverPurchasePayload: {
|
||||
get: () => ({
|
||||
apiVersion: store.apiVersion,
|
||||
connectPluginVersion: store.connectPluginVersion,
|
||||
deviceCount: store.deviceCount,
|
||||
email: store.email,
|
||||
guid: store.guid,
|
||||
keyTypeForPurchase: store.state === 'PLUS' ? 'Plus' : store.state === 'PRO' ? 'Pro' : 'Trial',
|
||||
locale: store.locale,
|
||||
osVersion: store.osVersion,
|
||||
osVersionBranch: store.osVersionBranch,
|
||||
registered: store.registered ?? false,
|
||||
regExp: store.regExp,
|
||||
regTy: store.regTy,
|
||||
regUpdatesExpired: store.regUpdatesExpired,
|
||||
state: store.state,
|
||||
site: store.site,
|
||||
}),
|
||||
},
|
||||
serverAccountPayload: {
|
||||
get: () => ({
|
||||
apiVersion: store.apiVersion,
|
||||
caseModel: store.caseModel,
|
||||
connectPluginVersion: store.connectPluginVersion,
|
||||
deviceCount: store.deviceCount,
|
||||
description: store.description,
|
||||
deviceCount: store.deviceCount,
|
||||
expireTime: store.expireTime,
|
||||
flashProduct: store.flashProduct,
|
||||
flashVendor: store.flashVendor,
|
||||
guid: store.guid,
|
||||
locale: store.locale,
|
||||
name: store.name,
|
||||
osVersion: store.osVersion,
|
||||
osVersionBranch: store.osVersionBranch,
|
||||
registered: store.registered ?? false,
|
||||
regExp: store.regExp,
|
||||
regGen: store.regGen,
|
||||
regGuid: store.regGuid,
|
||||
regTy: store.regTy,
|
||||
regUpdatesExpired: store.regUpdatesExpired,
|
||||
state: store.state,
|
||||
wanFQDN: store.wanFQDN,
|
||||
}),
|
||||
},
|
||||
serverAccountPayload: {
|
||||
get: () => ({
|
||||
deviceCount: store.deviceCount,
|
||||
description: store.description,
|
||||
expireTime: store.expireTime,
|
||||
flashProduct: store.flashProduct,
|
||||
flashVendor: store.flashVendor,
|
||||
guid: store.guid,
|
||||
keyfile: store.keyfile,
|
||||
locale: store.locale,
|
||||
name: store.name,
|
||||
osVersion: store.osVersion,
|
||||
osVersionBranch: store.osVersionBranch,
|
||||
registered: store.registered ?? false,
|
||||
regExp: store.regExp,
|
||||
regGen: store.regGen,
|
||||
regGuid: store.regGuid,
|
||||
regTy: store.regTy,
|
||||
regUpdatesExpired: store.regUpdatesExpired,
|
||||
state: store.state,
|
||||
wanFQDN: store.wanFQDN,
|
||||
}),
|
||||
@@ -549,49 +557,65 @@ describe('useServerStore', () => {
|
||||
const store = getStore();
|
||||
|
||||
store.setServer({
|
||||
apiVersion: '1.0.0',
|
||||
connectPluginVersion: '2.0.0',
|
||||
deviceCount: 6,
|
||||
email: 'test@example.com',
|
||||
description: 'Test Server',
|
||||
expireTime: 123,
|
||||
flashProduct: 'TestFlash',
|
||||
flashVendor: 'TestVendor',
|
||||
guid: '123456',
|
||||
inIframe: false,
|
||||
locale: 'en-US',
|
||||
name: 'TestServer',
|
||||
osVersion: '6.10.3',
|
||||
osVersionBranch: 'stable',
|
||||
registered: true,
|
||||
regGen: 7,
|
||||
regGuid: 'reg-guid-1',
|
||||
regExp: 1234567890,
|
||||
regTy: 'Plus',
|
||||
state: 'PLUS' as ServerState,
|
||||
site: 'local',
|
||||
wanFQDN: 'test.myunraid.net',
|
||||
});
|
||||
|
||||
const payload = store.serverPurchasePayload;
|
||||
|
||||
expect(payload.apiVersion).toBe('1.0.0');
|
||||
expect(payload.connectPluginVersion).toBe('2.0.0');
|
||||
expect(payload.description).toBe('Test Server');
|
||||
expect(payload.deviceCount).toBe(6);
|
||||
expect(payload.email).toBe('test@example.com');
|
||||
expect(payload.expireTime).toBe(123);
|
||||
expect(payload.flashProduct).toBe('TestFlash');
|
||||
expect(payload.flashVendor).toBe('TestVendor');
|
||||
expect(payload.guid).toBe('123456');
|
||||
expect(payload.keyTypeForPurchase).toBe('Plus');
|
||||
expect(payload.locale).toBe('en-US');
|
||||
expect(payload.name).toBe('TestServer');
|
||||
expect(payload.osVersion).toBe('6.10.3');
|
||||
expect(payload.osVersionBranch).toBe('stable');
|
||||
expect(payload.registered).toBe(true);
|
||||
expect(payload.regExp).toBe(1234567890);
|
||||
expect(payload.regGen).toBe(7);
|
||||
expect(payload.regGuid).toBe('reg-guid-1');
|
||||
expect(payload.regTy).toBe('Plus');
|
||||
expect(payload.state).toBe('PLUS');
|
||||
expect(payload.wanFQDN).toBe('test.myunraid.net');
|
||||
});
|
||||
|
||||
it('should create serverAccountPayload correctly', () => {
|
||||
const store = getStore();
|
||||
|
||||
store.setServer({
|
||||
apiVersion: '1.0.0',
|
||||
caseModel: 'TestCase',
|
||||
connectPluginVersion: '2.0.0',
|
||||
deviceCount: 6,
|
||||
description: 'Test Server',
|
||||
expireTime: 123,
|
||||
flashProduct: 'TestFlash',
|
||||
flashVendor: 'TestVendor',
|
||||
guid: '123456',
|
||||
keyfile: '/boot/config/Plus.key',
|
||||
locale: 'en-US',
|
||||
name: 'TestServer',
|
||||
osVersion: '6.10.3',
|
||||
osVersionBranch: 'stable',
|
||||
registered: true,
|
||||
regExp: 1234567890,
|
||||
regGen: 7,
|
||||
regGuid: 'reg-guid-1',
|
||||
regTy: 'Plus',
|
||||
state: 'PLUS' as ServerState,
|
||||
wanFQDN: 'test.myunraid.net',
|
||||
@@ -599,16 +623,23 @@ describe('useServerStore', () => {
|
||||
|
||||
const payload = store.serverAccountPayload;
|
||||
|
||||
expect(payload.apiVersion).toBe('1.0.0');
|
||||
expect(payload.caseModel).toBe('TestCase');
|
||||
expect(payload.connectPluginVersion).toBe('2.0.0');
|
||||
expect(payload.deviceCount).toBe(6);
|
||||
expect(payload.description).toBe('Test Server');
|
||||
expect(payload.expireTime).toBe(123);
|
||||
expect(payload.flashProduct).toBe('TestFlash');
|
||||
expect(payload.flashVendor).toBe('TestVendor');
|
||||
expect(payload.guid).toBe('123456');
|
||||
expect(payload.keyfile).toBe('/boot/config/Plus.key');
|
||||
expect(payload.locale).toBe('en-US');
|
||||
expect(payload.name).toBe('TestServer');
|
||||
expect(payload.osVersion).toBe('6.10.3');
|
||||
expect(payload.osVersionBranch).toBe('stable');
|
||||
expect(payload.registered).toBe(true);
|
||||
expect(payload.regExp).toBe(1234567890);
|
||||
expect(payload.regGen).toBe(7);
|
||||
expect(payload.regGuid).toBe('reg-guid-1');
|
||||
expect(payload.regTy).toBe('Plus');
|
||||
expect(payload.regUpdatesExpired).toBe(true);
|
||||
expect(payload.state).toBe('PLUS');
|
||||
expect(payload.wanFQDN).toBe('test.myunraid.net');
|
||||
});
|
||||
|
||||
@@ -6,13 +6,10 @@ import { createApp, nextTick, ref } from 'vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
|
||||
import { defaultColors } from '~/themes/default';
|
||||
import hexToRgba from 'hex-to-rgba';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Theme } from '~/themes/types';
|
||||
|
||||
import { globalPinia } from '~/store/globalPinia';
|
||||
import { THEME_STORAGE_KEY, useThemeStore } from '~/store/theme';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useQuery: () => ({
|
||||
@@ -21,17 +18,34 @@ vi.mock('@vue/apollo-composable', () => ({
|
||||
onResult: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
useLazyQuery: () => ({
|
||||
load: vi.fn(),
|
||||
result: ref(null),
|
||||
loading: ref(false),
|
||||
onResult: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('hex-to-rgba', () => ({
|
||||
default: vi.fn((hex, opacity) => `rgba(mock-${hex}-${opacity})`),
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
isDarkModeActive: vi.fn(() => {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const cssVar = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--theme-dark-mode')
|
||||
.trim();
|
||||
if (cssVar === '1') return true;
|
||||
if (cssVar === '0') return false;
|
||||
if (document.documentElement.classList.contains('dark')) return true;
|
||||
if (document.body?.classList.contains('dark')) return true;
|
||||
if (document.querySelector('.unapi.dark')) return true;
|
||||
return false;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Theme Store', () => {
|
||||
const originalAddClassFn = document.body.classList.add;
|
||||
const originalRemoveClassFn = document.body.classList.remove;
|
||||
const originalStyleCssText = document.body.style.cssText;
|
||||
const originalDocumentElementSetProperty = document.documentElement.style.setProperty;
|
||||
const originalDocumentElementAddClass = document.documentElement.classList.add;
|
||||
const originalDocumentElementRemoveClass = document.documentElement.classList.remove;
|
||||
|
||||
@@ -49,9 +63,13 @@ describe('Theme Store', () => {
|
||||
document.body.classList.add = vi.fn();
|
||||
document.body.classList.remove = vi.fn();
|
||||
document.body.style.cssText = '';
|
||||
document.documentElement.style.setProperty = vi.fn();
|
||||
document.documentElement.classList.add = vi.fn();
|
||||
document.documentElement.classList.remove = vi.fn();
|
||||
document.documentElement.style.removeProperty('--theme-dark-mode');
|
||||
document.documentElement.style.removeProperty('--theme-name');
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.body.classList.remove('dark');
|
||||
document.querySelectorAll('.unapi').forEach((el) => el.classList.remove('dark'));
|
||||
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
@@ -64,13 +82,18 @@ describe('Theme Store', () => {
|
||||
afterEach(() => {
|
||||
store?.$dispose();
|
||||
store = undefined;
|
||||
app?.unmount();
|
||||
if (app) {
|
||||
try {
|
||||
app.unmount();
|
||||
} catch {
|
||||
// App was not mounted, ignore
|
||||
}
|
||||
}
|
||||
app = undefined;
|
||||
|
||||
document.body.classList.add = originalAddClassFn;
|
||||
document.body.classList.remove = originalRemoveClassFn;
|
||||
document.body.style.cssText = originalStyleCssText;
|
||||
document.documentElement.style.setProperty = originalDocumentElementSetProperty;
|
||||
document.documentElement.classList.add = originalDocumentElementAddClass;
|
||||
document.documentElement.classList.remove = originalDocumentElementRemoveClass;
|
||||
vi.restoreAllMocks();
|
||||
@@ -88,8 +111,6 @@ describe('Theme Store', () => {
|
||||
it('should initialize with default theme', () => {
|
||||
const store = createStore();
|
||||
|
||||
expect(typeof store.$persist).toBe('function');
|
||||
|
||||
expect(store.theme).toEqual({
|
||||
name: 'white',
|
||||
banner: false,
|
||||
@@ -102,44 +123,39 @@ describe('Theme Store', () => {
|
||||
expect(store.activeColorVariables).toEqual(defaultColors.white);
|
||||
});
|
||||
|
||||
it('should compute darkMode correctly', () => {
|
||||
it('should compute darkMode from CSS variable when set to 1', () => {
|
||||
document.documentElement.style.setProperty('--theme-dark-mode', '1');
|
||||
const store = createStore();
|
||||
|
||||
expect(store.darkMode).toBe(false);
|
||||
|
||||
store.setTheme({ ...store.theme, name: 'black' });
|
||||
expect(store.darkMode).toBe(true);
|
||||
});
|
||||
|
||||
store.setTheme({ ...store.theme, name: 'gray' });
|
||||
expect(store.darkMode).toBe(true);
|
||||
|
||||
store.setTheme({ ...store.theme, name: 'white' });
|
||||
it('should compute darkMode from CSS variable when set to 0', () => {
|
||||
document.documentElement.style.setProperty('--theme-dark-mode', '0');
|
||||
const store = createStore();
|
||||
expect(store.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
it('should compute bannerGradient correctly', () => {
|
||||
it('should compute bannerGradient from CSS variable when set', async () => {
|
||||
document.documentElement.style.setProperty('--theme-dark-mode', '0');
|
||||
// Set the gradient with the resolved value (not nested var()) since getComputedStyle resolves it
|
||||
document.documentElement.style.setProperty(
|
||||
'--banner-gradient',
|
||||
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0, rgba(0, 0, 0, 0.7) 30%)'
|
||||
);
|
||||
|
||||
const store = createStore();
|
||||
store.setTheme({ banner: true, bannerGradient: true });
|
||||
await nextTick();
|
||||
expect(store.theme.banner).toBe(true);
|
||||
expect(store.theme.bannerGradient).toBe(true);
|
||||
expect(store.darkMode).toBe(false);
|
||||
expect(store.bannerGradient).toBe(true);
|
||||
});
|
||||
|
||||
expect(store.bannerGradient).toBeUndefined();
|
||||
|
||||
store.setTheme({
|
||||
...store.theme,
|
||||
banner: true,
|
||||
bannerGradient: true,
|
||||
});
|
||||
expect(store.bannerGradient).toMatchInlineSnapshot(
|
||||
`"background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, var(--header-background-color) 90%);"`
|
||||
);
|
||||
|
||||
store.setTheme({
|
||||
...store.theme,
|
||||
banner: true,
|
||||
bannerGradient: true,
|
||||
bgColor: '#123456',
|
||||
});
|
||||
expect(store.bannerGradient).toMatchInlineSnapshot(
|
||||
`"background-image: linear-gradient(90deg, var(--header-gradient-start) 0, var(--header-gradient-end) 90%);"`
|
||||
);
|
||||
it('should return false when bannerGradient CSS variable is not set', () => {
|
||||
document.documentElement.style.removeProperty('--banner-gradient');
|
||||
const store = createStore();
|
||||
expect(store.bannerGradient).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,12 +185,16 @@ describe('Theme Store', () => {
|
||||
await nextTick();
|
||||
|
||||
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
|
||||
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
|
||||
expect(store.darkMode).toBe(true);
|
||||
|
||||
store.setTheme({ ...store.theme, name: 'white' });
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(document.body.classList.remove).toHaveBeenCalledWith('dark');
|
||||
expect(document.documentElement.classList.remove).toHaveBeenCalledWith('dark');
|
||||
expect(store.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
it('should update activeColorVariables when theme changes', async () => {
|
||||
@@ -191,93 +211,80 @@ describe('Theme Store', () => {
|
||||
await nextTick();
|
||||
|
||||
// activeColorVariables now contains the theme defaults from defaultColors
|
||||
// Custom values are applied as CSS variables on the documentElement
|
||||
// The white theme's --color-beta is a reference to var(--header-text-primary)
|
||||
expect(store.activeColorVariables['--color-beta']).toBe('var(--header-text-primary)');
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-text-primary',
|
||||
'#333333'
|
||||
);
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-text-secondary',
|
||||
'#666666'
|
||||
);
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-background-color',
|
||||
'#ffffff'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle banner gradient correctly', async () => {
|
||||
it('should apply dark mode classes when theme changes', async () => {
|
||||
const store = createStore();
|
||||
const mockHexToRgba = vi.mocked(hexToRgba);
|
||||
|
||||
mockHexToRgba.mockClear();
|
||||
|
||||
store.setTheme({
|
||||
...store.theme,
|
||||
banner: true,
|
||||
bannerGradient: true,
|
||||
bgColor: '#112233',
|
||||
name: 'black',
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0);
|
||||
expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0.7);
|
||||
|
||||
// Banner gradient values are now set as custom CSS variables on documentElement
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-gradient-start',
|
||||
'rgba(mock-#112233-0)'
|
||||
);
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-gradient-end',
|
||||
'rgba(mock-#112233-0.7)'
|
||||
);
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--banner-gradient',
|
||||
'linear-gradient(90deg, rgba(mock-#112233-0) 0, rgba(mock-#112233-0.7) 90%)'
|
||||
);
|
||||
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
|
||||
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
|
||||
expect(store.darkMode).toBe(true);
|
||||
});
|
||||
|
||||
it('should hydrate theme from cache when available', () => {
|
||||
const cachedTheme = {
|
||||
name: 'black',
|
||||
banner: true,
|
||||
bannerGradient: false,
|
||||
bgColor: '#222222',
|
||||
descriptionShow: true,
|
||||
metaColor: '#aaaaaa',
|
||||
textColor: '#ffffff',
|
||||
} satisfies Theme;
|
||||
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify({ theme: cachedTheme }));
|
||||
|
||||
it('should update darkMode reactively when theme changes', async () => {
|
||||
const store = createStore();
|
||||
|
||||
expect(store.theme).toEqual(cachedTheme);
|
||||
});
|
||||
expect(store.darkMode).toBe(false);
|
||||
|
||||
it('should persist server theme responses to cache', async () => {
|
||||
const store = createStore();
|
||||
|
||||
const serverTheme = {
|
||||
store.setTheme({
|
||||
...store.theme,
|
||||
name: 'gray',
|
||||
banner: false,
|
||||
bannerGradient: false,
|
||||
bgColor: '#111111',
|
||||
descriptionShow: false,
|
||||
metaColor: '#999999',
|
||||
textColor: '#eeeeee',
|
||||
} satisfies Theme;
|
||||
});
|
||||
|
||||
store.setTheme(serverTheme, { source: 'server' });
|
||||
await nextTick();
|
||||
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toEqual(
|
||||
JSON.stringify({ theme: serverTheme })
|
||||
);
|
||||
expect(store.darkMode).toBe(true);
|
||||
|
||||
store.setTheme({
|
||||
...store.theme,
|
||||
name: 'white',
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(store.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize dark mode from CSS variable on store creation', () => {
|
||||
// Mock getComputedStyle to return dark mode
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
vi.spyOn(window, 'getComputedStyle').mockImplementation((el) => {
|
||||
const style = originalGetComputedStyle(el);
|
||||
if (el === document.documentElement) {
|
||||
return {
|
||||
...style,
|
||||
getPropertyValue: (prop: string) => {
|
||||
if (prop === '--theme-dark-mode') {
|
||||
return '1';
|
||||
}
|
||||
if (prop === '--theme-name') {
|
||||
return 'black';
|
||||
}
|
||||
return style.getPropertyValue(prop);
|
||||
},
|
||||
} as CSSStyleDeclaration;
|
||||
}
|
||||
return style;
|
||||
});
|
||||
|
||||
document.documentElement.style.setProperty('--theme-dark-mode', '1');
|
||||
const store = createStore();
|
||||
|
||||
// Should have added dark class to documentElement and body
|
||||
expect(document.documentElement.classList.add).toHaveBeenCalledWith('dark');
|
||||
expect(document.body.classList.add).toHaveBeenCalledWith('dark');
|
||||
expect(store.darkMode).toBe(true);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,41 @@
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import { WEBGUI_REDIRECT } from '~/helpers/urls';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
|
||||
const mockSend = vi.fn();
|
||||
|
||||
vi.mock('@unraid/shared-callbacks', () => ({
|
||||
useCallback: vi.fn(() => ({
|
||||
send: vi.fn(),
|
||||
send: mockSend,
|
||||
watcher: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('~/composables/preventClose', () => ({
|
||||
addPreventClose: vi.fn(),
|
||||
removePreventClose: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('~/store/account', () => ({
|
||||
useAccountStore: () => ({
|
||||
accountActionStatus: 'ready',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('~/store/installKey', () => ({
|
||||
useInstallKeyStore: () => ({
|
||||
keyInstallStatus: 'ready',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('~/store/updateOsActions', () => ({
|
||||
useUpdateOsActionsStore: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock('~/composables/services/webgui', () => {
|
||||
return {
|
||||
WebguiCheckForUpdate: vi.fn().mockResolvedValue({
|
||||
@@ -104,6 +128,40 @@ describe('UpdateOs Store', () => {
|
||||
expect(store.updateOsModalVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('should send update install through redirect.htm', () => {
|
||||
const originalLocation = window.location;
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
...originalLocation,
|
||||
origin: 'https://littlebox.tail45affd.ts.net',
|
||||
href: 'https://littlebox.tail45affd.ts.net/Plugins',
|
||||
},
|
||||
});
|
||||
|
||||
store.fetchAndConfirmInstall('test-sha256');
|
||||
|
||||
const expectedUrl = new URL(WEBGUI_REDIRECT, window.location.origin).toString();
|
||||
|
||||
expect(mockSend).toHaveBeenCalledWith(
|
||||
expectedUrl,
|
||||
[
|
||||
{
|
||||
sha256: 'test-sha256',
|
||||
type: 'updateOs',
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
'forUpc'
|
||||
);
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors when checking for updates', async () => {
|
||||
const { WebguiCheckForUpdate } = await import('~/composables/services/webgui');
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { ExternalUpdateOsAction } from '@unraid/shared-callbacks';
|
||||
import type { Release } from '~/store/updateOsActions';
|
||||
|
||||
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
|
||||
import { testTranslate } from '../utils/i18n';
|
||||
|
||||
vi.mock('~/helpers/urls', () => ({
|
||||
WEBGUI_TOOLS_UPDATE: 'https://webgui/tools/update',
|
||||
@@ -48,20 +49,34 @@ vi.mock('~/store/account', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockServerStore = {
|
||||
guid: 'test-guid',
|
||||
keyfile: 'test-keyfile',
|
||||
osVersion: '6.12.4',
|
||||
osVersionBranch: 'stable',
|
||||
regUpdatesExpired: false,
|
||||
regTy: 'Plus',
|
||||
locale: 'en_US' as string | undefined,
|
||||
rebootType: '',
|
||||
updateOsResponse: null as { date: string } | null,
|
||||
};
|
||||
|
||||
vi.mock('~/store/server', () => ({
|
||||
useServerStore: () => ({
|
||||
guid: 'test-guid',
|
||||
keyfile: 'test-keyfile',
|
||||
osVersion: '6.12.4',
|
||||
osVersionBranch: 'stable',
|
||||
regUpdatesExpired: false,
|
||||
rebootType: '',
|
||||
}),
|
||||
useServerStore: () => mockServerStore,
|
||||
}));
|
||||
|
||||
const mockUpdateOsStore = {
|
||||
available: '6.12.5',
|
||||
availableWithRenewal: false,
|
||||
};
|
||||
|
||||
vi.mock('~/store/updateOs', () => ({
|
||||
useUpdateOsStore: () => ({
|
||||
available: '6.12.5',
|
||||
useUpdateOsStore: () => mockUpdateOsStore,
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: testTranslate,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -70,6 +85,19 @@ describe('UpdateOsActions Store', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
// Reset mocks to default values
|
||||
mockServerStore.guid = 'test-guid';
|
||||
mockServerStore.keyfile = 'test-keyfile';
|
||||
mockServerStore.osVersion = '6.12.4';
|
||||
mockServerStore.osVersionBranch = 'stable';
|
||||
mockServerStore.regUpdatesExpired = false;
|
||||
mockServerStore.regTy = 'Plus';
|
||||
mockServerStore.locale = 'en_US';
|
||||
mockServerStore.rebootType = '';
|
||||
mockServerStore.updateOsResponse = null;
|
||||
mockUpdateOsStore.available = '6.12.5';
|
||||
mockUpdateOsStore.availableWithRenewal = false;
|
||||
|
||||
store = useUpdateOsActionsStore();
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -417,4 +445,106 @@ describe('UpdateOsActions Store', () => {
|
||||
expect(store.status).toBe('updating');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formattedReleaseDate', () => {
|
||||
it('should return empty string when no release date is available', () => {
|
||||
mockUpdateOsStore.availableWithRenewal = false;
|
||||
mockServerStore.updateOsResponse = null;
|
||||
store = useUpdateOsActionsStore();
|
||||
expect(store.formattedReleaseDate).toBe('');
|
||||
});
|
||||
|
||||
it('should format date correctly with locale from server store', () => {
|
||||
mockUpdateOsStore.availableWithRenewal = true;
|
||||
mockServerStore.updateOsResponse = { date: '2023-10-15' };
|
||||
mockServerStore.locale = 'en_US';
|
||||
store = useUpdateOsActionsStore();
|
||||
|
||||
const formatted = store.formattedReleaseDate;
|
||||
expect(formatted).toBeTruthy();
|
||||
expect(formatted).toContain('2023');
|
||||
expect(formatted).toContain('October');
|
||||
expect(formatted).toContain('15');
|
||||
});
|
||||
|
||||
it('should normalize locale underscores to hyphens', () => {
|
||||
mockUpdateOsStore.availableWithRenewal = true;
|
||||
mockServerStore.updateOsResponse = { date: '2023-10-15' };
|
||||
mockServerStore.locale = 'fr_FR';
|
||||
store = useUpdateOsActionsStore();
|
||||
|
||||
const formatted = store.formattedReleaseDate;
|
||||
expect(formatted).toBeTruthy();
|
||||
expect(typeof formatted).toBe('string');
|
||||
expect(formatted.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should fall back to navigator.language when locale is missing', () => {
|
||||
const originalLanguage = navigator.language;
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: 'de-DE',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockUpdateOsStore.availableWithRenewal = true;
|
||||
mockServerStore.updateOsResponse = { date: '2023-10-15' };
|
||||
mockServerStore.locale = undefined;
|
||||
store = useUpdateOsActionsStore();
|
||||
|
||||
const formatted = store.formattedReleaseDate;
|
||||
expect(formatted).toBeTruthy();
|
||||
expect(typeof formatted).toBe('string');
|
||||
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: originalLanguage,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fall back to en-US when locale and navigator.language are missing', () => {
|
||||
const originalLanguage = navigator.language;
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockUpdateOsStore.availableWithRenewal = true;
|
||||
mockServerStore.updateOsResponse = { date: '2023-10-15' };
|
||||
mockServerStore.locale = undefined;
|
||||
store = useUpdateOsActionsStore();
|
||||
|
||||
const formatted = store.formattedReleaseDate;
|
||||
expect(formatted).toBeTruthy();
|
||||
expect(formatted).toContain('2023');
|
||||
expect(formatted).toContain('October');
|
||||
expect(formatted).toContain('15');
|
||||
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: originalLanguage,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse date correctly to avoid off-by-one errors', () => {
|
||||
mockUpdateOsStore.availableWithRenewal = true;
|
||||
mockServerStore.updateOsResponse = { date: '2023-01-01' };
|
||||
mockServerStore.locale = 'en-US';
|
||||
store = useUpdateOsActionsStore();
|
||||
|
||||
const formatted = store.formattedReleaseDate;
|
||||
expect(formatted).toContain('January');
|
||||
expect(formatted).toContain('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ineligibleText', () => {
|
||||
it('should return empty string when eligible', () => {
|
||||
mockServerStore.guid = 'test-guid';
|
||||
mockServerStore.keyfile = 'test-keyfile';
|
||||
mockServerStore.osVersion = '6.12.4';
|
||||
mockServerStore.regUpdatesExpired = false;
|
||||
store = useUpdateOsActionsStore();
|
||||
expect(store.ineligibleText).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,30 +35,30 @@ type LocaleMessages = typeof enUS;
|
||||
|
||||
const localeMessages: Record<string, LocaleMessages> = {
|
||||
en_US: enUS,
|
||||
ar: ar as LocaleMessages,
|
||||
bn: bn as LocaleMessages,
|
||||
ca: ca as LocaleMessages,
|
||||
cs: cs as LocaleMessages,
|
||||
da: da as LocaleMessages,
|
||||
de: de as LocaleMessages,
|
||||
es: es as LocaleMessages,
|
||||
fr: fr as LocaleMessages,
|
||||
hi: hi as LocaleMessages,
|
||||
hr: hr as LocaleMessages,
|
||||
hu: hu as LocaleMessages,
|
||||
it: it as LocaleMessages,
|
||||
ja: ja as LocaleMessages,
|
||||
ko: ko as LocaleMessages,
|
||||
lv: lv as LocaleMessages,
|
||||
nl: nl as LocaleMessages,
|
||||
no: no as LocaleMessages,
|
||||
pl: pl as LocaleMessages,
|
||||
pt: pt as LocaleMessages,
|
||||
ro: ro as LocaleMessages,
|
||||
ru: ru as LocaleMessages,
|
||||
sv: sv as LocaleMessages,
|
||||
uk: uk as LocaleMessages,
|
||||
zh: zh as LocaleMessages,
|
||||
ar: ar as unknown as LocaleMessages,
|
||||
bn: bn as unknown as LocaleMessages,
|
||||
ca: ca as unknown as LocaleMessages,
|
||||
cs: cs as unknown as LocaleMessages,
|
||||
da: da as unknown as LocaleMessages,
|
||||
de: de as unknown as LocaleMessages,
|
||||
es: es as unknown as LocaleMessages,
|
||||
fr: fr as unknown as LocaleMessages,
|
||||
hi: hi as unknown as LocaleMessages,
|
||||
hr: hr as unknown as LocaleMessages,
|
||||
hu: hu as unknown as LocaleMessages,
|
||||
it: it as unknown as LocaleMessages,
|
||||
ja: ja as unknown as LocaleMessages,
|
||||
ko: ko as unknown as LocaleMessages,
|
||||
lv: lv as unknown as LocaleMessages,
|
||||
nl: nl as unknown as LocaleMessages,
|
||||
no: no as unknown as LocaleMessages,
|
||||
pl: pl as unknown as LocaleMessages,
|
||||
pt: pt as unknown as LocaleMessages,
|
||||
ro: ro as unknown as LocaleMessages,
|
||||
ru: ru as unknown as LocaleMessages,
|
||||
sv: sv as unknown as LocaleMessages,
|
||||
uk: uk as unknown as LocaleMessages,
|
||||
zh: zh as unknown as LocaleMessages,
|
||||
};
|
||||
|
||||
type AnyObject = Record<string, unknown>;
|
||||
|
||||
@@ -138,7 +138,6 @@ if ($display['theme'] === 'black' || $display['theme'] === 'azure') {
|
||||
<unraid-auth></connect-auth>
|
||||
</div>
|
||||
<div class="ComponentWrapper">
|
||||
<unraid-download-api-logs></connect-download-api-logs>
|
||||
</div>
|
||||
<div class="ComponentWrapper">
|
||||
<unraid-key-actions></connect-key-actions>
|
||||
|
||||
1
web/components.d.ts
vendored
1
web/components.d.ts
vendored
@@ -47,7 +47,6 @@ declare module 'vue' {
|
||||
'DevThemeSwitcher.standalone': typeof import('./src/components/DevThemeSwitcher.standalone.vue')['default']
|
||||
Downgrade: typeof import('./src/components/UpdateOs/Downgrade.vue')['default']
|
||||
'DowngradeOs.standalone': typeof import('./src/components/DowngradeOs.standalone.vue')['default']
|
||||
'DownloadApiLogs.standalone': typeof import('./src/components/DownloadApiLogs.standalone.vue')['default']
|
||||
DropdownConnectStatus: typeof import('./src/components/UserProfile/DropdownConnectStatus.vue')['default']
|
||||
DropdownContent: typeof import('./src/components/UserProfile/DropdownContent.vue')['default']
|
||||
DropdownError: typeof import('./src/components/UserProfile/DropdownError.vue')['default']
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/web",
|
||||
"version": "4.26.2",
|
||||
"version": "4.29.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -11,8 +11,8 @@
|
||||
"preview": "vite preview",
|
||||
"serve": "NODE_ENV=production PORT=${PORT:-4321} vite preview --port ${PORT:-4321}",
|
||||
"// Build": "",
|
||||
"prebuild:dev": "pnpm predev",
|
||||
"build:dev": "pnpm run build && pnpm run deploy-to-unraid:dev",
|
||||
"prebuild": "pnpm predev",
|
||||
"build": "NODE_ENV=production vite build && pnpm run manifest-ts",
|
||||
"prebuild:watch": "pnpm predev",
|
||||
"build:watch": "vite build --watch && pnpm run manifest-ts",
|
||||
@@ -109,7 +109,7 @@
|
||||
"@jsonforms/vue-vanilla": "3.6.0",
|
||||
"@jsonforms/vue-vuetify": "3.6.0",
|
||||
"@nuxt/ui": "4.0.0-alpha.0",
|
||||
"@unraid/shared-callbacks": "1.1.1",
|
||||
"@unraid/shared-callbacks": "3.0.0",
|
||||
"@unraid/ui": "link:../unraid-ui",
|
||||
"@vue/apollo-composable": "4.2.2",
|
||||
"@vueuse/components": "13.8.0",
|
||||
|
||||
@@ -157,6 +157,21 @@ iframe#progressFrame {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Banner gradient tuning */
|
||||
:root {
|
||||
--banner-gradient-stop: 30%;
|
||||
}
|
||||
|
||||
.unraid-banner-gradient-layer {
|
||||
background-image: var(--banner-gradient);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--banner-gradient-stop: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header banner compatibility tweaks */
|
||||
#header.image {
|
||||
background-position: center center;
|
||||
@@ -178,16 +193,8 @@ iframe#progressFrame {
|
||||
background-position: left center, right center;
|
||||
background-size: min(30%, 320px) 100%, min(30%, 320px) 100%;
|
||||
background-image:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--color-header-gradient-end, rgba(0, 0, 0, 0.7)) 0%,
|
||||
var(--color-header-gradient-start, rgba(0, 0, 0, 0)) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
270deg,
|
||||
var(--color-header-gradient-end, rgba(0, 0, 0, 0.7)) 0%,
|
||||
var(--color-header-gradient-start, rgba(0, 0, 0, 0)) 100%
|
||||
);
|
||||
var(--banner-gradient),
|
||||
linear-gradient(270deg, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 0%, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 10%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 90%, transparent) 25%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 60%, transparent) 40%, color-mix(in srgb, var(--header-gradient-end, var(--color-header-gradient-end, rgba(0, 0, 0, 1))) 30%, transparent) 55%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) 70%, var(--header-gradient-start, var(--color-header-gradient-start, rgba(0, 0, 0, 0))) var(--banner-gradient-stop, 30%));
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
updateConnectSettings,
|
||||
} from '~/components/ConnectSettings/graphql/settings.query';
|
||||
import OidcDebugLogs from '~/components/ConnectSettings/OidcDebugLogs.vue';
|
||||
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
|
||||
import { useServerStore } from '~/store/server';
|
||||
|
||||
// Disable automatic attribute inheritance
|
||||
@@ -115,8 +114,6 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
<Label>{{ t('connectSettings.accountStatusLabel') }}</Label>
|
||||
<Auth />
|
||||
</template>
|
||||
<Label>{{ t('downloadApiLogs.downloadUnraidApiLogs') }}:</Label>
|
||||
<DownloadApiLogs />
|
||||
</SettingsGrid>
|
||||
<!-- auto-generated settings form -->
|
||||
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">
|
||||
|
||||
17
web/src/components/DevThemeSwitcher.mutation.ts
Normal file
17
web/src/components/DevThemeSwitcher.mutation.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { graphql } from '~/composables/gql/gql';
|
||||
|
||||
export const SET_THEME_MUTATION = graphql(/* GraphQL */ `
|
||||
mutation setTheme($theme: ThemeName!) {
|
||||
customization {
|
||||
setTheme(theme: $theme) {
|
||||
name
|
||||
showBannerImage
|
||||
showBannerGradient
|
||||
headerBackgroundColor
|
||||
showHeaderDescription
|
||||
headerPrimaryTextColor
|
||||
headerSecondaryTextColor
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -1,67 +1,139 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
import type { GetThemeQuery } from '~/composables/gql/graphql';
|
||||
|
||||
import { SET_THEME_MUTATION } from '~/components/DevThemeSwitcher.mutation';
|
||||
import { ThemeName } from '~/composables/gql/graphql';
|
||||
import { DARK_UI_THEMES, GET_THEME_QUERY, useThemeStore } from '~/store/theme';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'white', label: 'White' },
|
||||
{ value: 'black', label: 'Black' },
|
||||
{ value: 'gray', label: 'Gray' },
|
||||
{ value: 'azure', label: 'Azure' },
|
||||
] as const;
|
||||
const themeOptions: Array<{ value: ThemeName; label: string }> = [
|
||||
{ value: ThemeName.WHITE, label: 'White' },
|
||||
{ value: ThemeName.BLACK, label: 'Black' },
|
||||
{ value: ThemeName.GRAY, label: 'Gray' },
|
||||
{ value: ThemeName.AZURE, label: 'Azure' },
|
||||
];
|
||||
|
||||
const STORAGE_KEY_THEME = 'unraid:test:theme';
|
||||
const THEME_COOKIE_KEY = 'unraid_dev_theme';
|
||||
|
||||
const { theme } = storeToRefs(themeStore);
|
||||
|
||||
const currentTheme = ref<string>(theme.value.name);
|
||||
const themeValues = new Set<ThemeName>(themeOptions.map((option) => option.value));
|
||||
|
||||
const getCurrentTheme = (): string => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlTheme = urlParams.get('theme');
|
||||
const normalizeTheme = (value?: string | ThemeName | null): ThemeName | null => {
|
||||
const normalized = (value ?? '').toString().toLowerCase();
|
||||
return themeValues.has(normalized as ThemeName) ? (normalized as ThemeName) : null;
|
||||
};
|
||||
|
||||
if (urlTheme && themeOptions.some((t) => t.value === urlTheme)) {
|
||||
return urlTheme;
|
||||
const readCookieTheme = (): string | null => {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (theme.value?.name) {
|
||||
return theme.value.name;
|
||||
const cookies = document.cookie?.split(';') ?? [];
|
||||
for (const cookie of cookies) {
|
||||
const [name, ...rest] = cookie.split('=');
|
||||
if (name?.trim() === THEME_COOKIE_KEY) {
|
||||
return decodeURIComponent(rest.join('=').trim());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const readLocalStorageTheme = (): string | null => {
|
||||
try {
|
||||
return window.localStorage?.getItem(STORAGE_KEY_THEME) || 'white';
|
||||
return window.localStorage?.getItem(STORAGE_KEY_THEME) ?? null;
|
||||
} catch {
|
||||
return 'white';
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const updateTheme = (themeName: string, skipUrlUpdate = false) => {
|
||||
if (!skipUrlUpdate) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('theme', themeName);
|
||||
window.history.replaceState({}, '', url);
|
||||
const readCssTheme = (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getComputedStyle(document.documentElement).getPropertyValue('--theme-name').trim() || null;
|
||||
};
|
||||
|
||||
const resolveInitialTheme = async (): Promise<ThemeName> => {
|
||||
const candidates = [readCssTheme(), readCookieTheme(), readLocalStorageTheme(), theme.value?.name];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeTheme(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return ThemeName.WHITE;
|
||||
};
|
||||
|
||||
const currentTheme = ref<ThemeName>(normalizeTheme(theme.value.name) ?? ThemeName.WHITE);
|
||||
const isSaving = ref(false);
|
||||
const isQueryLoading = ref(false);
|
||||
|
||||
const { onResult: onThemeResult, loading: queryLoading } = useQuery<GetThemeQuery>(
|
||||
GET_THEME_QUERY,
|
||||
null,
|
||||
{ fetchPolicy: 'network-only' }
|
||||
);
|
||||
|
||||
onThemeResult(({ data }) => {
|
||||
const serverTheme = normalizeTheme(data?.publicTheme?.name);
|
||||
if (serverTheme) {
|
||||
void applyThemeSelection(serverTheme, { skipStore: false });
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => queryLoading.value,
|
||||
(loading) => {
|
||||
isQueryLoading.value = loading;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const { mutate: setThemeMutation } = useMutation(SET_THEME_MUTATION);
|
||||
|
||||
const persistThemePreference = (themeName: ThemeName) => {
|
||||
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
|
||||
document.cookie = `${THEME_COOKIE_KEY}=${encodeURIComponent(themeName)}; path=/; SameSite=Lax; expires=${expires}`;
|
||||
try {
|
||||
window.localStorage?.setItem(STORAGE_KEY_THEME, themeName);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
themeStore.setTheme({ name: themeName });
|
||||
themeStore.setCssVars();
|
||||
const syncDomForTheme = (themeName: ThemeName) => {
|
||||
const root = document.documentElement;
|
||||
const isDark = DARK_UI_THEMES.includes(themeName as (typeof DARK_UI_THEMES)[number]);
|
||||
const method: 'add' | 'remove' = isDark ? 'add' : 'remove';
|
||||
|
||||
root.style.setProperty('--theme-name', themeName);
|
||||
root.style.setProperty('--theme-dark-mode', isDark ? '1' : '0');
|
||||
root.setAttribute('data-theme', themeName);
|
||||
root.classList[method]('dark');
|
||||
document.body?.classList[method]('dark');
|
||||
document.querySelectorAll('.unapi').forEach((el) => el.classList[method]('dark'));
|
||||
};
|
||||
|
||||
const updateThemeCssLink = (themeName: ThemeName) => {
|
||||
const linkId = 'dev-theme-css-link';
|
||||
let themeLink = document.getElementById(linkId) as HTMLLinkElement | null;
|
||||
|
||||
const themeCssMap: Record<string, string> = {
|
||||
azure: '/test-pages/unraid-assets/themes/azure.css',
|
||||
black: '/test-pages/unraid-assets/themes/black.css',
|
||||
gray: '/test-pages/unraid-assets/themes/gray.css',
|
||||
white: '/test-pages/unraid-assets/themes/white.css',
|
||||
const themeCssMap: Record<ThemeName, string> = {
|
||||
[ThemeName.AZURE]: '/test-pages/unraid-assets/themes/azure.css',
|
||||
[ThemeName.BLACK]: '/test-pages/unraid-assets/themes/black.css',
|
||||
[ThemeName.GRAY]: '/test-pages/unraid-assets/themes/gray.css',
|
||||
[ThemeName.WHITE]: '/test-pages/unraid-assets/themes/white.css',
|
||||
};
|
||||
|
||||
const cssUrl = themeCssMap[themeName];
|
||||
@@ -74,52 +146,74 @@ const updateTheme = (themeName: string, skipUrlUpdate = false) => {
|
||||
document.head.appendChild(themeLink);
|
||||
}
|
||||
themeLink.href = cssUrl;
|
||||
} else {
|
||||
if (themeLink) {
|
||||
themeLink.remove();
|
||||
} else if (themeLink) {
|
||||
themeLink.remove();
|
||||
}
|
||||
};
|
||||
|
||||
const applyThemeSelection = async (
|
||||
themeName: string | null | undefined,
|
||||
{ persist = false, skipStore = false }: { persist?: boolean; skipStore?: boolean } = {}
|
||||
) => {
|
||||
const normalized = normalizeTheme(themeName) ?? ThemeName.WHITE;
|
||||
currentTheme.value = normalized;
|
||||
|
||||
persistThemePreference(normalized);
|
||||
syncDomForTheme(normalized);
|
||||
updateThemeCssLink(normalized);
|
||||
|
||||
if (!skipStore) {
|
||||
themeStore.setTheme({ name: normalized });
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await setThemeMutation({ theme: normalized });
|
||||
} catch (error) {
|
||||
console.warn('[DevThemeSwitcher] Failed to persist theme via GraphQL', error);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeChange = (event: Event) => {
|
||||
const newTheme = (event.target as HTMLSelectElement).value;
|
||||
if (newTheme === currentTheme.value) {
|
||||
const newTheme = normalizeTheme((event.target as HTMLSelectElement).value);
|
||||
if (!newTheme || newTheme === currentTheme.value) {
|
||||
return;
|
||||
}
|
||||
currentTheme.value = newTheme;
|
||||
updateTheme(newTheme);
|
||||
|
||||
void applyThemeSelection(newTheme, { persist: true });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
themeStore.setDevOverride(true);
|
||||
|
||||
const initialTheme = getCurrentTheme();
|
||||
currentTheme.value = initialTheme;
|
||||
|
||||
const existingLink = document.getElementById('dev-theme-css-link') as HTMLLinkElement | null;
|
||||
if (!existingLink || !existingLink.href) {
|
||||
updateTheme(initialTheme, true);
|
||||
} else {
|
||||
themeStore.setTheme({ name: initialTheme });
|
||||
themeStore.setCssVars();
|
||||
}
|
||||
const initialTheme = await resolveInitialTheme();
|
||||
await applyThemeSelection(initialTheme);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => theme.value.name,
|
||||
(newName) => {
|
||||
if (newName && newName !== currentTheme.value) {
|
||||
currentTheme.value = newName;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('theme', newName);
|
||||
window.history.replaceState({}, '', url);
|
||||
const normalized = normalizeTheme(newName);
|
||||
if (!normalized || normalized === currentTheme.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
void applyThemeSelection(normalized, { skipStore: true });
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select :value="currentTheme" class="dev-theme-select" @change="handleThemeChange">
|
||||
<select
|
||||
:value="currentTheme"
|
||||
class="dev-theme-select"
|
||||
:disabled="isSaving || isQueryLoading"
|
||||
@change="handleThemeChange"
|
||||
>
|
||||
<option v-for="option in themeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
@@ -147,4 +241,9 @@ watch(
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.dev-theme-select:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||
import { BrandButton } from '@unraid/ui';
|
||||
import { CONNECT_FORUMS, CONTACT, DISCORD, WEBGUI_GRAPHQL } from '~/helpers/urls';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const joinPaths = (base: string, path: string) => {
|
||||
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
||||
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
return `${normalizedBase}/${normalizedPath}`;
|
||||
};
|
||||
|
||||
const downloadUrl = computed(() => {
|
||||
const csrfToken = globalThis.csrf_token ?? '';
|
||||
const downloadPath = joinPaths(WEBGUI_GRAPHQL, '/api/logs');
|
||||
const params = new URLSearchParams({ csrf_token: csrfToken });
|
||||
return `${downloadPath}?${params.toString()}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex max-w-3xl flex-col gap-y-4 whitespace-normal">
|
||||
<p class="text-start text-sm">
|
||||
{{ t('downloadApiLogs.thePrimaryMethodOfSupportFor') }}
|
||||
{{ t('downloadApiLogs.ifYouAreAskedToSupply') }}
|
||||
{{ t('downloadApiLogs.theLogsMayContainSensitiveInformation') }}
|
||||
</p>
|
||||
<span class="flex flex-col gap-y-4">
|
||||
<div class="flex">
|
||||
<BrandButton
|
||||
class="shrink-0 grow-0"
|
||||
download
|
||||
:external="true"
|
||||
:href="downloadUrl"
|
||||
:icon="ArrowDownTrayIcon"
|
||||
size="12px"
|
||||
:text="t('downloadApiLogs.downloadUnraidApiLogs')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-baseline gap-2">
|
||||
<a
|
||||
:href="CONNECT_FORUMS.toString()"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
|
||||
>
|
||||
{{ t('downloadApiLogs.unraidConnectForums') }}
|
||||
<ArrowTopRightOnSquareIcon class="w-4" />
|
||||
</a>
|
||||
<a
|
||||
:href="DISCORD.toString()"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
|
||||
>
|
||||
{{ t('downloadApiLogs.unraidDiscord') }}
|
||||
<ArrowTopRightOnSquareIcon class="w-4" />
|
||||
</a>
|
||||
<a
|
||||
:href="CONTACT.toString()"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
|
||||
>
|
||||
{{ t('downloadApiLogs.unraidContactPage') }}
|
||||
<ArrowTopRightOnSquareIcon class="w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { ArrowTopRightOnSquareIcon, KeyIcon } from '@heroicons/vue/24/solid';
|
||||
import { ArrowPathIcon, ArrowTopRightOnSquareIcon, KeyIcon } from '@heroicons/vue/24/solid';
|
||||
import { Badge, BrandButton } from '@unraid/ui';
|
||||
import { DOCS_REGISTRATION_REPLACE_KEY } from '~/helpers/urls';
|
||||
|
||||
@@ -11,20 +12,30 @@ import { useReplaceRenewStore } from '~/store/replaceRenew';
|
||||
const { t } = useI18n();
|
||||
const replaceRenewStore = useReplaceRenewStore();
|
||||
const { replaceStatusOutput } = storeToRefs(replaceRenewStore);
|
||||
|
||||
const isError = computed(() => replaceStatusOutput.value?.variant === 'red');
|
||||
const showButton = computed(() => !replaceStatusOutput.value || isError.value);
|
||||
|
||||
const handleCheck = () => {
|
||||
if (isError.value) {
|
||||
replaceRenewStore.reset();
|
||||
}
|
||||
replaceRenewStore.check(true);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<BrandButton
|
||||
v-if="!replaceStatusOutput"
|
||||
:icon="KeyIcon"
|
||||
:text="t('registration.replaceCheck.checkEligibility')"
|
||||
v-if="showButton"
|
||||
:icon="isError ? ArrowPathIcon : KeyIcon"
|
||||
:text="isError ? t('common.retry') : t('registration.replaceCheck.checkEligibility')"
|
||||
class="grow"
|
||||
@click="replaceRenewStore.check"
|
||||
@click="handleCheck"
|
||||
/>
|
||||
|
||||
<Badge v-else :variant="replaceStatusOutput.variant" :icon="replaceStatusOutput.icon" size="md">
|
||||
{{ t(replaceStatusOutput.text ?? 'Unknown') }}
|
||||
<Badge v-else :variant="replaceStatusOutput?.variant" :icon="replaceStatusOutput?.icon" size="md">
|
||||
{{ t(replaceStatusOutput?.text ?? 'Unknown') }}
|
||||
</Badge>
|
||||
|
||||
<span class="inline-flex flex-wrap items-center justify-end gap-2">
|
||||
|
||||
@@ -19,7 +19,8 @@ import { computed, onBeforeMount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { BrandLoading, PageContainer } from '@unraid/ui';
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||
import { BrandButton, PageContainer } from '@unraid/ui';
|
||||
import { WEBGUI_TOOLS_UPDATE } from '~/helpers/urls';
|
||||
|
||||
import UpdateOsStatus from '~/components/UpdateOs/Status.vue';
|
||||
@@ -47,25 +48,42 @@ const subtitle = computed(() => {
|
||||
return '';
|
||||
});
|
||||
|
||||
/** when we're not prompting for reboot /Tools/Update will automatically send the user to account.unraid.net/server/update-os */
|
||||
const showLoader = computed(
|
||||
() => window.location.pathname === WEBGUI_TOOLS_UPDATE && rebootType.value === ''
|
||||
// Show a prompt to continue in the Account app when no reboot is pending.
|
||||
const showRedirectPrompt = computed(
|
||||
() =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.location.pathname === WEBGUI_TOOLS_UPDATE &&
|
||||
rebootType.value === ''
|
||||
);
|
||||
|
||||
const openAccountUpdate = () => {
|
||||
accountStore.updateOs(true);
|
||||
};
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (showLoader.value) {
|
||||
accountStore.updateOs(true);
|
||||
}
|
||||
serverStore.setRebootVersion(props.rebootVersion);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer>
|
||||
<div v-show="showLoader">
|
||||
<BrandLoading class="mx-auto my-12 max-w-[160px]" />
|
||||
<div
|
||||
v-if="showRedirectPrompt"
|
||||
class="mx-auto flex max-w-[720px] flex-col items-center gap-4 py-8 text-center"
|
||||
>
|
||||
<h1 class="text-2xl font-semibold">{{ t('updateOs.updateUnraidOs') }}</h1>
|
||||
<p class="text-base leading-relaxed opacity-75">
|
||||
{{ t('updateOs.update.receiveTheLatestAndGreatestFor') }}
|
||||
</p>
|
||||
<BrandButton
|
||||
data-testid="update-os-account-button"
|
||||
:icon-right="ArrowTopRightOnSquareIcon"
|
||||
@click="openAccountUpdate"
|
||||
>
|
||||
{{ t('updateOs.update.viewAvailableUpdates') }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
<div v-show="!showLoader">
|
||||
<div v-else>
|
||||
<UpdateOsStatus
|
||||
:show-update-check="true"
|
||||
:title="t('updateOs.updateUnraidOs')"
|
||||
|
||||
@@ -198,7 +198,7 @@ const navigateToRegistration = () => {
|
||||
variant="yellow"
|
||||
:icon="() => h(ExclamationTriangleIcon, { style: 'width: 16px; height: 16px;' })"
|
||||
>
|
||||
{{ t(rebootTypeText) }}
|
||||
{{ rebootTypeText }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const { rebootTypeText } = storeToRefs(useUpdateOsActionsStore());
|
||||
<div class="grid gap-y-4">
|
||||
<h3 class="flex flex-row items-center gap-2 text-xl leading-normal font-semibold">
|
||||
<ExclamationTriangleIcon class="w-5 shrink-0" />
|
||||
{{ t(rebootTypeText) }}
|
||||
{{ rebootTypeText }}
|
||||
</h3>
|
||||
<div class="text-base leading-relaxed whitespace-normal opacity-75">
|
||||
<p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user