Compare commits

..

2 Commits

Author SHA1 Message Date
renovate[bot]
5fb34f3c91 Merge d159d4b0dd into 4168f43e3e 2025-07-09 15:03:20 +00:00
renovate[bot]
d159d4b0dd fix(deps): update all non-major dependencies 2025-07-09 15:03:17 +00:00
1160 changed files with 30857 additions and 107250 deletions

View File

@@ -1,3 +0,0 @@
{
"permissions": {}
}

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(rg:*)",
"Bash(find:*)",
"Bash(pnpm codegen:*)",
"Bash(pnpm dev:*)",
"Bash(pnpm build:*)",
"Bash(pnpm test:*)",
"Bash(grep:*)",
"Bash(pnpm type-check:*)",
"Bash(pnpm lint:*)",
"Bash(pnpm --filter ./api lint)",
"Bash(mv:*)"
]
},
"enableAllProjectMcpServers": false
}

View File

@@ -10,5 +10,4 @@ alwaysApply: false
* Test suite is VITEST, do not use jest
pnpm --filter ./api test
* Prefer to not mock simple dependencies
* For error testing, use `.rejects.toThrow()` without arguments - don't test exact error message strings unless the message format is specifically what you're testing

View File

@@ -4,10 +4,6 @@ globs: **/*.test.ts,**/__test__/components/**/*.ts,**/__test__/store/**/*.ts,**/
alwaysApply: false
---
## General Testing Best Practices
- **Error Testing:** Use `.rejects.toThrow()` without arguments to test that functions throw errors. Don't test exact error message strings unless the message format is specifically what you're testing
- **Focus on Behavior:** Test what the code does, not implementation details like exact error message wording
## Vue Component Testing Best Practices
- This is a Nuxt.js app but we are testing with vitest outside of the Nuxt environment
- Nuxt is currently set to auto import so some vue files may need compute or ref imported
@@ -241,3 +237,4 @@ const pinia = createTestingPinia({
- Set initial state for focused testing
- Test computed properties by accessing them directly
- Verify state changes by updating the store

20
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,20 @@
# Default owners for everything in the repo
* @elibosley @pujitm @mdatelle @zackspear
# API specific files
/api/ @elibosley @pujitm @mdatelle
# Web frontend files
/web/ @elibosley @mdatelle @zackspear
# Plugin related files
/plugin/ @elibosley
# Unraid UI specific files
/unraid-ui/ @mdatelle @zackspear @pujitm
# GitHub workflows and configuration
/.github/ @elibosley
# Documentation
*.md @elibosley @pujitm @mdatelle @zackspear

View File

@@ -1,208 +0,0 @@
name: Build Artifacts
on:
workflow_call:
inputs:
ref:
type: string
required: false
description: "Git ref to checkout (commit SHA, branch, or tag)"
version_override:
type: string
required: false
description: "Override version (for manual releases)"
outputs:
build_number:
description: "Build number for the artifacts"
value: ${{ jobs.build-api.outputs.build_number }}
secrets:
VITE_ACCOUNT:
required: true
VITE_CONNECT:
required: true
VITE_UNRAID_NET:
required: true
VITE_CALLBACK_KEY:
required: true
UNRAID_BOT_GITHUB_ADMIN_TOKEN:
required: false
jobs:
build-api:
name: Build API
runs-on: ubuntu-latest
outputs:
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@v6
with:
ref: ${{ inputs.ref || github.ref }}
fetch-depth: 0
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
- name: PNPM Install
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile
- name: Get Git Short Sha and API version
id: vars
run: |
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=${{ inputs.version_override && format('"{0}"', inputs.version_override) || '${PACKAGE_LOCK_VERSION}' }}
if [ -z "${{ inputs.version_override }}" ] && [ -z "$IS_TAGGED" ]; then
API_VERSION="${PACKAGE_LOCK_VERSION}+${GIT_SHA}"
fi
export API_VERSION
echo "API_VERSION=${API_VERSION}" >> $GITHUB_ENV
echo "PACKAGE_LOCK_VERSION=${PACKAGE_LOCK_VERSION}" >> $GITHUB_OUTPUT
- 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@v6
with:
name: unraid-api
path: ${{ github.workspace }}/api/deploy/unraid-api.tgz
build-unraid-ui-webcomponents:
name: Build Unraid UI Library (Webcomponent Version)
defaults:
run:
working-directory: unraid-ui
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
- name: Install dependencies
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile --filter @unraid/ui
- name: Lint
run: pnpm run lint
- name: Build
run: pnpm run build:wc
- name: Upload Artifact to Github
uses: actions/upload-artifact@v6
with:
name: unraid-wc-ui
path: unraid-ui/dist-wc/
build-web:
name: Build Web App
defaults:
run:
working-directory: web
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Create env file
run: |
touch .env
echo VITE_ACCOUNT=${{ secrets.VITE_ACCOUNT }} >> .env
echo VITE_CONNECT=${{ secrets.VITE_CONNECT }} >> .env
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: PNPM Install
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile --filter @unraid/web --filter @unraid/ui
- name: Build Unraid UI
run: |
cd ${{ github.workspace }}/unraid-ui
pnpm run build
- name: Lint files
run: pnpm run lint
- name: Type Check
run: pnpm run type-check
- name: Build
run: pnpm run build
- name: Upload build to Github artifacts
uses: actions/upload-artifact@v6
with:
name: unraid-wc-rich
path: web/dist

View File

@@ -27,15 +27,6 @@ on:
type: string
required: true
description: "Build number for the plugin builds"
ref:
type: string
required: false
description: "Git ref (commit SHA, branch, or tag) to checkout"
TRIGGER_PRODUCTION_RELEASE:
type: boolean
required: false
default: false
description: "Whether to automatically trigger the release-production workflow (default: false)"
secrets:
CF_ACCESS_KEY_ID:
required: true
@@ -45,8 +36,6 @@ on:
required: true
CF_ENDPOINT:
required: true
UNRAID_BOT_GITHUB_ADMIN_TOKEN:
required: false
jobs:
build-plugin:
name: Build and Deploy Plugin
@@ -56,21 +45,25 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Get API Version
id: vars
@@ -78,42 +71,36 @@ 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)
# 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
API_VERSION=$([[ -n "$IS_TAGGED" ]] && echo "$PACKAGE_LOCK_VERSION" || echo "${PACKAGE_LOCK_VERSION}+${GIT_SHA}")
echo "API_VERSION=${API_VERSION}" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile --filter @unraid/connect-plugin
- name: Download Unraid UI Components
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
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@v7
uses: actions/download-artifact@v4
with:
pattern: unraid-wc-rich
path: ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone
path: ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt
merge-multiple: true
- name: Download Unraid API
uses: actions/download-artifact@v7
uses: actions/download-artifact@v4
with:
name: unraid-api
path: ${{ github.workspace }}/plugin/api/
@@ -142,7 +129,7 @@ jobs:
fi
- name: Upload to GHA
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: unraid-plugin-${{ github.run_id }}-${{ inputs.RELEASE_TAG }}
path: plugin/deploy/
@@ -160,12 +147,12 @@ jobs:
done
- name: Workflow Dispatch and wait
if: inputs.RELEASE_CREATED == 'true' && inputs.TRIGGER_PRODUCTION_RELEASE == true
if: inputs.RELEASE_CREATED == 'true'
uses: the-actions-org/workflow-dispatch@v4.0.0
with:
workflow: release-production.yml
inputs: '{ "version": "v${{ steps.vars.outputs.API_VERSION }}" }'
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
inputs: '{ "version": "${{ steps.vars.outputs.API_VERSION }}" }'
token: ${{ secrets.WORKFLOW_TRIGGER_PAT }}
- name: Upload to Cloudflare
if: inputs.RELEASE_CREATED == 'false'
@@ -194,40 +181,3 @@ jobs:
```
${{ inputs.BASE_URL }}/tag/${{ inputs.TAG }}/dynamix.unraid.net.plg
```
- name: Clean up old preview builds
if: inputs.RELEASE_CREATED == 'false' && github.event_name == 'push'
continue-on-error: true
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
run: |
echo "🧹 Cleaning up old preview builds (keeping last 7 days)..."
# Calculate cutoff date (7 days ago)
CUTOFF_DATE=$(date -d "7 days ago" +"%Y.%m.%d")
echo "Deleting builds older than: ${CUTOFF_DATE}"
# List and delete old timestamped .txz files
OLD_FILES=$(aws s3 ls "s3://${{ secrets.CF_BUCKET_PREVIEW }}/unraid-api/" \
--endpoint-url ${{ secrets.CF_ENDPOINT }} --recursive | \
grep -E "dynamix\.unraid\.net-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}\.txz" | \
awk '{print $4}' || true)
DELETED_COUNT=0
if [ -n "$OLD_FILES" ]; then
while IFS= read -r file; do
if [[ $file =~ ([0-9]{4}\.[0-9]{2}\.[0-9]{2})\.[0-9]{4}\.txz ]]; then
FILE_DATE="${BASH_REMATCH[1]}"
if [[ "$FILE_DATE" < "$CUTOFF_DATE" ]]; then
echo "Deleting old build: $(basename "$file")"
aws s3 rm "s3://${{ secrets.CF_BUCKET_PREVIEW }}/${file}" \
--endpoint-url ${{ secrets.CF_ENDPOINT }} || true
((DELETED_COUNT++))
fi
fi
done <<< "$OLD_FILES"
fi
echo "✅ Deleted ${DELETED_COUNT} old builds"

View File

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

View File

@@ -0,0 +1,59 @@
name: Update API Documentation
on:
push:
branches:
- main
paths:
- 'api/docs/**'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Add permissions for GITHUB_TOKEN
permissions:
contents: write
pull-requests: write
jobs:
create-docs-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout source repository
uses: actions/checkout@v4
with:
path: source-repo
- name: Checkout docs repository
uses: actions/checkout@v4
with:
repository: unraid/docs
path: docs-repo
token: ${{ secrets.DOCS_PAT_UNRAID_BOT }}
- name: Copy updated docs
run: |
if [ ! -d "source-repo/api/docs" ]; then
echo "Source directory does not exist!"
exit 1
fi
rm -rf docs-repo/docs/API/
mkdir -p docs-repo/docs/API
cp -r source-repo/api/docs/public/. docs-repo/docs/API/
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.DOCS_PAT_UNRAID_BOT }}
path: docs-repo
commit-message: 'docs: update API documentation'
title: 'Update API Documentation'
body: |
This PR updates the API documentation based on changes from the main repository.
Changes were automatically generated from api/docs/* directory.
@coderabbitai ignore
reviewers: ljm42, elibosley, pujitm, mdatelle
branch: update-api-docs
base: main
delete-branch: true

View File

@@ -20,21 +20,20 @@ jobs:
name: Deploy Storybook
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
version: 1.0
@@ -66,7 +65,7 @@ jobs:
- name: Comment PR with deployment URL
if: github.event_name == 'pull_request'
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({

View File

@@ -1,210 +0,0 @@
name: Generate Release Notes
on:
workflow_call:
inputs:
version:
description: 'Version number (e.g., 4.25.3)'
required: true
type: string
target_commitish:
description: 'Commit SHA or branch (leave empty for current HEAD)'
required: false
type: string
release_notes:
description: 'Custom release notes (leave empty to auto-generate)'
required: false
type: string
outputs:
release_notes:
description: 'Generated or provided release notes'
value: ${{ jobs.generate.outputs.release_notes }}
secrets:
UNRAID_BOT_GITHUB_ADMIN_TOKEN:
required: true
jobs:
generate:
name: Generate Release Notes
runs-on: ubuntu-latest
outputs:
release_notes: ${{ steps.generate_notes.outputs.release_notes }}
steps:
- name: Checkout repo
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@v6
with:
node-version: '20'
- name: Generate Release Notes
id: generate_notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="v${{ inputs.version }}"
VERSION="${{ inputs.version }}"
if [ -n "${{ inputs.release_notes }}" ]; then
NOTES="${{ inputs.release_notes }}"
else
CHANGELOG_PATH="api/CHANGELOG.md"
if [ -f "$CHANGELOG_PATH" ]; then
echo "Extracting release notes from CHANGELOG.md for version ${VERSION}"
NOTES=$(awk -v ver="$VERSION" '
BEGIN {
found=0; capture=0; output="";
gsub(/\./, "\\.", ver);
}
/^## \[/ {
if (capture) exit;
if ($0 ~ "\\[" ver "\\]") {
found=1;
capture=1;
}
}
capture {
if (output != "") output = output "\n";
output = output $0;
}
END {
if (found) print output;
else exit 1;
}
' "$CHANGELOG_PATH") || EXTRACTION_STATUS=$?
if [ ${EXTRACTION_STATUS:-0} -eq 0 ] && [ -n "$NOTES" ]; then
echo "✓ Found release notes in CHANGELOG.md"
else
echo "⚠ Version ${VERSION} not found in CHANGELOG.md, generating with conventional-changelog"
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
CHANGELOG_GENERATED=false
if [ -n "$PREV_TAG" ]; then
echo "Generating changelog from ${PREV_TAG}..HEAD using conventional-changelog"
npm install -g conventional-changelog-cli
TEMP_NOTES=$(mktemp)
conventional-changelog -p conventionalcommits \
--release-count 1 \
--output-unreleased \
> "$TEMP_NOTES" 2>/dev/null || true
if [ -s "$TEMP_NOTES" ]; then
NOTES=$(cat "$TEMP_NOTES")
if [ -n "$NOTES" ]; then
echo "✓ Generated changelog with conventional-changelog"
CHANGELOG_GENERATED=true
TEMP_CHANGELOG=$(mktemp)
{
if [ -f "$CHANGELOG_PATH" ]; then
head -n 1 "$CHANGELOG_PATH"
echo ""
echo "$NOTES"
echo ""
tail -n +2 "$CHANGELOG_PATH"
else
echo "# Changelog"
echo ""
echo "$NOTES"
fi
} > "$TEMP_CHANGELOG"
mv "$TEMP_CHANGELOG" "$CHANGELOG_PATH"
echo "✓ Updated CHANGELOG.md with generated notes"
else
echo "⚠ conventional-changelog produced empty output, using GitHub auto-generation"
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${{ inputs.target_commitish || github.sha }}" \
-f previous_tag_name="${PREV_TAG}" \
--jq '.body')
fi
else
echo "⚠ conventional-changelog failed, using GitHub auto-generation"
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${{ inputs.target_commitish || github.sha }}" \
-f previous_tag_name="${PREV_TAG}" \
--jq '.body')
fi
rm -f "$TEMP_NOTES"
else
echo "⚠ No previous tag found, using GitHub auto-generation"
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${{ inputs.target_commitish || github.sha }}" \
--jq '.body' || echo "Release ${VERSION}")
fi
if [ "$CHANGELOG_GENERATED" = true ]; then
BRANCH_OR_SHA="${{ inputs.target_commitish || github.ref }}"
if git show-ref --verify --quiet "refs/heads/${BRANCH_OR_SHA}"; then
echo ""
echo "=========================================="
echo "CHANGELOG GENERATED AND COMMITTED"
echo "=========================================="
echo ""
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BEFORE_SHA=$(git rev-parse HEAD)
git add "$CHANGELOG_PATH"
git commit -m "chore: add changelog for version ${VERSION}"
git push origin "HEAD:${BRANCH_OR_SHA}"
AFTER_SHA=$(git rev-parse HEAD)
echo "✓ Changelog committed and pushed successfully"
echo ""
echo "Previous SHA: ${BEFORE_SHA}"
echo "New SHA: ${AFTER_SHA}"
echo ""
echo "⚠️ CRITICAL: A new commit was created, but github.sha is immutable."
echo "⚠️ github.sha = ${BEFORE_SHA} (original workflow trigger)"
echo "⚠️ The release tag must point to ${AFTER_SHA} (with changelog)"
echo ""
echo "Re-run this workflow to create the release with the correct commit."
echo ""
exit 1
else
echo "⚠ Target is a commit SHA, not a branch. Cannot push changelog updates."
echo "Changelog was generated but not committed."
fi
fi
fi
else
echo "⚠ CHANGELOG.md not found, using GitHub auto-generation"
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
NOTES=$(gh api repos/${{ github.repository }}/releases/generate-notes \
-f tag_name="${TAG_NAME}" \
-f target_commitish="${{ inputs.target_commitish || github.sha }}" \
-f previous_tag_name="${PREV_TAG}" \
--jq '.body')
else
NOTES="Release ${VERSION}"
fi
fi
fi
echo "release_notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT

View File

@@ -6,15 +6,29 @@ on:
branches:
- main
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: true
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
# Only run release-please on pushes to main
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
- id: release
uses: googleapis/release-please-action@v4
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
outputs:
releases_created: ${{ steps.release.outputs.releases_created || 'false' }}
tag_name: ${{ steps.release.outputs.tag_name || '' }}
test-api:
name: Test API
defaults:
@@ -23,26 +37,37 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install Node
uses: actions/setup-node@v4
with:
fetch-depth: 0
node-version-file: ".nvmrc"
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
version: 1.0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Install Node
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
cache: 'pnpm'
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
- uses: actions/cache@v4
name: Setup pnpm cache
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system php-cli
version: 1.0
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: PNPM Install
run: pnpm install --frozen-lockfile
@@ -92,113 +117,265 @@ jobs:
# Verify libvirt is running using sudo to bypass group membership delays
sudo virsh list --all || true
- name: Build UI Package First
run: |
echo "🔧 Building UI package for web tests dependency..."
cd ../unraid-ui && pnpm run build
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Run Tests Concurrently
run: |
set -e
# Run all tests in parallel with labeled output and coverage generation
# Run all tests in parallel with labeled output
echo "🚀 Starting API coverage tests..."
pnpm run coverage > api-test.log 2>&1 &
API_PID=$!
echo "🚀 Starting Connect plugin tests..."
(cd ../packages/unraid-api-plugin-connect && pnpm test --coverage 2>/dev/null || pnpm test) > connect-test.log 2>&1 &
(cd ../packages/unraid-api-plugin-connect && pnpm test) > connect-test.log 2>&1 &
CONNECT_PID=$!
echo "🚀 Starting Shared package tests..."
(cd ../packages/unraid-shared && pnpm test --coverage 2>/dev/null || pnpm test) > shared-test.log 2>&1 &
(cd ../packages/unraid-shared && pnpm test) > shared-test.log 2>&1 &
SHARED_PID=$!
echo "🚀 Starting Web package coverage tests..."
(cd ../web && (pnpm test --coverage || pnpm test)) > web-test.log 2>&1 &
WEB_PID=$!
echo "🚀 Starting UI package coverage tests..."
(cd ../unraid-ui && pnpm test --coverage 2>/dev/null || pnpm test) > ui-test.log 2>&1 &
UI_PID=$!
echo "🚀 Starting Plugin tests..."
(cd ../plugin && pnpm test) > plugin-test.log 2>&1 &
PLUGIN_PID=$!
# Wait for all processes and capture exit codes
wait $API_PID && echo "✅ API tests completed" || { echo "❌ API tests failed"; API_EXIT=1; }
wait $CONNECT_PID && echo "✅ Connect tests completed" || { echo "❌ Connect tests failed"; CONNECT_EXIT=1; }
wait $SHARED_PID && echo "✅ Shared tests completed" || { echo "❌ Shared tests failed"; SHARED_EXIT=1; }
wait $WEB_PID && echo "✅ Web tests completed" || { echo "❌ Web tests failed"; WEB_EXIT=1; }
wait $UI_PID && echo "✅ UI tests completed" || { echo "❌ UI tests failed"; UI_EXIT=1; }
wait $PLUGIN_PID && echo "✅ Plugin tests completed" || { echo "❌ Plugin tests failed"; PLUGIN_EXIT=1; }
# Display all outputs
echo "📋 API Test Results:" && cat api-test.log
echo "📋 Connect Plugin Test Results:" && cat connect-test.log
echo "📋 Shared Package Test Results:" && cat shared-test.log
echo "📋 Web Package Test Results:" && cat web-test.log
echo "📋 UI Package Test Results:" && cat ui-test.log
echo "📋 Plugin Test Results:" && cat plugin-test.log
# Exit with error if any test failed
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 || ${WEB_EXIT:-0} -eq 1 || ${UI_EXIT:-0} -eq 1 || ${PLUGIN_EXIT:-0} -eq 1 ]]; then
if [[ ${API_EXIT:-0} -eq 1 || ${CONNECT_EXIT:-0} -eq 1 || ${SHARED_EXIT:-0} -eq 1 ]]; then
exit 1
fi
- name: Upload all coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/coverage-final.json,../web/coverage/coverage-final.json,../unraid-ui/coverage/coverage-final.json,../packages/unraid-api-plugin-connect/coverage/coverage-final.json,../packages/unraid-shared/coverage/coverage-final.json
fail_ci_if_error: false
build-artifacts:
name: Build All Artifacts
uses: ./.github/workflows/build-artifacts.yml
secrets:
VITE_ACCOUNT: ${{ secrets.VITE_ACCOUNT }}
VITE_CONNECT: ${{ secrets.VITE_CONNECT }}
VITE_UNRAID_NET: ${{ secrets.VITE_UNRAID_NET }}
VITE_CALLBACK_KEY: ${{ secrets.VITE_CALLBACK_KEY }}
UNRAID_BOT_GITHUB_ADMIN_TOKEN: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
release-please:
name: Release Please
build-api:
name: Build API
runs-on: ubuntu-latest
# Only run on pushes to main AND after tests pass
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-api
- build-artifacts
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- id: release
uses: googleapis/release-please-action@v4
outputs:
releases_created: ${{ steps.release.outputs.releases_created || 'false' }}
tag_name: ${{ steps.release.outputs.tag_name || '' }}
build_number: ${{ steps.buildnumber.outputs.build_number }}
defaults:
run:
working-directory: api
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
- name: PNPM Install
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile
- name: Build
run: pnpm run build
- name: Get Git Short Sha and API version
id: vars
run: |
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}")
export API_VERSION
echo "API_VERSION=${API_VERSION}" >> $GITHUB_ENV
echo "PACKAGE_LOCK_VERSION=${PACKAGE_LOCK_VERSION}" >> $GITHUB_OUTPUT
- name: Generate build number
id: buildnumber
uses: onyxmueller/build-tag-number@v1
with:
token: ${{secrets.github_token}}
prefix: ${{steps.vars.outputs.PACKAGE_LOCK_VERSION}}
- 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
with:
name: unraid-api
path: ${{ github.workspace }}/api/deploy/unraid-api.tgz
build-unraid-ui-webcomponents:
name: Build Unraid UI Library (Webcomponent Version)
defaults:
run:
working-directory: unraid-ui
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Cache APT Packages
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
with:
packages: bash procps python3 libvirt-dev jq zstd git build-essential
version: 1.0
- name: Install dependencies
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile --filter @unraid/ui
- name: Lint
run: pnpm run lint
- name: Build
run: pnpm run build:wc
- name: Upload Artifact to Github
uses: actions/upload-artifact@v4
with:
name: unraid-wc-ui
path: unraid-ui/dist-wc/
build-web:
# needs: [build-unraid-ui]
name: Build Web App
defaults:
run:
working-directory: web
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Create env file
run: |
touch .env
echo VITE_ACCOUNT=${{ secrets.VITE_ACCOUNT }} >> .env
echo VITE_CONNECT=${{ secrets.VITE_CONNECT }} >> .env
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
cat .env
- name: Install Node
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: PNPM Install
run: |
cd ${{ github.workspace }}
pnpm install --frozen-lockfile --filter @unraid/web --filter @unraid/ui
- name: Build Unraid UI
run: |
cd ${{ github.workspace }}/unraid-ui
pnpm run build
- name: Lint files
run: pnpm run lint
- name: Type Check
run: pnpm run type-check
- name: Test
run: pnpm run test:ci
- name: Build
run: pnpm run build
- name: Upload build to Github artifacts
uses: actions/upload-artifact@v4
with:
name: unraid-wc-rich
path: web/.nuxt/nuxt-custom-elements/dist/unraid-components
build-plugin-staging-pr:
name: Build and Deploy Plugin
needs:
- build-artifacts
- release-please
- build-api
- build-web
- build-unraid-ui-webcomponents
- test-api
uses: ./.github/workflows/build-plugin.yml
with:
RELEASE_CREATED: 'false'
RELEASE_CREATED: false
TAG: ${{ github.event.pull_request.number && format('PR{0}', github.event.pull_request.number) || '' }}
BUCKET_PATH: ${{ github.event.pull_request.number && format('unraid-api/tag/PR{0}', github.event.pull_request.number) || 'unraid-api' }}
BASE_URL: "https://preview.dl.unraid.net/unraid-api"
BUILD_NUMBER: ${{ needs.build-artifacts.outputs.build_number }}
BUILD_NUMBER: ${{ needs.build-api.outputs.build_number }}
secrets:
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
@@ -210,19 +387,20 @@ jobs:
name: Build and Deploy Production Plugin
needs:
- release-please
- build-artifacts
- build-api
- build-web
- build-unraid-ui-webcomponents
- test-api
uses: ./.github/workflows/build-plugin.yml
with:
RELEASE_CREATED: 'true'
RELEASE_CREATED: true
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
TAG: ""
BUCKET_PATH: unraid-api
BASE_URL: "https://stable.dl.unraid.net/unraid-api"
BUILD_NUMBER: ${{ needs.build-artifacts.outputs.build_number }}
TRIGGER_PRODUCTION_RELEASE: true
BUILD_NUMBER: ${{ needs.build-api.outputs.build_number }}
secrets:
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
CF_BUCKET_PREVIEW: ${{ secrets.CF_BUCKET_PREVIEW }}
CF_ENDPOINT: ${{ secrets.CF_ENDPOINT }}
UNRAID_BOT_GITHUB_ADMIN_TOKEN: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}

View File

@@ -1,239 +0,0 @@
name: Manual Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 4.25.3)'
required: true
type: string
target_commitish:
description: 'Commit SHA or branch (leave empty for current HEAD)'
required: false
type: string
release_notes:
description: 'Release notes/changelog (leave empty to auto-generate from commits)'
required: false
type: string
prerelease:
description: 'Mark as prerelease'
required: false
type: boolean
default: false
permissions:
contents: write
pull-requests: write
jobs:
validate-version:
name: Validate and Update Package Versions
runs-on: ubuntu-latest
steps:
- name: Checkout repo
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@v6
with:
node-version: '20'
- name: Check and Update Package Versions
run: |
EXPECTED_VERSION="${{ inputs.version }}"
MISMATCHES_FOUND=false
PACKAGE_JSONS=(
"package.json"
"api/package.json"
"web/package.json"
"unraid-ui/package.json"
"plugin/package.json"
"packages/unraid-shared/package.json"
"packages/unraid-api-plugin-health/package.json"
"packages/unraid-api-plugin-generator/package.json"
"packages/unraid-api-plugin-connect/package.json"
)
echo "Checking package.json versions against expected version: ${EXPECTED_VERSION}"
for pkg in "${PACKAGE_JSONS[@]}"; do
if [ -f "$pkg" ]; then
CURRENT_VERSION=$(node -p "require('./$pkg').version")
if [ "$CURRENT_VERSION" != "$EXPECTED_VERSION" ]; then
echo "❌ Version mismatch in $pkg: $CURRENT_VERSION != $EXPECTED_VERSION"
MISMATCHES_FOUND=true
# Detect indentation by checking the first property line
INDENT_SPACES=$(head -10 "$pkg" | grep '^ *"' | head -1 | sed 's/".*//g' | wc -c)
INDENT_SPACES=$((INDENT_SPACES - 1))
jq --indent "$INDENT_SPACES" --arg version "$EXPECTED_VERSION" '.version = $version' "$pkg" > "$pkg.tmp" && mv "$pkg.tmp" "$pkg"
echo "✓ Updated $pkg to version $EXPECTED_VERSION"
else
echo "✓ $pkg version matches: $CURRENT_VERSION"
fi
fi
done
if [ "$MISMATCHES_FOUND" = true ]; then
echo ""
echo "=========================================="
echo "Version mismatches found!"
echo "=========================================="
echo ""
BRANCH_OR_SHA="${{ inputs.target_commitish || github.ref }}"
if git show-ref --verify --quiet "refs/heads/${BRANCH_OR_SHA}"; then
echo "Creating commit with version updates and pushing to branch: ${BRANCH_OR_SHA}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BEFORE_SHA=$(git rev-parse HEAD)
git add ${PACKAGE_JSONS[@]}
git commit -m "chore: update package versions to ${{ inputs.version }}"
git push origin "HEAD:${BRANCH_OR_SHA}"
AFTER_SHA=$(git rev-parse HEAD)
echo ""
echo "=========================================="
echo "WORKFLOW MUST BE RE-RUN"
echo "=========================================="
echo ""
echo "✓ Version updates committed and pushed successfully"
echo ""
echo "Previous SHA: ${BEFORE_SHA}"
echo "New SHA: ${AFTER_SHA}"
echo ""
echo "⚠️ CRITICAL: A new commit was created, but github.sha is immutable."
echo "⚠️ github.sha = ${BEFORE_SHA} (original workflow trigger)"
echo "⚠️ The release tag must point to ${AFTER_SHA} (with version updates)"
echo ""
echo "Re-run this workflow to create the release with the correct commit."
echo ""
exit 1
else
echo "Target is a commit SHA, not a branch. Cannot push version updates."
echo "Please update the package.json versions manually and re-run the workflow."
exit 1
fi
fi
echo ""
echo "✓ All package.json versions match the expected version: ${EXPECTED_VERSION}"
build-artifacts:
name: Build All Artifacts
needs:
- validate-version
uses: ./.github/workflows/build-artifacts.yml
with:
ref: ${{ inputs.target_commitish || github.ref }}
version_override: ${{ inputs.version }}
secrets:
VITE_ACCOUNT: ${{ secrets.VITE_ACCOUNT }}
VITE_CONNECT: ${{ secrets.VITE_CONNECT }}
VITE_UNRAID_NET: ${{ secrets.VITE_UNRAID_NET }}
VITE_CALLBACK_KEY: ${{ secrets.VITE_CALLBACK_KEY }}
UNRAID_BOT_GITHUB_ADMIN_TOKEN: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
generate-release-notes:
name: Generate Release Notes
needs:
- build-artifacts
uses: ./.github/workflows/generate-release-notes.yml
with:
version: ${{ inputs.version }}
target_commitish: ${{ inputs.target_commitish || github.ref }}
release_notes: ${{ inputs.release_notes }}
secrets:
UNRAID_BOT_GITHUB_ADMIN_TOKEN: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
create-release:
name: Create GitHub Release (Draft)
runs-on: ubuntu-latest
needs:
- generate-release-notes
outputs:
tag_name: ${{ steps.create_release.outputs.tag_name }}
release_notes: ${{ needs.generate-release-notes.outputs.release_notes }}
steps:
- name: Checkout repo
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_commitish || github.ref }}
fetch-depth: 0
- name: Create or Update Release as Draft
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="v${{ inputs.version }}"
TARGET="${{ inputs.target_commitish || github.sha }}"
echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT
if gh release view "${TAG_NAME}" > /dev/null 2>&1; then
echo "Release ${TAG_NAME} already exists, updating as draft..."
gh release edit "${TAG_NAME}" \
--draft \
--notes "${{ needs.generate-release-notes.outputs.release_notes }}" \
${{ inputs.prerelease && '--prerelease' || '' }}
else
echo "Creating new draft release ${TAG_NAME}..."
git tag "${TAG_NAME}" "${TARGET}" || true
git push origin "${TAG_NAME}" || true
gh release create "${TAG_NAME}" \
--draft \
--title "${{ inputs.version }}" \
--notes "${{ needs.generate-release-notes.outputs.release_notes }}" \
--target "${TARGET}" \
${{ inputs.prerelease && '--prerelease' || '' }}
fi
build-plugin-production:
name: Build and Deploy Production Plugin
needs:
- create-release
- build-artifacts
uses: ./.github/workflows/build-plugin.yml
with:
RELEASE_CREATED: 'true'
RELEASE_TAG: ${{ needs.create-release.outputs.tag_name }}
TAG: ""
BUCKET_PATH: unraid-api
BASE_URL: "https://stable.dl.unraid.net/unraid-api"
BUILD_NUMBER: ${{ needs.build-artifacts.outputs.build_number }}
ref: ${{ inputs.target_commitish || github.ref }}
secrets:
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
CF_BUCKET_PREVIEW: ${{ secrets.CF_BUCKET_PREVIEW }}
CF_ENDPOINT: ${{ secrets.CF_ENDPOINT }}
UNRAID_BOT_GITHUB_ADMIN_TOKEN: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
publish-release:
name: Publish Release
runs-on: ubuntu-latest
needs:
- create-release
- build-plugin-production
steps:
- name: Publish Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="${{ needs.create-release.outputs.tag_name }}"
echo "Publishing release ${TAG_NAME}..."
gh release edit "${TAG_NAME}" --draft=false --repo ${{ github.repository }}

