mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
2 Commits
v4.29.2
...
4.9.2-buil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb34f3c91 | ||
|
|
d159d4b0dd |
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"permissions": {}
|
||||
}
|
||||
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
20
.github/CODEOWNERS
vendored
Normal 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
|
||||
208
.github/workflows/build-artifacts.yml
vendored
208
.github/workflows/build-artifacts.yml
vendored
@@ -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
|
||||
|
||||
106
.github/workflows/build-plugin.yml
vendored
106
.github/workflows/build-plugin.yml
vendored
@@ -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"
|
||||
|
||||
8
.github/workflows/codeql-analysis.yml
vendored
8
.github/workflows/codeql-analysis.yml
vendored
@@ -24,17 +24,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@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
|
||||
59
.github/workflows/create-docusaurus-pr.yml
vendored
Normal file
59
.github/workflows/create-docusaurus-pr.yml
vendored
Normal 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
|
||||
17
.github/workflows/deploy-storybook.yml
vendored
17
.github/workflows/deploy-storybook.yml
vendored
@@ -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({
|
||||
|
||||
210
.github/workflows/generate-release-notes.yml
vendored
210
.github/workflows/generate-release-notes.yml
vendored
@@ -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
|
||||
|
||||
352
.github/workflows/main.yml
vendored
352
.github/workflows/main.yml
vendored
@@ -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 }}
|
||||
|
||||
239
.github/workflows/manual-release.yml
vendored
239
.github/workflows/manual-release.yml
vendored
@@ -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 }}
|
||||
|
||||
30
.github/workflows/publish-schema.yml
vendored
30
.github/workflows/publish-schema.yml
vendored
@@ -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
|
||||
|
||||
100
.github/workflows/push-staging-pr-on-close.yml
vendored
100
.github/workflows/push-staging-pr-on-close.yml
vendored
@@ -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 }}
|
||||
|
||||
25
.github/workflows/release-production.yml
vendored
25
.github/workflows/release-production.yml
vendored
@@ -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
71
.github/workflows/test-libvirt.yml
vendored
Normal 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
17
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.29.2"}
|
||||
{".":"4.9.2"}
|
||||
|
||||
14
.vscode/settings.json
vendored
Normal file
14
.vscode/settings.json
vendored
Normal 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
22
.vscode/sftp-template.json
vendored
Normal 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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%));
|
||||
}
|
||||
31
CLAUDE.md
31
CLAUDE.md
@@ -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)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"parsers": {
|
||||
"**/*.ts": [
|
||||
"@depcheck/parser-typescript",
|
||||
{
|
||||
"project": "tsconfig.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
108
api/.eslintrc.ts
108
api/.eslintrc.ts
@@ -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
14
api/.gitignore
vendored
@@ -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
9
api/.vscode/settings.json
vendored
Normal 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"
|
||||
}
|
||||
518
api/CHANGELOG.md
518
api/CHANGELOG.md
@@ -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 -> 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 < 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 -> 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)
|
||||
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
11
api/dev/keys/b5b4aa3d-8e40-4c92-bc40-d50182071886.json
Normal file
11
api/dev/keys/b5b4aa3d-8e40-4c92-bc40-d50182071886.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# custom log directory for tests & development
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
timestamp=1730937600
|
||||
event=Temperature Test
|
||||
subject=Warning [UNRAID] - High disk temperature detected: 45 °C
|
||||
description=Disk 1 temperature has reached 45 °C (threshold: 40 °C)<br><br>Current temperatures:<br>Parity - 32 °C [OK]<br>Disk 1 - 45 °C [WARNING]<br>Disk 2 - 38 °C [OK]<br>Cache - 28 °C [OK]<br><br>Please check cooling system.
|
||||
importance=warning
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
4
api/docs/public/_category_.json
Normal file
4
api/docs/public/_category_.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Unraid API",
|
||||
"position": 4
|
||||
}
|
||||
162
api/docs/public/cli.md
Normal file
162
api/docs/public/cli.md
Normal 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
|
||||
202
api/docs/public/how-to-use-the-api.md
Normal file
202
api/docs/public/how-to-use-the-api.md
Normal 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
37
api/docs/public/index.md
Normal 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).
|
||||
@@ -1 +0,0 @@
|
||||
# All Content Here has been permanently moved to [Unraid Docs](https://github.com/unraid/docs)
|
||||
71
api/docs/public/upcoming-features.md
Normal file
71
api/docs/public/upcoming-features.md
Normal 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.
|
||||
@@ -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
129
api/package.json
129
api/package.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
45
api/src/__test__/common/allowed-origins.test.ts
Normal file
45
api/src/__test__/common/allowed-origins.test.ts
Normal 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);
|
||||
});
|
||||
137
api/src/__test__/config/api-config.test.ts
Normal file
137
api/src/__test__/config/api-config.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
api/src/__test__/core/utils/files/config-file-normalizer.test.ts
Normal file
158
api/src/__test__/core/utils/files/config-file-normalizer.test.ts
Normal 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": "",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
/* eslint-disable no-undef */
|
||||
// Dummy process for PM2 testing
|
||||
setInterval(() => {
|
||||
// Keep process alive
|
||||
}, 1000);
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -17,6 +17,7 @@ exports[`Returns paths 1`] = `
|
||||
"myservers-base",
|
||||
"myservers-config",
|
||||
"myservers-config-states",
|
||||
"myservers-env",
|
||||
"myservers-keepalive",
|
||||
"keyfile-base",
|
||||
"machine-id",
|
||||
|
||||
303
api/src/__test__/store/modules/config.test.ts
Normal file
303
api/src/__test__/store/modules/config.test.ts
Normal 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',
|
||||
// });
|
||||
// });
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
34
api/src/__test__/store/sync/registration-sync.test.ts
Normal file
34
api/src/__test__/store/sync/registration-sync.test.ts
Normal 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",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
20
api/src/__test__/upnp/helpers.test.ts
Normal file
20
api/src/__test__/upnp/helpers.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
99
api/src/common/allowed-origins.ts
Normal file
99
api/src/common/allowed-origins.ts
Normal 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));
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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))),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
40
api/src/core/utils/files/config-file-normalizer.ts
Normal file
40
api/src/core/utils/files/config-file-normalizer.ts
Normal 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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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, '');
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user