View File

@@ -1,30 +0,0 @@
name: Publish GraphQL Schema
on:
push:
branches:
- main
paths:
- 'api/generated-schema.graphql'
workflow_dispatch:
jobs:
publish-schema:
name: Publish Schema to Apollo Studio
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
- name: Install Apollo Rover CLI
run: |
curl -sSL https://rover.apollo.dev/nix/latest | sh
echo "$HOME/.rover/bin" >> $GITHUB_PATH
- name: Publish schema to Apollo Studio
env:
APOLLO_KEY: ${{ secrets.APOLLO_KEY }}
run: |
rover graph publish Unraid-API@current \
--schema api/generated-schema.graphql

View File

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

View File

@@ -28,16 +28,16 @@ jobs:
with:
latest: true
prerelease: false
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version: 22.19.0
node-version: '22.17.0'
- run: |
cat << 'EOF' > release-notes.txt
${{ steps.release-info.outputs.body }}
EOF
- run: npm install html-escaper@2 xml2js
- name: Update Plugin Changelog
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
@@ -124,22 +124,3 @@ jobs:
--no-guess-mime-type \
--content-encoding none \
--acl public-read
- name: Discord Webhook Notification
uses: tsickert/discord-webhook@v7.0.0
with:
webhook-url: ${{ secrets.PUBLIC_DISCORD_RELEASE_ENDPOINT }}
username: "Unraid API Bot"
avatar-url: "https://craftassets.unraid.net/uploads/logos/un-mark-gradient.png"
embed-title: "🚀 Unraid API ${{ inputs.version }} Released!"
embed-url: "https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }}"
embed-description: |
A new version of Unraid API has been released!
**Version:** `${{ inputs.version }}`
**Release Page:** [View on GitHub](https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }})
**📋 Changelog:**
${{ steps.release-info.outputs.body }}
embed-color: 16734296
embed-footer-text: "Unraid API • Automated Release"

71
.github/workflows/test-libvirt.yml vendored Normal file
View File

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

17
.gitignore vendored
View File

@@ -29,10 +29,6 @@ unraid-ui/node_modules/
# TypeScript v1 declaration files
typings/
# Auto-generated type declarations for Nuxt UI
auto-imports.d.ts
components.d.ts
# Optional npm cache directory
.npm
@@ -80,9 +76,6 @@ typescript
# Github actions
RELEASE_NOTES.md
# Test backups
api/dev/configs/api.json.backup
# Docker Deploy Folder
deploy/*
!deploy/.gitkeep
@@ -116,13 +109,3 @@ plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/dat
# Config file that changes between versions
api/dev/Unraid.net/myservers.cfg
# Claude local settings
.claude/settings.local.json
# local Mise settings
.mise.toml
# Compiled test pages (generated from Nunjucks templates)
web/public/test-pages/*.html

2
.nvmrc
View File

@@ -1 +1 @@
22.18.0
22.17.0

View File

@@ -1 +1 @@
{".":"4.29.2"}
{".":"4.9.2"}

14
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,14 @@
{
"files.associations": {
"*.page": "php"
},
"editor.codeActionsOnSave": {
"source.fixAll": "never",
"source.fixAll.eslint": "explicit"
},
"i18n-ally.localesPaths": ["locales"],
"i18n-ally.keystyle": "flat",
"eslint.experimental.useFlatConfig": true,
"typescript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifier": "non-relative"
}

22
.vscode/sftp-template.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
"_comment": "rename this file to .vscode/sftp.json and replace name/host/privateKeyPath for your system",
"name": "Tower",
"host": "Tower.local",
"protocol": "sftp",
"port": 22,
"username": "root",
"privateKeyPath": "C:/Users/username/.ssh/tower",
"remotePath": "/",
"context": "plugin/source/dynamix.unraid.net/",
"uploadOnSave": true,
"useTempFile": false,
"openSsh": false,
"ignore": [
"// comment: ignore dot files/dirs in root of repo",
".github",
".vscode",
".git",
".DS_Store"
]
}

View File

@@ -1,89 +0,0 @@
@custom-variant dark (&:where(.dark, .dark *));
/* Utility defaults for web components (when we were using shadow DOM) */
:host,
.unapi {
--tw-divide-y-reverse: 0;
--tw-border-style: solid;
--tw-font-weight: initial;
--tw-tracking: initial;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-rotate-x: rotateX(0);
--tw-rotate-y: rotateY(0);
--tw-rotate-z: rotateZ(0);
--tw-skew-x: skewX(0);
--tw-skew-y: skewY(0);
--tw-space-x-reverse: 0;
--tw-gradient-position: initial;
--tw-gradient-from: #0000;
--tw-gradient-via: #0000;
--tw-gradient-to: #0000;
--tw-gradient-stops: initial;
--tw-gradient-via-stops: initial;
--tw-gradient-from-position: 0%;
--tw-gradient-via-position: 50%;
--tw-gradient-to-position: 100%;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-blur: initial;
--tw-brightness: initial;
--tw-contrast: initial;
--tw-grayscale: initial;
--tw-hue-rotate: initial;
--tw-invert: initial;
--tw-opacity: initial;
--tw-saturate: initial;
--tw-sepia: initial;
--tw-drop-shadow: initial;
--tw-duration: initial;
--tw-ease: initial;
}
/* Global border color - this is what's causing the issue! */
/* Commenting out since it affects all elements globally
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: hsl(var(--border));
}
*/
.unapi {
}
.unapi button:not(:disabled),
.unapi [role='button']:not(:disabled) {
cursor: pointer;
}
/* Font size overrides for SSO button component */
.unapi unraid-sso-button,
unraid-sso-button.unapi {
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
--text-3xl: 1.875rem;
--text-4xl: 2.25rem;
--text-5xl: 3rem;
--text-6xl: 3.75rem;
--text-7xl: 4.5rem;
--text-8xl: 6rem;
--text-9xl: 8rem;
}

View File

@@ -1,144 +0,0 @@
/* Hybrid theme system: Native CSS + Theme Store fallback */
/* Light mode defaults */
:root {
/* Nuxt UI Color System - Primary (Orange for Unraid) */
--ui-color-primary-50: #fff7ed;
--ui-color-primary-100: #ffedd5;
--ui-color-primary-200: #fed7aa;
--ui-color-primary-300: #fdba74;
--ui-color-primary-400: #fb923c;
--ui-color-primary-500: #ff8c2f;
--ui-color-primary-600: #ea580c;
--ui-color-primary-700: #c2410c;
--ui-color-primary-800: #9a3412;
--ui-color-primary-900: #7c2d12;
--ui-color-primary-950: #431407;
/* Nuxt UI Color System - Neutral (True Gray) */
--ui-color-neutral-50: #fafafa;
--ui-color-neutral-100: #f5f5f5;
--ui-color-neutral-200: #e5e5e5;
--ui-color-neutral-300: #d4d4d4;
--ui-color-neutral-400: #a3a3a3;
--ui-color-neutral-500: #737373;
--ui-color-neutral-600: #525252;
--ui-color-neutral-700: #404040;
--ui-color-neutral-800: #262626;
--ui-color-neutral-900: #171717;
--ui-color-neutral-950: #0a0a0a;
/* Nuxt UI Default color shades */
--ui-primary: var(--ui-color-primary-500);
--ui-secondary: var(--ui-color-neutral-500);
/* Nuxt UI Design Tokens - Text */
--ui-text-dimmed: var(--ui-color-neutral-400);
--ui-text-muted: var(--ui-color-neutral-500);
--ui-text-toned: var(--ui-color-neutral-600);
--ui-text: var(--ui-color-neutral-700);
--ui-text-highlighted: var(--ui-color-neutral-900);
--ui-text-inverted: white;
/* Nuxt UI Design Tokens - Background */
--ui-bg: white;
--ui-bg-muted: var(--ui-color-neutral-50);
--ui-bg-elevated: var(--ui-color-neutral-100);
--ui-bg-accented: var(--ui-color-neutral-200);
--ui-bg-inverted: var(--ui-color-neutral-900);
/* Nuxt UI Design Tokens - Border */
--ui-border: var(--ui-color-neutral-200);
--ui-border-muted: var(--ui-color-neutral-200);
--ui-border-accented: var(--ui-color-neutral-300);
--ui-border-inverted: var(--ui-color-neutral-900);
/* Nuxt UI Radius */
--ui-radius: 0.5rem;
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--primary: 24 100% 50%; /* Orange #ff8c2f in HSL */
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--ring: 24 100% 50%; /* Orange ring to match primary */
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
/* Dark mode */
.dark {
/* Nuxt UI Default color shades - Dark mode */
--ui-primary: var(--ui-color-primary-400);
--ui-secondary: var(--ui-color-neutral-400);
/* Nuxt UI Design Tokens - Text (Dark) */
--ui-text-dimmed: var(--ui-color-neutral-500);
--ui-text-muted: var(--ui-color-neutral-400);
--ui-text-toned: var(--ui-color-neutral-300);
--ui-text: var(--ui-color-neutral-200);
--ui-text-highlighted: white;
--ui-text-inverted: var(--ui-color-neutral-900);
/* Nuxt UI Design Tokens - Background (Dark) */
--ui-bg: var(--ui-color-neutral-900);
--ui-bg-muted: var(--ui-color-neutral-800);
--ui-bg-elevated: var(--ui-color-neutral-800);
--ui-bg-accented: var(--ui-color-neutral-700);
--ui-bg-inverted: white;
/* Nuxt UI Design Tokens - Border (Dark) */
--ui-border: var(--ui-color-neutral-800);
--ui-border-muted: var(--ui-color-neutral-700);
--ui-border-accented: var(--ui-color-neutral-700);
--ui-border-inverted: white;
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--primary: 24 100% 50%; /* Orange #ff8c2f in HSL */
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--ring: 24 100% 50%; /* Orange ring to match primary */
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
/* Alternative class-based dark mode support for specific Unraid themes */
.dark[data-theme='black'],
.dark[data-theme='gray'] {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--border: 0 0% 14.9%;
}

View File

@@ -1,5 +0,0 @@
/* Tailwind Shared Styles - Single entry point for all shared CSS */
@import './css-variables.css';
@import './unraid-theme.css';
@import './theme-variants.css';
@import './base-utilities.css';

View File

@@ -1,68 +0,0 @@
/**
* Tailwind v4 Theme Variants
* Defines theme-specific CSS variables that can be switched via classes
* These are applied dynamically based on the theme selected in GraphQL
*/
/* Default/White Theme */
.Theme--white {
--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 {
--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,
.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 {
--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;
}

View File

@@ -1,280 +0,0 @@
@theme static {
/* Breakpoints */
--breakpoint-xs: 30rem;
--breakpoint-2xl: 100rem;
--breakpoint-3xl: 120rem;
/* Container settings */
--container-center: true;
--container-padding: 2rem;
--container-screen-2xl: 1400px;
/* Font families */
--font-sans:
clear-sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
/* Grid template columns */
--grid-template-columns-settings: 35% 1fr;
/* Border color default */
--default-border-color: var(--color-border);
--ui-border-muted: hsl(var(--border));
--ui-radius: 0.5rem;
--ui-primary: var(--color-primary-500);
--ui-primary-hover: var(--color-primary-600);
--ui-primary-active: var(--color-primary-700);
/* Color palette */
--color-inherit: inherit;
--color-transparent: transparent;
--color-black: #1c1b1b;
--color-grey-darkest: #222;
--color-grey-darker: #606f7b;
--color-grey-dark: #383735;
--color-grey-mid: #999999;
--color-grey: #e0e0e0;
--color-grey-light: #dae1e7;
--color-grey-lighter: #f1f5f8;
--color-grey-lightest: #f2f2f2;
--color-white: #ffffff;
/* Unraid colors */
--color-yellow-accent: #e9bf41;
--color-orange-dark: #f15a2c;
--color-orange: #ff8c2f;
/* Unraid red palette */
--color-unraid-red: #e22828;
--color-unraid-red-50: #fef2f2;
--color-unraid-red-100: #ffe1e1;
--color-unraid-red-200: #ffc9c9;
--color-unraid-red-300: #fea3a3;
--color-unraid-red-400: #fc6d6d;
--color-unraid-red-500: #f43f3f;
--color-unraid-red-600: #e22828;
--color-unraid-red-700: #bd1818;
--color-unraid-red-800: #9c1818;
--color-unraid-red-900: #821a1a;
--color-unraid-red-950: #470808;
/* Unraid green palette */
--color-unraid-green: #63a659;
--color-unraid-green-50: #f5f9f4;
--color-unraid-green-100: #e7f3e5;
--color-unraid-green-200: #d0e6cc;
--color-unraid-green-300: #aad1a4;
--color-unraid-green-400: #7db474;
--color-unraid-green-500: #63a659;
--color-unraid-green-600: #457b3e;
--color-unraid-green-700: #396134;
--color-unraid-green-800: #314e2d;
--color-unraid-green-900: #284126;
--color-unraid-green-950: #122211;
/* Primary colors (orange) */
--color-primary-50: #fff7ed;
--color-primary-100: #ffedd5;
--color-primary-200: #fed7aa;
--color-primary-300: #fdba74;
--color-primary-400: #fb923c;
--color-primary-500: #ff6600;
--color-primary-600: #ea580c;
--color-primary-700: #c2410c;
--color-primary-800: #9a3412;
--color-primary-900: #7c2d12;
--color-primary-950: #431407;
/* Header colors - defaults will be overridden by theme */
--color-header-text-primary: var(--header-text-primary, #1c1c1c);
--color-header-text-secondary: var(--header-text-secondary, #999999);
--color-header-background: var(--header-background-color, #f2f2f2);
/* Legacy colors - defaults (overridden by theme-variants.css) */
--color-alpha: #ff8c2f;
--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);
/* Gradients - defaults (overridden by theme-variants.css) */
--color-header-gradient-start: rgba(242, 242, 242, 0);
--color-header-gradient-end: rgba(242, 242, 242, 0.85);
--color-banner-gradient: none;
/* Font sizes */
--font-10px: 10px;
--font-12px: 12px;
--font-14px: 14px;
--font-16px: 16px;
--font-18px: 18px;
--font-20px: 20px;
--font-24px: 24px;
--font-30px: 30px;
/* Spacing */
--spacing-4_5: 1.125rem;
--spacing--8px: -8px;
--spacing-2px: 2px;
--spacing-4px: 4px;
--spacing-6px: 6px;
--spacing-8px: 8px;
--spacing-10px: 10px;
--spacing-12px: 12px;
--spacing-14px: 14px;
--spacing-16px: 16px;
--spacing-20px: 20px;
--spacing-24px: 24px;
--spacing-28px: 28px;
--spacing-32px: 32px;
--spacing-36px: 36px;
--spacing-40px: 40px;
--spacing-64px: 64px;
--spacing-80px: 80px;
--spacing-90px: 90px;
--spacing-150px: 150px;
--spacing-160px: 160px;
--spacing-200px: 200px;
--spacing-260px: 260px;
--spacing-300px: 300px;
--spacing-310px: 310px;
--spacing-350px: 350px;
--spacing-448px: 448px;
--spacing-512px: 512px;
--spacing-640px: 640px;
--spacing-800px: 800px;
/* Width and Height values */
--width-36px: 36px;
--height-36px: 36px;
/* Min/Max widths */
--min-width-86px: 86px;
--min-width-160px: 160px;
--min-width-260px: 260px;
--min-width-300px: 300px;
--min-width-310px: 310px;
--min-width-350px: 350px;
--min-width-800px: 800px;
--max-width-86px: 86px;
--max-width-160px: 160px;
--max-width-260px: 260px;
--max-width-300px: 300px;
--max-width-310px: 310px;
--max-width-350px: 350px;
--max-width-640px: 640px;
--max-width-800px: 800px;
--max-width-1024px: 1024px;
/* Container sizes adjusted for 10px base font size (1.6x scale) */
--container-xs: 32rem;
--container-sm: 38.4rem;
--container-md: 44.8rem;
--container-lg: 51.2rem;
--container-xl: 57.6rem;
--container-2xl: 67.2rem;
--container-3xl: 76.8rem;
--container-4xl: 89.6rem;
--container-5xl: 102.4rem;
--container-6xl: 115.2rem;
--container-7xl: 128rem;
/* Extended width scale for max-w-* utilities */
--width-5xl: 102.4rem;
--width-6xl: 115.2rem;
--width-7xl: 128rem;
--width-8xl: 140.8rem;
--width-9xl: 153.6rem;
--width-10xl: 166.4rem;
/* Animations */
--animate-mark-2: mark-2 1.5s ease infinite;
--animate-mark-3: mark-3 1.5s ease infinite;
--animate-mark-6: mark-6 1.5s ease infinite;
--animate-mark-7: mark-7 1.5s ease infinite;
/* Radius */
--radius: 0.5rem;
/* Text Resizing */
--text-xs: 1.2rem; /* 12px at 10px base */
--text-sm: 1.4rem; /* 14px at 10px base */
--text-base: 1.6rem; /* 16px at 10px base */
--text-lg: 1.8rem; /* 18px at 10px base */
--text-xl: 2rem; /* 20px at 10px base */
--text-2xl: 2.4rem; /* 24px at 10px base */
--text-3xl: 3rem; /* 30px at 10px base */
--text-4xl: 3.6rem; /* 36px at 10px base */
--text-5xl: 4.8rem; /* 48px at 10px base */
--text-6xl: 6rem; /* 60px at 10px base */
--text-7xl: 7.2rem; /* 72px at 10px base */
--text-8xl: 9.6rem; /* 96px at 10px base */
--text-9xl: 12.8rem; /* 128px at 10px base */
--spacing: 0.4rem; /* 4px at 10px base */
}
/* Keyframes */
@keyframes mark-2 {
50% {
transform: translateY(-40px);
}
to {
transform: translateY(0);
}
}
@keyframes mark-3 {
50% {
transform: translateY(-62px);
}
to {
transform: translateY(0);
}
}
@keyframes mark-6 {
50% {
transform: translateY(40px);
}
to {
transform: translateY(0);
}
}
@keyframes mark-7 {
50% {
transform: translateY(62px);
}
to {
transform: translateY(0);
}
}
/* Theme colors that reference CSS variables */
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1, 12 76% 61%));
--color-chart-2: hsl(var(--chart-2, 173 58% 39%));
--color-chart-3: hsl(var(--chart-3, 197 37% 24%));
--color-chart-4: hsl(var(--chart-4, 43 74% 66%));
--color-chart-5: hsl(var(--chart-5, 27 87% 67%));
}

View File

@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
This is the Unraid API monorepo containing multiple packages that provide API functionality for Unraid servers. It uses pnpm workspaces with the following structure:
- `/api` - Core NestJS API server with GraphQL
- `/web` - Vue 3 frontend application
- `/web` - Nuxt.js frontend application
- `/unraid-ui` - Vue 3 component library
- `/plugin` - Unraid plugin package (.plg)
- `/packages` - Shared packages and API plugins
@@ -46,16 +46,6 @@ cd api && pnpm codegen # Generate GraphQL types
pnpm unraid:deploy <SERVER_IP> # Deploy all to Unraid server
```
### Developer Tools
```bash
unraid-api developer # Interactive prompt for tools
unraid-api developer --sandbox true # Enable GraphQL sandbox
unraid-api developer --sandbox false # Disable GraphQL sandbox
unraid-api developer --enable-modal # Enable modal testing tool
unraid-api developer --disable-modal # Disable modal testing tool
```
## Architecture Notes
### API Structure (NestJS)
@@ -120,14 +110,10 @@ Enables GraphQL playground at `http://tower.local/graphql`
### Testing Guidelines
#### General Testing Best Practices
- **Error Testing:** Use `.rejects.toThrow()` without arguments to test that functions throw errors. Don't test exact error message strings unless the message format is specifically what you're testing
- **Focus on Behavior:** Test what the code does, not implementation details like exact error message wording
- **Avoid Brittleness:** Don't write tests that break when minor changes are made to error messages, log formats, or other non-essential details
- **Use Mocks Correctly**: Mocks should be used as nouns, not verbs.
#### Vue Component Testing
- This is a Nuxt.js app but we are testing with vitest outside of the Nuxt environment
- Nuxt is currently set to auto import so some vue files may need compute or ref imported
- Use pnpm when running terminal commands and stay within the web directory
- Tests are located under `web/__test__`, run with `pnpm test`
- Use `mount` from Vue Test Utils for component testing
@@ -149,12 +135,3 @@ Enables GraphQL playground at `http://tower.local/graphql`
- Place all mock declarations at the top level
- Use factory functions for module mocks to avoid hoisting issues
- Clear mocks between tests to ensure isolation
## Development Memories
- We are using tailwind v4 we do not need a tailwind config anymore
- always search the internet for tailwind v4 documentation when making tailwind related style changes
- never run or restart the API server or web server. I will handle the lifecycle, simply wait and ask me to do this for you
- Never use the `any` type. Always prefer proper typing
- Avoid using casting whenever possible, prefer proper typing from the start
- **IMPORTANT:** cache-manager v7 expects TTL values in **milliseconds**, not seconds. Always use milliseconds when setting cache TTL (e.g., 600000 for 10 minutes, not 600)

View File

@@ -1,10 +0,0 @@
{
"parsers": {
"**/*.ts": [
"@depcheck/parser-typescript",
{
"project": "tsconfig.json"
}
]
}
}

View File

@@ -15,10 +15,6 @@ PATHS_ACTIVATION_BASE=./dev/activation
PATHS_PASSWD=./dev/passwd
PATHS_RCLONE_SOCKET=./dev/rclone-socket
PATHS_LOG_BASE=./dev/log # Where we store logs
PATHS_LOGS_FILE=./dev/log/graphql-api.log
PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file
PATHS_OIDC_JSON=./dev/configs/oidc.local.json
PATHS_LOCAL_SESSION_FILE=./dev/local-session
ENVIRONMENT="development"
NODE_ENV="development"
PORT="3001"
@@ -31,5 +27,3 @@ BYPASS_CORS_CHECKS=true
CHOKIDAR_USEPOLLING=true
LOG_TRANSPORT=console
LOG_LEVEL=trace
ENABLE_NEXT_DOCKER_RELEASE=true
SKIP_CONNECT_PLUGIN_CHECK=true

View File

@@ -13,7 +13,5 @@ PATHS_PARITY_CHECKS=./dev/states/parity-checks.log
PATHS_CONFIG_MODULES=./dev/configs
PATHS_ACTIVATION_BASE=./dev/activation
PATHS_PASSWD=./dev/passwd
PATHS_LOGS_FILE=./dev/log/graphql-api.log
PATHS_LOCAL_SESSION_FILE=./dev/local-session
PORT=5000
NODE_ENV="test"

View File

@@ -4,62 +4,54 @@ import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths';
import prettier from 'eslint-plugin-prettier';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['src/graphql/generated/client/**/*', 'src/**/**/dummy-process.js'],
export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, {
plugins: {
'no-relative-import-paths': noRelativeImportPaths,
prettier: prettier,
import: importPlugin,
},
{
plugins: {
'no-relative-import-paths': noRelativeImportPaths,
prettier: prettier,
import: importPlugin,
},
rules: {
'@typescript-eslint/no-redundant-type-constituents': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'no-use-before-define': ['off'],
'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }],
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'import/no-unresolved': 'off',
'import/no-absolute-path': 'off',
'import/prefer-default-export': 'off',
'no-relative-import-paths/no-relative-import-paths': [
'error',
{ allowSameFolder: false, rootDir: 'src', prefix: '@app' },
],
'prettier/prettier': 'error',
'import/extensions': [
'error',
'ignorePackages',
{
js: 'always',
mjs: 'always',
cjs: 'always',
ts: 'never',
tsx: 'never',
},
],
'no-restricted-globals': [
'error',
{
name: '__dirname',
message: 'Use import.meta.url instead of __dirname in ESM',
},
{
name: '__filename',
message: 'Use import.meta.url instead of __filename in ESM',
},
],
'eol-last': ['error', 'always'],
},
}
);
rules: {
'@typescript-eslint/no-redundant-type-constituents': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'no-use-before-define': ['off'],
'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }],
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'import/no-unresolved': 'off',
'import/no-absolute-path': 'off',
'import/prefer-default-export': 'off',
'no-relative-import-paths/no-relative-import-paths': [
'error',
{ allowSameFolder: false, rootDir: 'src', prefix: '@app' },
],
'prettier/prettier': 'error',
'import/extensions': [
'error',
'ignorePackages',
{
js: 'always',
ts: 'always',
},
],
'no-restricted-globals': [
'error',
{
name: '__dirname',
message: 'Use import.meta.url instead of __dirname in ESM',
},
{
name: '__filename',
message: 'Use import.meta.url instead of __filename in ESM',
},
],
'eol-last': ['error', 'always'],
},
ignores: ['src/graphql/generated/client/**/*'],
});

14
api/.gitignore vendored
View File

@@ -82,17 +82,3 @@ deploy/*
.idea
!**/*.login.*
# local api configs - don't need project-wide tracking
dev/connectStatus.json
dev/configs/*
# local status - doesn't need to be tracked
dev/connectStatus.json
# mock local session file
dev/local-session
# local OIDC config for testing - contains secrets
dev/configs/oidc.local.json
# local api keys
dev/keys/*

9
api/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"eslint.lintTask.options": "--flag unstable_ts_config",
"eslint.options": {
"flags": ["unstable_ts_config"],
"overrideConfigFile": ".eslintrc.ts"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifier": "non-relative"
}

View File

@@ -1,523 +1,5 @@
# Changelog
## [4.29.2](https://github.com/unraid/api/compare/v4.29.1...v4.29.2) (2025-12-19)
### Bug Fixes
* unraid-connect plugin not loaded when connect is installed ([#1856](https://github.com/unraid/api/issues/1856)) ([73135b8](https://github.com/unraid/api/commit/73135b832801f5c76d60020161492e4770958c3d))
## [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)
### Bug Fixes
* **theme:** Missing header background color ([e2fdf6c](https://github.com/unraid/api/commit/e2fdf6cadbd816559b8c82546c2bc771a81ffa9e))
## [4.26.1](https://github.com/unraid/api/compare/v4.26.0...v4.26.1) (2025-11-18)
### Bug Fixes
* **theme:** update theme class naming and scoping logic ([b28ef1e](https://github.com/unraid/api/commit/b28ef1ea334cb4842f01fa992effa7024185c6c9))
## [4.26.0](https://github.com/unraid/api/compare/v4.25.3...v4.26.0) (2025-11-17)
### Features
* add cpu power query & subscription ([#1745](https://github.com/unraid/api/issues/1745)) ([d7aca81](https://github.com/unraid/api/commit/d7aca81c60281bfa47fb9113929c1ead6ed3361b))
* add schema publishing to apollo studio ([#1772](https://github.com/unraid/api/issues/1772)) ([7e13202](https://github.com/unraid/api/commit/7e13202aa1c02803095bb72bb1bcb2472716f53a))
* add workflow_dispatch trigger to schema publishing workflow ([818e7ce](https://github.com/unraid/api/commit/818e7ce997059663e07efcf1dab706bf0d7fc9da))
* apollo studio readme link ([c4cd0c6](https://github.com/unraid/api/commit/c4cd0c63520deec15d735255f38811f0360fe3a1))
* **cli:** make `unraid-api plugins remove` scriptable ([#1774](https://github.com/unraid/api/issues/1774)) ([64eb9ce](https://github.com/unraid/api/commit/64eb9ce9b5d1ff4fb1f08d9963522c5d32221ba7))
* use persisted theme css to fix flashes on header ([#1784](https://github.com/unraid/api/issues/1784)) ([854b403](https://github.com/unraid/api/commit/854b403fbd85220a3012af58ce033cf0b8418516))
### Bug Fixes
* **api:** decode html entities before parsing notifications ([#1768](https://github.com/unraid/api/issues/1768)) ([42406e7](https://github.com/unraid/api/commit/42406e795da1e5b95622951a467722dde72d51a8))
* **connect:** disable api plugin if unraid plugin is absent ([#1773](https://github.com/unraid/api/issues/1773)) ([c264a18](https://github.com/unraid/api/commit/c264a1843cf115e8cc1add1ab4f12fdcc932405a))
* detection of flash backup activation state ([#1769](https://github.com/unraid/api/issues/1769)) ([d18eaf2](https://github.com/unraid/api/commit/d18eaf2364e0c04992c52af38679ff0a0c570440))
* re-add missing header gradient styles ([#1787](https://github.com/unraid/api/issues/1787)) ([f8a6785](https://github.com/unraid/api/commit/f8a6785e9c92f81acaef76ac5eb78a4a769e69da))
* respect OS safe mode in plugin loader ([#1775](https://github.com/unraid/api/issues/1775)) ([92af3b6](https://github.com/unraid/api/commit/92af3b61156cabae70368cf5222a2f7ac5b4d083))
## [4.25.3](https://github.com/unraid/unraid-api/compare/v4.25.2...v4.25.3) (2025-10-22)
### Bug Fixes
* flaky watch on boot drive's dynamix config ([ec7aa06](https://github.com/unraid/unraid-api/commit/ec7aa06d4a5fb1f0e84420266b0b0d7ee09a3663))
## [4.25.2](https://github.com/unraid/api/compare/v4.25.1...v4.25.2) (2025-09-30)
### Bug Fixes
* enhance activation code modal visibility logic ([#1733](https://github.com/unraid/api/issues/1733)) ([e57ec00](https://github.com/unraid/api/commit/e57ec00627e54ce76d903fd0fa8686ad02b393f3))
## [4.25.1](https://github.com/unraid/api/compare/v4.25.0...v4.25.1) (2025-09-30)
### Bug Fixes
* add cache busting to web component extractor ([#1731](https://github.com/unraid/api/issues/1731)) ([0d165a6](https://github.com/unraid/api/commit/0d165a608740505bdc505dcf69fb615225969741))
* Connect won't appear within Apps - Previous Apps ([#1727](https://github.com/unraid/api/issues/1727)) ([d73953f](https://github.com/unraid/api/commit/d73953f8ff3d7425c0aed32d16236ededfd948e1))
## [4.25.0](https://github.com/unraid/api/compare/v4.24.1...v4.25.0) (2025-09-26)
### Features
* add Tailwind scoping plugin and integrate into Vite config ([#1722](https://github.com/unraid/api/issues/1722)) ([b7afaf4](https://github.com/unraid/api/commit/b7afaf463243b073e1ab1083961a16a12ac6c4a3))
* notification filter controls pill buttons ([#1718](https://github.com/unraid/api/issues/1718)) ([661865f](https://github.com/unraid/api/commit/661865f97611cf802f239fde8232f3109281dde6))
### Bug Fixes
* enable auth guard for nested fields - thanks [@ingel81](https://github.com/ingel81) ([7bdeca8](https://github.com/unraid/api/commit/7bdeca8338a3901f15fde06fd7aede3b0c16e087))
* enhance user context validation in auth module ([#1726](https://github.com/unraid/api/issues/1726)) ([cd5eff1](https://github.com/unraid/api/commit/cd5eff11bcb4398581472966cb7ec124eac7ad0a))
## [4.24.1](https://github.com/unraid/api/compare/v4.24.0...v4.24.1) (2025-09-23)
### Bug Fixes
* cleanup leftover removed packages on upgrade ([#1719](https://github.com/unraid/api/issues/1719)) ([9972a5f](https://github.com/unraid/api/commit/9972a5f178f9a251e6c129d85c5f11cfd25e6281))
* enhance version comparison logic in installation script ([d9c561b](https://github.com/unraid/api/commit/d9c561bfebed0c553fe4bfa26b088ae71ca59755))
* issue with incorrect permissions on viewer / other roles ([378cdb7](https://github.com/unraid/api/commit/378cdb7f102f63128dd236c13f1a3745902d5a2c))
## [4.24.0](https://github.com/unraid/api/compare/v4.23.1...v4.24.0) (2025-09-18)
### Features
* improve dom content loading by being more efficient about component mounting ([#1716](https://github.com/unraid/api/issues/1716)) ([d8b166e](https://github.com/unraid/api/commit/d8b166e4b6a718e07783d9c8ac8393b50ec89ae3))
## [4.23.1](https://github.com/unraid/api/compare/v4.23.0...v4.23.1) (2025-09-17)
### Bug Fixes
* cleanup ini parser logic with better fallbacks ([#1713](https://github.com/unraid/api/issues/1713)) ([1691362](https://github.com/unraid/api/commit/16913627de9497a5d2f71edb710cec6e2eb9f890))
## [4.23.0](https://github.com/unraid/api/compare/v4.22.2...v4.23.0) (2025-09-16)
### Features
* add unraid api status manager ([#1708](https://github.com/unraid/api/issues/1708)) ([1d9ce0a](https://github.com/unraid/api/commit/1d9ce0aa3d067726c2c880929408c68f53e13e0d))
### Bug Fixes
* **logging:** remove colorized logs ([#1705](https://github.com/unraid/api/issues/1705)) ([1d2c670](https://github.com/unraid/api/commit/1d2c6701ce56b1d40afdb776065295e9273d08e9))
* no sizeRootFs unless queried ([#1710](https://github.com/unraid/api/issues/1710)) ([9714b21](https://github.com/unraid/api/commit/9714b21c5c07160b92a11512e8b703908adb0620))
* use virtual-modal-container ([#1709](https://github.com/unraid/api/issues/1709)) ([44b4d77](https://github.com/unraid/api/commit/44b4d77d803aa724968307cfa463f7c440791a10))
## [4.22.2](https://github.com/unraid/api/compare/v4.22.1...v4.22.2) (2025-09-15)
### Bug Fixes
* **deps:** pin dependency conventional-changelog-conventionalcommits to 9.1.0 ([#1697](https://github.com/unraid/api/issues/1697)) ([9a86c61](https://github.com/unraid/api/commit/9a86c615da2e975f568922fa012cc29b3f9cde0e))
* **deps:** update dependency filenamify to v7 ([#1703](https://github.com/unraid/api/issues/1703)) ([b80988a](https://github.com/unraid/api/commit/b80988aaabebc4b8dbf2bf31f0764bf2f28e1575))
* **deps:** update graphqlcodegenerator monorepo (major) ([#1689](https://github.com/unraid/api/issues/1689)) ([ba4a43a](https://github.com/unraid/api/commit/ba4a43aec863fc30c47dd17370d74daed7f84703))
* false positive on verify_install script being external shell ([#1704](https://github.com/unraid/api/issues/1704)) ([31a255c](https://github.com/unraid/api/commit/31a255c9281b29df983d0f5d0475cd5a69790a48))
* improve vue mount speed by 10x ([c855caa](https://github.com/unraid/api/commit/c855caa9b2d4d63bead1a992f5c583e00b9ba843))
## [4.22.1](https://github.com/unraid/api/compare/v4.22.0...v4.22.1) (2025-09-12)
### Bug Fixes
* set input color in SSO field rather than inside of the main.css ([01d353f](https://github.com/unraid/api/commit/01d353fa08a3df688b37a495a204605138f7f71d))
## [4.22.0](https://github.com/unraid/api/compare/v4.21.0...v4.22.0) (2025-09-12)
### Features
* improved update ui ([#1691](https://github.com/unraid/api/issues/1691)) ([a59b363](https://github.com/unraid/api/commit/a59b363ebc1e660f854c55d50fc02c823c2fd0cc))
### Bug Fixes
* **deps:** update dependency camelcase-keys to v10 ([#1687](https://github.com/unraid/api/issues/1687)) ([95faeaa](https://github.com/unraid/api/commit/95faeaa2f39bf7bd16502698d7530aaa590b286d))
* **deps:** update dependency p-retry to v7 ([#1608](https://github.com/unraid/api/issues/1608)) ([c782cf0](https://github.com/unraid/api/commit/c782cf0e8710c6690050376feefda3edb30dd549))
* **deps:** update dependency uuid to v13 ([#1688](https://github.com/unraid/api/issues/1688)) ([2fef10c](https://github.com/unraid/api/commit/2fef10c94aae910e95d9f5bcacf7289e2cca6ed9))
* **deps:** update dependency vue-sonner to v2 ([#1475](https://github.com/unraid/api/issues/1475)) ([f95ca9c](https://github.com/unraid/api/commit/f95ca9c9cb69725dcf3bb4bcbd0b558a2074e311))
* display settings fix for languages on less than 7.2-beta.2.3 ([#1696](https://github.com/unraid/api/issues/1696)) ([03dae7c](https://github.com/unraid/api/commit/03dae7ce66b3409593eeee90cd5b56e2a920ca44))
* hide reset help option when sso is being checked ([#1695](https://github.com/unraid/api/issues/1695)) ([222ced7](https://github.com/unraid/api/commit/222ced7518d40c207198a3b8548f0e024bc865b0))
* progressFrame white on black ([0990b89](https://github.com/unraid/api/commit/0990b898bd02c231153157c20d5142e5fd4513cd))
## [4.21.0](https://github.com/unraid/api/compare/v4.20.4...v4.21.0) (2025-09-10)
### Features
* add zsh shell detection to install script ([#1539](https://github.com/unraid/api/issues/1539)) ([50ea2a3](https://github.com/unraid/api/commit/50ea2a3ffb82b30152fb85e0fb9b0d178d596efe))
* **api:** determine if docker container has update ([#1582](https://github.com/unraid/api/issues/1582)) ([e57d81e](https://github.com/unraid/api/commit/e57d81e0735772758bb85e0b3c89dce15c56635e))
### Bug Fixes
* white on white login text ([ae4d3ec](https://github.com/unraid/api/commit/ae4d3ecbc417454ae3c6e02018f8e4c49bbfc902))
## [4.20.4](https://github.com/unraid/api/compare/v4.20.3...v4.20.4) (2025-09-09)
### Bug Fixes
* staging PR plugin fixes + UI issues on 7.2 beta ([b79b44e](https://github.com/unraid/api/commit/b79b44e95c65a124313814ab55b0d0a745a799c7))
## [4.20.3](https://github.com/unraid/api/compare/v4.20.2...v4.20.3) (2025-09-09)
### Bug Fixes
* header background color issues fixed on 7.2 - thanks Nick! ([73c1100](https://github.com/unraid/api/commit/73c1100d0ba396fe4342f8ce7561017ab821e68b))
## [4.20.2](https://github.com/unraid/api/compare/v4.20.1...v4.20.2) (2025-09-09)
### Bug Fixes
* trigger deployment ([a27453f](https://github.com/unraid/api/commit/a27453fda81e4eeb07f257e60516bebbbc27cf7a))
## [4.20.1](https://github.com/unraid/api/compare/v4.20.0...v4.20.1) (2025-09-09)
### Bug Fixes
* adjust header styles to fix flashing and width issues - thanks ZarZ ([4759b3d](https://github.com/unraid/api/commit/4759b3d0b3fb6bc71636f75f807cd6f4f62305d1))
## [4.20.0](https://github.com/unraid/api/compare/v4.19.1...v4.20.0) (2025-09-08)
### Features
* **disks:** add isSpinning field to Disk type ([#1527](https://github.com/unraid/api/issues/1527)) ([193be3d](https://github.com/unraid/api/commit/193be3df3672514be9904e3d4fbdff776470afc0))
### Bug Fixes
* better component loading to prevent per-page strange behavior ([095c222](https://github.com/unraid/api/commit/095c2221c94f144f8ad410a69362b15803765531))
* **deps:** pin dependencies ([#1669](https://github.com/unraid/api/issues/1669)) ([413db4b](https://github.com/unraid/api/commit/413db4bd30a06aa69d3ca86e793782854f822589))
* **plugin:** add fallback for unraid-api stop in deprecation cleanup ([#1668](https://github.com/unraid/api/issues/1668)) ([797bf50](https://github.com/unraid/api/commit/797bf50ec702ebc8244ff71a8ef1a80ea5cd2169))
* prepend 'v' to API version in workflow dispatch inputs ([f0cffbd](https://github.com/unraid/api/commit/f0cffbdc7ac36e7037ab60fe9dddbb2cab4a5e10))
* progress frame background color fix ([#1672](https://github.com/unraid/api/issues/1672)) ([785f1f5](https://github.com/unraid/api/commit/785f1f5eb1a1cc8b41f6eb502e4092d149cfbd80))
* properly override header values ([#1673](https://github.com/unraid/api/issues/1673)) ([aecf70f](https://github.com/unraid/api/commit/aecf70ffad60c83074347d3d6ec23f73acbd1aee))
## [4.19.1](https://github.com/unraid/api/compare/v4.19.0...v4.19.1) (2025-09-05)
### Bug Fixes
* custom path detection to fix setup issues ([#1664](https://github.com/unraid/api/issues/1664)) ([2ecdb99](https://github.com/unraid/api/commit/2ecdb99052f39d89af21bbe7ad3f80b83bb1eaa9))
## [4.19.0](https://github.com/unraid/api/compare/v4.18.2...v4.19.0) (2025-09-04)
### Features
* mount vue apps, not web components ([#1639](https://github.com/unraid/api/issues/1639)) ([88087d5](https://github.com/unraid/api/commit/88087d5201992298cdafa791d5d1b5bb23dcd72b))
### Bug Fixes
* api version json response ([#1653](https://github.com/unraid/api/issues/1653)) ([292bc0f](https://github.com/unraid/api/commit/292bc0fc810a0d0f0cce6813b0631ff25099cc05))
* enhance DOM validation and cleanup in vue-mount-app ([6cf7c88](https://github.com/unraid/api/commit/6cf7c88242f2f4fe9f83871560039767b5b90273))
* enhance getKeyFile function to handle missing key file gracefully ([#1659](https://github.com/unraid/api/issues/1659)) ([728b38a](https://github.com/unraid/api/commit/728b38ac11faeacd39ce9d0157024ad140e29b36))
* info alert docker icon ([#1661](https://github.com/unraid/api/issues/1661)) ([239cdd6](https://github.com/unraid/api/commit/239cdd6133690699348e61f68e485d2b54fdcbdb))
* oidc cache busting issues fixed ([#1656](https://github.com/unraid/api/issues/1656)) ([e204eb8](https://github.com/unraid/api/commit/e204eb80a00ab9242e3dca4ccfc3e1b55a7694b7))
* **plugin:** restore cleanup behavior for unsupported unraid versions ([#1658](https://github.com/unraid/api/issues/1658)) ([534a077](https://github.com/unraid/api/commit/534a07788b76de49e9ba14059a9aed0bf16e02ca))
* UnraidToaster component and update dialog close button ([#1657](https://github.com/unraid/api/issues/1657)) ([44774d0](https://github.com/unraid/api/commit/44774d0acdd25aa33cb60a5d0b4f80777f4068e5))
* vue mounting logic with tests ([#1651](https://github.com/unraid/api/issues/1651)) ([33774aa](https://github.com/unraid/api/commit/33774aa596124a031a7452b62ca4c43743a09951))
## [4.18.2](https://github.com/unraid/api/compare/v4.18.1...v4.18.2) (2025-09-03)
### Bug Fixes
* add missing CPU guest metrics to CPU responses ([#1644](https://github.com/unraid/api/issues/1644)) ([99dbad5](https://github.com/unraid/api/commit/99dbad57d55a256f5f3f850f9a47a6eaa6348065))
* **plugin:** raise minimum unraid os version to 6.12.15 ([#1649](https://github.com/unraid/api/issues/1649)) ([bc15bd3](https://github.com/unraid/api/commit/bc15bd3d7008acb416ac3c6fb1f4724c685ec7e7))
* update GitHub Actions token for workflow trigger ([4d8588b](https://github.com/unraid/api/commit/4d8588b17331afa45ba8caf84fcec8c0ea03591f))
* update OIDC URL validation and add tests ([#1646](https://github.com/unraid/api/issues/1646)) ([c7c3bb5](https://github.com/unraid/api/commit/c7c3bb57ea482633a7acff064b39fbc8d4e07213))
* use shared bg & border color for styled toasts ([#1647](https://github.com/unraid/api/issues/1647)) ([7c3aee8](https://github.com/unraid/api/commit/7c3aee8f3f9ba82ae8c8ed3840c20ab47f3cb00f))
## [4.18.1](https://github.com/unraid/api/compare/v4.18.0...v4.18.1) (2025-09-03)
### Bug Fixes
* OIDC and API Key management issues ([#1642](https://github.com/unraid/api/issues/1642)) ([0fe2c2c](https://github.com/unraid/api/commit/0fe2c2c1c85dcc547e4b1217a3b5636d7dd6d4b4))
* rm redundant emission to `$HOME/.pm2/logs` ([#1640](https://github.com/unraid/api/issues/1640)) ([a8e4119](https://github.com/unraid/api/commit/a8e4119270868a1dabccd405853a7340f8dcd8a5))
## [4.18.0](https://github.com/unraid/api/compare/v4.17.0...v4.18.0) (2025-09-02)
### Features
* **api:** enhance OIDC redirect URI handling in service and tests ([#1618](https://github.com/unraid/api/issues/1618)) ([4e945f5](https://github.com/unraid/api/commit/4e945f5f56ce059eb275a9576caf3194a5df8a90))
### Bug Fixes
* api key creation cli ([#1637](https://github.com/unraid/api/issues/1637)) ([c147a6b](https://github.com/unraid/api/commit/c147a6b5075969e77798210c4a5cfd1fa5b96ae3))
* **cli:** support `--log-level` for `start` and `restart` cmds ([#1623](https://github.com/unraid/api/issues/1623)) ([a1ee915](https://github.com/unraid/api/commit/a1ee915ca52e5a063eccf8facbada911a63f37f6))
* confusing server -&gt; status query ([#1635](https://github.com/unraid/api/issues/1635)) ([9d42b36](https://github.com/unraid/api/commit/9d42b36f74274cad72490da5152fdb98fdc5b89b))
* use unraid css variables in sonner ([#1634](https://github.com/unraid/api/issues/1634)) ([26a95af](https://github.com/unraid/api/commit/26a95af9539d05a837112d62dc6b7dd46761c83f))
## [4.17.0](https://github.com/unraid/api/compare/v4.16.0...v4.17.0) (2025-08-27)
### Features
* add tailwind class sort plugin ([#1562](https://github.com/unraid/api/issues/1562)) ([ab11e7f](https://github.com/unraid/api/commit/ab11e7ff7ff74da1f1cd5e49938459d00bfc846b))
### Bug Fixes
* cleanup obsoleted legacy api keys on api startup (cli / connect) ([#1630](https://github.com/unraid/api/issues/1630)) ([6469d00](https://github.com/unraid/api/commit/6469d002b7b18e49c77ee650a4255974ab43e790))
## [4.16.0](https://github.com/unraid/api/compare/v4.15.1...v4.16.0) (2025-08-27)
### Features
* add `parityCheckStatus` field to `array` query ([#1611](https://github.com/unraid/api/issues/1611)) ([c508366](https://github.com/unraid/api/commit/c508366702b9fa20d9ed05559fe73da282116aa6))
* generated UI API key management + OAuth-like API Key Flows ([#1609](https://github.com/unraid/api/issues/1609)) ([674323f](https://github.com/unraid/api/commit/674323fd87bbcc55932e6b28f6433a2de79b7ab0))
### Bug Fixes
* **connect:** clear `wanport` upon disabling remote access ([#1624](https://github.com/unraid/api/issues/1624)) ([9df6a3f](https://github.com/unraid/api/commit/9df6a3f5ebb0319aa7e3fe3be6159d39ec6f587f))
* **connect:** valid LAN FQDN while remote access is enabled ([#1625](https://github.com/unraid/api/issues/1625)) ([aa58888](https://github.com/unraid/api/commit/aa588883cc2e2fe4aa4aea1d035236c888638f5b))
* correctly parse periods in share names from ini file ([#1629](https://github.com/unraid/api/issues/1629)) ([7d67a40](https://github.com/unraid/api/commit/7d67a404333a38d6e1ba5c3febf02be8b1b71901))
* **rc.unraid-api:** remove profile sourcing ([#1622](https://github.com/unraid/api/issues/1622)) ([6947b5d](https://github.com/unraid/api/commit/6947b5d4aff70319116eb65cf4c639444f3749e9))
* remove unused api key calls ([#1628](https://github.com/unraid/api/issues/1628)) ([9cd0d6a](https://github.com/unraid/api/commit/9cd0d6ac658475efa25683ef6e3f2e1d68f7e903))
* retry VMs init for up to 2 min ([#1612](https://github.com/unraid/api/issues/1612)) ([b2e7801](https://github.com/unraid/api/commit/b2e78012384e6b3f2630341281fc811026be23b9))
## [4.15.1](https://github.com/unraid/api/compare/v4.15.0...v4.15.1) (2025-08-20)
### Bug Fixes
* minor duplicate click handler and version resolver nullability issue ([ac198d5](https://github.com/unraid/api/commit/ac198d5d1a3073fdeb053c2ff8f704b0dba0d047))
## [4.15.0](https://github.com/unraid/api/compare/v4.14.0...v4.15.0) (2025-08-20)
### Features
* **api:** restructure versioning information in GraphQL schema ([#1600](https://github.com/unraid/api/issues/1600)) ([d0c6602](https://github.com/unraid/api/commit/d0c66020e1d1d5b6fcbc4ee8979bba4b3d34c7ad))
## [4.14.0](https://github.com/unraid/api/compare/v4.13.1...v4.14.0) (2025-08-19)
### Features
* **api:** add cpu utilization query and subscription ([#1590](https://github.com/unraid/api/issues/1590)) ([2b4c2a2](https://github.com/unraid/api/commit/2b4c2a264bb2769f88c3000d16447889cae57e98))
* enhance OIDC claim evaluation with array handling ([#1596](https://github.com/unraid/api/issues/1596)) ([b7798b8](https://github.com/unraid/api/commit/b7798b82f44aae9a428261270fd9dbde35ff7751))
### Bug Fixes
* remove unraid-api sso users & always apply sso modification on &lt; 7.2 ([#1595](https://github.com/unraid/api/issues/1595)) ([4262830](https://github.com/unraid/api/commit/426283011afd41e3af7e48cfbb2a2d351c014bd1))
* update Docusaurus PR workflow to process and copy API docs ([3a10871](https://github.com/unraid/api/commit/3a10871918fe392a1974b69d16a135546166e058))
* update OIDC provider setup documentation for navigation clarity ([1a01696](https://github.com/unraid/api/commit/1a01696dc7b947abf5f2f097de1b231d5593c2ff))
* update OIDC provider setup documentation for redirect URI and screenshots ([1bc5251](https://github.com/unraid/api/commit/1bc52513109436b3ce8237c3796af765e208f9fc))
## [4.13.1](https://github.com/unraid/api/compare/v4.13.0...v4.13.1) (2025-08-15)
### Bug Fixes
* insecure routes not working for SSO ([#1587](https://github.com/unraid/api/issues/1587)) ([a4ff3c4](https://github.com/unraid/api/commit/a4ff3c40926915f6989ed4af679b30cf295ea15d))
## [4.13.0](https://github.com/unraid/api/compare/v4.12.0...v4.13.0) (2025-08-15)
### Features
* `createDockerFolder` & `setDockerFolderChildren` mutations ([#1558](https://github.com/unraid/api/issues/1558)) ([557b03f](https://github.com/unraid/api/commit/557b03f8829d3f179b5e26162fa250121cb33420))
* `deleteDockerEntries` mutation ([#1564](https://github.com/unraid/api/issues/1564)) ([78997a0](https://github.com/unraid/api/commit/78997a02c6d96ec0ed75352dfc9849524147428c))
* add `moveDockerEntriesToFolder` mutation ([#1569](https://github.com/unraid/api/issues/1569)) ([20c2d5b](https://github.com/unraid/api/commit/20c2d5b4457ad50d1e287fb3141aa98e8e7de665))
* add docker -&gt; organizer query ([#1555](https://github.com/unraid/api/issues/1555)) ([dfe352d](https://github.com/unraid/api/commit/dfe352dfa1bd6aa059cab56357ba6bff5e8ed7cb))
* connect settings page updated for responsive webgui ([#1585](https://github.com/unraid/api/issues/1585)) ([96c120f](https://github.com/unraid/api/commit/96c120f9b24d3c91df5e9401917c8994eef36c46))
* implement OIDC provider management in GraphQL API ([#1563](https://github.com/unraid/api/issues/1563)) ([979a267](https://github.com/unraid/api/commit/979a267bc5e128a8b789f0123e23c61860ebb11b))
### Bug Fixes
* change config file loading error log to debug ([#1565](https://github.com/unraid/api/issues/1565)) ([3534d6f](https://github.com/unraid/api/commit/3534d6fdd7c59e65615167cfe306deebad9ca4d3))
* **connect:** remove unraid-api folder before creating symlink ([#1556](https://github.com/unraid/api/issues/1556)) ([514a0ef](https://github.com/unraid/api/commit/514a0ef560a90595f774b6c0db60f1d2b4cd853c))
* **deps:** pin dependencies ([#1586](https://github.com/unraid/api/issues/1586)) ([5721785](https://github.com/unraid/api/commit/57217852a337ead4c8c8e7596d1b7d590b64a26f))
* **deps:** update all non-major dependencies ([#1543](https://github.com/unraid/api/issues/1543)) ([18b5209](https://github.com/unraid/api/commit/18b52090874c0ba86878d0f7e31bf0dc42734d75))
* **deps:** update all non-major dependencies ([#1579](https://github.com/unraid/api/issues/1579)) ([ad6aa3b](https://github.com/unraid/api/commit/ad6aa3b6743aeeb42eff34d1c89ad874dfd0af09))
* refactor API client to support Unix socket connections ([#1575](https://github.com/unraid/api/issues/1575)) ([a2c5d24](https://github.com/unraid/api/commit/a2c5d2495ffc02efa1ec5c63f0a1c5d23c9ed7ff))
* **theme:** API key white text on white background ([#1584](https://github.com/unraid/api/issues/1584)) ([b321687](https://github.com/unraid/api/commit/b3216874faae208cdfc3edec719629fce428b6a3))
## [4.12.0](https://github.com/unraid/api/compare/v4.11.0...v4.12.0) (2025-07-30)
### Features
* add ups monitoring to graphql api ([#1526](https://github.com/unraid/api/issues/1526)) ([6ea94f0](https://github.com/unraid/api/commit/6ea94f061d5b2e6c6fbfa6949006960501e3f4e7))
### Bug Fixes
* enhance plugin management with interactive removal prompts ([#1549](https://github.com/unraid/api/issues/1549)) ([23ef760](https://github.com/unraid/api/commit/23ef760d763c525a38108048200fa73fc8531aed))
* remove connect api plugin upon removal of Connect Unraid plugin ([#1548](https://github.com/unraid/api/issues/1548)) ([782d5eb](https://github.com/unraid/api/commit/782d5ebadc67854298f3b2355255983024d2a225))
* SSO not being detected ([#1546](https://github.com/unraid/api/issues/1546)) ([6b3b951](https://github.com/unraid/api/commit/6b3b951d8288cd31d096252be544537dc2bfce50))
## [4.11.0](https://github.com/unraid/api/compare/v4.10.0...v4.11.0) (2025-07-28)
### Features
* tailwind v4 ([#1522](https://github.com/unraid/api/issues/1522)) ([2c62e0a](https://github.com/unraid/api/commit/2c62e0ad09c56d2293b76d07833dfb142c898937))
* **web:** install and configure nuxt ui ([#1524](https://github.com/unraid/api/issues/1524)) ([407585c](https://github.com/unraid/api/commit/407585cd40c409175d8e7b861f8d61d8cabc11c9))
### Bug Fixes
* add missing breakpoints ([#1535](https://github.com/unraid/api/issues/1535)) ([f5352e3](https://github.com/unraid/api/commit/f5352e3a26a2766e85d19ffb5f74960c536b91b3))
* border color incorrect in tailwind ([#1544](https://github.com/unraid/api/issues/1544)) ([f14b74a](https://github.com/unraid/api/commit/f14b74af91783b08640c0949c51ba7f18508f06f))
* **connect:** omit extraneous fields during connect config validation ([#1538](https://github.com/unraid/api/issues/1538)) ([45bd736](https://github.com/unraid/api/commit/45bd73698b2bd534a8aff2c6ac73403de6c58561))
* **deps:** pin dependencies ([#1528](https://github.com/unraid/api/issues/1528)) ([a74d935](https://github.com/unraid/api/commit/a74d935b566dd7af1a21824c9b7ab562232f9d8b))
* **deps:** pin dependency @nuxt/ui to 3.2.0 ([#1532](https://github.com/unraid/api/issues/1532)) ([8279531](https://github.com/unraid/api/commit/8279531f2b86a78e81a77e6c037a0fb752e98062))
* **deps:** update all non-major dependencies ([#1510](https://github.com/unraid/api/issues/1510)) ([1a8da6d](https://github.com/unraid/api/commit/1a8da6d92b96d3afa2a8b42446b36f1ee98b64a0))
* **deps:** update all non-major dependencies ([#1520](https://github.com/unraid/api/issues/1520)) ([e2fa648](https://github.com/unraid/api/commit/e2fa648d1cf5a6cbe3e55c3f52c203d26bb4d526))
* inject Tailwind CSS into client entry point ([#1537](https://github.com/unraid/api/issues/1537)) ([86b6c4f](https://github.com/unraid/api/commit/86b6c4f85b7b30bb4a13d57450a76bf4c28a3fff))
* make settings grid responsive ([#1463](https://github.com/unraid/api/issues/1463)) ([9dfdb8d](https://github.com/unraid/api/commit/9dfdb8dce781fa662d6434ee432e4521f905ffa5))
* **notifications:** gracefully handle & mask invalid notifications ([#1529](https://github.com/unraid/api/issues/1529)) ([05056e7](https://github.com/unraid/api/commit/05056e7ca1702eb7bf6c507950460b6b15bf7916))
* truncate log files when they take up more than 5mb of space ([#1530](https://github.com/unraid/api/issues/1530)) ([0a18b38](https://github.com/unraid/api/commit/0a18b38008dd86a125cde7f684636d5dbb36f082))
* use async for primary file read/writes ([#1531](https://github.com/unraid/api/issues/1531)) ([23b2b88](https://github.com/unraid/api/commit/23b2b8846158a27d1c9808bce0cc1506779c4dc3))
## [4.10.0](https://github.com/unraid/api/compare/v4.9.5...v4.10.0) (2025-07-15)
### Features
* trial extension allowed within 5 days of expiration ([#1490](https://github.com/unraid/api/issues/1490)) ([f34a33b](https://github.com/unraid/api/commit/f34a33bc9f1a7e135d453d9d31888789bfc3f878))
### Bug Fixes
* delay `nginx:reload` file mod effect by 10 seconds ([#1512](https://github.com/unraid/api/issues/1512)) ([af33e99](https://github.com/unraid/api/commit/af33e999a0480a77e3e6b2aa833b17b38b835656))
* **deps:** update all non-major dependencies ([#1489](https://github.com/unraid/api/issues/1489)) ([53b05eb](https://github.com/unraid/api/commit/53b05ebe5e2050cb0916fcd65e8d41370aee0624))
* ensure no crash if emhttp state configs are missing ([#1514](https://github.com/unraid/api/issues/1514)) ([1a7d35d](https://github.com/unraid/api/commit/1a7d35d3f6972fd8aff58c17b2b0fb79725e660e))
* **my.servers:** improve DNS resolution robustness for backup server ([#1518](https://github.com/unraid/api/issues/1518)) ([eecd9b1](https://github.com/unraid/api/commit/eecd9b1017a63651d1dc782feaa224111cdee8b6))
* over-eager cloud query from web components ([#1506](https://github.com/unraid/api/issues/1506)) ([074370c](https://github.com/unraid/api/commit/074370c42cdecc4dbc58193ff518aa25735c56b3))
* replace myservers.cfg reads in UpdateFlashBackup.php ([#1517](https://github.com/unraid/api/issues/1517)) ([441e180](https://github.com/unraid/api/commit/441e1805c108a6c1cd35ee093246b975a03f8474))
* rm short-circuit in `rc.unraid-api` if plugin config dir is absent ([#1515](https://github.com/unraid/api/issues/1515)) ([29dcb7d](https://github.com/unraid/api/commit/29dcb7d0f088937cefc5158055f48680e86e5c36))
## [4.9.5](https://github.com/unraid/api/compare/v4.9.4...v4.9.5) (2025-07-10)
### Bug Fixes
* **connect:** rm eager restart on `ERROR_RETYING` connection status ([#1502](https://github.com/unraid/api/issues/1502)) ([dd759d9](https://github.com/unraid/api/commit/dd759d9f0f841b296f8083bc67c6cd3f7a69aa5b))
## [4.9.4](https://github.com/unraid/api/compare/v4.9.3...v4.9.4) (2025-07-09)
### Bug Fixes
* backport `<unraid-modals>` upon plg install when necessary ([#1499](https://github.com/unraid/api/issues/1499)) ([33e0b1a](https://github.com/unraid/api/commit/33e0b1ab24bedb6a2c7b376ea73dbe65bc3044be))
* DefaultPageLayout patch rollback omits legacy header logo ([#1497](https://github.com/unraid/api/issues/1497)) ([ea20d1e](https://github.com/unraid/api/commit/ea20d1e2116fcafa154090fee78b42ec5d9ba584))
* event emitter setup for writing status ([#1496](https://github.com/unraid/api/issues/1496)) ([ca4e2db](https://github.com/unraid/api/commit/ca4e2db1f29126a1fa3784af563832edda64b0ca))
## [4.9.3](https://github.com/unraid/api/compare/v4.9.2...v4.9.3) (2025-07-09)
### Bug Fixes
* duplicated header logo after api stops ([#1493](https://github.com/unraid/api/issues/1493)) ([4168f43](https://github.com/unraid/api/commit/4168f43e3ecd51479bec3aae585abbe6dcd3e416))
## [4.9.2](https://github.com/unraid/api/compare/v4.9.1...v4.9.2) (2025-07-09)

View File

@@ -1,7 +1,7 @@
###########################################################
# Development/Build Image
###########################################################
FROM node:22.18.0-bookworm-slim AS development
FROM node:22.17.0-bookworm-slim AS development
# Install build tools and dependencies
RUN apt-get update -y && apt-get install -y \

View File

@@ -71,10 +71,6 @@ unraid-api report -vv
If you found this file you're likely a developer. If you'd like to know more about the API and when it's available please join [our discord](https://discord.unraid.net/).
## Internationalization
- Run `pnpm --filter @unraid/api i18n:extract` to scan the Nest.js source for translation helper usages and update `src/i18n/en.json` with any new keys. The extractor keeps existing translations intact and appends new keys with their English source text.
## License
Copyright Lime Technology Inc. All rights reserved.

View File

@@ -17,7 +17,6 @@ const config: CodegenConfig = {
URL: 'URL',
Port: 'number',
UUID: 'string',
BigInt: 'number',
},
scalarSchemas: {
URL: 'z.instanceof(URL)',
@@ -25,17 +24,22 @@ const config: CodegenConfig = {
JSON: 'z.record(z.string(), z.any())',
Port: 'z.number()',
UUID: 'z.string()',
BigInt: 'z.number()',
},
},
generates: {
// Generate Types for CLI Internal GraphQL Queries
'src/unraid-api/cli/generated/': {
documents: ['src/unraid-api/cli/queries/**/*.ts', 'src/unraid-api/cli/mutations/**/*.ts'],
schema: './generated-schema.graphql',
// Generate Types for Mothership GraphQL Client
'src/graphql/generated/client/': {
documents: './src/graphql/mothership/*.ts',
schema: {
[process.env.MOTHERSHIP_GRAPHQL_LINK as string]: {
headers: {
origin: 'https://forums.unraid.net',
},
},
},
preset: 'client',
presetConfig: {
gqlTagName: 'gql',
gqlTagName: 'graphql',
},
config: {
useTypeImports: true,
@@ -43,6 +47,21 @@ const config: CodegenConfig = {
},
plugins: [{ add: { content: '/* eslint-disable */' } }],
},
'src/graphql/generated/client/validators.ts': {
schema: {
[process.env.MOTHERSHIP_GRAPHQL_LINK as string]: {
headers: {
origin: 'https://forums.unraid.net',
},
},
},
plugins: ['typescript-validation-schema', { add: { content: '/* eslint-disable */' } }],
config: {
importFrom: '@app/graphql/generated/client/graphql.js',
strictScalars: false,
schema: 'zod',
},
},
},
};

View File

@@ -1,34 +0,0 @@
# Development Configuration Files
This directory contains configuration files for local development.
## OIDC Configuration
### oidc.json
The default OIDC configuration file. This file is committed to git and should only contain non-sensitive test configurations.
### Using a Local Configuration (gitignored)
For local testing with real OAuth providers:
1. Create an `oidc.local.json` file based on `oidc.json`
2. Set the environment variable: `PATHS_OIDC_JSON=./dev/configs/oidc.local.json`
3. The API will load your local configuration instead of the default
Example:
```bash
PATHS_OIDC_JSON=./dev/configs/oidc.local.json pnpm dev
```
### Setting up OAuth Apps
#### Google
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing
3. Enable Google+ API
4. Create OAuth 2.0 credentials
5. Add authorized redirect URI: `http://localhost:3000/graphql/api/auth/oidc/callback`
#### GitHub
1. Go to GitHub Settings > Developer settings > OAuth Apps
2. Create a new OAuth App
3. Set Authorization callback URL: `http://localhost:3000/graphql/api/auth/oidc/callback`

View File

@@ -1,9 +1,10 @@
{
"version": "4.27.2",
"extraOrigins": [],
"version": "4.8.0",
"extraOrigins": [
"https://google.com",
"https://test.com"
],
"sandbox": true,
"ssoSubIds": [],
"plugins": [
"unraid-api-plugin-connect"
]
"plugins": ["unraid-api-plugin-connect"]
}

View File

@@ -1,12 +1,16 @@
{
"wanaccess": true,
"wanport": 8443,
"wanaccess": false,
"wanport": 0,
"upnpEnabled": false,
"apikey": "",
"localApiKey": "_______________________LOCAL_API_KEY_HERE_________________________",
"email": "test@example.com",
"username": "zspearmint",
"avatar": "https://via.placeholder.com/200",
"regWizTime": "1611175408732_0951-1653-3509-FBA155FA23C0",
"dynamicRemoteAccessType": "STATIC"
"localApiKey": "",
"email": "",
"username": "",
"avatar": "",
"regWizTime": "",
"accesstoken": "",
"idtoken": "",
"refreshtoken": "",
"dynamicRemoteAccessType": "DISABLED",
"ssoSubIds": []
}

View File

@@ -1,22 +0,0 @@
{
"providers": [
{
"id": "unraid.net",
"name": "Unraid.net",
"clientId": "CONNECT_SERVER_SSO",
"issuer": "https://account.unraid.net",
"authorizationEndpoint": "https://account.unraid.net/sso/",
"tokenEndpoint": "https://account.unraid.net/api/oauth2/token",
"scopes": [
"openid",
"profile",
"email"
],
"authorizedSubIds": [
"297294e2-b31c-4bcc-a441-88aee0ad609f"
],
"buttonText": "Login With Unraid.net"
}
],
"defaultAllowedOrigins": []
}

View File

@@ -0,0 +1,11 @@
{
"createdAt": "2025-01-27T16:22:56.501Z",
"description": "API key for Connect user",
"id": "b5b4aa3d-8e40-4c92-bc40-d50182071886",
"key": "_______________________LOCAL_API_KEY_HERE_________________________",
"name": "Connect",
"permissions": [],
"roles": [
"CONNECT"
]
}

View File

@@ -1 +0,0 @@
# custom log directory for tests & development

View File

@@ -1,6 +0,0 @@
timestamp=1730937600
event=Hashtag Test
subject=Warning [UNRAID] - #1 OS is cooking
description=Disk 1 temperature has reached #epic # levels of proportion
importance=warning

View File

@@ -1,6 +0,0 @@
timestamp=1730937600
event=Temperature Test
subject=Warning [UNRAID] - High disk temperature detected: 45&#8201;&#176;C
description=Disk 1 temperature has reached 45&#8201;&#176;C (threshold: 40&#8201;&#176;C)<br><br>Current temperatures:<br>Parity - 32&#8201;&#176;C [OK]<br>Disk 1 - 45&#8201;&#176;C [WARNING]<br>Disk 2 - 38&#8201;&#176;C [OK]<br>Cache - 28&#8201;&#176;C [OK]<br><br>Please check cooling system.
importance=warning

View File

@@ -65,38 +65,4 @@ color="yellow-on"
size="0"
free="9091184"
used="32831348"
luksStatus="0"
["system.with.periods"]
name="system.with.periods"
nameOrig="system.with.periods"
comment="system data with periods"
allocator="highwater"
splitLevel="1"
floor="0"
include=""
exclude=""
useCache="prefer"
cachePool="cache"
cow="auto"
color="yellow-on"
size="0"
free="9091184"
used="32831348"
luksStatus="0"
["system.with.🚀"]
name="system.with.🚀"
nameOrig="system.with.🚀"
comment="system data with 🚀"
allocator="highwater"
splitLevel="1"
floor="0"
include=""
exclude=""
useCache="prefer"
cachePool="cache"
cow="auto"
color="yellow-on"
size="0"
free="9091184"
used="32831348"
luksStatus="0"

View File

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

View File

@@ -62,18 +62,15 @@ To build all packages in the monorepo:
pnpm build
```
### Plugin Building (Docker Required)
### Watch Mode Building
The plugin build requires Docker. This command automatically builds all dependencies (API, web) before starting Docker:
For continuous building during development:
```bash
cd plugin
pnpm run docker:build-and-run
# Then inside the container:
pnpm build
pnpm build:watch
```
This serves the plugin at `http://YOUR_IP:5858/` for installation on your Unraid server.
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.
### Package-Specific Building

View File

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

162
api/docs/public/cli.md Normal file
View File

@@ -0,0 +1,162 @@
# CLI Commands
### Start
```bash
unraid-api start [--log-level <level>]
```
Starts the Unraid API service.
Options:
- `--log-level`: Set logging level (trace|debug|info|warn|error)
### Stop
```bash
unraid-api stop [--delete]
```
Stops the Unraid API service.
- `--delete`: Optional. Delete the PM2 home directory
### Restart
```bash
unraid-api restart
```
Restarts the Unraid API service.
### Logs
```bash
unraid-api logs [-l <lines>]
```
View the API logs.
- `-l, --lines`: Optional. Number of lines to tail (default: 100)
## Configuration Commands
### Config
```bash
unraid-api config
```
Displays current configuration values.
### Switch Environment
```bash
unraid-api switch-env [-e <environment>]
```
Switch between production and staging environments.
- `-e, --environment`: Optional. Target environment (production|staging)
### Developer Mode
```bash
unraid-api developer
```
Configure developer features for the API (e.g., GraphQL sandbox).
## API Key Management
### API Key Commands
```bash
unraid-api apikey [options]
```
Create and manage API keys.
Options:
- `--name <name>`: Name of the key
- `--create`: Create a new key
- `-r, --roles <roles>`: Comma-separated list of roles
- `-p, --permissions <permissions>`: Comma-separated list of permissions
- `-d, --description <description>`: Description for the key
## SSO (Single Sign-On) Management
### SSO Base Command
```bash
unraid-api sso
```
#### Add SSO User
```bash
unraid-api sso add-user
# or
unraid-api sso add
# or
unraid-api sso a
```
Add a new user for SSO authentication.
#### Remove SSO User
```bash
unraid-api sso remove-user
# or
unraid-api sso remove
# or
unraid-api sso r
```
Remove a user (or all users) from SSO.
#### List SSO Users
```bash
unraid-api sso list-users
# or
unraid-api sso list
# or
unraid-api sso l
```
List all configured SSO users.
#### Validate SSO Token
```bash
unraid-api sso validate-token <token>
# or
unraid-api sso validate
# or
unraid-api sso v
```
Validates an SSO token and returns its status.
## Report Generation
### Generate Report
```bash
unraid-api report [-r] [-j]
```
Generate a system report.
- `-r, --raw`: Display raw command output
- `-j, --json`: Display output in JSON format
## Notes
1. Most commands require appropriate permissions to modify system state
2. Some commands require the API to be running or stopped
3. Store API keys securely as they provide system access
4. SSO configuration changes may require a service restart

View File

@@ -0,0 +1,202 @@
# Using the Unraid API
The Unraid API provides a GraphQL interface that allows you to interact with your Unraid server. This guide will help you get started with exploring and using the API.
## Enabling the GraphQL Sandbox
1. First, enable developer mode using the CLI:
```bash
unraid-api developer
```
2. Follow the prompts to enable the sandbox. This will allow you to access the Apollo Sandbox interface.
3. Access the GraphQL playground by navigating to:
```txt
http://YOUR_SERVER_IP/graphql
```
## Authentication
Most queries and mutations require authentication. You can authenticate using either:
1. API Keys
2. Cookies (default method when signed into the WebGUI)
### Creating an API Key
Use the CLI to create an API key:
```bash
unraid-api apikey --create
```
Follow the prompts to set:
- Name
- Description
- Roles
- Permissions
The generated API key should be included in your GraphQL requests as a header:
```json
{
"x-api-key": "YOUR_API_KEY"
}
```
## Available Schemas
The API provides access to various aspects of your Unraid server:
### System Information
- Query system details including CPU, memory, and OS information
- Monitor system status and health
- Access baseboard and hardware information
### Array Management
- Query array status and configuration
- Manage array operations (start/stop)
- Monitor disk status and health
- Perform parity checks
### Docker Management
- List and manage Docker containers
- Monitor container status
- Manage Docker networks
### Remote Access
- Configure and manage remote access settings
- Handle SSO configuration
- Manage allowed origins
### Example Queries
1. Check System Status:
```graphql
query {
info {
os {
platform
distro
release
uptime
}
cpu {
manufacturer
brand
cores
threads
}
}
}
```
2. Monitor Array Status:
```graphql
query {
array {
state
capacity {
disks {
free
used
total
}
}
disks {
name
size
status
temp
}
}
}
```
3. List Docker Containers:
```graphql
query {
dockerContainers {
id
names
state
status
autoStart
}
}
```
## Schema Types
The API includes several core types:
### Base Types
- `Node`: Interface for objects with unique IDs - please see [Object Identification](https://graphql.org/learn/global-object-identification/)
- `JSON`: For complex JSON data
- `DateTime`: For timestamp values
- `Long`: For 64-bit integers
### Resource Types
- `Array`: Array and disk management
- `Docker`: Container and network management
- `Info`: System information
- `Config`: Server configuration
- `Connect`: Remote access settings
### Role-Based Access
Available roles:
- `admin`: Full access
- `connect`: Remote access features
- `guest`: Limited read access
## Best Practices
1. Use the Apollo Sandbox to explore the schema and test queries
2. Start with small queries and gradually add fields as needed
3. Monitor your query complexity to maintain performance
4. Use appropriate roles and permissions for your API keys
5. Keep your API keys secure and rotate them periodically
## Rate Limiting
The API implements rate limiting to prevent abuse. Ensure your applications handle rate limit responses appropriately.
## Error Handling
The API returns standard GraphQL errors in the following format:
```json
{
"errors": [
{
"message": "Error description",
"locations": [...],
"path": [...]
}
]
}
```
## Additional Resources
- Use the Apollo Sandbox's schema explorer to browse all available types and fields
- Check the documentation tab in Apollo Sandbox for detailed field descriptions
- Monitor the API's health using `unraid-api status`
- Generate reports using `unraid-api report` for troubleshooting
For more information about specific commands and configuration options, refer to the CLI documentation or run `unraid-api --help`.

37
api/docs/public/index.md Normal file
View File

@@ -0,0 +1,37 @@
# Unraid API
The Unraid API provides a GraphQL interface for programmatic interaction with your Unraid server. It enables automation, monitoring, and integration capabilities.
## Current Availability
The API is available through the Unraid Connect Plugin:
1. Install Unraid Connect Plugin from Apps
2. [Configure the plugin](./how-to-use-the-api.md#enabling-the-graphql-sandbox)
3. Access API functionality through the [GraphQL Sandbox](./how-to-use-the-api.md#accessing-the-graphql-sandbox)
## Future Availability
The API will be integrated directly into the Unraid operating system in an upcoming OS release. This integration will:
- Make the API a core part of the Unraid system
- Remove the need for separate plugin installation
- Enable deeper system integration capabilities
## Documentation Sections
- [CLI Commands](./cli.md) - Reference for all available command-line interface commands
- [Using the Unraid API](./how-to-use-the-api.md) - Comprehensive guide on using the GraphQL API
- [Upcoming Features](./upcoming-features.md) - Roadmap of planned features and improvements
## Key Features
The API provides:
- GraphQL Interface: Modern, flexible API with strong typing
- Authentication: Secure access via API keys or session cookies
- Comprehensive Coverage: Access to system information, array management, and Docker operations
- Developer Tools: Built-in GraphQL sandbox for testing
- Role-Based Access: Granular permission control
For detailed usage instructions, see [CLI Commands](./cli.md).

View File

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

View File

@@ -0,0 +1,71 @@
# Upcoming Features
Note: This roadmap outlines planned features and improvements for the Unraid API. Features and timelines may change based on development priorities and community feedback.
## Core Infrastructure
| Feature | Status | Tag |
|---------|--------|-----|
| API Development Environment Improvements | Done | v4.0.0 |
| Include API in Unraid OS | Planned (Q1 2025) | - |
| Make API Open Source | Planned (Q1 2025) | - |
| Separate API from Connect Plugin | Planned (Q2 2025) | - |
| Developer Tools for Plugins | Planned (Q2 2025) | - |
## Security & Authentication
| Feature | Status | Tag |
|---------|--------|-----|
| Permissions System Rewrite | Done | v4.0.0 |
| User Interface Component Library | In Progress | - |
## User Interface Improvements
| Feature | Status | Tag |
|---------|--------|-----|
| New Settings Pages | Planned (Q2 2025) | - |
| Custom Theme Creator | Planned (Q2-Q3 2025) | - |
| New Connect Settings Interface | Planned (Q1 2025) | - |
## Array Management
| Feature | Status | Tag |
|---------|--------|-----|
| Array Status Monitoring | Done | v4.0.0 |
| Storage Pool Creation Interface | Planned (Q2 2025) | - |
| Storage Pool Status Interface | Planned (Q2 2025) | - |
## Docker Integration
| Feature | Status | Tag |
|---------|--------|-----|
| Docker Container Status Monitoring | Done | v4.0.0 |
| New Docker Status Interface Design | Planned (Q3 2025) | - |
| New Docker Status Interface | Planned (Q3 2025) | - |
| Docker Container Setup Interface | Planned (Q3 2025) | - |
| Docker Compose Support | Planned | - |
## Share Management
| Feature | Status | Tag |
|---------|--------|-----|
| Array/Cache Share Status Monitoring | Done | v4.0.0 |
| Storage Share Creation & Settings | Planned | - |
| Storage Share Management Interface | Planned | - |
## Plugin System
| Feature | Status | Tag |
|---------|--------|-----|
| New Plugins Interface | Planned (Q3 2025) | - |
| Plugin Management Interface | Planned | - |
| Plugin Development Tools | Planned | - |
## Notifications
| Feature | Status | Tag |
|---------|--------|-----|
| Notifications System | Done | v4.0.0 |
| Notifications Interface | Done | v4.0.0 |
Features marked as "Done" are available in current releases. The tag column shows the version where a feature was first introduced.

View File

@@ -7,15 +7,13 @@
"cwd": "/usr/local/unraid-api",
"exec_mode": "fork",
"wait_ready": true,
"listen_timeout": 30000,
"listen_timeout": 15000,
"max_restarts": 10,
"min_uptime": 10000,
"watch": false,
"interpreter": "/usr/local/bin/node",
"ignore_watch": ["node_modules", "src", ".env.*", "myservers.cfg"],
"out_file": "/var/log/graphql-api.log",
"error_file": "/var/log/graphql-api.log",
"merge_logs": true,
"log_file": "/var/log/graphql-api.log",
"kill_timeout": 10000
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.29.2",
"version": "4.9.2",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {
@@ -10,14 +10,14 @@
"author": "Lime Technology, Inc. <unraid.net>",
"license": "GPL-2.0-or-later",
"engines": {
"pnpm": "10.15.0"
"pnpm": "10.13.1"
},
"scripts": {
"// Development": "",
"start": "node dist/main.js",
"dev": "clear && vite",
"dev": "vite",
"dev:debug": "NODE_OPTIONS='--inspect-brk=9229 --enable-source-maps' vite",
"command": "COMMAND_TESTER=true pnpm run build > /dev/null 2>&1 && NODE_ENV=development ./dist/cli.js",
"command": "pnpm run build && clear && ./dist/cli.js",
"command:raw": "./dist/cli.js",
"// Build and Deploy": "",
"build": "vite build --mode=production",
@@ -28,10 +28,9 @@
"preunraid:deploy": "pnpm build",
"unraid:deploy": "./scripts/deploy-dev.sh",
"// GraphQL Codegen": "",
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
"// Internationalization": "",
"i18n:extract": "node ./scripts/extract-translations.mjs",
"codegen": "MOTHERSHIP_GRAPHQL_LINK='https://staging.mothership.unraid.net/ws' graphql-codegen --config codegen.ts -r dotenv/config './.env.staging'",
"codegen:watch": "DOTENV_CONFIG_PATH='./.env.staging' graphql-codegen --config codegen.ts --watch -r dotenv/config",
"codegen:local": "NODE_TLS_REJECT_UNAUTHORIZED=0 MOTHERSHIP_GRAPHQL_LINK='https://mothership.localhost/ws' graphql-codegen --config codegen.ts --watch",
"// Code Quality": "",
"lint": "eslint --config .eslintrc.ts src/",
"lint:fix": "eslint --fix --config .eslintrc.ts src/",
@@ -53,26 +52,26 @@
"unraid-api": "dist/cli.js"
},
"dependencies": {
"@apollo/client": "3.14.0",
"@apollo/client": "3.13.8",
"@apollo/server": "4.12.2",
"@as-integrations/fastify": "2.1.1",
"@fastify/cookie": "11.0.2",
"@fastify/helmet": "13.0.1",
"@graphql-codegen/client-preset": "5.0.0",
"@graphql-codegen/client-preset": "4.8.3",
"@graphql-tools/load-files": "7.0.1",
"@graphql-tools/merge": "9.1.1",
"@graphql-tools/schema": "10.0.25",
"@graphql-tools/utils": "10.9.1",
"@graphql-tools/merge": "9.0.24",
"@graphql-tools/schema": "10.0.23",
"@graphql-tools/utils": "10.8.6",
"@jsonforms/core": "3.6.0",
"@nestjs/apollo": "13.1.0",
"@nestjs/cache-manager": "3.0.1",
"@nestjs/common": "11.1.6",
"@nestjs/common": "11.1.3",
"@nestjs/config": "4.0.2",
"@nestjs/core": "11.1.6",
"@nestjs/core": "11.1.3",
"@nestjs/event-emitter": "3.0.1",
"@nestjs/graphql": "13.1.0",
"@nestjs/passport": "11.0.5",
"@nestjs/platform-fastify": "11.1.6",
"@nestjs/platform-fastify": "11.1.3",
"@nestjs/schedule": "6.0.0",
"@nestjs/throttler": "6.4.0",
"@reduxjs/toolkit": "2.8.2",
@@ -81,12 +80,11 @@
"@unraid/libvirt": "2.1.0",
"@unraid/shared": "workspace:*",
"accesscontrol": "2.2.1",
"atomically": "2.0.3",
"bycontract": "2.0.11",
"bytes": "3.1.2",
"cache-manager": "7.2.0",
"cache-manager": "7.0.1",
"cacheable-lookup": "7.0.0",
"camelcase-keys": "10.0.0",
"camelcase-keys": "9.1.3",
"casbin": "5.38.0",
"change-case": "5.4.4",
"chokidar": "4.0.3",
@@ -96,17 +94,16 @@
"command-exists": "1.2.9",
"convert": "5.12.0",
"cookie": "1.0.2",
"cron": "4.3.0",
"cron": "4.3.1",
"cross-fetch": "4.1.0",
"diff": "8.0.2",
"dockerode": "4.0.7",
"dotenv": "17.2.1",
"escape-html": "1.0.3",
"dotenv": "17.1.0",
"execa": "9.6.0",
"exit-hook": "4.0.0",
"fastify": "5.5.0",
"filenamify": "7.0.0",
"fs-extra": "11.3.1",
"fastify": "5.4.0",
"filenamify": "6.0.0",
"fs-extra": "11.3.0",
"glob": "11.0.3",
"global-agent": "3.0.0",
"got": "14.4.7",
@@ -115,36 +112,34 @@
"graphql-scalars": "1.24.2",
"graphql-subscriptions": "3.0.0",
"graphql-tag": "2.12.6",
"graphql-ws": "6.0.6",
"html-entities": "^2.6.0",
"graphql-ws": "6.0.5",
"ini": "5.0.0",
"ip": "2.0.1",
"jose": "6.0.13",
"jose": "6.0.11",
"json-bigint-patch": "0.0.8",
"lodash-es": "4.17.21",
"multi-ini": "2.3.2",
"mustache": "4.2.0",
"nest-authz": "2.17.0",
"nest-commander": "3.19.0",
"nest-commander": "3.17.0",
"nestjs-pino": "4.4.0",
"node-cache": "5.1.2",
"node-window-polyfill": "1.0.4",
"openid-client": "6.6.4",
"p-retry": "7.0.0",
"p-retry": "6.2.1",
"passport-custom": "1.1.1",
"passport-http-header-strategy": "1.1.0",
"path-type": "6.0.0",
"pino": "9.9.0",
"pino": "9.7.0",
"pino-http": "10.5.0",
"pino-pretty": "13.1.1",
"pino-pretty": "13.0.0",
"pm2": "6.0.8",
"reflect-metadata": "^0.1.14",
"request": "2.88.2",
"rxjs": "7.8.2",
"semver": "7.7.2",
"strftime": "0.10.3",
"systeminformation": "5.27.8",
"undici": "7.15.0",
"uuid": "13.0.0",
"systeminformation": "5.27.7",
"uuid": "11.1.0",
"ws": "8.18.3",
"zen-observable-ts": "1.1.0",
"zod": "3.25.76"
@@ -158,27 +153,27 @@
}
},
"devDependencies": {
"@eslint/js": "9.34.0",
"@graphql-codegen/add": "6.0.0",
"@graphql-codegen/cli": "6.0.0",
"@graphql-codegen/fragment-matcher": "6.0.0",
"@eslint/js": "9.30.1",
"@graphql-codegen/add": "5.0.3",
"@graphql-codegen/cli": "5.0.7",
"@graphql-codegen/fragment-matcher": "5.1.0",
"@graphql-codegen/import-types-preset": "3.0.1",
"@graphql-codegen/typed-document-node": "6.0.0",
"@graphql-codegen/typescript": "5.0.0",
"@graphql-codegen/typescript-operations": "5.0.0",
"@graphql-codegen/typescript-resolvers": "5.0.0",
"@graphql-codegen/typed-document-node": "5.1.2",
"@graphql-codegen/typescript": "4.1.6",
"@graphql-codegen/typescript-operations": "4.6.1",
"@graphql-codegen/typescript-resolvers": "4.5.1",
"@graphql-typed-document-node/core": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
"@nestjs/testing": "11.1.6",
"@ianvs/prettier-plugin-sort-imports": "4.4.2",
"@nestjs/testing": "11.1.3",
"@originjs/vite-plugin-commonjs": "1.0.3",
"@rollup/plugin-node-resolve": "16.0.1",
"@swc/core": "1.13.5",
"@swc/core": "1.12.11",
"@types/async-exit-hook": "2.0.2",
"@types/bytes": "3.1.5",
"@types/cli-table": "0.3.4",
"@types/command-exists": "1.2.3",
"@types/cors": "2.8.19",
"@types/dockerode": "3.3.43",
"@types/dockerode": "3.3.42",
"@types/graphql-fields": "1.3.9",
"@types/graphql-type-uuid": "0.2.6",
"@types/ini": "4.1.1",
@@ -186,41 +181,43 @@
"@types/lodash": "4.17.20",
"@types/lodash-es": "4.17.12",
"@types/mustache": "4.2.6",
"@types/node": "22.18.0",
"@types/node": "22.16.2",
"@types/pify": "6.1.0",
"@types/semver": "7.7.0",
"@types/sendmail": "1.4.7",
"@types/stoppable": "1.1.3",
"@types/strftime": "0.9.8",
"@types/supertest": "6.0.3",
"@types/uuid": "11.0.0",
"@types/uuid": "10.0.0",
"@types/ws": "8.18.1",
"@types/wtfnode": "0.10.0",
"@types/wtfnode": "0.7.3",
"@vitest/coverage-v8": "3.2.4",
"@vitest/ui": "3.2.4",
"eslint": "9.34.0",
"cz-conventional-changelog": "3.3.0",
"eslint": "9.30.1",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.21.0",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "5.5.4",
"jiti": "2.5.1",
"eslint-plugin-prettier": "5.5.1",
"graphql-codegen-typescript-validation-schema": "0.17.1",
"jiti": "2.4.2",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"rollup-plugin-node-externals": "8.1.0",
"supertest": "7.1.4",
"tsx": "4.20.5",
"type-fest": "5.0.0",
"typescript": "5.9.2",
"typescript-eslint": "8.41.0",
"unplugin-swc": "1.5.7",
"vite": "7.1.3",
"rollup-plugin-node-externals": "8.0.1",
"commit-and-tag-version": "9.6.0",
"tsx": "4.20.3",
"type-fest": "4.41.0",
"typescript": "5.8.3",
"typescript-eslint": "8.36.0",
"unplugin-swc": "1.5.5",
"vite": "7.0.3",
"vite-plugin-node": "7.0.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"zx": "8.8.1"
"zx": "8.6.2"
},
"overrides": {
"eslint": {
"jiti": "2.5.1"
"jiti": "2.4.2"
},
"@as-integrations/fastify": {
"fastify": "$fastify"
@@ -231,5 +228,5 @@
}
},
"private": true,
"packageManager": "pnpm@10.15.0"
"packageManager": "pnpm@10.13.1"
}

View File

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

View File

@@ -1,162 +0,0 @@
#!/usr/bin/env node
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { glob } from 'glob';
import ts from 'typescript';
const projectRoot = process.cwd();
const sourcePatterns = 'src/**/*.{ts,js}';
const ignorePatterns = [
'**/__tests__/**',
'**/__test__/**',
'**/*.spec.ts',
'**/*.spec.js',
'**/*.test.ts',
'**/*.test.js',
];
const englishLocaleFile = path.resolve(projectRoot, 'src/i18n/en.json');
const identifierTargets = new Set(['t', 'translate']);
const propertyTargets = new Set([
'i18n.t',
'i18n.translate',
'ctx.t',
'this.translate',
'this.i18n.translate',
'this.i18n.t',
]);
function getPropertyChain(node) {
if (ts.isIdentifier(node)) {
return node.text;
}
if (ts.isPropertyAccessExpression(node)) {
const left = getPropertyChain(node.expression);
if (!left) return undefined;
return `${left}.${node.name.text}`;
}
return undefined;
}
function extractLiteral(node) {
if (ts.isStringLiteralLike(node)) {
return node.text;
}
if (ts.isNoSubstitutionTemplateLiteral(node)) {
return node.text;
}
return undefined;
}
function collectKeysFromSource(sourceFile) {
const keys = new Set();
function visit(node) {
if (ts.isCallExpression(node)) {
const expr = node.expression;
let matches = false;
if (ts.isIdentifier(expr) && identifierTargets.has(expr.text)) {
matches = true;
} else if (ts.isPropertyAccessExpression(expr)) {
const chain = getPropertyChain(expr);
if (chain && propertyTargets.has(chain)) {
matches = true;
}
}
if (matches) {
const [firstArg] = node.arguments;
if (firstArg) {
const literal = extractLiteral(firstArg);
if (literal) {
keys.add(literal);
}
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return keys;
}
async function loadEnglishCatalog() {
try {
const raw = await readFile(englishLocaleFile, 'utf8');
const parsed = raw.trim() ? JSON.parse(raw) : {};
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('English locale file must contain a JSON object.');
}
return parsed;
} catch (error) {
if (error && error.code === 'ENOENT') {
return {};
}
throw error;
}
}
async function ensureEnglishCatalog(keys) {
const existingCatalog = await loadEnglishCatalog();
const existingKeys = new Set(Object.keys(existingCatalog));
let added = 0;
const combinedKeys = new Set([...existingKeys, ...keys]);
const sortedKeys = Array.from(combinedKeys).sort((a, b) => a.localeCompare(b));
const nextCatalog = {};
for (const key of sortedKeys) {
if (Object.prototype.hasOwnProperty.call(existingCatalog, key)) {
nextCatalog[key] = existingCatalog[key];
} else {
nextCatalog[key] = key;
added += 1;
}
}
const nextJson = `${JSON.stringify(nextCatalog, null, 2)}\n`;
const existingJson = JSON.stringify(existingCatalog, null, 2) + '\n';
if (nextJson !== existingJson) {
await writeFile(englishLocaleFile, nextJson, 'utf8');
}
return added;
}
async function main() {
const files = await glob(sourcePatterns, {
cwd: projectRoot,
ignore: ignorePatterns,
absolute: true,
});
const collectedKeys = new Set();
await Promise.all(
files.map(async (file) => {
const content = await readFile(file, 'utf8');
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
const keys = collectKeysFromSource(sourceFile);
keys.forEach((key) => collectedKeys.add(key));
}),
);
const added = await ensureEnglishCatalog(collectedKeys);
if (added === 0) {
console.log('[i18n] No new backend translation keys detected.');
} else {
console.log(`[i18n] Added ${added} key(s) to src/i18n/en.json.`);
}
}
main().catch((error) => {
console.error('[i18n] Failed to extract backend translations.', error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,45 @@
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
import { store } from '@app/store/index.js';
import { loadConfigFile } from '@app/store/modules/config.js';
import { loadStateFiles } from '@app/store/modules/emhttp.js';
import 'reflect-metadata';
import { expect, test } from 'vitest';
test('Returns allowed origins', async () => {
// Load state files into store
await store.dispatch(loadStateFiles()).unwrap();
await store.dispatch(loadConfigFile()).unwrap();
// Get allowed origins
const allowedOrigins = getAllowedOrigins();
// Test that the result is an array
expect(Array.isArray(allowedOrigins)).toBe(true);
// Test that it contains the expected socket paths
expect(allowedOrigins).toContain('/var/run/unraid-notifications.sock');
expect(allowedOrigins).toContain('/var/run/unraid-php.sock');
expect(allowedOrigins).toContain('/var/run/unraid-cli.sock');
// Test that it contains the expected local URLs
expect(allowedOrigins).toContain('http://localhost:8080');
expect(allowedOrigins).toContain('https://localhost:4443');
// Test that it contains the expected connect URLs
expect(allowedOrigins).toContain('https://connect.myunraid.net');
expect(allowedOrigins).toContain('https://connect-staging.myunraid.net');
expect(allowedOrigins).toContain('https://dev-my.myunraid.net:4000');
// Test that it contains the extra origins from config
expect(allowedOrigins).toContain('https://google.com');
expect(allowedOrigins).toContain('https://test.com');
// Test that it contains some of the remote URLs
expect(allowedOrigins).toContain('https://tower.local:4443');
expect(allowedOrigins).toContain('https://192.168.1.150:4443');
// Test that there are no duplicates
expect(allowedOrigins.length).toBe(new Set(allowedOrigins).size);
});

View File

@@ -0,0 +1,137 @@
import { ConfigService } from '@nestjs/config';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
describe('ApiConfigPersistence', () => {
let service: ApiConfigPersistence;
let configService: ConfigService;
let persistenceHelper: ConfigPersistenceHelper;
beforeEach(() => {
configService = {
get: vi.fn(),
set: vi.fn(),
} as any;
persistenceHelper = {} as ConfigPersistenceHelper;
service = new ApiConfigPersistence(configService, persistenceHelper);
});
describe('convertLegacyConfig', () => {
it('should migrate sandbox from string "yes" to boolean true', () => {
const legacyConfig = {
local: { sandbox: 'yes' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.sandbox).toBe(true);
});
it('should migrate sandbox from string "no" to boolean false', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.sandbox).toBe(false);
});
it('should migrate extraOrigins from comma-separated string to array', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: 'https://example.com,https://test.com' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.extraOrigins).toEqual(['https://example.com', 'https://test.com']);
});
it('should filter out non-HTTP origins from extraOrigins', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: {
extraOrigins: 'https://example.com,invalid-origin,http://test.com,ftp://bad.com',
},
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.extraOrigins).toEqual(['https://example.com', 'http://test.com']);
});
it('should handle empty extraOrigins string', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.extraOrigins).toEqual([]);
});
it('should migrate ssoSubIds from comma-separated string to array', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: 'user1,user2,user3' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.ssoSubIds).toEqual(['user1', 'user2', 'user3']);
});
it('should handle empty ssoSubIds string', () => {
const legacyConfig = {
local: { sandbox: 'no' },
api: { extraOrigins: '' },
remote: { ssoSubIds: '' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.ssoSubIds).toEqual([]);
});
it('should handle undefined config sections', () => {
const legacyConfig = {};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.sandbox).toBe(false);
expect(result.extraOrigins).toEqual([]);
expect(result.ssoSubIds).toEqual([]);
});
it('should handle complete migration with all fields', () => {
const legacyConfig = {
local: { sandbox: 'yes' },
api: { extraOrigins: 'https://app1.example.com,https://app2.example.com' },
remote: { ssoSubIds: 'sub1,sub2,sub3' },
};
const result = service.convertLegacyConfig(legacyConfig);
expect(result.sandbox).toBe(true);
expect(result.extraOrigins).toEqual([
'https://app1.example.com',
'https://app2.example.com',
]);
expect(result.ssoSubIds).toEqual(['sub1', 'sub2', 'sub3']);
});
});
});

View File

@@ -0,0 +1,158 @@
import 'reflect-metadata';
import { cloneDeep } from 'lodash-es';
import { expect, test } from 'vitest';
import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer.js';
import { initialState } from '@app/store/modules/config.js';
test('it creates a FLASH config with NO OPTIONAL values', () => {
const basicConfig = initialState;
const config = getWriteableConfig(basicConfig, 'flash');
expect(config).toMatchInlineSnapshot(`
{
"api": {
"extraOrigins": "",
"version": "",
},
"local": {
"sandbox": "no",
},
"remote": {
"accesstoken": "",
"apikey": "",
"avatar": "",
"dynamicRemoteAccessType": "DISABLED",
"email": "",
"idtoken": "",
"localApiKey": "",
"refreshtoken": "",
"regWizTime": "",
"ssoSubIds": "",
"upnpEnabled": "",
"username": "",
"wanaccess": "",
"wanport": "",
},
}
`);
});
test('it creates a MEMORY config with NO OPTIONAL values', () => {
const basicConfig = initialState;
const config = getWriteableConfig(basicConfig, 'memory');
expect(config).toMatchInlineSnapshot(`
{
"api": {
"extraOrigins": "",
"version": "",
},
"connectionStatus": {
"minigraph": "PRE_INIT",
"upnpStatus": "",
},
"local": {
"sandbox": "no",
},
"remote": {
"accesstoken": "",
"allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000",
"apikey": "",
"avatar": "",
"dynamicRemoteAccessType": "DISABLED",
"email": "",
"idtoken": "",
"localApiKey": "",
"refreshtoken": "",
"regWizTime": "",
"ssoSubIds": "",
"upnpEnabled": "",
"username": "",
"wanaccess": "",
"wanport": "",
},
}
`);
});
test('it creates a FLASH config with OPTIONAL values', () => {
const basicConfig = cloneDeep(initialState);
// 2fa & t2fa should be ignored
basicConfig.remote['2Fa'] = 'yes';
basicConfig.local['2Fa'] = 'yes';
basicConfig.api.extraOrigins = 'myextra.origins';
basicConfig.remote.upnpEnabled = 'yes';
basicConfig.connectionStatus.upnpStatus = 'Turned On';
const config = getWriteableConfig(basicConfig, 'flash');
expect(config).toMatchInlineSnapshot(`
{
"api": {
"extraOrigins": "myextra.origins",
"version": "",
},
"local": {
"sandbox": "no",
},
"remote": {
"accesstoken": "",
"apikey": "",
"avatar": "",
"dynamicRemoteAccessType": "DISABLED",
"email": "",
"idtoken": "",
"localApiKey": "",
"refreshtoken": "",
"regWizTime": "",
"ssoSubIds": "",
"upnpEnabled": "yes",
"username": "",
"wanaccess": "",
"wanport": "",
},
}
`);
});
test('it creates a MEMORY config with OPTIONAL values', () => {
const basicConfig = cloneDeep(initialState);
// 2fa & t2fa should be ignored
basicConfig.remote['2Fa'] = 'yes';
basicConfig.local['2Fa'] = 'yes';
basicConfig.api.extraOrigins = 'myextra.origins';
basicConfig.remote.upnpEnabled = 'yes';
basicConfig.connectionStatus.upnpStatus = 'Turned On';
const config = getWriteableConfig(basicConfig, 'memory');
expect(config).toMatchInlineSnapshot(`
{
"api": {
"extraOrigins": "myextra.origins",
"version": "",
},
"connectionStatus": {
"minigraph": "PRE_INIT",
"upnpStatus": "Turned On",
},
"local": {
"sandbox": "no",
},
"remote": {
"accesstoken": "",
"allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000",
"apikey": "",
"avatar": "",
"dynamicRemoteAccessType": "DISABLED",
"email": "",
"idtoken": "",
"localApiKey": "",
"refreshtoken": "",
"regWizTime": "",
"ssoSubIds": "",
"upnpEnabled": "yes",
"username": "",
"wanaccess": "",
"wanport": "",
},
}
`);
});

View File

@@ -4,18 +4,23 @@ import {
getBannerPathIfPresent,
getCasePathIfPresent,
} from '@app/core/utils/images/image-file-helpers.js';
import { loadDynamixConfig } from '@app/store/index.js';
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
import { store } from '@app/store/index.js';
test('get case path returns expected result', async () => {
await expect(getCasePathIfPresent()).resolves.toContain('/dev/dynamix/case-model.png');
});
test('get banner path returns null (state unloaded)', async () => {
await expect(getBannerPathIfPresent()).resolves.toMatchInlineSnapshot('null');
});
test('get banner path returns the banner (state loaded)', async () => {
loadDynamixConfig();
await store.dispatch(loadDynamixConfigFile()).unwrap();
await expect(getBannerPathIfPresent()).resolves.toContain('/dev/dynamix/banner.png');
});
test('get banner path returns null when no banner (state loaded)', async () => {
loadDynamixConfig();
await store.dispatch(loadDynamixConfigFile()).unwrap();
await expect(getBannerPathIfPresent('notabanner.png')).resolves.toMatchInlineSnapshot('null');
});

View File

@@ -1,12 +1,11 @@
import { expect, test, vi } from 'vitest';
import { expect, test } from 'vitest';
import { store } from '@app/store/index.js';
import { FileLoadStatus, StateFileKey } from '@app/store/types.js';
import '@app/core/utils/misc/get-key-file.js';
import '@app/store/modules/emhttp.js';
vi.mock('fs/promises');
test('Before loading key returns null', async () => {
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
const { status } = store.getState().registration;
@@ -49,70 +48,21 @@ test('Returns empty key if key location is empty', async () => {
await expect(getKeyFile()).resolves.toBe('');
});
test('Returns empty string when key file does not exist (ENOENT)', async () => {
const { readFile } = await import('fs/promises');
// Mock readFile to throw ENOENT error
const readFileMock = vi.mocked(readFile);
readFileMock.mockRejectedValueOnce(
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' })
);
// Clear the module cache and re-import to get fresh module with mock
vi.resetModules();
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
const { updateEmhttpState } = await import('@app/store/modules/emhttp.js');
const { store: freshStore } = await import('@app/store/index.js');
// Set key file location to a non-existent file
freshStore.dispatch(
updateEmhttpState({
field: StateFileKey.var,
state: {
regFile: '/boot/config/Pro.key',
},
})
);
// Should return empty string when file doesn't exist
await expect(getKeyFile()).resolves.toBe('');
// Clear mock
readFileMock.mockReset();
vi.resetModules();
});
test('Returns decoded key file if key location exists', async () => {
const { readFile } = await import('fs/promises');
// Mock a valid key file content
const mockKeyContent =
'hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w==';
const binaryContent = Buffer.from(mockKeyContent, 'base64').toString('binary');
const readFileMock = vi.mocked(readFile);
readFileMock.mockResolvedValue(binaryContent);
// Clear the module cache and re-import to get fresh module with mock
vi.resetModules();
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
const { loadStateFiles } = await import('@app/store/modules/emhttp.js');
const { loadRegistrationKey } = await import('@app/store/modules/registration.js');
const { store: freshStore } = await import('@app/store/index.js');
// Load state files into store
await freshStore.dispatch(loadStateFiles());
await freshStore.dispatch(loadRegistrationKey());
// Check if store has state files loaded
const { status } = freshStore.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
const result = await getKeyFile();
expect(result).toBe(
'hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w'
);
// Clear mock
readFileMock.mockReset();
vi.resetModules();
}, 10000);
test(
'Returns decoded key file if key location exists',
async () => {
const { getKeyFile } = await import('@app/core/utils/misc/get-key-file.js');
const { loadStateFiles } = await import('@app/store/modules/emhttp.js');
const { loadRegistrationKey } = await import('@app/store/modules/registration.js');
// Load state files into store
await store.dispatch(loadStateFiles());
await store.dispatch(loadRegistrationKey());
// Check if store has state files loaded
const { status } = store.getState().registration;
expect(status).toBe(FileLoadStatus.LOADED);
await expect(getKeyFile()).resolves.toMatchInlineSnapshot(
'"hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w"'
);
},
{ timeout: 10000 }
);

View File

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

View File

@@ -1,5 +0,0 @@
/* eslint-disable no-undef */
// Dummy process for PM2 testing
setInterval(() => {
// Keep process alive
}, 1000);

View File

@@ -1,222 +0,0 @@
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execa } from 'execa';
import pm2 from 'pm2';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running.js';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = join(__dirname, '../../../../..');
const DUMMY_PROCESS_PATH = join(__dirname, 'dummy-process.js');
const CLI_PATH = join(PROJECT_ROOT, 'dist/cli.js');
const TEST_PROCESS_NAME = 'test-unraid-api';
// Shared PM2 connection state
let pm2Connected = false;
// Helper to ensure PM2 connection is established
async function ensurePM2Connection() {
if (pm2Connected) return;
return new Promise<void>((resolve, reject) => {
pm2.connect((err) => {
if (err) {
reject(err);
return;
}
pm2Connected = true;
resolve();
});
});
}
// Helper to delete specific test processes (lightweight, reuses connection)
async function deleteTestProcesses() {
if (!pm2Connected) {
// No connection, nothing to clean up
return;
}
const deletePromise = new Promise<void>((resolve) => {
// Delete specific processes we might have created
const processNames = ['unraid-api', TEST_PROCESS_NAME];
let deletedCount = 0;
const deleteNext = () => {
if (deletedCount >= processNames.length) {
resolve();
return;
}
const processName = processNames[deletedCount];
pm2.delete(processName, () => {
// Ignore errors, process might not exist
deletedCount++;
deleteNext();
});
};
deleteNext();
});
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), 3000); // 3 second timeout
});
return Promise.race([deletePromise, timeoutPromise]);
}
// Helper to ensure PM2 is completely clean (heavy cleanup with daemon kill)
async function cleanupAllPM2Processes() {
// First delete test processes if we have a connection
if (pm2Connected) {
await deleteTestProcesses();
}
return new Promise<void>((resolve) => {
// Always connect fresh for daemon kill (in case we weren't connected)
pm2.connect((err) => {
if (err) {
// If we can't connect, assume PM2 is not running
pm2Connected = false;
resolve();
return;
}
// Kill the daemon to ensure fresh state
pm2.killDaemon(() => {
pm2.disconnect();
pm2Connected = false;
// Small delay to let PM2 fully shutdown
setTimeout(resolve, 500);
});
});
});
}
describe.skipIf(!!process.env.CI)('PM2 integration tests', () => {
beforeAll(async () => {
// Set PM2_HOME to use home directory for testing (not /var/log)
process.env.PM2_HOME = join(homedir(), '.pm2');
// Build the CLI if it doesn't exist (only for CLI tests)
if (!existsSync(CLI_PATH)) {
console.log('Building CLI for integration tests...');
try {
await execa('pnpm', ['build'], {
cwd: PROJECT_ROOT,
stdio: 'inherit',
timeout: 120000, // 2 minute timeout for build
});
} catch (error) {
console.error('Failed to build CLI:', error);
throw new Error(
'Cannot run CLI integration tests without built CLI. Run `pnpm build` first.'
);
}
}
// Only do a full cleanup once at the beginning
await cleanupAllPM2Processes();
}, 150000); // 2.5 minute timeout for setup
afterAll(async () => {
// Only do a full cleanup once at the end
await cleanupAllPM2Processes();
});
afterEach(async () => {
// Lightweight cleanup after each test - just delete our test processes
await deleteTestProcesses();
}, 5000); // 5 second timeout for cleanup
describe('isUnraidApiRunning function', () => {
it('should return false when PM2 is not running the unraid-api process', async () => {
const result = await isUnraidApiRunning();
expect(result).toBe(false);
});
it('should return true when PM2 has unraid-api process running', async () => {
// Ensure PM2 connection
await ensurePM2Connection();
// Start a dummy process with the name 'unraid-api'
await new Promise<void>((resolve, reject) => {
pm2.start(
{
script: DUMMY_PROCESS_PATH,
name: 'unraid-api',
},
(startErr) => {
if (startErr) return reject(startErr);
resolve();
}
);
});
// Give PM2 time to start the process
await new Promise((resolve) => setTimeout(resolve, 2000));
const result = await isUnraidApiRunning();
expect(result).toBe(true);
}, 30000);
it('should return false when unraid-api process is stopped', async () => {
// Ensure PM2 connection
await ensurePM2Connection();
// Start and then stop the process
await new Promise<void>((resolve, reject) => {
pm2.start(
{
script: DUMMY_PROCESS_PATH,
name: 'unraid-api',
},
(startErr) => {
if (startErr) return reject(startErr);
// Stop the process after starting
setTimeout(() => {
pm2.stop('unraid-api', (stopErr) => {
if (stopErr) return reject(stopErr);
resolve();
});
}, 1000);
}
);
});
await new Promise((resolve) => setTimeout(resolve, 1000));
const result = await isUnraidApiRunning();
expect(result).toBe(false);
}, 30000);
it('should handle PM2 connection errors gracefully', async () => {
// Disconnect PM2 first to ensure we're testing fresh connection
await new Promise<void>((resolve) => {
pm2.disconnect();
pm2Connected = false;
setTimeout(resolve, 100);
});
// Set an invalid PM2_HOME to force connection failure
const originalPM2Home = process.env.PM2_HOME;
process.env.PM2_HOME = '/invalid/path/that/does/not/exist';
const result = await isUnraidApiRunning();
expect(result).toBe(false);
// Restore original PM2_HOME
if (originalPM2Home) {
process.env.PM2_HOME = originalPM2Home;
} else {
delete process.env.PM2_HOME;
}
}, 15000); // 15 second timeout to allow for the Promise.race timeout
});
});

View File

@@ -95,48 +95,6 @@ test('Returns both disk and user shares', async () => {
"type": "user",
"used": 33619300,
},
{
"allocator": "highwater",
"cachePool": "cache",
"color": "yellow-on",
"comment": "system data with periods",
"cow": "auto",
"exclude": [],
"floor": "0",
"free": 9309372,
"id": "system.with.periods",
"include": [],
"luksStatus": "0",
"name": "system.with.periods",
"nameOrig": "system.with.periods",
"nfs": {},
"size": 0,
"smb": {},
"splitLevel": "1",
"type": "user",
"used": 33619300,
},
{
"allocator": "highwater",
"cachePool": "cache",
"color": "yellow-on",
"comment": "system data with 🚀",
"cow": "auto",
"exclude": [],
"floor": "0",
"free": 9309372,
"id": "system.with.🚀",
"include": [],
"luksStatus": "0",
"name": "system.with.🚀",
"nameOrig": "system.with.🚀",
"nfs": {},
"size": 0,
"smb": {},
"splitLevel": "1",
"type": "user",
"used": 33619300,
},
],
}
`);
@@ -253,48 +211,6 @@ test('Returns shares by type', async () => {
"type": "user",
"used": 33619300,
},
{
"allocator": "highwater",
"cachePool": "cache",
"color": "yellow-on",
"comment": "system data with periods",
"cow": "auto",
"exclude": [],
"floor": "0",
"free": 9309372,
"id": "system.with.periods",
"include": [],
"luksStatus": "0",
"name": "system.with.periods",
"nameOrig": "system.with.periods",
"nfs": {},
"size": 0,
"smb": {},
"splitLevel": "1",
"type": "user",
"used": 33619300,
},
{
"allocator": "highwater",
"cachePool": "cache",
"color": "yellow-on",
"comment": "system data with 🚀",
"cow": "auto",
"exclude": [],
"floor": "0",
"free": 9309372,
"id": "system.with.🚀",
"include": [],
"luksStatus": "0",
"name": "system.with.🚀",
"nameOrig": "system.with.🚀",
"nfs": {},
"size": 0,
"smb": {},
"splitLevel": "1",
"type": "user",
"used": 33619300,
},
]
`);
expect(getShares('disk')).toMatchInlineSnapshot('null');

View File

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

View File

@@ -3,7 +3,6 @@ import '@app/__test__/setup/env-setup.js';
import '@app/__test__/setup/keyserver-mock.js';
import '@app/__test__/setup/config-setup.js';
import '@app/__test__/setup/store-reset.js';
import '@app/__test__/setup/api-json-backup.js';
// This file is automatically loaded by Vitest before running tests
// It imports all the setup files that need to be run before tests

View File

@@ -1,36 +0,0 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join, resolve } from 'path';
import { afterAll, beforeAll } from 'vitest';
// Get the project root directory
const projectRoot = resolve(process.cwd());
const apiJsonPath = join(projectRoot, 'dev/configs/api.json');
const apiJsonBackupPath = join(projectRoot, 'dev/configs/api.json.backup');
let originalContent: string | null = null;
/**
* Backs up api.json before tests run and restores it after tests complete.
* This prevents tests from permanently modifying the development configuration.
*/
export function setupApiJsonBackup() {
beforeAll(() => {
// Save the original content if the file exists
if (existsSync(apiJsonPath)) {
originalContent = readFileSync(apiJsonPath, 'utf-8');
// Create a backup file as well for safety
writeFileSync(apiJsonBackupPath, originalContent, 'utf-8');
}
});
afterAll(() => {
// Restore the original content if we saved it
if (originalContent !== null) {
writeFileSync(apiJsonPath, originalContent, 'utf-8');
}
});
}
// Auto-run for all tests that import this module
setupApiJsonBackup();

View File

@@ -17,6 +17,7 @@ exports[`Returns paths 1`] = `
"myservers-base",
"myservers-config",
"myservers-config-states",
"myservers-env",
"myservers-keepalive",
"keyfile-base",
"machine-id",

View File

@@ -0,0 +1,303 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { store } from '@app/store/index.js';
import { MyServersConfigMemory } from '@app/types/my-servers-config.js';
describe.skip('config tests', () => {
// Mock dependencies
vi.mock('@app/core/pubsub.js', () => {
const mockPublish = vi.fn();
return {
pubsub: {
publish: mockPublish,
},
PUBSUB_CHANNEL: {
OWNER: 'OWNER',
SERVERS: 'SERVERS',
},
__esModule: true,
default: {
pubsub: {
publish: mockPublish,
},
PUBSUB_CHANNEL: {
OWNER: 'OWNER',
SERVERS: 'SERVERS',
},
},
};
});
// Get the mock function for pubsub.publish
const mockPublish = vi.mocked(pubsub.publish);
// Clear mock before each test
beforeEach(() => {
mockPublish.mockClear();
});
vi.mock('@app/mothership/graphql-client.js', () => ({
GraphQLClient: {
clearInstance: vi.fn(),
},
}));
vi.mock('@app/mothership/jobs/ping-timeout-jobs.js', () => ({
stopPingTimeoutJobs: vi.fn(),
}));
const createConfigMatcher = (specificValues: Partial<MyServersConfigMemory> = {}) => {
const defaultMatcher = {
api: expect.objectContaining({
extraOrigins: expect.any(String),
version: expect.any(String),
}),
connectionStatus: expect.objectContaining({
minigraph: expect.any(String),
upnpStatus: expect.any(String),
}),
local: expect.objectContaining({
sandbox: expect.any(String),
}),
nodeEnv: expect.any(String),
remote: expect.objectContaining({
accesstoken: expect.any(String),
allowedOrigins: expect.any(String),
apikey: expect.any(String),
avatar: expect.any(String),
dynamicRemoteAccessType: expect.any(String),
email: expect.any(String),
idtoken: expect.any(String),
localApiKey: expect.any(String),
refreshtoken: expect.any(String),
regWizTime: expect.any(String),
ssoSubIds: expect.any(String),
upnpEnabled: expect.any(String),
username: expect.any(String),
wanaccess: expect.any(String),
wanport: expect.any(String),
}),
status: expect.any(String),
};
return expect.objectContaining({
...defaultMatcher,
...specificValues,
});
};
// test('Before init returns default values for all fields', async () => {
// const state = store.getState().config;
// expect(state).toMatchSnapshot();
// }, 10_000);
test('After init returns values from cfg file for all fields', async () => {
const { loadConfigFile } = await import('@app/store/modules/config.js');
// Load cfg into store
await store.dispatch(loadConfigFile());
// Check if store has cfg contents loaded
const state = store.getState().config;
expect(state).toMatchObject(createConfigMatcher());
});
test('updateUserConfig merges in changes to current state', async () => {
const { loadConfigFile, updateUserConfig } = await import('@app/store/modules/config.js');
// Load cfg into store
await store.dispatch(loadConfigFile());
// Update store
store.dispatch(
updateUserConfig({
remote: { avatar: 'https://via.placeholder.com/200' },
})
);
const state = store.getState().config;
expect(state).toMatchObject(
createConfigMatcher({
remote: expect.objectContaining({
avatar: 'https://via.placeholder.com/200',
}),
})
);
});
test('loginUser updates state and publishes to pubsub', async () => {
const { loginUser } = await import('@app/store/modules/config.js');
const userInfo = {
email: 'test@example.com',
avatar: 'https://via.placeholder.com/200',
username: 'testuser',
apikey: 'test-api-key',
localApiKey: 'test-local-api-key',
};
await store.dispatch(loginUser(userInfo));
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.OWNER, {
owner: {
username: userInfo.username,
url: '',
avatar: userInfo.avatar,
},
});
const state = store.getState().config;
expect(state).toMatchObject(
createConfigMatcher({
remote: expect.objectContaining(userInfo),
})
);
});
test('logoutUser clears state and publishes to pubsub', async () => {
const { logoutUser } = await import('@app/store/modules/config.js');
await store.dispatch(logoutUser({ reason: 'test logout' }));
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.SERVERS, { servers: [] });
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.OWNER, {
owner: {
username: 'root',
url: '',
avatar: '',
},
});
// expect(stopPingTimeoutJobs).toHaveBeenCalled();
// expect(GraphQLClient.clearInstance).toHaveBeenCalled();
});
test('updateAccessTokens updates token fields', async () => {
const { updateAccessTokens } = await import('@app/store/modules/config.js');
const tokens = {
accesstoken: 'new-access-token',
refreshtoken: 'new-refresh-token',
idtoken: 'new-id-token',
};
store.dispatch(updateAccessTokens(tokens));
const state = store.getState().config;
expect(state).toMatchObject(
createConfigMatcher({
remote: expect.objectContaining(tokens),
})
);
});
test('updateAllowedOrigins updates extraOrigins', async () => {
const { updateAllowedOrigins } = await import('@app/store/modules/config.js');
const origins = ['https://test1.com', 'https://test2.com'];
store.dispatch(updateAllowedOrigins(origins));
const state = store.getState().config;
expect(state.api.extraOrigins).toBe(origins.join(', '));
});
test('setUpnpState updates upnp settings', async () => {
const { setUpnpState } = await import('@app/store/modules/config.js');
store.dispatch(setUpnpState({ enabled: 'yes', status: 'active' }));
const state = store.getState().config;
expect(state.remote.upnpEnabled).toBe('yes');
expect(state.connectionStatus.upnpStatus).toBe('active');
});
test('setWanPortToValue updates wanport', async () => {
const { setWanPortToValue } = await import('@app/store/modules/config.js');
store.dispatch(setWanPortToValue(8443));
const state = store.getState().config;
expect(state.remote.wanport).toBe('8443');
});
test('setWanAccess updates wanaccess', async () => {
const { setWanAccess } = await import('@app/store/modules/config.js');
store.dispatch(setWanAccess('yes'));
const state = store.getState().config;
expect(state.remote.wanaccess).toBe('yes');
});
// test('addSsoUser adds user to ssoSubIds', async () => {
// const { addSsoUser } = await import('@app/store/modules/config.js');
// store.dispatch(addSsoUser('user1'));
// store.dispatch(addSsoUser('user2'));
// const state = store.getState().config;
// expect(state.remote.ssoSubIds).toBe('user1,user2');
// });
// test('removeSsoUser removes user from ssoSubIds', async () => {
// const { addSsoUser, removeSsoUser } = await import('@app/store/modules/config.js');
// store.dispatch(addSsoUser('user1'));
// store.dispatch(addSsoUser('user2'));
// store.dispatch(removeSsoUser('user1'));
// const state = store.getState().config;
// expect(state.remote.ssoSubIds).toBe('user2');
// });
// test('removeSsoUser with null clears all ssoSubIds', async () => {
// const { addSsoUser, removeSsoUser } = await import('@app/store/modules/config.js');
// store.dispatch(addSsoUser('user1'));
// store.dispatch(addSsoUser('user2'));
// store.dispatch(removeSsoUser(null));
// const state = store.getState().config;
// expect(state.remote.ssoSubIds).toBe('');
// });
test('setLocalApiKey updates localApiKey', async () => {
const { setLocalApiKey } = await import('@app/store/modules/config.js');
store.dispatch(setLocalApiKey('new-local-api-key'));
const state = store.getState().config;
expect(state.remote.localApiKey).toBe('new-local-api-key');
});
test('setLocalApiKey with null clears localApiKey', async () => {
const { setLocalApiKey } = await import('@app/store/modules/config.js');
store.dispatch(setLocalApiKey(null));
const state = store.getState().config;
expect(state.remote.localApiKey).toBe('');
});
// test('setGraphqlConnectionStatus updates minigraph status', async () => {
// store.dispatch(setGraphqlConnectionStatus({ status: MinigraphStatus.CONNECTED, error: null }));
// const state = store.getState().config;
// expect(state.connectionStatus.minigraph).toBe(MinigraphStatus.CONNECTED);
// });
// test('setupRemoteAccessThunk.fulfilled updates remote access settings', async () => {
// const remoteAccessSettings = {
// accessType: WAN_ACCESS_TYPE.DYNAMIC,
// forwardType: WAN_FORWARD_TYPE.UPNP,
// };
// await store.dispatch(setupRemoteAccessThunk(remoteAccessSettings));
// const state = store.getState().config;
// expect(state.remote).toMatchObject({
// wanaccess: 'no',
// dynamicRemoteAccessType: 'UPNP',
// wanport: '',
// upnpEnabled: 'yes',
// });
// });
});

View File

@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { expect, test } from 'vitest';
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
import { store } from '@app/store/index.js';
import { FileLoadStatus } from '@app/store/types.js';
@@ -25,7 +24,7 @@ test('Before init returns default values for all fields', async () => {
`);
});
test('After init returns values from cfg file for all fields', { timeout: 30000 }, async () => {
test('After init returns values from cfg file for all fields', async () => {
const { loadStateFiles } = await import('@app/store/modules/emhttp.js');
// Load state files into store
@@ -211,7 +210,6 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
"fsUsed": null,
"id": "ST18000NM000J-2TV103_ZR585CPY",
"idx": 0,
"isSpinning": true,
"name": "parity",
"numErrors": 0,
"numReads": 0,
@@ -236,7 +234,6 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
"fsUsed": 4116003021,
"id": "ST18000NM000J-2TV103_ZR5B1W9X",
"idx": 1,
"isSpinning": true,
"name": "disk1",
"numErrors": 0,
"numReads": 0,
@@ -261,7 +258,6 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
"fsUsed": 11904860828,
"id": "WDC_WD120EDAZ-11F3RA0_5PJRD45C",
"idx": 2,
"isSpinning": true,
"name": "disk2",
"numErrors": 0,
"numReads": 0,
@@ -286,7 +282,6 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
"fsUsed": 6478056481,
"id": "WDC_WD120EMAZ-11BLFA0_5PH8BTYD",
"idx": 3,
"isSpinning": true,
"name": "disk3",
"numErrors": 0,
"numReads": 0,
@@ -311,7 +306,6 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
"fsUsed": 137273827,
"id": "Samsung_SSD_850_EVO_250GB_S2R5NX0H643734Z",
"idx": 30,
"isSpinning": true,
"name": "cache",
"numErrors": 0,
"numReads": 0,
@@ -336,7 +330,6 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
"fsUsed": null,
"id": "KINGSTON_SA2000M8250G_50026B7282669D9E",
"idx": 31,
"isSpinning": true,
"name": "cache2",
"numErrors": 0,
"numReads": 0,
@@ -361,7 +354,6 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
"fsUsed": 851325,
"id": "Cruzer",
"idx": 32,
"isSpinning": true,
"name": "flash",
"numErrors": 0,
"numReads": 0,
@@ -454,44 +446,6 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
"splitLevel": "1",
"used": 33619300,
},
{
"allocator": "highwater",
"cache": false,
"cachePool": "cache",
"color": "yellow-on",
"comment": "system data with periods",
"cow": "auto",
"exclude": [],
"floor": "0",
"free": 9309372,
"id": "system.with.periods",
"include": [],
"luksStatus": "0",
"name": "system.with.periods",
"nameOrig": "system.with.periods",
"size": 0,
"splitLevel": "1",
"used": 33619300,
},
{
"allocator": "highwater",
"cache": false,
"cachePool": "cache",
"color": "yellow-on",
"comment": "system data with 🚀",
"cow": "auto",
"exclude": [],
"floor": "0",
"free": 9309372,
"id": "system.with.🚀",
"include": [],
"luksStatus": "0",
"name": "system.with.🚀",
"nameOrig": "system.with.🚀",
"size": 0,
"splitLevel": "1",
"used": 33619300,
},
]
`);
expect(nfsShares).toMatchInlineSnapshot(`
@@ -1156,209 +1110,3 @@ test('After init returns values from cfg file for all fields', { timeout: 30000
}
`);
});
describe('Share parsing with periods in names', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('parseConfig handles periods in INI section names', () => {
const mockIniContent = `
["share.with.periods"]
name=share.with.periods
useCache=yes
include=
exclude=
[normal_share]
name=normal_share
useCache=no
include=
exclude=
`;
const result = parseConfig<any>({
file: mockIniContent,
type: 'ini',
});
// The result should now have properly flattened keys
expect(result).toHaveProperty('shareWithPeriods');
expect(result).toHaveProperty('normalShare');
expect(result.shareWithPeriods.name).toBe('share.with.periods');
expect(result.normalShare.name).toBe('normal_share');
});
test('shares parser handles periods in share names correctly', async () => {
const { parse } = await import('@app/store/state-parsers/shares.js');
// The parser expects an object where values are share configs
const mockSharesState = {
shareWithPeriods: {
name: 'share.with.periods',
free: '1000000',
used: '500000',
size: '1500000',
include: '',
exclude: '',
useCache: 'yes',
},
normalShare: {
name: 'normal_share',
free: '2000000',
used: '750000',
size: '2750000',
include: '',
exclude: '',
useCache: 'no',
},
} as any;
const result = parse(mockSharesState);
expect(result).toHaveLength(2);
const periodShare = result.find((s) => s.name === 'share.with.periods');
const normalShare = result.find((s) => s.name === 'normal_share');
expect(periodShare).toBeDefined();
expect(periodShare?.id).toBe('share.with.periods');
expect(periodShare?.name).toBe('share.with.periods');
expect(periodShare?.cache).toBe(true);
expect(normalShare).toBeDefined();
expect(normalShare?.id).toBe('normal_share');
expect(normalShare?.name).toBe('normal_share');
expect(normalShare?.cache).toBe(false);
});
test('SMB parser handles periods in share names', async () => {
const { parse } = await import('@app/store/state-parsers/smb.js');
const mockSmbState = {
'share.with.periods': {
export: 'e',
security: 'public',
writeList: '',
readList: '',
volsizelimit: '0',
},
normal_share: {
export: 'e',
security: 'private',
writeList: 'user1,user2',
readList: '',
volsizelimit: '1000',
},
} as any;
const result = parse(mockSmbState);
expect(result).toHaveLength(2);
const periodShare = result.find((s) => s.name === 'share.with.periods');
const normalShare = result.find((s) => s.name === 'normal_share');
expect(periodShare).toBeDefined();
expect(periodShare?.name).toBe('share.with.periods');
expect(periodShare?.enabled).toBe(true);
expect(normalShare).toBeDefined();
expect(normalShare?.name).toBe('normal_share');
expect(normalShare?.writeList).toEqual(['user1', 'user2']);
});
test('NFS parser handles periods in share names', async () => {
const { parse } = await import('@app/store/state-parsers/nfs.js');
const mockNfsState = {
'share.with.periods': {
export: 'e',
security: 'public',
writeList: '',
readList: 'user1',
hostList: '',
},
normal_share: {
export: 'd',
security: 'private',
writeList: 'user2',
readList: '',
hostList: '192.168.1.0/24',
},
} as any;
const result = parse(mockNfsState);
expect(result).toHaveLength(2);
const periodShare = result.find((s) => s.name === 'share.with.periods');
const normalShare = result.find((s) => s.name === 'normal_share');
expect(periodShare).toBeDefined();
expect(periodShare?.name).toBe('share.with.periods');
expect(periodShare?.enabled).toBe(true);
expect(periodShare?.readList).toEqual(['user1']);
expect(normalShare).toBeDefined();
expect(normalShare?.name).toBe('normal_share');
expect(normalShare?.enabled).toBe(false);
});
});
describe('Share lookup with periods in names', () => {
test('getShares finds user shares with periods in names', async () => {
// Mock the store state
const mockStore = await import('@app/store/index.js');
const mockEmhttpState = {
shares: [
{
id: 'share.with.periods',
name: 'share.with.periods',
cache: true,
free: 1000000,
used: 500000,
size: 1500000,
include: [],
exclude: [],
},
{
id: 'normal_share',
name: 'normal_share',
cache: false,
free: 2000000,
used: 750000,
size: 2750000,
include: [],
exclude: [],
},
],
smbShares: [
{ name: 'share.with.periods', enabled: true, security: 'public' },
{ name: 'normal_share', enabled: true, security: 'private' },
],
nfsShares: [
{ name: 'share.with.periods', enabled: false },
{ name: 'normal_share', enabled: true },
],
disks: [],
};
const gettersSpy = vi.spyOn(mockStore, 'getters', 'get').mockReturnValue({
emhttp: () => mockEmhttpState,
} as any);
const { getShares } = await import('@app/core/utils/shares/get-shares.js');
const periodShare = getShares('user', { name: 'share.with.periods' });
const normalShare = getShares('user', { name: 'normal_share' });
expect(periodShare).not.toBeNull();
expect(periodShare?.name).toBe('share.with.periods');
expect(periodShare?.type).toBe('user');
expect(normalShare).not.toBeNull();
expect(normalShare?.name).toBe('normal_share');
expect(normalShare?.type).toBe('user');
gettersSpy.mockRestore();
});
});

View File

@@ -24,6 +24,7 @@ test('Returns paths', async () => {
'myservers-base': '/boot/config/plugins/dynamix.my.servers/',
'myservers-config': expect.stringContaining('api/dev/Unraid.net/myservers.cfg'),
'myservers-config-states': expect.stringContaining('api/dev/states/myservers.cfg'),
'myservers-env': '/boot/config/plugins/dynamix.my.servers/env',
'myservers-keepalive': './dev/Unraid.net/fb_keepalive',
'keyfile-base': expect.stringContaining('api/dev/Unraid.net'),
'machine-id': expect.stringContaining('api/dev/data/machine-id'),

View File

@@ -92,44 +92,6 @@ test('Returns parsed state file', async () => {
"splitLevel": "1",
"used": 33619300,
},
{
"allocator": "highwater",
"cache": false,
"cachePool": "cache",
"color": "yellow-on",
"comment": "system data with periods",
"cow": "auto",
"exclude": [],
"floor": "0",
"free": 9309372,
"id": "system.with.periods",
"include": [],
"luksStatus": "0",
"name": "system.with.periods",
"nameOrig": "system.with.periods",
"size": 0,
"splitLevel": "1",
"used": 33619300,
},
{
"allocator": "highwater",
"cache": false,
"cachePool": "cache",
"color": "yellow-on",
"comment": "system data with 🚀",
"cow": "auto",
"exclude": [],
"floor": "0",
"free": 9309372,
"id": "system.with.🚀",
"include": [],
"luksStatus": "0",
"name": "system.with.🚀",
"nameOrig": "system.with.🚀",
"size": 0,
"splitLevel": "1",
"used": 33619300,
},
]
`);
});

View File

@@ -28,7 +28,6 @@ test('Returns parsed state file', async () => {
"fsUsed": null,
"id": "ST18000NM000J-2TV103_ZR585CPY",
"idx": 0,
"isSpinning": true,
"name": "parity",
"numErrors": 0,
"numReads": 0,
@@ -53,7 +52,6 @@ test('Returns parsed state file', async () => {
"fsUsed": 4116003021,
"id": "ST18000NM000J-2TV103_ZR5B1W9X",
"idx": 1,
"isSpinning": true,
"name": "disk1",
"numErrors": 0,
"numReads": 0,
@@ -78,7 +76,6 @@ test('Returns parsed state file', async () => {
"fsUsed": 11904860828,
"id": "WDC_WD120EDAZ-11F3RA0_5PJRD45C",
"idx": 2,
"isSpinning": true,
"name": "disk2",
"numErrors": 0,
"numReads": 0,
@@ -103,7 +100,6 @@ test('Returns parsed state file', async () => {
"fsUsed": 6478056481,
"id": "WDC_WD120EMAZ-11BLFA0_5PH8BTYD",
"idx": 3,
"isSpinning": true,
"name": "disk3",
"numErrors": 0,
"numReads": 0,
@@ -128,7 +124,6 @@ test('Returns parsed state file', async () => {
"fsUsed": 137273827,
"id": "Samsung_SSD_850_EVO_250GB_S2R5NX0H643734Z",
"idx": 30,
"isSpinning": true,
"name": "cache",
"numErrors": 0,
"numReads": 0,
@@ -153,7 +148,6 @@ test('Returns parsed state file', async () => {
"fsUsed": null,
"id": "KINGSTON_SA2000M8250G_50026B7282669D9E",
"idx": 31,
"isSpinning": true,
"name": "cache2",
"numErrors": 0,
"numReads": 0,
@@ -178,7 +172,6 @@ test('Returns parsed state file', async () => {
"fsUsed": 851325,
"id": "Cruzer",
"idx": 32,
"isSpinning": true,
"name": "flash",
"numErrors": 0,
"numReads": 0,

View File

@@ -0,0 +1,34 @@
import { expect, test, vi } from 'vitest';
import { store } from '@app/store/index.js';
import { loadStateFiles } from '@app/store/modules/emhttp.js';
import { loadRegistrationKey } from '@app/store/modules/registration.js';
import { createRegistrationEvent } from '@app/store/sync/registration-sync.js';
vi.mock('@app/core/pubsub', () => ({
pubsub: { publish: vi.fn() },
}));
test('Creates a registration event', async () => {
// Load state files into store
const config = await store.dispatch(loadStateFiles()).unwrap();
await store.dispatch(loadRegistrationKey());
expect(config.var.regFile).toBe('/app/dev/Unraid.net/Pro.key');
const state = store.getState();
const registrationEvent = createRegistrationEvent(state);
expect(registrationEvent).toMatchInlineSnapshot(`
{
"registration": {
"guid": "13FE-4200-C300-58C372A52B19",
"keyFile": {
"contents": "hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w",
"location": "/app/dev/Unraid.net/Pro.key",
},
"state": "PRO",
"type": "PRO",
},
}
`);
});

View File

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

View File

@@ -0,0 +1,20 @@
import { type Mapping } from '@runonflux/nat-upnp';
import { expect, test, vi } from 'vitest';
import { getWanPortForUpnp } from '@app/upnp/helpers.js';
test('it successfully gets a wan port given no exclusions', () => {
const port = getWanPortForUpnp(null, 36_000, 38_000);
expect(port).toBeGreaterThan(35_999);
expect(port).toBeLessThan(38_001);
});
test('it fails to get a wan port given exclusions', () => {
const port = getWanPortForUpnp([{ public: { port: 36_000 } }] as Mapping[], 36_000, 36_000);
expect(port).toBeNull();
});
test('it succeeds in getting a wan port given exclusions', () => {
const port = getWanPortForUpnp([{ public: { port: 36_000 } }] as Mapping[], 30_000, 36_000);
expect(port).not.toBeNull();
});

View File

@@ -1,37 +1,29 @@
import '@app/dotenv.js';
import { Logger } from '@nestjs/common';
import { execa } from 'execa';
import { CommandFactory } from 'nest-commander';
import { LOG_LEVEL, SUPPRESS_LOGS } from '@app/environment.js';
import { internalLogger, logger } from '@app/core/log.js';
import { LOG_LEVEL } from '@app/environment.js';
import { CliModule } from '@app/unraid-api/cli/cli.module.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
const getUnraidApiLocation = async () => {
const { execa } = await import('execa');
try {
const shellToUse = await execa('which unraid-api');
return shellToUse.stdout.trim();
} catch (err) {
logger.debug('Could not find unraid-api in PATH, using default location');
return '/usr/bin/unraid-api';
}
};
const getLogger = () => {
if (LOG_LEVEL === 'TRACE' && !SUPPRESS_LOGS) {
return new LogService();
}
return false;
};
const logger = getLogger();
try {
await import('json-bigint-patch');
const { CliModule } = await import('@app/unraid-api/cli/cli.module.js');
await CommandFactory.run(CliModule, {
cliName: 'unraid-api',
logger: logger, // - enable this to see nest initialization issues
logger: LOG_LEVEL === 'TRACE' ? new LogService() : false, // - enable this to see nest initialization issues
completion: {
fig: false,
cmd: 'completion-script',
@@ -40,8 +32,10 @@ try {
});
process.exit(0);
} catch (error) {
if (logger) {
logger.error('ERROR:', error);
}
logger.error('ERROR:', error);
internalLogger.error({
message: 'Failed to start unraid-api',
error,
});
process.exit(1);
}

View File

@@ -0,0 +1,99 @@
import { uniq } from 'lodash-es';
import type { RootState } from '@app/store/index.js';
import { logger } from '@app/core/log.js';
import { GRAPHQL_INTROSPECTION } from '@app/environment.js';
import { getServerIps, getUrlForField } from '@app/graphql/resolvers/subscription/network.js';
import { getters, store } from '@app/store/index.js';
import { FileLoadStatus } from '@app/store/types.js';
const getAllowedSocks = (): string[] => [
// Notifier bridge
'/var/run/unraid-notifications.sock',
// Unraid PHP scripts
'/var/run/unraid-php.sock',
// CLI
'/var/run/unraid-cli.sock',
];
const getLocalAccessUrlsForServer = (state: RootState = store.getState()): string[] => {
const { emhttp } = state;
if (emhttp.status !== FileLoadStatus.LOADED) {
return [];
}
const { nginx } = emhttp;
try {
return [
getUrlForField({
url: 'localhost',
port: nginx.httpPort,
}).toString(),
getUrlForField({
url: 'localhost',
portSsl: nginx.httpsPort,
}).toString(),
];
} catch (error: unknown) {
logger.debug('Caught error in getLocalAccessUrlsForServer: \n%o', error);
return [];
}
};
const getRemoteAccessUrlsForAllowedOrigins = (state: RootState = store.getState()): string[] => {
const { urls } = getServerIps(state);
if (urls) {
return urls.reduce<string[]>((acc, curr) => {
if ((curr.ipv4 && curr.ipv6) || curr.ipv4) {
acc.push(curr.ipv4.toString());
} else if (curr.ipv6) {
acc.push(curr.ipv6.toString());
}
return acc;
}, []);
}
return [];
};
export const getExtraOrigins = (): string[] => {
const { extraOrigins } = getters.config().api;
if (extraOrigins) {
return extraOrigins
.replaceAll(' ', '')
.split(',')
.filter((origin) => origin.startsWith('http://') || origin.startsWith('https://'));
}
return [];
};
const getConnectOrigins = (): string[] => {
const connectMain = 'https://connect.myunraid.net';
const connectStaging = 'https://connect-staging.myunraid.net';
const connectDev = 'https://dev-my.myunraid.net:4000';
return [connectMain, connectStaging, connectDev];
};
const getApolloSandbox = (): string[] => {
if (GRAPHQL_INTROSPECTION) {
return ['https://studio.apollographql.com'];
}
return [];
};
export const getAllowedOrigins = (state: RootState = store.getState()): string[] =>
uniq([
...getAllowedSocks(),
...getLocalAccessUrlsForServer(state),
...getRemoteAccessUrlsForAllowedOrigins(state),
...getExtraOrigins(),
...getConnectOrigins(),
...getApolloSandbox(),
]).map((url) => (url.endsWith('/') ? url.slice(0, -1) : url));

View File

@@ -1,12 +0,0 @@
import { existsSync } from 'node:fs';
/**
* Local filesystem and env checks stay synchronous so we can branch at module load.
* @returns True if the Connect Unraid plugin is installed, false otherwise.
*/
export const isConnectPluginInstalled = () => {
if (process.env.SKIP_CONNECT_PLUGIN_CHECK === 'true') {
return true;
}
return existsSync('/boot/config/plugins/dynamix.unraid.net.plg');
};

View File

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

View File

@@ -1,7 +1,7 @@
import pino from 'pino';
import { pino } from 'pino';
import pretty from 'pino-pretty';
import { API_VERSION, LOG_LEVEL, LOG_TYPE, SUPPRESS_LOGS } from '@app/environment.js';
import { API_VERSION, LOG_LEVEL, LOG_TYPE } from '@app/environment.js';
export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
@@ -9,43 +9,18 @@ export type LogLevel = (typeof levels)[number];
const level = levels[levels.indexOf(LOG_LEVEL.toLowerCase() as LogLevel)] ?? 'info';
const nullDestination = pino.destination({
write() {
// Suppress all logs
},
});
export const logDestination = pino.destination();
export const logDestination =
process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination();
// Since PM2 captures stdout and writes to the log file, we should not colorize stdout
// to avoid ANSI escape codes in the log file
const stream = SUPPRESS_LOGS
? nullDestination
: LOG_TYPE === 'pretty'
? pretty({
singleLine: true,
hideObject: false,
colorize: false, // No colors since PM2 writes stdout to file
colorizeObjects: false,
levelFirst: false,
ignore: 'hostname,pid',
destination: logDestination,
translateTime: 'HH:mm:ss',
customPrettifiers: {
time: (timestamp: string | object) => `[${timestamp}`,
level: (_logLevel: string | object, _key: string, log: any, extras: any) => {
// Use label instead of labelColorized for non-colored output
const { label } = extras;
const context = log.context || log.logger || 'app';
return `${label} ${context}]`;
},
},
messageFormat: (log: any, messageKey: string) => {
const msg = log[messageKey] || log.msg || '';
return msg;
},
})
: logDestination;
const stream =
LOG_TYPE === 'pretty'
? pretty({
singleLine: true,
hideObject: false,
colorize: true,
ignore: 'hostname,pid',
destination: logDestination,
})
: logDestination;
export const logger = pino(
{
@@ -95,7 +70,6 @@ export const keyServerLogger = logger.child({ logger: 'key-server' });
export const remoteAccessLogger = logger.child({ logger: 'remote-access' });
export const remoteQueryLogger = logger.child({ logger: 'remote-query' });
export const apiLogger = logger.child({ logger: 'api' });
export const pluginLogger = logger.child({ logger: 'plugin' });
export const loggers = [
internalLogger,

View File

@@ -1,7 +1,6 @@
import { GraphQLError } from 'graphql';
import { sum } from 'lodash-es';
import { getParityCheckStatus } from '@app/core/modules/array/parity-check-status.js';
import { store } from '@app/store/index.js';
import { FileLoadStatus } from '@app/store/types.js';
import {
@@ -62,6 +61,5 @@ export const getArrayData = (getState = store.getState): UnraidArray => {
parities,
disks,
caches,
parityCheckStatus: getParityCheckStatus(emhttp.var),
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,72 +0,0 @@
import { toNumberAlways } from '@unraid/shared/util/data.js';
import type { Var } from '@app/core/types/states/var.js';
import type { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js';
export enum ParityCheckStatus {
NEVER_RUN = 'never_run',
RUNNING = 'running',
PAUSED = 'paused',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
FAILED = 'failed',
}
function calculateParitySpeed(deltaTime: number, deltaBlocks: number) {
if (deltaTime === 0 || deltaBlocks === 0) return 0;
const deltaBytes = deltaBlocks * 1024;
const speedMBps = deltaBytes / deltaTime / 1024 / 1024;
return Math.round(speedMBps);
}
type RelevantVarData = Pick<
Var,
| 'mdResyncPos'
| 'mdResyncDt'
| 'sbSyncExit'
| 'sbSynced'
| 'sbSynced2'
| 'mdResyncDb'
| 'mdResyncSize'
>;
function getStatusFromVarData(varData: RelevantVarData): ParityCheckStatus {
const { mdResyncPos, mdResyncDt, sbSyncExit, sbSynced, sbSynced2 } = varData;
const mdResyncDtNumber = toNumberAlways(mdResyncDt, 0);
const sbSyncExitNumber = toNumberAlways(sbSyncExit, 0);
switch (true) {
case mdResyncPos > 0:
return mdResyncDtNumber > 0 ? ParityCheckStatus.RUNNING : ParityCheckStatus.PAUSED;
case sbSynced === 0:
return ParityCheckStatus.NEVER_RUN;
case sbSyncExitNumber === -4:
return ParityCheckStatus.CANCELLED;
case sbSyncExitNumber !== 0:
return ParityCheckStatus.FAILED;
case sbSynced2 > 0:
return ParityCheckStatus.COMPLETED;
default:
return ParityCheckStatus.NEVER_RUN;
}
}
export function getParityCheckStatus(varData: RelevantVarData): ParityCheck {
const { sbSynced, sbSynced2, mdResyncDt, mdResyncDb, mdResyncPos, mdResyncSize } = varData;
const deltaTime = toNumberAlways(mdResyncDt, 0);
const deltaBlocks = toNumberAlways(mdResyncDb, 0);
// seconds since epoch (unix timestamp)
const now = sbSynced2 > 0 ? sbSynced2 : Date.now() / 1000;
return {
status: getStatusFromVarData(varData),
speed: String(calculateParitySpeed(deltaTime, deltaBlocks)),
date: sbSynced > 0 ? new Date(sbSynced * 1000) : undefined,
duration: sbSynced > 0 ? Math.round(now - sbSynced) : undefined,
// percentage as integer, clamped to [0, 100]
progress:
mdResyncSize <= 0
? 0
: Math.round(Math.min(100, Math.max(0, (mdResyncPos / mdResyncSize) * 100))),
};
}

View File

@@ -8,7 +8,7 @@ export class NginxManager {
await execa('/etc/rc.d/rc.nginx', ['reload']);
return true;
} catch (err: unknown) {
logger.warn('Failed to restart Nginx with error: %o', err as object);
logger.warn('Failed to restart Nginx with error: ', err);
return false;
}
};

View File

@@ -8,7 +8,7 @@ export class UpdateDNSManager {
await execa('/usr/bin/php', ['/usr/local/emhttp/plugins/dynamix/include/UpdateDNS.php']);
return true;
} catch (err: unknown) {
logger.warn('Failed to call Update DNS with error: %o', err as object);
logger.warn('Failed to call Update DNS with error: ', err);
return false;
}
};

View File

@@ -13,11 +13,8 @@ export const pubsub = new PubSub({ eventEmitter });
/**
* Create a pubsub subscription.
* @param channel The pubsub channel to subscribe to. Can be either a predefined GRAPHQL_PUBSUB_CHANNEL
* or a dynamic string for runtime-generated topics (e.g., log file paths like "LOG_FILE:/var/log/test.log")
* @param channel The pubsub channel to subscribe to.
*/
export const createSubscription = <T = any>(
channel: GRAPHQL_PUBSUB_CHANNEL | string
): AsyncIterableIterator<T> => {
return pubsub.asyncIterableIterator<T>(channel);
export const createSubscription = (channel: GRAPHQL_PUBSUB_CHANNEL) => {
return pubsub.asyncIterableIterator(channel);
};

View File

@@ -68,24 +68,11 @@ export type Var = {
mdNumStripes: number;
mdNumStripesDefault: number;
mdNumStripesStatus: string;
/**
* Serves a dual purpose depending on context:
* - Total size of the operation (in sectors/blocks)
* - Running state indicator (0 = paused, >0 = running)
*/
mdResync: number;
mdResyncAction: string;
mdResyncCorr: string;
mdResyncDb: string;
/** Average time interval (delta time) in seconds of current parity operations */
mdResyncDt: string;
/**
* Current position in the parity operation (in sectors/blocks).
* When mdResyncPos > 0, a parity operation is active.
* When mdResyncPos = 0, no parity operation is running.
*
* Used to calculate progress percentage.
*/
mdResyncPos: number;
mdResyncSize: number;
mdState: ArrayState;
@@ -149,36 +136,9 @@ export type Var = {
sbName: string;
sbNumDisks: number;
sbState: string;
/**
* Unix timestamp when parity operation started.
* When sbSynced = 0, indicates no parity check has ever been run.
*
* Used to calculate elapsed time during active operations.
*/
sbSynced: number;
sbSynced2: number;
/**
* Unix timestamp when parity operation completed (successfully or with errors).
* Used to display completion time in status messages.
*
* When sbSynced2 = 0, indicates operation started but not yet finished
*/
sbSyncErrs: number;
/**
* Exit status code that indicates how the last parity operation completed, following standard Unix conventions.
*
* sbSyncExit = 0 - Successful Completion
* - Parity operation completed normally without errors
* - Used to calculate speed and display success message
*
* sbSyncExit = -4 - Aborted/Cancelled
* - Operation was manually cancelled by user
* - Displays as "aborted" in the UI
*
* sbSyncExit ≠ 0 (other values) - Failed/Incomplete
* - Operation failed due to errors or other issues
* - Displays the numeric error code
*/
sbSyncExit: string;
sbUpdated: string;
sbVersion: string;

View File

@@ -1,66 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { isSafeModeEnabled } from '@app/core/utils/safe-mode.js';
import { store } from '@app/store/index.js';
import * as stateFileLoader from '@app/store/services/state-file-loader.js';
describe('isSafeModeEnabled', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('returns the safe mode flag already present in the store', () => {
const baseState = store.getState();
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
emhttp: {
...baseState.emhttp,
var: {
...(baseState.emhttp?.var ?? {}),
safeMode: true,
},
},
});
const loaderSpy = vi.spyOn(stateFileLoader, 'loadStateFileSync');
expect(isSafeModeEnabled()).toBe(true);
expect(loaderSpy).not.toHaveBeenCalled();
});
it('falls back to the synchronous loader when store state is missing', () => {
const baseState = store.getState();
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
emhttp: {
...baseState.emhttp,
var: {
...(baseState.emhttp?.var ?? {}),
safeMode: undefined as unknown as boolean,
} as typeof baseState.emhttp.var,
} as typeof baseState.emhttp,
} as typeof baseState);
vi.spyOn(stateFileLoader, 'loadStateFileSync').mockReturnValue({
...(baseState.emhttp?.var ?? {}),
safeMode: true,
} as any);
expect(isSafeModeEnabled()).toBe(true);
});
it('defaults to false when loader cannot provide state', () => {
const baseState = store.getState();
vi.spyOn(store, 'getState').mockReturnValue({
...baseState,
emhttp: {
...baseState.emhttp,
var: {
...(baseState.emhttp?.var ?? {}),
safeMode: undefined as unknown as boolean,
} as typeof baseState.emhttp.var,
} as typeof baseState.emhttp,
} as typeof baseState);
vi.spyOn(stateFileLoader, 'loadStateFileSync').mockReturnValue(null);
expect(isSafeModeEnabled()).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
import { isEqual, merge } from 'lodash-es';
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
import { initialState } from '@app/store/modules/config.js';
import {
MyServersConfig,
MyServersConfigMemory,
MyServersConfigMemorySchema,
MyServersConfigSchema,
} from '@app/types/my-servers-config.js';
// Define ConfigType and ConfigObject
export type ConfigType = 'flash' | 'memory';
/**
* Get a writeable configuration based on the mode ('flash' or 'memory').
*/
export const getWriteableConfig = <T extends ConfigType>(
config: T extends 'memory' ? MyServersConfigMemory : MyServersConfig,
mode: T
): T extends 'memory' ? MyServersConfigMemory : MyServersConfig => {
const schema = mode === 'memory' ? MyServersConfigMemorySchema : MyServersConfigSchema;
const defaultConfig = schema.parse(initialState);
// Use a type assertion for the mergedConfig to include `connectionStatus` only if `mode === 'memory`
const mergedConfig = merge<
MyServersConfig,
T extends 'memory' ? MyServersConfigMemory : MyServersConfig
>(defaultConfig, config);
if (mode === 'memory') {
(mergedConfig as MyServersConfigMemory).remote.allowedOrigins = getAllowedOrigins().join(', ');
(mergedConfig as MyServersConfigMemory).connectionStatus = {
...(defaultConfig as MyServersConfigMemory).connectionStatus,
...(config as MyServersConfigMemory).connectionStatus,
};
}
return schema.parse(mergedConfig) as T extends 'memory' ? MyServersConfigMemory : MyServersConfig; // Narrowing ensures correct typing
};

View File

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

View File

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

View File

@@ -16,22 +16,11 @@ export const getKeyFile = async function (appStore: RootState = store.getState()
const keyFileName = basename(emhttp.var?.regFile);
const registrationKeyFilePath = join(paths['keyfile-base'], keyFileName);
try {
const keyFile = await readFile(registrationKeyFilePath, 'binary');
return Buffer.from(keyFile, 'binary')
.toString('base64')
.trim()
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
} catch (error) {
// Handle ENOENT error when Pro.key file doesn't exist
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
// Return empty string when key file is missing (ENOKEYFILE state)
return '';
}
// Re-throw other errors
throw error;
}
const keyFile = await readFile(registrationKeyFilePath, 'binary');
return Buffer.from(keyFile, 'binary')
.toString('base64')
.trim()
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
};

View File

@@ -26,7 +26,7 @@ export const loadState = <T extends Record<string, unknown>>(filePath: string):
logger.trace(
'Failed loading state file "%s" with "%s"',
filePath,
error instanceof Error ? error.message : String(error)
error instanceof Error ? error.message : error
);
}

View File

@@ -23,54 +23,6 @@ type OptionsWithLoadedFile = {
type: ConfigType;
};
/**
* Flattens nested objects that were incorrectly created by periods in INI section names.
* For example: { system: { with: { periods: {...} } } } -> { "system.with.periods": {...} }
*/
const flattenPeriodSections = (obj: Record<string, any>, prefix = ''): Record<string, any> => {
const result: Record<string, any> = {};
const isNestedObject = (value: unknown) =>
Boolean(value && typeof value === 'object' && !Array.isArray(value));
// prevent prototype pollution/injection
const isUnsafeKey = (k: string) => k === '__proto__' || k === 'prototype' || k === 'constructor';
for (const [key, value] of Object.entries(obj)) {
if (isUnsafeKey(key)) continue;
const fullKey = prefix ? `${prefix}.${key}` : key;
if (!isNestedObject(value)) {
result[fullKey] = value;
continue;
}
const section = {};
const nestedObjs = {};
let hasSectionProps = false;
for (const [propKey, propValue] of Object.entries(value)) {
if (isUnsafeKey(propKey)) continue;
if (isNestedObject(propValue)) {
nestedObjs[propKey] = propValue;
} else {
section[propKey] = propValue;
hasSectionProps = true;
}
}
// Process direct properties first to maintain order
if (hasSectionProps) {
result[fullKey] = section;
}
// Then process nested objects
if (Object.keys(nestedObjs).length > 0) {
Object.assign(result, flattenPeriodSections(nestedObjs, fullKey));
}
}
return result;
};
/**
* Converts the following
* ```
@@ -175,8 +127,6 @@ export const parseConfig = <T extends Record<string, any>>(
let data: Record<string, any>;
try {
data = parseIni(fileContents);
// Fix nested objects created by periods in section names
data = flattenPeriodSections(data);
} catch (error) {
throw new AppError(
`Failed to parse config file: ${error instanceof Error ? error.message : String(error)}`

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