mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
31 Commits
v4.25.2
...
4.25.2-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f981129764 | ||
|
|
1f1516735e | ||
|
|
74c53653e8 | ||
|
|
9e04487f13 | ||
|
|
01ecbbdf56 | ||
|
|
5fda8fc1e2 | ||
|
|
fa62d70ed8 | ||
|
|
f494394825 | ||
|
|
95adc92cd3 | ||
|
|
be2d253060 | ||
|
|
9d63220146 | ||
|
|
f3da2a4caf | ||
|
|
e78819d9b7 | ||
|
|
3e2b1eff18 | ||
|
|
c58199b3ed | ||
|
|
2e8e4baa5a | ||
|
|
ff2906e52a | ||
|
|
a0d6cc92c8 | ||
|
|
57acfaacf0 | ||
|
|
ea816c7a5c | ||
|
|
cafde72d38 | ||
|
|
2b481c397c | ||
|
|
8c4e9dd7ae | ||
|
|
f212dce88b | ||
|
|
8cd2a4c124 | ||
|
|
10f048ee1f | ||
|
|
e9e271ade5 | ||
|
|
31c41027fc | ||
|
|
fabe6a2c4b | ||
|
|
754966d5d3 | ||
|
|
ed594e9147 |
201
.github/workflows/build-artifacts.yml
vendored
Normal file
201
.github/workflows/build-artifacts.yml
vendored
Normal file
@@ -0,0 +1,201 @@
|
||||
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 }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
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@v5
|
||||
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
|
||||
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: 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@v5
|
||||
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@v5
|
||||
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@v4
|
||||
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@v5
|
||||
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@v5
|
||||
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@v4
|
||||
with:
|
||||
name: unraid-wc-rich
|
||||
path: web/dist
|
||||
|
||||
12
.github/workflows/build-plugin.yml
vendored
12
.github/workflows/build-plugin.yml
vendored
@@ -27,6 +27,15 @@ 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
|
||||
@@ -49,6 +58,7 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -136,7 +146,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Workflow Dispatch and wait
|
||||
if: inputs.RELEASE_CREATED == 'true'
|
||||
if: inputs.RELEASE_CREATED == 'true' && inputs.TRIGGER_PRODUCTION_RELEASE == true
|
||||
uses: the-actions-org/workflow-dispatch@v4.0.0
|
||||
with:
|
||||
workflow: release-production.yml
|
||||
|
||||
103
.github/workflows/claude-code-review.yml
vendored
103
.github/workflows/claude-code-review.yml
vendored
@@ -1,103 +0,0 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Skip reviews for non-code changes
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "**/package-lock.json"
|
||||
- "**/pnpm-lock.yaml"
|
||||
- "**/.gitignore"
|
||||
- "**/LICENSE"
|
||||
- "**/*.config.js"
|
||||
- "**/*.config.ts"
|
||||
- "**/tsconfig.json"
|
||||
- "**/.github/workflows/*.yml"
|
||||
- "**/docs/**"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Skip review for bot PRs and WIP/skip-review PRs
|
||||
# Only run if changes are significant (>10 lines)
|
||||
if: |
|
||||
(github.event.pull_request.additions > 10 || github.event.pull_request.deletions > 10) &&
|
||||
!contains(github.event.pull_request.title, '[skip-review]') &&
|
||||
!contains(github.event.pull_request.title, '[WIP]') &&
|
||||
!endsWith(github.event.pull_request.user.login, '[bot]') &&
|
||||
github.event.pull_request.user.login != 'dependabot' &&
|
||||
github.event.pull_request.user.login != 'renovate'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
|
||||
# model: "claude-opus-4-20250514"
|
||||
|
||||
# Direct prompt for automated review (no @claude mention needed)
|
||||
direct_prompt: |
|
||||
IMPORTANT: Review ONLY the DIFF/CHANGESET - the actual lines that were added or modified in this PR.
|
||||
DO NOT review the entire file context, only analyze the specific changes being made.
|
||||
|
||||
Look for HIGH-PRIORITY issues in the CHANGED LINES ONLY:
|
||||
|
||||
1. CRITICAL BUGS: Logic errors, null pointer issues, infinite loops, race conditions
|
||||
2. SECURITY: SQL injection, XSS, authentication bypass, exposed secrets, unsafe operations
|
||||
3. BREAKING CHANGES: API contract violations, removed exports, changed function signatures
|
||||
4. DATA LOSS RISKS: Destructive operations without safeguards, missing data validation
|
||||
|
||||
DO NOT comment on:
|
||||
- Code that wasn't changed in this PR
|
||||
- Style, formatting, or documentation
|
||||
- Test coverage (unless tests are broken by the changes)
|
||||
- Minor optimizations or best practices
|
||||
- Existing code issues that weren't introduced by this PR
|
||||
|
||||
If you find no critical issues in the DIFF, respond with: "✅ No critical issues found in changes"
|
||||
|
||||
Keep response under 10 lines. Reference specific line numbers from the diff when reporting issues.
|
||||
|
||||
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
|
||||
use_sticky_comment: true
|
||||
|
||||
# Context-aware review based on PR characteristics
|
||||
# Uncomment to enable different review strategies based on context
|
||||
# direct_prompt: |
|
||||
# ${{
|
||||
# (github.event.pull_request.additions > 500) &&
|
||||
# 'Large PR detected. Focus only on architectural issues and breaking changes. Skip minor issues.' ||
|
||||
# contains(github.event.pull_request.title, 'fix') &&
|
||||
# 'Bug fix PR: Verify the fix addresses the root cause and check for regression risks.' ||
|
||||
# contains(github.event.pull_request.title, 'deps') &&
|
||||
# 'Dependency update: Check for breaking changes and security advisories only.' ||
|
||||
# contains(github.event.pull_request.title, 'refactor') &&
|
||||
# 'Refactor PR: Verify no behavior changes and check for performance regressions.' ||
|
||||
# contains(github.event.pull_request.title, 'feat') &&
|
||||
# 'New feature: Check for security issues, edge cases, and integration problems only.' ||
|
||||
# 'Standard review: Check for critical bugs, security issues, and breaking changes only.'
|
||||
# }}
|
||||
|
||||
# Optional: Add specific tools for running tests or linting
|
||||
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
|
||||
|
||||
# Optional: Skip review for certain conditions
|
||||
# if: |
|
||||
# !contains(github.event.pull_request.title, '[skip-review]') &&
|
||||
# !contains(github.event.pull_request.title, '[WIP]')
|
||||
|
||||
210
.github/workflows/generate-release-notes.yml
vendored
Normal file
210
.github/workflows/generate-release-notes.yml
vendored
Normal file
@@ -0,0 +1,210 @@
|
||||
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@v5
|
||||
with:
|
||||
ref: ${{ inputs.target_commitish || github.ref }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
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
|
||||
|
||||
192
.github/workflows/main.yml
vendored
192
.github/workflows/main.yml
vendored
@@ -154,170 +154,15 @@ jobs:
|
||||
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-api:
|
||||
name: Build API
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build_number: ${{ steps.buildnumber.outputs.build_number }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: api
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
with:
|
||||
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.UNRAID_BOT_GITHUB_ADMIN_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@v5
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
with:
|
||||
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@v5
|
||||
|
||||
- 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@v5
|
||||
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@v4
|
||||
with:
|
||||
name: unraid-wc-rich
|
||||
path: web/dist
|
||||
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
|
||||
@@ -326,9 +171,7 @@ jobs:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-api
|
||||
- build-api
|
||||
- build-web
|
||||
- build-unraid-ui-webcomponents
|
||||
- build-artifacts
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -345,17 +188,15 @@ jobs:
|
||||
build-plugin-staging-pr:
|
||||
name: Build and Deploy Plugin
|
||||
needs:
|
||||
- build-api
|
||||
- build-web
|
||||
- build-unraid-ui-webcomponents
|
||||
- build-artifacts
|
||||
- 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-api.outputs.build_number }}
|
||||
BUILD_NUMBER: ${{ needs.build-artifacts.outputs.build_number }}
|
||||
secrets:
|
||||
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
|
||||
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
@@ -367,15 +208,16 @@ jobs:
|
||||
name: Build and Deploy Production Plugin
|
||||
needs:
|
||||
- release-please
|
||||
- build-api
|
||||
- build-artifacts
|
||||
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-api.outputs.build_number }}
|
||||
BUILD_NUMBER: ${{ needs.build-artifacts.outputs.build_number }}
|
||||
TRIGGER_PRODUCTION_RELEASE: true
|
||||
secrets:
|
||||
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
|
||||
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
|
||||
239
.github/workflows/manual-release.yml
vendored
Normal file
239
.github/workflows/manual-release.yml
vendored
Normal file
@@ -0,0 +1,239 @@
|
||||
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@v5
|
||||
with:
|
||||
ref: ${{ inputs.target_commitish || github.ref }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.UNRAID_BOT_GITHUB_ADMIN_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
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@v5
|
||||
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 }}
|
||||
|
||||
@@ -42,7 +42,10 @@ export default tseslint.config(
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'always',
|
||||
ts: 'always',
|
||||
mjs: 'always',
|
||||
cjs: 'always',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
'no-restricted-globals': [
|
||||
|
||||
@@ -71,6 +71,10 @@ 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.22.2",
|
||||
"version": "4.25.2",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -1401,6 +1401,17 @@ type CpuUtilization implements Node {
|
||||
cpus: [CpuLoad!]!
|
||||
}
|
||||
|
||||
type CpuPackages implements Node {
|
||||
"""Total CPU package power draw (W)"""
|
||||
totalPower: Float!
|
||||
|
||||
"""Power draw per package (W)"""
|
||||
power: [Float!]
|
||||
|
||||
"""description: 'Temperature per package (°C)"""
|
||||
temp: [Float!]
|
||||
}
|
||||
|
||||
type InfoCpu implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
@@ -1446,6 +1457,9 @@ type InfoCpu implements Node {
|
||||
"""Number of physical processors"""
|
||||
processors: Int
|
||||
|
||||
"""CPU packages information"""
|
||||
packages: CpuPackages
|
||||
|
||||
"""CPU socket type"""
|
||||
socket: String
|
||||
|
||||
@@ -2642,6 +2656,7 @@ type Subscription {
|
||||
arraySubscription: UnraidArray!
|
||||
logFile(path: String!): LogFileContent!
|
||||
systemMetricsCpu: CpuUtilization!
|
||||
systemMetricsCpuTelemetry: CpuPackages!
|
||||
systemMetricsMemory: MemoryUtilization!
|
||||
upsUpdates: UPSDevice!
|
||||
}
|
||||
@@ -30,6 +30,8 @@
|
||||
"// GraphQL Codegen": "",
|
||||
"codegen": "graphql-codegen --config codegen.ts",
|
||||
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
|
||||
"// Internationalization": "",
|
||||
"i18n:extract": "node ./scripts/extract-translations.mjs",
|
||||
"// Code Quality": "",
|
||||
"lint": "eslint --config .eslintrc.ts src/",
|
||||
"lint:fix": "eslint --fix --config .eslintrc.ts src/",
|
||||
|
||||
162
api/scripts/extract-translations.mjs
Normal file
162
api/scripts/extract-translations.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
#!/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;
|
||||
});
|
||||
1
api/src/i18n/ar.json
Normal file
1
api/src/i18n/ar.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/bn.json
Normal file
1
api/src/i18n/bn.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/ca.json
Normal file
1
api/src/i18n/ca.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/cs.json
Normal file
1
api/src/i18n/cs.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/da.json
Normal file
1
api/src/i18n/da.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/de.json
Normal file
1
api/src/i18n/de.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/en.json
Normal file
1
api/src/i18n/en.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/es.json
Normal file
1
api/src/i18n/es.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/fr.json
Normal file
1
api/src/i18n/fr.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/hi.json
Normal file
1
api/src/i18n/hi.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/hr.json
Normal file
1
api/src/i18n/hr.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/hu.json
Normal file
1
api/src/i18n/hu.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/it.json
Normal file
1
api/src/i18n/it.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/ja.json
Normal file
1
api/src/i18n/ja.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/ko.json
Normal file
1
api/src/i18n/ko.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/lv.json
Normal file
1
api/src/i18n/lv.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/nl.json
Normal file
1
api/src/i18n/nl.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/no.json
Normal file
1
api/src/i18n/no.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/pl.json
Normal file
1
api/src/i18n/pl.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/pt.json
Normal file
1
api/src/i18n/pt.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/ro.json
Normal file
1
api/src/i18n/ro.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/ru.json
Normal file
1
api/src/i18n/ru.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/sv.json
Normal file
1
api/src/i18n/sv.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/uk.json
Normal file
1
api/src/i18n/uk.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
api/src/i18n/zh.json
Normal file
1
api/src/i18n/zh.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
40
api/src/types/jsonforms-i18n.d.ts
vendored
Normal file
40
api/src/types/jsonforms-i18n.d.ts
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
import '@jsonforms/core/lib/models/jsonSchema4';
|
||||
import '@jsonforms/core/lib/models/jsonSchema7';
|
||||
import '@jsonforms/core/src/models/jsonSchema4';
|
||||
import '@jsonforms/core/src/models/jsonSchema7';
|
||||
|
||||
declare module '@jsonforms/core/lib/models/jsonSchema4' {
|
||||
interface JsonSchema4 {
|
||||
i18n?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@jsonforms/core/lib/models/jsonSchema7' {
|
||||
interface JsonSchema7 {
|
||||
i18n?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@jsonforms/core/src/models/jsonSchema4' {
|
||||
interface JsonSchema4 {
|
||||
i18n?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@jsonforms/core/src/models/jsonSchema7' {
|
||||
interface JsonSchema7 {
|
||||
i18n?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@jsonforms/core/lib/models/jsonSchema4.js' {
|
||||
interface JsonSchema4 {
|
||||
i18n?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@jsonforms/core/lib/models/jsonSchema7.js' {
|
||||
interface JsonSchema7 {
|
||||
i18n?: string;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,8 @@ export class PM2Service {
|
||||
...(needsPathUpdate && { PATH: finalPath }),
|
||||
};
|
||||
|
||||
const runCommand = () => execa(PM2_PATH, [...args], execOptions satisfies Options);
|
||||
const pm2Args = args.some((arg) => arg === '--no-color') ? args : ['--no-color', ...args];
|
||||
const runCommand = () => execa(PM2_PATH, pm2Args, execOptions satisfies Options);
|
||||
if (raw) {
|
||||
return runCommand();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,24 @@ import {
|
||||
createSimpleLabeledControl,
|
||||
} from '@app/unraid-api/graph/utils/form-utils.js';
|
||||
|
||||
const API_KEY_I18N = {
|
||||
name: 'jsonforms.apiKey.name',
|
||||
description: 'jsonforms.apiKey.description',
|
||||
roles: 'jsonforms.apiKey.roles',
|
||||
permissionPresets: 'jsonforms.apiKey.permissionPresets',
|
||||
customPermissions: {
|
||||
root: 'jsonforms.apiKey.customPermissions',
|
||||
resources: 'jsonforms.apiKey.customPermissions.resources',
|
||||
actions: 'jsonforms.apiKey.customPermissions.actions',
|
||||
},
|
||||
permissions: {
|
||||
header: 'jsonforms.apiKey.permissions.header',
|
||||
description: 'jsonforms.apiKey.permissions.description',
|
||||
subheader: 'jsonforms.apiKey.permissions.subheader',
|
||||
help: 'jsonforms.apiKey.permissions.help',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Helper to get GraphQL enum names for JSON Schema
|
||||
// GraphQL expects the enum names (keys) not the values
|
||||
function getAuthActionEnumNames(): string[] {
|
||||
@@ -82,6 +100,7 @@ export class ApiKeyFormService {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
i18n: API_KEY_I18N.name,
|
||||
title: 'API Key Name',
|
||||
description: 'A descriptive name for this API key',
|
||||
minLength: 1,
|
||||
@@ -89,12 +108,14 @@ export class ApiKeyFormService {
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
i18n: API_KEY_I18N.description,
|
||||
title: 'Description',
|
||||
description: 'Optional description of what this key is used for',
|
||||
maxLength: 500,
|
||||
},
|
||||
roles: {
|
||||
type: 'array',
|
||||
i18n: API_KEY_I18N.roles,
|
||||
title: 'Roles',
|
||||
description: 'Select one or more roles to grant pre-defined permission sets',
|
||||
items: {
|
||||
@@ -105,6 +126,7 @@ export class ApiKeyFormService {
|
||||
},
|
||||
permissionPresets: {
|
||||
type: 'string',
|
||||
i18n: API_KEY_I18N.permissionPresets,
|
||||
title: 'Add Permission Preset',
|
||||
description: 'Quick add common permission sets',
|
||||
enum: [
|
||||
@@ -119,6 +141,7 @@ export class ApiKeyFormService {
|
||||
},
|
||||
customPermissions: {
|
||||
type: 'array',
|
||||
i18n: API_KEY_I18N.customPermissions.root,
|
||||
title: 'Permissions',
|
||||
description: 'Configure specific permissions',
|
||||
items: {
|
||||
@@ -126,6 +149,7 @@ export class ApiKeyFormService {
|
||||
properties: {
|
||||
resources: {
|
||||
type: 'array',
|
||||
i18n: API_KEY_I18N.customPermissions.resources,
|
||||
title: 'Resources',
|
||||
items: {
|
||||
type: 'string',
|
||||
@@ -137,6 +161,7 @@ export class ApiKeyFormService {
|
||||
},
|
||||
actions: {
|
||||
type: 'array',
|
||||
i18n: API_KEY_I18N.customPermissions.actions,
|
||||
title: 'Actions',
|
||||
items: {
|
||||
type: 'string',
|
||||
@@ -167,6 +192,7 @@ export class ApiKeyFormService {
|
||||
controlOptions: {
|
||||
inputType: 'text',
|
||||
},
|
||||
i18nKey: API_KEY_I18N.name,
|
||||
}),
|
||||
createLabeledControl({
|
||||
scope: '#/properties/description',
|
||||
@@ -177,6 +203,7 @@ export class ApiKeyFormService {
|
||||
multi: true,
|
||||
rows: 3,
|
||||
},
|
||||
i18nKey: API_KEY_I18N.description,
|
||||
}),
|
||||
// Permissions section header
|
||||
{
|
||||
@@ -185,6 +212,7 @@ export class ApiKeyFormService {
|
||||
options: {
|
||||
format: 'title',
|
||||
},
|
||||
i18n: API_KEY_I18N.permissions.header,
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Label',
|
||||
@@ -192,6 +220,7 @@ export class ApiKeyFormService {
|
||||
options: {
|
||||
format: 'description',
|
||||
},
|
||||
i18n: API_KEY_I18N.permissions.description,
|
||||
} as LabelElement,
|
||||
// Roles selection
|
||||
createLabeledControl({
|
||||
@@ -210,6 +239,7 @@ export class ApiKeyFormService {
|
||||
),
|
||||
descriptions: this.getRoleDescriptions(),
|
||||
},
|
||||
i18nKey: API_KEY_I18N.roles,
|
||||
}),
|
||||
// Separator for permissions
|
||||
{
|
||||
@@ -218,6 +248,7 @@ export class ApiKeyFormService {
|
||||
options: {
|
||||
format: 'subtitle',
|
||||
},
|
||||
i18n: API_KEY_I18N.permissions.subheader,
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Label',
|
||||
@@ -225,6 +256,7 @@ export class ApiKeyFormService {
|
||||
options: {
|
||||
format: 'description',
|
||||
},
|
||||
i18n: API_KEY_I18N.permissions.help,
|
||||
} as LabelElement,
|
||||
// Permission preset dropdown
|
||||
createLabeledControl({
|
||||
@@ -242,6 +274,7 @@ export class ApiKeyFormService {
|
||||
network_admin: 'Network Admin (Network & Services Control)',
|
||||
},
|
||||
},
|
||||
i18nKey: API_KEY_I18N.permissionPresets,
|
||||
}),
|
||||
// Custom permissions array - following OIDC pattern exactly
|
||||
{
|
||||
@@ -269,6 +302,7 @@ export class ApiKeyFormService {
|
||||
{}
|
||||
),
|
||||
},
|
||||
i18nKey: API_KEY_I18N.customPermissions.resources,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/actions',
|
||||
@@ -278,6 +312,7 @@ export class ApiKeyFormService {
|
||||
multiple: true,
|
||||
labels: getAuthActionLabels(),
|
||||
},
|
||||
i18nKey: API_KEY_I18N.customPermissions.actions,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { constants as fsConstants } from 'node:fs';
|
||||
import { access, readdir, readFile } from 'node:fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class CpuTopologyService {
|
||||
private readonly logger = new Logger(CpuTopologyService.name);
|
||||
|
||||
private topologyCache: { id: number; cores: number[][] }[] | null = null;
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Read static CPU topology, per-package core thread pairs
|
||||
// -----------------------------------------------------------------
|
||||
async generateTopology(): Promise<number[][][]> {
|
||||
const packages: Record<number, number[][]> = {};
|
||||
const cpuDirs = await readdir('/sys/devices/system/cpu');
|
||||
|
||||
for (const dir of cpuDirs) {
|
||||
if (!/^cpu\d+$/.test(dir)) continue;
|
||||
|
||||
const basePath = join('/sys/devices/system/cpu', dir, 'topology');
|
||||
const pkgFile = join(basePath, 'physical_package_id');
|
||||
const siblingsFile = join(basePath, 'thread_siblings_list');
|
||||
|
||||
try {
|
||||
const [pkgIdStr, siblingsStrRaw] = await Promise.all([
|
||||
readFile(pkgFile, 'utf8'),
|
||||
readFile(siblingsFile, 'utf8'),
|
||||
]);
|
||||
|
||||
const pkgId = parseInt(pkgIdStr.trim(), 10);
|
||||
|
||||
// expand ranges
|
||||
const siblings = siblingsStrRaw
|
||||
.trim()
|
||||
.replace(/(\d+)-(\d+)/g, (_, start, end) =>
|
||||
Array.from(
|
||||
{ length: parseInt(end) - parseInt(start) + 1 },
|
||||
(_, i) => parseInt(start) + i
|
||||
).join(',')
|
||||
)
|
||||
.split(',')
|
||||
.map((n) => parseInt(n, 10));
|
||||
|
||||
if (!packages[pkgId]) packages[pkgId] = [];
|
||||
if (!packages[pkgId].some((arr) => arr.join(',') === siblings.join(','))) {
|
||||
packages[pkgId].push(siblings);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Topology read error for', dir, err);
|
||||
}
|
||||
}
|
||||
// Sort cores within each package, and packages by their lowest core index
|
||||
const result = Object.entries(packages)
|
||||
.sort((a, b) => a[1][0][0] - b[1][0][0]) // sort packages by first CPU ID
|
||||
.map(
|
||||
([pkgId, cores]) => cores.sort((a, b) => a[0] - b[0]) // sort cores within package
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Dynamic telemetry (power + temperature)
|
||||
// -----------------------------------------------------------------
|
||||
private async getPackageTemps(): Promise<number[]> {
|
||||
const temps: number[] = [];
|
||||
try {
|
||||
const hwmons = await readdir('/sys/class/hwmon');
|
||||
for (const hwmon of hwmons) {
|
||||
const path = join('/sys/class/hwmon', hwmon);
|
||||
try {
|
||||
const label = (await readFile(join(path, 'name'), 'utf8')).trim();
|
||||
if (/coretemp|k10temp|zenpower/i.test(label)) {
|
||||
const files = await readdir(path);
|
||||
for (const f of files) {
|
||||
if (f.startsWith('temp') && f.endsWith('_label')) {
|
||||
const lbl = (await readFile(join(path, f), 'utf8')).trim().toLowerCase();
|
||||
if (
|
||||
lbl.includes('package id') ||
|
||||
lbl.includes('tctl') ||
|
||||
lbl.includes('tdie')
|
||||
) {
|
||||
const inputFile = join(path, f.replace('_label', '_input'));
|
||||
try {
|
||||
const raw = await readFile(inputFile, 'utf8');
|
||||
temps.push(parseInt(raw.trim(), 10) / 1000);
|
||||
} catch (err) {
|
||||
this.logger.warn('Failed to read file', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn('Failed to read file', err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn('Failed to read file', err);
|
||||
}
|
||||
return temps;
|
||||
}
|
||||
|
||||
private async getPackagePower(): Promise<Record<number, Record<string, number>>> {
|
||||
const basePath = '/sys/class/powercap';
|
||||
const prefixes = ['intel-rapl', 'intel-rapl-mmio', 'amd-rapl'];
|
||||
const raplPaths: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = await readdir(basePath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isSymbolicLink() && prefixes.some((p) => entry.name.startsWith(p))) {
|
||||
if (/:\d+:\d+/.test(entry.name)) continue;
|
||||
raplPaths.push(join(basePath, entry.name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!raplPaths.length) return {};
|
||||
|
||||
const readEnergy = async (p: string): Promise<number | null> => {
|
||||
try {
|
||||
await access(join(p, 'energy_uj'), fsConstants.R_OK);
|
||||
const raw = await readFile(join(p, 'energy_uj'), 'utf8');
|
||||
return parseInt(raw.trim(), 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const prevE = new Map<string, number>();
|
||||
const prevT = new Map<string, bigint>();
|
||||
|
||||
for (const p of raplPaths) {
|
||||
const val = await readEnergy(p);
|
||||
if (val !== null) {
|
||||
prevE.set(p, val);
|
||||
prevT.set(p, process.hrtime.bigint());
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((res) => setTimeout(res, 100));
|
||||
|
||||
const results: Record<number, Record<string, number>> = {};
|
||||
|
||||
for (const p of raplPaths) {
|
||||
const now = await readEnergy(p);
|
||||
if (now === null) continue;
|
||||
|
||||
const prevVal = prevE.get(p);
|
||||
const prevTime = prevT.get(p);
|
||||
if (prevVal === undefined || prevTime === undefined) continue;
|
||||
|
||||
const diffE = now - prevVal;
|
||||
const diffT = Number(process.hrtime.bigint() - prevTime);
|
||||
if (diffT <= 0 || diffE < 0) continue;
|
||||
|
||||
const watts = (diffE * 1e-6) / (diffT * 1e-9);
|
||||
const powerW = Math.round(watts * 100) / 100;
|
||||
|
||||
const nameFile = join(p, 'name');
|
||||
let label = 'package';
|
||||
try {
|
||||
label = (await readFile(nameFile, 'utf8')).trim();
|
||||
} catch (err) {
|
||||
this.logger.warn('Failed to read file', err);
|
||||
}
|
||||
|
||||
const pkgMatch = label.match(/package-(\d+)/i);
|
||||
const pkgId = pkgMatch ? Number(pkgMatch[1]) : 0;
|
||||
|
||||
if (!results[pkgId]) results[pkgId] = {};
|
||||
results[pkgId][label] = powerW;
|
||||
}
|
||||
|
||||
for (const domains of Object.values(results)) {
|
||||
const total = Object.values(domains).reduce((a, b) => a + b, 0);
|
||||
(domains as any)['total'] = Math.round(total * 100) / 100;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async generateTelemetry(): Promise<{ id: number; power: number; temp: number }[]> {
|
||||
const temps = await this.getPackageTemps();
|
||||
const powerData = await this.getPackagePower();
|
||||
|
||||
const maxPkg = Math.max(temps.length - 1, ...Object.keys(powerData).map(Number), 0);
|
||||
|
||||
const result: {
|
||||
id: number;
|
||||
power: number;
|
||||
temp: number;
|
||||
}[] = [];
|
||||
|
||||
for (let pkgId = 0; pkgId <= maxPkg; pkgId++) {
|
||||
const entry = powerData[pkgId] ?? {};
|
||||
result.push({
|
||||
id: pkgId,
|
||||
power: entry.total ?? -1,
|
||||
temp: temps[pkgId] ?? -1,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,18 @@ export class CpuLoad {
|
||||
percentSteal!: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CpuPackages {
|
||||
@Field(() => Float, { description: 'Total CPU package power draw (W)' })
|
||||
totalPower?: number;
|
||||
|
||||
@Field(() => [Float], { description: 'Power draw per package (W)' })
|
||||
power?: number[];
|
||||
|
||||
@Field(() => [Float], { description: 'Temperature per package (°C)' })
|
||||
temp?: number[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class CpuUtilization extends Node {
|
||||
@Field(() => Float, { description: 'Total CPU load in percent' })
|
||||
@@ -100,4 +112,12 @@ export class InfoCpu extends Node {
|
||||
|
||||
@Field(() => [String], { nullable: true, description: 'CPU feature flags' })
|
||||
flags?: string[];
|
||||
|
||||
@Field(() => [[[Int]]], {
|
||||
description: 'Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]]',
|
||||
})
|
||||
topology!: number[][][];
|
||||
|
||||
@Field(() => CpuPackages)
|
||||
packages!: CpuPackages;
|
||||
}
|
||||
|
||||
10
api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts
Normal file
10
api/src/unraid-api/graph/resolvers/info/cpu/cpu.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [CpuService, CpuTopologyService],
|
||||
exports: [CpuService, CpuTopologyService],
|
||||
})
|
||||
export class CpuModule {}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
|
||||
vi.mock('systeminformation', () => ({
|
||||
@@ -88,9 +89,27 @@ vi.mock('systeminformation', () => ({
|
||||
|
||||
describe('CpuService', () => {
|
||||
let service: CpuService;
|
||||
let cpuTopologyService: CpuTopologyService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CpuService();
|
||||
cpuTopologyService = {
|
||||
generateTopology: vi.fn().mockResolvedValue([
|
||||
[
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
],
|
||||
[
|
||||
[4, 5],
|
||||
[6, 7],
|
||||
],
|
||||
]),
|
||||
generateTelemetry: vi.fn().mockResolvedValue([
|
||||
{ power: 32.5, temp: 45.0 },
|
||||
{ power: 33.0, temp: 46.0 },
|
||||
]),
|
||||
} as any;
|
||||
|
||||
service = new CpuService(cpuTopologyService);
|
||||
});
|
||||
|
||||
describe('generateCpu', () => {
|
||||
@@ -121,6 +140,21 @@ describe('CpuService', () => {
|
||||
l3: 12582912,
|
||||
},
|
||||
flags: ['fpu', 'vme', 'de', 'pse', 'tsc', 'msr', 'pae', 'mce', 'cx8'],
|
||||
packages: {
|
||||
totalPower: 65.5,
|
||||
power: [32.5, 33.0],
|
||||
temp: [45.0, 46.0],
|
||||
},
|
||||
topology: [
|
||||
[
|
||||
[0, 1],
|
||||
[2, 3],
|
||||
],
|
||||
[
|
||||
[4, 5],
|
||||
[6, 7],
|
||||
],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,25 +2,50 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { cpu, cpuFlags, currentLoad } from 'systeminformation';
|
||||
|
||||
import { CpuUtilization, InfoCpu } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
|
||||
import {
|
||||
CpuPackages,
|
||||
CpuUtilization,
|
||||
InfoCpu,
|
||||
} from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
|
||||
@Injectable()
|
||||
export class CpuService {
|
||||
constructor(private readonly cpuTopologyService: CpuTopologyService) {}
|
||||
|
||||
async generateCpu(): Promise<InfoCpu> {
|
||||
const { cores, physicalCores, speedMin, speedMax, stepping, ...rest } = await cpu();
|
||||
const { cores, physicalCores, speedMin, speedMax, stepping, processors, ...rest } = await cpu();
|
||||
const flags = await cpuFlags()
|
||||
.then((flags) => flags.split(' '))
|
||||
.then((f) => f.split(' '))
|
||||
.catch(() => []);
|
||||
|
||||
// Gather telemetry
|
||||
const packageList = await this.cpuTopologyService.generateTelemetry();
|
||||
const topology = await this.cpuTopologyService.generateTopology();
|
||||
|
||||
// Compute total power (2 decimals)
|
||||
const totalPower =
|
||||
Math.round(packageList.reduce((sum, pkg) => sum + (pkg.power ?? 0), 0) * 100) / 100;
|
||||
|
||||
// Build CpuPackages object
|
||||
const packages: CpuPackages = {
|
||||
totalPower,
|
||||
power: packageList.map((pkg) => pkg.power ?? -1),
|
||||
temp: packageList.map((pkg) => pkg.temp ?? -1),
|
||||
};
|
||||
|
||||
return {
|
||||
id: 'info/cpu',
|
||||
...rest,
|
||||
cores: physicalCores,
|
||||
threads: cores,
|
||||
processors,
|
||||
flags,
|
||||
stepping: Number(stepping),
|
||||
speedmin: speedMin || -1,
|
||||
speedmax: speedMax || -1,
|
||||
packages,
|
||||
topology,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { CpuModule } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.module.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
@@ -14,7 +15,7 @@ import { VersionsService } from '@app/unraid-api/graph/resolvers/info/versions/v
|
||||
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule, ServicesModule],
|
||||
imports: [ConfigModule, ServicesModule, CpuModule],
|
||||
providers: [
|
||||
// Main resolver
|
||||
InfoResolver,
|
||||
@@ -25,7 +26,6 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j
|
||||
CoreVersionsResolver,
|
||||
|
||||
// Services
|
||||
CpuService,
|
||||
MemoryService,
|
||||
DevicesService,
|
||||
OsService,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Test } from '@nestjs/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { DevicesResolver } from '@app/unraid-api/graph/resolvers/info/devices/devices.resolver.js';
|
||||
import { DevicesService } from '@app/unraid-api/graph/resolvers/info/devices/devices.service.js';
|
||||
@@ -28,6 +29,7 @@ describe('InfoResolver Integration Tests', () => {
|
||||
InfoResolver,
|
||||
DevicesResolver,
|
||||
CpuService,
|
||||
CpuTopologyService,
|
||||
MemoryService,
|
||||
DevicesService,
|
||||
OsService,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
|
||||
import { CpuModule } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.module.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js';
|
||||
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [ServicesModule],
|
||||
providers: [MetricsResolver, CpuService, MemoryService],
|
||||
imports: [ServicesModule, CpuModule],
|
||||
providers: [MetricsResolver, MemoryService],
|
||||
exports: [MetricsResolver],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Test } from '@nestjs/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js';
|
||||
@@ -22,6 +23,7 @@ describe('MetricsResolver Integration Tests', () => {
|
||||
providers: [
|
||||
MetricsResolver,
|
||||
CpuService,
|
||||
CpuTopologyService,
|
||||
MemoryService,
|
||||
SubscriptionTrackerService,
|
||||
SubscriptionHelperService,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
import { MetricsResolver } from '@app/unraid-api/graph/resolvers/metrics/metrics.resolver.js';
|
||||
@@ -18,6 +19,7 @@ describe('MetricsResolver', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MetricsResolver,
|
||||
CpuTopologyService,
|
||||
{
|
||||
provide: CpuService,
|
||||
useValue: {
|
||||
@@ -163,6 +165,7 @@ describe('MetricsResolver', () => {
|
||||
|
||||
const testModule = new MetricsResolver(
|
||||
cpuService,
|
||||
{} as any,
|
||||
memoryService,
|
||||
subscriptionTracker as any,
|
||||
{} as any
|
||||
@@ -170,7 +173,7 @@ describe('MetricsResolver', () => {
|
||||
|
||||
testModule.onModuleInit();
|
||||
|
||||
expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(2);
|
||||
expect(subscriptionTracker.registerTopic).toHaveBeenCalledTimes(3);
|
||||
expect(subscriptionTracker.registerTopic).toHaveBeenCalledWith(
|
||||
'CPU_UTILIZATION',
|
||||
expect.any(Function),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { OnModuleInit } from '@nestjs/common';
|
||||
import { Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
import { CpuTopologyService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu-topology.service.js';
|
||||
import { CpuPackages, CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
import { CpuService } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.service.js';
|
||||
import { MemoryUtilization } from '@app/unraid-api/graph/resolvers/info/memory/memory.model.js';
|
||||
import { MemoryService } from '@app/unraid-api/graph/resolvers/info/memory/memory.service.js';
|
||||
@@ -15,8 +16,10 @@ import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subsc
|
||||
|
||||
@Resolver(() => Metrics)
|
||||
export class MetricsResolver implements OnModuleInit {
|
||||
private readonly logger = new Logger(MetricsResolver.name);
|
||||
constructor(
|
||||
private readonly cpuService: CpuService,
|
||||
private readonly cpuTopologyService: CpuTopologyService,
|
||||
private readonly memoryService: MemoryService,
|
||||
private readonly subscriptionTracker: SubscriptionTrackerService,
|
||||
private readonly subscriptionHelper: SubscriptionHelperService
|
||||
@@ -33,6 +36,33 @@ export class MetricsResolver implements OnModuleInit {
|
||||
1000
|
||||
);
|
||||
|
||||
this.subscriptionTracker.registerTopic(
|
||||
PUBSUB_CHANNEL.CPU_TELEMETRY,
|
||||
async () => {
|
||||
const packageList = (await this.cpuTopologyService.generateTelemetry()) ?? [];
|
||||
|
||||
// Compute total power with 2 decimals
|
||||
const totalPower = Number(
|
||||
packageList.reduce((sum, pkg) => sum + (pkg.power ?? 0), 0).toFixed(2)
|
||||
);
|
||||
|
||||
const packages: CpuPackages = {
|
||||
totalPower,
|
||||
power: packageList.map((pkg) => pkg.power ?? -1),
|
||||
temp: packageList.map((pkg) => pkg.temp ?? -1),
|
||||
};
|
||||
this.logger.debug(`CPU_TELEMETRY payload: ${JSON.stringify(packages)}`);
|
||||
|
||||
// Publish the payload
|
||||
pubsub.publish(PUBSUB_CHANNEL.CPU_TELEMETRY, {
|
||||
systemMetricsCpuTelemetry: packages,
|
||||
});
|
||||
|
||||
this.logger.debug(`CPU_TELEMETRY payload2: ${JSON.stringify(packages)}`);
|
||||
},
|
||||
5000
|
||||
);
|
||||
|
||||
// Register memory polling with 2 second interval
|
||||
this.subscriptionTracker.registerTopic(
|
||||
PUBSUB_CHANNEL.MEMORY_UTILIZATION,
|
||||
@@ -77,6 +107,18 @@ export class MetricsResolver implements OnModuleInit {
|
||||
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
}
|
||||
|
||||
@Subscription(() => CpuPackages, {
|
||||
name: 'systemMetricsCpuTelemetry',
|
||||
resolve: (value) => value.systemMetricsCpuTelemetry,
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.INFO,
|
||||
})
|
||||
public async systemMetricsCpuTelemetrySubscription() {
|
||||
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_TELEMETRY);
|
||||
}
|
||||
|
||||
@Subscription(() => MemoryUtilization, {
|
||||
name: 'systemMetricsMemory',
|
||||
resolve: (value) => value.systemMetricsMemory,
|
||||
|
||||
@@ -11,6 +11,10 @@ import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/
|
||||
import { createLabeledControl } from '@app/unraid-api/graph/utils/form-utils.js';
|
||||
import { SettingSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
|
||||
const API_SETTINGS_I18N = {
|
||||
sandbox: 'jsonforms.apiSettings.sandbox',
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class ApiSettings {
|
||||
private readonly logger = new Logger(ApiSettings.name);
|
||||
@@ -83,6 +87,7 @@ export class ApiSettings {
|
||||
properties: {
|
||||
sandbox: {
|
||||
type: 'boolean',
|
||||
i18n: API_SETTINGS_I18N.sandbox,
|
||||
title: 'Enable Developer Sandbox',
|
||||
default: false,
|
||||
},
|
||||
@@ -95,6 +100,7 @@ export class ApiSettings {
|
||||
controlOptions: {
|
||||
toggle: true,
|
||||
},
|
||||
i18nKey: API_SETTINGS_I18N.sandbox,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -21,6 +21,59 @@ import {
|
||||
} from '@app/unraid-api/graph/utils/form-utils.js';
|
||||
import { SettingSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
|
||||
const OIDC_I18N = {
|
||||
provider: {
|
||||
id: 'jsonforms.oidc.provider.id',
|
||||
name: 'jsonforms.oidc.provider.name',
|
||||
clientId: 'jsonforms.oidc.provider.clientId',
|
||||
clientSecret: 'jsonforms.oidc.provider.clientSecret',
|
||||
issuer: 'jsonforms.oidc.provider.issuer',
|
||||
scopes: 'jsonforms.oidc.provider.scopes',
|
||||
discoveryToggle: 'jsonforms.oidc.provider.discoveryToggle',
|
||||
authorizationEndpoint: 'jsonforms.oidc.provider.authorizationEndpoint',
|
||||
tokenEndpoint: 'jsonforms.oidc.provider.tokenEndpoint',
|
||||
userInfoEndpoint: 'jsonforms.oidc.provider.userInfoEndpoint',
|
||||
jwksUri: 'jsonforms.oidc.provider.jwksUri',
|
||||
unraidNet: 'jsonforms.oidc.provider.unraidNet',
|
||||
},
|
||||
restrictions: {
|
||||
sectionTitle: 'jsonforms.oidc.restrictions.title',
|
||||
sectionHelp: 'jsonforms.oidc.restrictions.help',
|
||||
allowedDomains: 'jsonforms.oidc.restrictions.allowedDomains',
|
||||
allowedEmails: 'jsonforms.oidc.restrictions.allowedEmails',
|
||||
allowedUserIds: 'jsonforms.oidc.restrictions.allowedUserIds',
|
||||
workspaceDomain: 'jsonforms.oidc.restrictions.workspaceDomain',
|
||||
},
|
||||
rules: {
|
||||
mode: 'jsonforms.oidc.rules.mode',
|
||||
claim: 'jsonforms.oidc.rules.claim',
|
||||
operator: 'jsonforms.oidc.rules.operator',
|
||||
value: 'jsonforms.oidc.rules.value',
|
||||
collection: 'jsonforms.oidc.rules.collection',
|
||||
sectionTitle: 'jsonforms.oidc.rules.title',
|
||||
sectionDescription: 'jsonforms.oidc.rules.description',
|
||||
},
|
||||
buttons: {
|
||||
text: 'jsonforms.oidc.buttons.text',
|
||||
icon: 'jsonforms.oidc.buttons.icon',
|
||||
variant: 'jsonforms.oidc.buttons.variant',
|
||||
style: 'jsonforms.oidc.buttons.style',
|
||||
sectionTitle: 'jsonforms.oidc.buttons.title',
|
||||
sectionDescription: 'jsonforms.oidc.buttons.description',
|
||||
},
|
||||
accordion: {
|
||||
basicConfiguration: 'jsonforms.oidc.accordion.basicConfiguration',
|
||||
advancedEndpoints: 'jsonforms.oidc.accordion.advancedEndpoints',
|
||||
authorizationRules: 'jsonforms.oidc.accordion.authorizationRules',
|
||||
buttonCustomization: 'jsonforms.oidc.accordion.buttonCustomization',
|
||||
},
|
||||
// Add missing keys for the form schema
|
||||
sso: {
|
||||
providers: 'jsonforms.sso.providers',
|
||||
defaultAllowedOrigins: 'jsonforms.sso.defaultAllowedOrigins',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface OidcConfig {
|
||||
providers: OidcProvider[];
|
||||
defaultAllowedOrigins?: string[];
|
||||
@@ -592,6 +645,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
default: [],
|
||||
description:
|
||||
'Additional trusted redirect origins to allow redirects from custom ports, reverse proxies, Tailscale, etc.',
|
||||
i18n: OIDC_I18N.sso.defaultAllowedOrigins,
|
||||
};
|
||||
|
||||
// Add the control for defaultAllowedOrigins before the providers control using UnraidSettingsLayout
|
||||
@@ -624,27 +678,32 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.provider.id,
|
||||
title: 'Provider ID',
|
||||
description: 'Unique identifier for the provider',
|
||||
pattern: '^[a-zA-Z0-9._-]+$',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.provider.name,
|
||||
title: 'Provider Name',
|
||||
description: 'Display name for the provider',
|
||||
},
|
||||
clientId: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.provider.clientId,
|
||||
title: 'Client ID',
|
||||
description: 'OAuth2 client ID registered with the provider',
|
||||
},
|
||||
clientSecret: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.provider.clientSecret,
|
||||
title: 'Client Secret',
|
||||
description: 'OAuth2 client secret (if required)',
|
||||
},
|
||||
issuer: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.provider.issuer,
|
||||
title: 'Issuer URL',
|
||||
format: 'uri',
|
||||
allOf: [
|
||||
@@ -669,6 +728,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
{ type: 'string', minLength: 1, format: 'uri' },
|
||||
{ type: 'string', maxLength: 0 },
|
||||
],
|
||||
i18n: OIDC_I18N.provider.authorizationEndpoint,
|
||||
title: 'Authorization Endpoint',
|
||||
description: 'Optional - will be auto-discovered if not provided',
|
||||
},
|
||||
@@ -677,6 +737,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
{ type: 'string', minLength: 1, format: 'uri' },
|
||||
{ type: 'string', maxLength: 0 },
|
||||
],
|
||||
i18n: OIDC_I18N.provider.tokenEndpoint,
|
||||
title: 'Token Endpoint',
|
||||
description: 'Optional - will be auto-discovered if not provided',
|
||||
},
|
||||
@@ -685,12 +746,14 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
{ type: 'string', minLength: 1, format: 'uri' },
|
||||
{ type: 'string', maxLength: 0 },
|
||||
],
|
||||
i18n: OIDC_I18N.provider.jwksUri,
|
||||
title: 'JWKS URI',
|
||||
description: 'Optional - will be auto-discovered if not provided',
|
||||
},
|
||||
scopes: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
i18n: OIDC_I18N.provider.scopes,
|
||||
title: 'Scopes',
|
||||
default: ['openid', 'profile', 'email'],
|
||||
description: 'OAuth2 scopes to request',
|
||||
@@ -709,6 +772,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
allowedDomains: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
i18n: OIDC_I18N.restrictions.allowedDomains,
|
||||
title: 'Allowed Email Domains',
|
||||
description:
|
||||
'Email domains that are allowed to login (e.g., company.com)',
|
||||
@@ -716,6 +780,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
allowedEmails: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
i18n: OIDC_I18N.restrictions.allowedEmails,
|
||||
title: 'Specific Email Addresses',
|
||||
description:
|
||||
'Specific email addresses that are allowed to login',
|
||||
@@ -723,12 +788,14 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
allowedUserIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
i18n: OIDC_I18N.restrictions.allowedUserIds,
|
||||
title: 'Allowed User IDs',
|
||||
description:
|
||||
'Specific user IDs (sub claim) that are allowed to login',
|
||||
},
|
||||
googleWorkspaceDomain: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.restrictions.workspaceDomain,
|
||||
title: 'Google Workspace Domain',
|
||||
description:
|
||||
'Restrict to users from a specific Google Workspace domain',
|
||||
@@ -737,6 +804,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
},
|
||||
authorizationRuleMode: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.rules.mode,
|
||||
title: 'Rule Mode',
|
||||
enum: ['or', 'and'],
|
||||
default: 'or',
|
||||
@@ -750,29 +818,34 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
properties: {
|
||||
claim: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.rules.claim,
|
||||
title: 'Claim',
|
||||
description: 'JWT claim to check',
|
||||
},
|
||||
operator: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.rules.operator,
|
||||
title: 'Operator',
|
||||
enum: ['equals', 'contains', 'endsWith', 'startsWith'],
|
||||
},
|
||||
value: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
i18n: OIDC_I18N.rules.value,
|
||||
title: 'Values',
|
||||
description: 'Values to match against',
|
||||
},
|
||||
},
|
||||
required: ['claim', 'operator', 'value'],
|
||||
},
|
||||
i18n: OIDC_I18N.rules.collection,
|
||||
title: 'Claim Rules',
|
||||
description:
|
||||
'Define authorization rules based on claims in the ID token. Rule mode can be configured: OR logic (any rule matches) or AND logic (all rules must match).',
|
||||
},
|
||||
buttonText: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.buttons.text,
|
||||
title: 'Button Text',
|
||||
description: 'Custom text for the login button',
|
||||
},
|
||||
@@ -781,11 +854,13 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
{ type: 'string', minLength: 1 },
|
||||
{ type: 'string', maxLength: 0 },
|
||||
],
|
||||
i18n: OIDC_I18N.buttons.icon,
|
||||
title: 'Button Icon URL',
|
||||
description: 'URL or base64 encoded icon for the login button',
|
||||
},
|
||||
buttonVariant: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.buttons.variant,
|
||||
title: 'Button Style',
|
||||
enum: [
|
||||
'primary',
|
||||
@@ -800,6 +875,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
},
|
||||
buttonStyle: {
|
||||
type: 'string',
|
||||
i18n: OIDC_I18N.buttons.style,
|
||||
title: 'Custom CSS Styles',
|
||||
description:
|
||||
'Custom inline CSS styles for the button (e.g., "background: linear-gradient(to right, #4f46e5, #7c3aed); border-radius: 9999px;")',
|
||||
@@ -809,6 +885,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
},
|
||||
title: 'OIDC Providers',
|
||||
description: 'Configure OpenID Connect providers for SSO authentication',
|
||||
i18n: OIDC_I18N.sso.providers,
|
||||
},
|
||||
},
|
||||
elements: [
|
||||
@@ -835,6 +912,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
title: 'Unraid.net Provider',
|
||||
description:
|
||||
'This is the built-in Unraid.net provider. Only authorization rules can be modified.',
|
||||
i18n: OIDC_I18N.provider.unraidNet,
|
||||
},
|
||||
],
|
||||
detail: createAccordionLayout({
|
||||
@@ -846,6 +924,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
accordion: {
|
||||
title: 'Basic Configuration',
|
||||
description: 'Essential provider settings',
|
||||
i18n: OIDC_I18N.accordion.basicConfiguration,
|
||||
},
|
||||
},
|
||||
rule: {
|
||||
@@ -872,6 +951,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
schema: { const: 'unraid.net' },
|
||||
},
|
||||
},
|
||||
i18nKey: OIDC_I18N.provider.id,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/name',
|
||||
@@ -888,6 +968,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
schema: { const: 'unraid.net' },
|
||||
},
|
||||
},
|
||||
i18nKey: OIDC_I18N.provider.name,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/clientId',
|
||||
@@ -903,6 +984,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
schema: { const: 'unraid.net' },
|
||||
},
|
||||
},
|
||||
i18nKey: OIDC_I18N.provider.clientId,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/clientSecret',
|
||||
@@ -919,6 +1001,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
schema: { const: 'unraid.net' },
|
||||
},
|
||||
},
|
||||
i18nKey: OIDC_I18N.provider.clientSecret,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/issuer',
|
||||
@@ -935,6 +1018,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
schema: { const: 'unraid.net' },
|
||||
},
|
||||
},
|
||||
i18nKey: OIDC_I18N.provider.issuer,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/scopes',
|
||||
@@ -952,6 +1036,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
schema: { const: 'unraid.net' },
|
||||
},
|
||||
},
|
||||
i18nKey: OIDC_I18N.provider.scopes,
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -962,6 +1047,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
title: 'Advanced Endpoints',
|
||||
description:
|
||||
'Override auto-discovery settings (optional)',
|
||||
i18n: OIDC_I18N.accordion.advancedEndpoints,
|
||||
},
|
||||
},
|
||||
rule: {
|
||||
@@ -979,6 +1065,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
controlOptions: {
|
||||
inputType: 'url',
|
||||
},
|
||||
i18nKey: OIDC_I18N.provider.authorizationEndpoint,
|
||||
rule: {
|
||||
effect: RuleEffect.HIDE,
|
||||
condition: {
|
||||
@@ -994,6 +1081,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
controlOptions: {
|
||||
inputType: 'url',
|
||||
},
|
||||
i18nKey: OIDC_I18N.provider.tokenEndpoint,
|
||||
rule: {
|
||||
effect: RuleEffect.HIDE,
|
||||
condition: {
|
||||
@@ -1009,6 +1097,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
controlOptions: {
|
||||
inputType: 'url',
|
||||
},
|
||||
i18nKey: OIDC_I18N.provider.jwksUri,
|
||||
rule: {
|
||||
effect: RuleEffect.HIDE,
|
||||
condition: {
|
||||
@@ -1025,6 +1114,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
accordion: {
|
||||
title: 'Authorization Rules',
|
||||
description: 'Configure who can access your server',
|
||||
i18n: OIDC_I18N.accordion.authorizationRules,
|
||||
},
|
||||
},
|
||||
elements: [
|
||||
@@ -1035,6 +1125,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
description:
|
||||
'Choose between simple presets or advanced rule configuration',
|
||||
controlOptions: {},
|
||||
i18nKey: OIDC_I18N.rules.mode,
|
||||
}),
|
||||
// Simple Authorization Fields (shown when mode is 'simple')
|
||||
{
|
||||
@@ -1055,6 +1146,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
'Configure who can login using simple presets. At least one field must be configured.',
|
||||
format: 'title',
|
||||
},
|
||||
i18n: OIDC_I18N.restrictions.sectionTitle,
|
||||
},
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/simpleAuthorization/properties/allowedDomains',
|
||||
@@ -1066,6 +1158,8 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
inputType: 'text',
|
||||
placeholder: 'company.com',
|
||||
},
|
||||
i18nKey:
|
||||
OIDC_I18N.restrictions.allowedDomains,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/simpleAuthorization/properties/allowedEmails',
|
||||
@@ -1077,6 +1171,8 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
inputType: 'email',
|
||||
placeholder: 'user@example.com',
|
||||
},
|
||||
i18nKey:
|
||||
OIDC_I18N.restrictions.allowedEmails,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/simpleAuthorization/properties/allowedUserIds',
|
||||
@@ -1088,6 +1184,8 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
inputType: 'text',
|
||||
placeholder: 'user-id-123',
|
||||
},
|
||||
i18nKey:
|
||||
OIDC_I18N.restrictions.allowedUserIds,
|
||||
}),
|
||||
// Google-specific field (shown only for Google providers)
|
||||
{
|
||||
@@ -1109,6 +1207,9 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
inputType: 'text',
|
||||
placeholder: 'company.com',
|
||||
},
|
||||
i18nKey:
|
||||
OIDC_I18N.restrictions
|
||||
.workspaceDomain,
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -1141,6 +1242,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
description:
|
||||
'Define authorization rules based on claims in the ID token. Rule mode can be configured: OR logic (any rule matches) or AND logic (all rules must match).',
|
||||
},
|
||||
i18n: OIDC_I18N.rules.sectionTitle,
|
||||
},
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/authorizationRuleMode',
|
||||
@@ -1148,6 +1250,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
description:
|
||||
'How to evaluate multiple rules: OR (any rule passes) or AND (all rules must pass)',
|
||||
controlOptions: {},
|
||||
i18nKey: OIDC_I18N.rules.mode,
|
||||
}),
|
||||
{
|
||||
type: 'Control',
|
||||
@@ -1168,6 +1271,8 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
inputType: 'text',
|
||||
placeholder: 'email',
|
||||
},
|
||||
i18nKey:
|
||||
OIDC_I18N.rules.claim,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/operator',
|
||||
@@ -1175,6 +1280,8 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
description:
|
||||
'How to compare the claim value',
|
||||
controlOptions: {},
|
||||
i18nKey:
|
||||
OIDC_I18N.rules.operator,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/value',
|
||||
@@ -1187,9 +1294,12 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
placeholder:
|
||||
'@company.com',
|
||||
},
|
||||
i18nKey:
|
||||
OIDC_I18N.rules.value,
|
||||
}),
|
||||
],
|
||||
},
|
||||
i18n: OIDC_I18N.rules.collection,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1203,6 +1313,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
title: 'Button Customization',
|
||||
description:
|
||||
'Customize the appearance of the login button',
|
||||
i18n: OIDC_I18N.accordion.buttonCustomization,
|
||||
},
|
||||
},
|
||||
rule: {
|
||||
@@ -1221,6 +1332,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
inputType: 'text',
|
||||
placeholder: 'Sign in with Provider',
|
||||
},
|
||||
i18nKey: OIDC_I18N.buttons.text,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/buttonIcon',
|
||||
@@ -1230,12 +1342,14 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
controlOptions: {
|
||||
inputType: 'url',
|
||||
},
|
||||
i18nKey: OIDC_I18N.buttons.icon,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/buttonVariant',
|
||||
label: 'Button Style:',
|
||||
description: 'Visual style of the login button',
|
||||
controlOptions: {},
|
||||
i18nKey: OIDC_I18N.buttons.variant,
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/buttonStyle',
|
||||
@@ -1247,6 +1361,7 @@ export class OidcConfigPersistence extends ConfigFilePersister<OidcConfig> {
|
||||
placeholder:
|
||||
'background-color: #3b82f6; border-color: #3b82f6; color: white; transition: all 0.2s;',
|
||||
},
|
||||
i18nKey: OIDC_I18N.buttons.style,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -10,29 +10,40 @@ export function createSimpleLabeledControl({
|
||||
description,
|
||||
controlOptions,
|
||||
rule,
|
||||
i18nKey,
|
||||
}: {
|
||||
scope: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
controlOptions?: ControlElement['options'];
|
||||
rule?: Rule;
|
||||
i18nKey?: string;
|
||||
}): Layout {
|
||||
const labelElement = {
|
||||
type: 'Label',
|
||||
text: label,
|
||||
options: {
|
||||
description,
|
||||
},
|
||||
} as LabelElement;
|
||||
|
||||
if (i18nKey) {
|
||||
(labelElement as any).i18n = i18nKey;
|
||||
}
|
||||
|
||||
const controlElement = {
|
||||
type: 'Control',
|
||||
scope: scope,
|
||||
options: controlOptions,
|
||||
} as ControlElement;
|
||||
|
||||
if (i18nKey) {
|
||||
(controlElement as any).i18n = i18nKey;
|
||||
}
|
||||
|
||||
const layout: Layout = {
|
||||
type: 'VerticalLayout',
|
||||
elements: [
|
||||
{
|
||||
type: 'Label',
|
||||
text: label,
|
||||
options: {
|
||||
description,
|
||||
},
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Control',
|
||||
scope: scope,
|
||||
options: controlOptions,
|
||||
} as ControlElement,
|
||||
],
|
||||
elements: [labelElement, controlElement],
|
||||
};
|
||||
|
||||
// Add rule if provided
|
||||
@@ -56,6 +67,7 @@ export function createLabeledControl({
|
||||
layoutType = 'UnraidSettingsLayout',
|
||||
rule,
|
||||
passScopeToLayout = false,
|
||||
i18nKey,
|
||||
}: {
|
||||
scope: string;
|
||||
label: string;
|
||||
@@ -66,19 +78,29 @@ export function createLabeledControl({
|
||||
layoutType?: 'UnraidSettingsLayout' | 'VerticalLayout' | 'HorizontalLayout';
|
||||
rule?: Rule;
|
||||
passScopeToLayout?: boolean;
|
||||
i18nKey?: string;
|
||||
}): Layout {
|
||||
const elements: Array<LabelElement | ControlElement> = [
|
||||
{
|
||||
type: 'Label',
|
||||
text: label,
|
||||
options: { ...labelOptions, description },
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Control',
|
||||
scope: scope,
|
||||
options: controlOptions,
|
||||
} as ControlElement,
|
||||
];
|
||||
const labelElement = {
|
||||
type: 'Label',
|
||||
text: label,
|
||||
options: { ...labelOptions, description },
|
||||
} as LabelElement;
|
||||
|
||||
if (i18nKey) {
|
||||
(labelElement as any).i18n = i18nKey;
|
||||
}
|
||||
|
||||
const controlElement = {
|
||||
type: 'Control',
|
||||
scope: scope,
|
||||
options: controlOptions,
|
||||
} as ControlElement;
|
||||
|
||||
if (i18nKey) {
|
||||
(controlElement as any).i18n = i18nKey;
|
||||
}
|
||||
|
||||
const elements: Array<LabelElement | ControlElement> = [labelElement, controlElement];
|
||||
|
||||
const layout: Layout = {
|
||||
type: layoutType,
|
||||
@@ -113,6 +135,7 @@ export function createAccordionLayout({
|
||||
accordion?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
i18n?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ import { merge } from 'lodash-es';
|
||||
/**
|
||||
* JSON schema properties.
|
||||
*/
|
||||
export type DataSlice = Record<string, JsonSchema>;
|
||||
export type I18nJsonSchema = JsonSchema & {
|
||||
i18n?: string;
|
||||
};
|
||||
|
||||
export type DataSlice = Record<string, I18nJsonSchema>;
|
||||
|
||||
/**
|
||||
* A JSONForms UI schema element.
|
||||
|
||||
9
crowdin.yml
Normal file
9
crowdin.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
api_token_env: CROWDIN_API_TOKEN
|
||||
base_path: .
|
||||
preserve_hierarchy: true
|
||||
files:
|
||||
- source: /web/src/locales/en.json
|
||||
translation: /web/src/locales/%two_letters_code%.json
|
||||
- source: /api/src/i18n/en.json
|
||||
translation: /api/src/i18n/%two_letters_code%.json
|
||||
@@ -6,6 +6,7 @@
|
||||
"build": "pnpm -r build",
|
||||
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",
|
||||
"codegen": "pnpm -r codegen",
|
||||
"i18n:extract": "pnpm --filter @unraid/api i18n:extract && pnpm --filter @unraid/web i18n:extract",
|
||||
"dev": "pnpm -r dev",
|
||||
"unraid:deploy": "pnpm -r unraid:deploy",
|
||||
"test": "pnpm -r test",
|
||||
|
||||
68
packages/unraid-shared/deploy.sh
Executable file
68
packages/unraid-shared/deploy.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Arguments
|
||||
# $1: SSH server name (required)
|
||||
|
||||
# Check if the server name is provided
|
||||
if [[ -z "$1" ]]; then
|
||||
echo "Error: SSH server name is required."
|
||||
echo "Usage: $0 <server_name>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set server name from command-line argument
|
||||
server_name="$1"
|
||||
|
||||
# Build the package
|
||||
echo "Building unraid-shared package..."
|
||||
pnpm build
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Source directory path
|
||||
source_directory="./dist"
|
||||
|
||||
# Check if dist directory exists
|
||||
if [ ! -d "$source_directory" ]; then
|
||||
echo "The dist directory does not exist after build!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Destination directory path - deploy to node_modules/@unraid/shared/dist
|
||||
destination_directory="/usr/local/unraid-api/node_modules/@unraid/shared"
|
||||
|
||||
# Create destination directory on remote server
|
||||
ssh root@"${server_name}" "mkdir -p $destination_directory"
|
||||
|
||||
# Replace the value inside the rsync command with the user's input
|
||||
rsync_command="rsync -avz --delete --progress --stats -e ssh \"$source_directory/\" \"root@${server_name}:$destination_directory/\""
|
||||
|
||||
echo "Executing the following command:"
|
||||
echo "$rsync_command"
|
||||
|
||||
# Execute the rsync command and capture the exit code
|
||||
eval "$rsync_command"
|
||||
exit_code=$?
|
||||
|
||||
# Chown the directory
|
||||
ssh root@"${server_name}" "chown -R root:root /usr/local/unraid-api/node_modules/@unraid/"
|
||||
|
||||
# Run unraid-api restart on remote host
|
||||
ssh root@"${server_name}" 'INTROSPECTION=true LOG_LEVEL=trace unraid-api restart'
|
||||
|
||||
# Play built-in sound based on the operating system
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS
|
||||
afplay /System/Library/Sounds/Glass.aiff
|
||||
elif [[ "$OSTYPE" == "linux-gnu" ]]; then
|
||||
# Linux
|
||||
paplay /usr/share/sounds/freedesktop/stereo/complete.oga
|
||||
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then
|
||||
# Windows
|
||||
powershell.exe -c "(New-Object Media.SoundPlayer 'C:\Windows\Media\Windows Default.wav').PlaySync()"
|
||||
fi
|
||||
|
||||
# Exit with the rsync command's exit code
|
||||
exit $exit_code
|
||||
@@ -21,7 +21,8 @@
|
||||
"build": "rimraf dist && tsc --project tsconfig.build.json",
|
||||
"prepare": "npm run build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"unraid:deploy": "./deploy.sh"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Lime Technology, Inc. <unraid.net>",
|
||||
|
||||
@@ -5,6 +5,7 @@ export const GRAPHQL_PUBSUB_TOKEN = "GRAPHQL_PUBSUB";
|
||||
export enum GRAPHQL_PUBSUB_CHANNEL {
|
||||
ARRAY = "ARRAY",
|
||||
CPU_UTILIZATION = "CPU_UTILIZATION",
|
||||
CPU_TELEMETRY = "CPU_TELEMETRY",
|
||||
DASHBOARD = "DASHBOARD",
|
||||
DISPLAY = "DISPLAY",
|
||||
INFO = "INFO",
|
||||
|
||||
@@ -201,9 +201,9 @@ FILES_TO_BACKUP=(
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/myservers1.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/myservers2.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/translations.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/data/server-state.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/translations.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php"
|
||||
"/usr/local/emhttp/update.htm"
|
||||
"/usr/local/emhttp/logging.htm"
|
||||
@@ -344,9 +344,9 @@ exit 0
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/myservers1.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/myservers2.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/state.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/translations.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/data/server-state.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/reboot-details.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/translations.php"
|
||||
"/usr/local/emhttp/plugins/dynamix.my.servers/include/web-components-extractor.php"
|
||||
"/usr/local/emhttp/update.htm"
|
||||
"/usr/local/emhttp/logging.htm"
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
*/
|
||||
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
|
||||
require_once("$docroot/plugins/dynamix.my.servers/include/state.php");
|
||||
require_once("$docroot/plugins/dynamix.my.servers/include/translations.php");
|
||||
|
||||
$serverState = new ServerState();
|
||||
$wCTranslations = new WebComponentTranslations();
|
||||
$locale = $_SESSION['locale'] ?? 'en_US';
|
||||
?>
|
||||
<script>
|
||||
window.LOCALE_DATA = '<?= $wCTranslations->getTranslationsJson(true) ?>';
|
||||
window.LOCALE = <?= json_encode($locale, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>;
|
||||
</script>
|
||||
<unraid-user-profile server="<?= $serverState->getServerStateJsonForHtmlAttr() ?>"></unraid-user-profile>
|
||||
|
||||
@@ -8,31 +8,9 @@
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Welcome to the Thunderdome. A place where you can get lost in a sea of translations.
|
||||
*
|
||||
* This file is used to generate the translations for the Vue3 based web components.
|
||||
*
|
||||
* These key value pairs are derived from web/locales/en_US.json.
|
||||
* We use the en_US.json file as the source of truth for the translations.
|
||||
* This file is then used to generate the translations for the web components and delivered to them via PHP as a JSON object in myservers2.php (my favorite file name).
|
||||
* The web components then use the translations to display the appropriate text to the user.
|
||||
*
|
||||
* Workflow is as follows:
|
||||
* 1. Create a new translation in en_US.json
|
||||
* 2. Create a new translation in this file
|
||||
* 3. Open unraid/lang-en_US and add the new translation to the appropriate file – typically translations.txt.
|
||||
* 3a. This is done so that the translation is available to the rest of the Unraid webgui.
|
||||
* 3b. Unfortunately there are numerous "special characters" that aren't allowed in Unraid's translation keys as they're automatically stripped out.
|
||||
* 3c. This means that we have to create a new translation key that is a "safe" version of the translation key used in the web components.
|
||||
* 3d. Special characters that are not allowed are: ? { } | & ~ ! [ ] ( ) / : * ^ . " '
|
||||
* 3e. There are likely more but I'm unable to find the documentation PDF - updated list of invalid characters above as mentioned in the language guide document.
|
||||
*
|
||||
* Usage example:
|
||||
* ```
|
||||
* $wCTranslations = new WebComponentTranslations();
|
||||
* $wCTranslations->getTranslations();
|
||||
* ```
|
||||
* THIS FILE IS DEPRECATED BUT LEFT HERE FOR BACKWARD COMPATIBILITY.
|
||||
*/
|
||||
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
|
||||
require_once "$docroot/webGui/include/Translations.php";
|
||||
|
||||
176
pnpm-lock.yaml
generated
176
pnpm-lock.yaml
generated
@@ -1109,6 +1109,9 @@ importers:
|
||||
clsx:
|
||||
specifier: 2.1.1
|
||||
version: 2.1.1
|
||||
convert:
|
||||
specifier: 5.12.0
|
||||
version: 5.12.0
|
||||
crypto-js:
|
||||
specifier: 4.2.0
|
||||
version: 4.2.0
|
||||
@@ -1233,6 +1236,9 @@ importers:
|
||||
'@vue/apollo-util':
|
||||
specifier: 4.2.2
|
||||
version: 4.2.2
|
||||
'@vue/compiler-sfc':
|
||||
specifier: 3.5.20
|
||||
version: 3.5.20
|
||||
'@vue/test-utils':
|
||||
specifier: 2.4.6
|
||||
version: 2.4.6
|
||||
@@ -1263,6 +1269,9 @@ importers:
|
||||
eslint-plugin-vue:
|
||||
specifier: 10.4.0
|
||||
version: 10.4.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.5.1))(vue-eslint-parser@10.2.0(eslint@9.34.0(jiti@2.5.1)))
|
||||
glob:
|
||||
specifier: 11.0.3
|
||||
version: 11.0.3
|
||||
globals:
|
||||
specifier: 16.3.0
|
||||
version: 16.3.0
|
||||
@@ -1314,6 +1323,9 @@ importers:
|
||||
vue-eslint-parser:
|
||||
specifier: 10.2.0
|
||||
version: 10.2.0(eslint@9.34.0(jiti@2.5.1))
|
||||
vue-i18n-extract:
|
||||
specifier: 2.0.4
|
||||
version: 2.0.4
|
||||
vue-tsc:
|
||||
specifier: 3.0.6
|
||||
version: 3.0.6(typescript@5.9.2)
|
||||
@@ -1610,11 +1622,6 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.28.3':
|
||||
resolution: {integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@babel/parser@7.28.4':
|
||||
resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -5421,39 +5428,21 @@ packages:
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@vue/compiler-core@3.5.17':
|
||||
resolution: {integrity: sha512-Xe+AittLbAyV0pabcN7cP7/BenRBNcteM4aSDCtRvGw0d9OL+HG1u/XHLY/kt1q4fyMeZYXyIYrsHuPSiDPosA==}
|
||||
|
||||
'@vue/compiler-core@3.5.18':
|
||||
resolution: {integrity: sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==}
|
||||
|
||||
'@vue/compiler-core@3.5.20':
|
||||
resolution: {integrity: sha512-8TWXUyiqFd3GmP4JTX9hbiTFRwYHgVL/vr3cqhr4YQ258+9FADwvj7golk2sWNGHR67QgmCZ8gz80nQcMokhwg==}
|
||||
|
||||
'@vue/compiler-dom@3.5.17':
|
||||
resolution: {integrity: sha512-+2UgfLKoaNLhgfhV5Ihnk6wB4ljyW1/7wUIog2puUqajiC29Lp5R/IKDdkebh9jTbTogTbsgB+OY9cEWzG95JQ==}
|
||||
|
||||
'@vue/compiler-dom@3.5.18':
|
||||
resolution: {integrity: sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==}
|
||||
|
||||
'@vue/compiler-dom@3.5.20':
|
||||
resolution: {integrity: sha512-whB44M59XKjqUEYOMPYU0ijUV0G+4fdrHVKDe32abNdX/kJe1NUEMqsi4cwzXa9kyM9w5S8WqFsrfo1ogtBZGQ==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.17':
|
||||
resolution: {integrity: sha512-rQQxbRJMgTqwRugtjw0cnyQv9cP4/4BxWfTdRBkqsTfLOHWykLzbOc3C4GGzAmdMDxhzU/1Ija5bTjMVrddqww==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.18':
|
||||
resolution: {integrity: sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==}
|
||||
|
||||
'@vue/compiler-sfc@3.5.20':
|
||||
resolution: {integrity: sha512-SFcxapQc0/feWiSBfkGsa1v4DOrnMAQSYuvDMpEaxbpH5dKbnEM5KobSNSgU+1MbHCl+9ftm7oQWxvwDB6iBfw==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.17':
|
||||
resolution: {integrity: sha512-hkDbA0Q20ZzGgpj5uZjb9rBzQtIHLS78mMilwrlpWk2Ep37DYntUz0PonQ6kr113vfOEdM+zTBuJDaceNIW0tQ==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.18':
|
||||
resolution: {integrity: sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==}
|
||||
|
||||
'@vue/compiler-ssr@3.5.20':
|
||||
resolution: {integrity: sha512-RSl5XAMc5YFUXpDQi+UQDdVjH9FnEpLDHIALg5J0ITHxkEzJ8uQLlo7CIbjPYqmZtt6w0TsIPbo1izYXwDG7JA==}
|
||||
|
||||
@@ -5526,9 +5515,6 @@ packages:
|
||||
peerDependencies:
|
||||
vue: 3.5.20
|
||||
|
||||
'@vue/shared@3.5.17':
|
||||
resolution: {integrity: sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==}
|
||||
|
||||
'@vue/shared@3.5.18':
|
||||
resolution: {integrity: sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==}
|
||||
|
||||
@@ -6444,6 +6430,10 @@ packages:
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
commander@6.2.1:
|
||||
resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
commander@9.5.0:
|
||||
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
|
||||
engines: {node: ^12.20.0 || >=14}
|
||||
@@ -7018,6 +7008,10 @@ packages:
|
||||
dot-case@3.0.4:
|
||||
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
|
||||
|
||||
dot-object@2.1.5:
|
||||
resolution: {integrity: sha512-xHF8EP4XH/Ba9fvAF2LDd5O3IITVolerVV6xvkxoM8zlGEiCUrggpAnHyOoKJKCrhvPcGATFAUwIujj7bRG5UA==}
|
||||
hasBin: true
|
||||
|
||||
dot-prop@5.3.0:
|
||||
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -8691,6 +8685,10 @@ packages:
|
||||
is-utf8@0.2.1:
|
||||
resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==}
|
||||
|
||||
is-valid-glob@1.0.0:
|
||||
resolution: {integrity: sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-weakmap@2.0.2:
|
||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -12390,8 +12388,8 @@ packages:
|
||||
vue-component-type-helpers@3.0.6:
|
||||
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
|
||||
|
||||
vue-component-type-helpers@3.0.7:
|
||||
resolution: {integrity: sha512-TvyUcFXmjZcXUvU+r1MOyn4/vv4iF+tPwg5Ig33l/FJ3myZkxeQpzzQMLMFWcQAjr6Xs7BRwVy/TwbmNZUA/4w==}
|
||||
vue-component-type-helpers@3.1.0:
|
||||
resolution: {integrity: sha512-cC1pYNRZkSS1iCvdlaMbbg2sjDwxX098FucEjtz9Yig73zYjWzQsnMe5M9H8dRNv55hAIDGUI29hF2BEUA4FMQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -12418,6 +12416,10 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
|
||||
vue-i18n-extract@2.0.4:
|
||||
resolution: {integrity: sha512-a2N9HBp1sSNErvjGDnRHWvXxKAy4DypoN91Pc4Seu9nDx4axBFY1ZGzlwUsL19HDR1n7YC7C233h/bAEnReK6Q==}
|
||||
hasBin: true
|
||||
|
||||
vue-i18n@11.1.11:
|
||||
resolution: {integrity: sha512-LvyteQoXeQiuILbzqv13LbyBna/TEv2Ha+4ZWK2AwGHUzZ8+IBaZS0TJkCgn5izSPLcgZwXy9yyTrewCb2u/MA==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -13297,10 +13299,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.28.0
|
||||
|
||||
'@babel/parser@7.28.3':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.4
|
||||
|
||||
'@babel/parser@7.28.4':
|
||||
dependencies:
|
||||
'@babel/types': 7.28.4
|
||||
@@ -14995,7 +14993,7 @@ snapshots:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2': {}
|
||||
|
||||
@@ -16456,7 +16454,7 @@ snapshots:
|
||||
storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.20(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.0.7
|
||||
vue-component-type-helpers: 3.1.0
|
||||
|
||||
'@swc/core-darwin-arm64@1.13.5':
|
||||
optional: true
|
||||
@@ -17074,8 +17072,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/core': 7.27.4
|
||||
'@babel/preset-typescript': 7.26.0(@babel/core@7.27.4)
|
||||
'@vue/compiler-dom': 3.5.18
|
||||
'@vue/compiler-sfc': 3.5.18
|
||||
'@vue/compiler-dom': 3.5.20
|
||||
'@vue/compiler-sfc': 3.5.20
|
||||
'@vuedx/template-ast-types': 0.7.1
|
||||
fast-glob: 3.3.3
|
||||
prettier: 3.6.2
|
||||
@@ -17439,7 +17437,7 @@ snapshots:
|
||||
'@babel/helper-module-imports': 7.27.1
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
'@babel/parser': 7.28.4
|
||||
'@vue/compiler-sfc': 3.5.18
|
||||
'@vue/compiler-sfc': 3.5.20
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -17454,14 +17452,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vue/compiler-core@3.5.17':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.4
|
||||
'@vue/shared': 3.5.17
|
||||
entities: 4.5.0
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-core@3.5.18':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.4
|
||||
@@ -17478,11 +17468,6 @@ snapshots:
|
||||
estree-walker: 2.0.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-dom@3.5.17':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.17
|
||||
'@vue/shared': 3.5.17
|
||||
|
||||
'@vue/compiler-dom@3.5.18':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.18
|
||||
@@ -17493,52 +17478,18 @@ snapshots:
|
||||
'@vue/compiler-core': 3.5.20
|
||||
'@vue/shared': 3.5.20
|
||||
|
||||
'@vue/compiler-sfc@3.5.17':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.0
|
||||
'@vue/compiler-core': 3.5.17
|
||||
'@vue/compiler-dom': 3.5.17
|
||||
'@vue/compiler-ssr': 3.5.17
|
||||
'@vue/shared': 3.5.17
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.17
|
||||
postcss: 8.5.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-sfc@3.5.18':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.4
|
||||
'@vue/compiler-core': 3.5.18
|
||||
'@vue/compiler-dom': 3.5.18
|
||||
'@vue/compiler-ssr': 3.5.18
|
||||
'@vue/shared': 3.5.18
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.17
|
||||
postcss: 8.5.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-sfc@3.5.20':
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.3
|
||||
'@babel/parser': 7.28.4
|
||||
'@vue/compiler-core': 3.5.20
|
||||
'@vue/compiler-dom': 3.5.20
|
||||
'@vue/compiler-ssr': 3.5.20
|
||||
'@vue/shared': 3.5.20
|
||||
estree-walker: 2.0.2
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
postcss: 8.5.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@vue/compiler-ssr@3.5.17':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.17
|
||||
'@vue/shared': 3.5.17
|
||||
|
||||
'@vue/compiler-ssr@3.5.18':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.18
|
||||
'@vue/shared': 3.5.18
|
||||
|
||||
'@vue/compiler-ssr@3.5.20':
|
||||
dependencies:
|
||||
'@vue/compiler-dom': 3.5.20
|
||||
@@ -17611,8 +17562,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@volar/language-core': 1.11.1
|
||||
'@volar/source-map': 1.11.1
|
||||
'@vue/compiler-dom': 3.5.18
|
||||
'@vue/shared': 3.5.18
|
||||
'@vue/compiler-dom': 3.5.20
|
||||
'@vue/shared': 3.5.20
|
||||
computeds: 0.0.1
|
||||
minimatch: 9.0.5
|
||||
muggle-string: 0.3.1
|
||||
@@ -17624,7 +17575,7 @@ snapshots:
|
||||
'@vue/language-core@2.2.8(typescript@5.9.2)':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.22
|
||||
'@vue/compiler-dom': 3.5.18
|
||||
'@vue/compiler-dom': 3.5.20
|
||||
'@vue/compiler-vue2': 2.7.16
|
||||
'@vue/shared': 3.5.20
|
||||
alien-signals: 1.0.13
|
||||
@@ -17669,8 +17620,6 @@ snapshots:
|
||||
'@vue/shared': 3.5.20
|
||||
vue: 3.5.20(typescript@5.9.2)
|
||||
|
||||
'@vue/shared@3.5.17': {}
|
||||
|
||||
'@vue/shared@3.5.18': {}
|
||||
|
||||
'@vue/shared@3.5.20': {}
|
||||
@@ -17687,7 +17636,7 @@ snapshots:
|
||||
|
||||
'@vuedx/template-ast-types@0.7.1':
|
||||
dependencies:
|
||||
'@vue/compiler-core': 3.5.18
|
||||
'@vue/compiler-core': 3.5.20
|
||||
|
||||
'@vuetify/loader-shared@2.1.0(vue@3.5.20(typescript@5.9.2))(vuetify@3.9.6)':
|
||||
dependencies:
|
||||
@@ -18654,6 +18603,8 @@ snapshots:
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@6.2.1: {}
|
||||
|
||||
commander@9.5.0:
|
||||
optional: true
|
||||
|
||||
@@ -19220,6 +19171,11 @@ snapshots:
|
||||
no-case: 3.0.4
|
||||
tslib: 2.8.1
|
||||
|
||||
dot-object@2.1.5:
|
||||
dependencies:
|
||||
commander: 6.2.1
|
||||
glob: 7.2.3
|
||||
|
||||
dot-prop@5.3.0:
|
||||
dependencies:
|
||||
is-obj: 2.0.0
|
||||
@@ -20256,7 +20212,7 @@ snapshots:
|
||||
'@capsizecss/unpack': 2.4.0
|
||||
css-tree: 3.1.0
|
||||
magic-regexp: 0.10.0
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
pathe: 2.0.3
|
||||
ufo: 1.6.1
|
||||
unplugin: 2.3.8
|
||||
@@ -21141,6 +21097,8 @@ snapshots:
|
||||
|
||||
is-utf8@0.2.1: {}
|
||||
|
||||
is-valid-glob@1.0.0: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
|
||||
is-weakref@1.1.1:
|
||||
@@ -21626,7 +21584,7 @@ snapshots:
|
||||
magic-regexp@0.10.0:
|
||||
dependencies:
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
mlly: 1.7.4
|
||||
regexp-tree: 0.1.27
|
||||
type-level-regexp: 0.1.17
|
||||
@@ -23832,7 +23790,7 @@ snapshots:
|
||||
shadcn-vue@2.2.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2)(vue@3.5.20(typescript@5.9.2)):
|
||||
dependencies:
|
||||
'@unovue/detypes': 0.8.5
|
||||
'@vue/compiler-sfc': 3.5.17
|
||||
'@vue/compiler-sfc': 3.5.20
|
||||
commander: 14.0.0
|
||||
consola: 3.4.2
|
||||
cosmiconfig: 9.0.0(typescript@5.9.2)
|
||||
@@ -24672,7 +24630,7 @@ snapshots:
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
unplugin: 2.3.8
|
||||
|
||||
undefsafe@2.0.5: {}
|
||||
@@ -24729,7 +24687,7 @@ snapshots:
|
||||
escape-string-regexp: 5.0.0
|
||||
estree-walker: 3.0.3
|
||||
local-pkg: 1.1.2
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
mlly: 1.7.4
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
@@ -24746,7 +24704,7 @@ snapshots:
|
||||
escape-string-regexp: 5.0.0
|
||||
estree-walker: 3.0.3
|
||||
local-pkg: 1.1.1
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
mlly: 1.7.4
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
@@ -25164,9 +25122,9 @@ snapshots:
|
||||
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.0)
|
||||
'@babel/plugin-transform-typescript': 7.27.1(@babel/core@7.28.0)
|
||||
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.28.0)
|
||||
'@vue/compiler-dom': 3.5.18
|
||||
'@vue/compiler-dom': 3.5.20
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
vite: 7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -25312,7 +25270,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.0.6: {}
|
||||
|
||||
vue-component-type-helpers@3.0.7: {}
|
||||
vue-component-type-helpers@3.1.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)):
|
||||
dependencies:
|
||||
@@ -25322,10 +25280,10 @@ snapshots:
|
||||
|
||||
vue-docgen-api@4.79.2(vue@3.5.20(typescript@5.9.2)):
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.0
|
||||
'@babel/parser': 7.28.4
|
||||
'@babel/types': 7.28.4
|
||||
'@vue/compiler-dom': 3.5.18
|
||||
'@vue/compiler-sfc': 3.5.18
|
||||
'@vue/compiler-dom': 3.5.20
|
||||
'@vue/compiler-sfc': 3.5.20
|
||||
ast-types: 0.16.1
|
||||
esm-resolve: 1.0.11
|
||||
hash-sum: 2.0.0
|
||||
@@ -25348,6 +25306,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-i18n-extract@2.0.4:
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
dot-object: 2.1.5
|
||||
glob: 7.2.3
|
||||
is-valid-glob: 1.0.0
|
||||
js-yaml: 4.1.0
|
||||
|
||||
vue-i18n@11.1.11(vue@3.5.20(typescript@5.9.2)):
|
||||
dependencies:
|
||||
'@intlify/core-base': 11.1.11
|
||||
@@ -25370,7 +25336,7 @@ snapshots:
|
||||
fs-extra: 11.3.1
|
||||
glob: 11.0.3
|
||||
lodash-es: 4.17.21
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
micromatch: 4.0.8
|
||||
node-html-parser: 7.0.1
|
||||
postcss: 8.5.6
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
[![LinkedIn][linkedin-shield]][linkedin-url]
|
||||
[![Codecov][codecov-shield]][codecov-url]
|
||||
|
||||
[](https://deepwiki.com/unraid/api)
|
||||
|
||||
</div>
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
|
||||
@@ -37,7 +37,9 @@ const props = withDefaults(defineProps<BrandButtonProps>(), {
|
||||
title: '',
|
||||
});
|
||||
|
||||
defineEmits(['click']);
|
||||
const emit = defineEmits<{
|
||||
(event: 'click'): void;
|
||||
}>();
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
@@ -56,6 +58,31 @@ const needsBrandGradientBackground = computed(() => {
|
||||
|
||||
const isLink = computed(() => Boolean(props.href));
|
||||
const isButton = computed(() => !isLink.value);
|
||||
|
||||
const triggerClick = () => {
|
||||
if (props.click) {
|
||||
props.click();
|
||||
} else {
|
||||
emit('click');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.disabled) {
|
||||
triggerClick();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isButton.value || props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
triggerClick();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,17 +96,8 @@ const isButton = computed(() => !isLink.value);
|
||||
:target="external ? '_blank' : ''"
|
||||
:class="classes.button"
|
||||
:title="title"
|
||||
@click="!disabled && (click ?? $emit('click'))"
|
||||
@keydown="
|
||||
isButton &&
|
||||
!disabled &&
|
||||
((e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(click ?? $emit('click'))();
|
||||
}
|
||||
})
|
||||
"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div
|
||||
v-if="variant === 'fill'"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="space-y-4">
|
||||
<DispatchRenderer
|
||||
:schema="layout.schema"
|
||||
:uischema="element as UISchemaElement"
|
||||
:uischema="element as unknown as UISchemaElement"
|
||||
:path="layout.path || ''"
|
||||
:enabled="layout.enabled"
|
||||
:renderers="layout.renderers"
|
||||
@@ -44,11 +44,10 @@ import {
|
||||
import { jsonFormsAjv } from '@/forms/config';
|
||||
import type { BaseUISchemaElement, Labelable, Layout, UISchemaElement } from '@jsonforms/core';
|
||||
import { isVisible } from '@jsonforms/core';
|
||||
import { DispatchRenderer, useJsonFormsLayout } from '@jsonforms/vue';
|
||||
import type { RendererProps } from '@jsonforms/vue';
|
||||
import { DispatchRenderer, rendererProps, useJsonFormsLayout } from '@jsonforms/vue';
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
const props = defineProps<RendererProps<Layout>>();
|
||||
const props = defineProps(rendererProps<Layout>());
|
||||
|
||||
// Use the JsonForms layout composable - returns layout with all necessary props
|
||||
const { layout } = useJsonFormsLayout(props);
|
||||
@@ -58,7 +57,7 @@ const jsonFormsContext = inject('jsonforms') as { core?: { data?: unknown } } |
|
||||
|
||||
// Get elements to render - filter out invisible elements based on rules
|
||||
const elements = computed(() => {
|
||||
const allElements = props.uischema?.elements || [];
|
||||
const allElements = props.uischema.elements || [];
|
||||
|
||||
// Filter elements based on visibility rules
|
||||
return allElements.filter((element) => {
|
||||
@@ -72,7 +71,7 @@ const elements = computed(() => {
|
||||
try {
|
||||
// Get the root data from JSONForms context for rule evaluation
|
||||
const rootData = jsonFormsContext?.core?.data || {};
|
||||
const formData = props.data || rootData;
|
||||
const formData = (layout.value?.data as unknown) ?? rootData;
|
||||
const formPath = props.path || layout.value.path || '';
|
||||
|
||||
const visible = isVisible(element, formData, formPath, jsonFormsAjv);
|
||||
@@ -85,12 +84,12 @@ const elements = computed(() => {
|
||||
});
|
||||
|
||||
// Extract accordion configuration from options
|
||||
const accordionOptions = computed(() => props.uischema?.options?.accordion || {});
|
||||
const accordionOptions = computed(() => props.uischema.options?.accordion || {});
|
||||
|
||||
// Determine which items should be open by default
|
||||
const defaultOpenItems = computed(() => {
|
||||
const defaultOpen = accordionOptions.value?.defaultOpen;
|
||||
const allElements = props.uischema?.elements || [];
|
||||
const allElements = props.uischema.elements || [];
|
||||
|
||||
// Helper function to map original index to filtered position
|
||||
const mapOriginalToFiltered = (originalIndex: number): number | null => {
|
||||
@@ -128,7 +127,7 @@ const defaultOpenItems = computed(() => {
|
||||
});
|
||||
|
||||
// Get title for accordion item from element options
|
||||
const getAccordionTitle = (element: UISchemaElement, index: number): string => {
|
||||
const getAccordionTitle = (element: BaseUISchemaElement, index: number): string => {
|
||||
const el = element as BaseUISchemaElement & Labelable;
|
||||
const options = el.options;
|
||||
const accordionTitle = options?.accordion?.title;
|
||||
@@ -138,9 +137,8 @@ const getAccordionTitle = (element: UISchemaElement, index: number): string => {
|
||||
};
|
||||
|
||||
// Get description for accordion item from element options
|
||||
const getAccordionDescription = (element: UISchemaElement, _index: number): string => {
|
||||
const el = element as BaseUISchemaElement;
|
||||
const options = el.options;
|
||||
const getAccordionDescription = (element: BaseUISchemaElement, _index: number): string => {
|
||||
const options = element.options;
|
||||
const accordionDescription = options?.accordion?.description;
|
||||
const description = options?.description;
|
||||
return accordionDescription || description || '';
|
||||
|
||||
@@ -63,3 +63,8 @@ Both `VITE_ALLOW_CONSOLE_LOGS` and `VITE_TAILWIND_BASE_FONT_SIZE` should never b
|
||||
## Interfacing with `unraid-api`
|
||||
|
||||
@todo [Apollo VueJS Guide on Colocating Fragments](https://v4.apollo.vuejs.org/guide-composable/fragments.html#colocating-fragments)
|
||||
|
||||
## Internationalization
|
||||
|
||||
- The WebGUI now exposes the active locale as `window.LOCALE`; the app loads the matching bundle from `src/locales` at runtime and falls back to `en_US`.
|
||||
- Run `pnpm --filter @unraid/web i18n:extract` to add any missing translation keys discovered in Vue components to `src/locales/en.json`. Other locale files receive English fallbacks for new keys so translators can keep them in sync.
|
||||
|
||||
@@ -7,9 +7,8 @@ import { mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import ActivationModal from '~/components/Activation/ActivationModal.vue';
|
||||
import { createTestI18n, testTranslate } from '../../utils/i18n';
|
||||
|
||||
vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
@@ -36,7 +35,7 @@ vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockT = (key: string, args?: unknown[]) => (args ? `${key} ${JSON.stringify(args)}` : key);
|
||||
const mockT = testTranslate;
|
||||
|
||||
const mockComponents = {
|
||||
ActivationPartnerLogo: {
|
||||
@@ -73,13 +72,6 @@ const mockPurchaseStore = {
|
||||
activate: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock all imports
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: mockT,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('~/components/Activation/store/activationCodeModal', () => {
|
||||
const store = {
|
||||
useActivationCodeModalStore: () => {
|
||||
@@ -136,8 +128,8 @@ describe('Activation/ActivationModal.vue', () => {
|
||||
|
||||
const mountComponent = () => {
|
||||
return mount(ActivationModal, {
|
||||
props: { t: mockT as unknown as ComposerTranslation },
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: mockComponents,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mount } from '@vue/test-utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ActivationSteps from '~/components/Activation/ActivationSteps.vue';
|
||||
import { createTestI18n } from '../../utils/i18n';
|
||||
|
||||
interface Props {
|
||||
activeStep?: number;
|
||||
@@ -59,6 +60,9 @@ describe('ActivationSteps', () => {
|
||||
const mountComponent = (props: Props = {}) => {
|
||||
return mount(ActivationSteps, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import WelcomeModal from '~/components/Activation/WelcomeModal.standalone.vue';
|
||||
import { testTranslate } from '../../utils/i18n';
|
||||
|
||||
vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
@@ -36,7 +37,7 @@ vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const mockT = (key: string, args?: unknown[]) => (args ? `${key} ${JSON.stringify(args)}` : key);
|
||||
const mockT = testTranslate;
|
||||
|
||||
const mockComponents = {
|
||||
ActivationPartnerLogo: {
|
||||
@@ -126,34 +127,29 @@ describe('Activation/WelcomeModal.standalone.vue', () => {
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
it('uses the correct title text when no partner name is provided', () => {
|
||||
mountComponent();
|
||||
it('uses the correct title text when no partner name is provided', async () => {
|
||||
const wrapper = await mountComponent();
|
||||
|
||||
expect(mockT('Welcome to Unraid!')).toBe('Welcome to Unraid!');
|
||||
expect(wrapper.find('h1').text()).toBe(testTranslate('activation.welcomeModal.welcomeToUnraid'));
|
||||
});
|
||||
|
||||
it('uses the correct title text when partner name is provided', () => {
|
||||
it('uses the correct title text when partner name is provided', async () => {
|
||||
mockWelcomeModalDataStore.partnerInfo.value = {
|
||||
hasPartnerLogo: true,
|
||||
partnerName: 'Test Partner',
|
||||
};
|
||||
mountComponent();
|
||||
const wrapper = await mountComponent();
|
||||
|
||||
expect(mockT('Welcome to your new {0} system, powered by Unraid!', ['Test Partner'])).toBe(
|
||||
'Welcome to your new {0} system, powered by Unraid! ["Test Partner"]'
|
||||
expect(wrapper.find('h1').text()).toBe(
|
||||
testTranslate('activation.welcomeModal.welcomeToYourNewSystemPowered', ['Test Partner'])
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the correct description text', () => {
|
||||
mountComponent();
|
||||
it('uses the correct description text', async () => {
|
||||
const wrapper = await mountComponent();
|
||||
|
||||
const descriptionText = mockT(
|
||||
`First, you'll create your device's login credentials, then you'll activate your Unraid license—your device's operating system (OS).`
|
||||
);
|
||||
|
||||
expect(descriptionText).toBe(
|
||||
"First, you'll create your device's login credentials, then you'll activate your Unraid license—your device's operating system (OS)."
|
||||
);
|
||||
const description = testTranslate('activation.welcomeModal.firstYouLlCreateYourDevice');
|
||||
expect(wrapper.text()).toContain(description);
|
||||
});
|
||||
|
||||
it('displays the partner logo when available', async () => {
|
||||
|
||||
@@ -13,12 +13,17 @@ import type { ServerconnectPluginInstalled } from '~/types/server';
|
||||
|
||||
import Auth from '~/components/Auth.standalone.vue';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { createTestI18n, testTranslate } from '../utils/i18n';
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>();
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: testTranslate,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('crypto-js/aes', () => ({
|
||||
default: {},
|
||||
@@ -65,7 +70,7 @@ describe('Auth Component', () => {
|
||||
it('displays an authentication button when authAction is available', async () => {
|
||||
const wrapper = mount(Auth, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -89,7 +94,7 @@ describe('Auth Component', () => {
|
||||
it('displays error messages when stateData.error is true', () => {
|
||||
const wrapper = mount(Auth, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -113,7 +118,7 @@ describe('Auth Component', () => {
|
||||
it('calls the click handler when button is clicked', async () => {
|
||||
const wrapper = mount(Auth, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -134,7 +139,7 @@ describe('Auth Component', () => {
|
||||
it('does not render button when authAction is undefined', () => {
|
||||
const wrapper = mount(Auth, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Avatar from '~/components/Brand/Avatar.vue';
|
||||
import BrandMark from '~/components/Brand/Mark.vue';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { createTestI18n, testTranslate } from '../../utils/i18n';
|
||||
|
||||
vi.mock('crypto-js/aes.js', () => ({
|
||||
default: {},
|
||||
@@ -18,6 +19,17 @@ vi.mock('@unraid/shared-callbacks', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock vue-i18n for store tests
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>();
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: testTranslate,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Avatar', () => {
|
||||
let serverStore: ReturnType<typeof useServerStore>;
|
||||
let pinia: ReturnType<typeof createTestingPinia>;
|
||||
@@ -39,7 +51,7 @@ describe('Avatar', () => {
|
||||
|
||||
const wrapper = mount(Avatar, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
plugins: [pinia, createTestI18n()],
|
||||
stubs: {
|
||||
BrandMark: true,
|
||||
},
|
||||
@@ -57,7 +69,7 @@ describe('Avatar', () => {
|
||||
|
||||
const wrapper = mount(Avatar, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
plugins: [pinia, createTestI18n()],
|
||||
stubs: {
|
||||
BrandMark: true,
|
||||
},
|
||||
@@ -75,7 +87,7 @@ describe('Avatar', () => {
|
||||
|
||||
const wrapper = mount(Avatar, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
plugins: [pinia, createTestI18n()],
|
||||
stubs: {
|
||||
BrandMark: true,
|
||||
},
|
||||
@@ -94,7 +106,7 @@ describe('Avatar', () => {
|
||||
|
||||
const wrapper = mount(Avatar, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
plugins: [pinia, createTestI18n()],
|
||||
stubs: {
|
||||
BrandMark: true,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DOCS } from '~/helpers/urls';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
|
||||
import { createTestI18n } from '../utils/i18n';
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
BrandButton: { template: '<button><slot /></button>' },
|
||||
@@ -24,7 +25,7 @@ vi.mock('@heroicons/vue/24/solid', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('~/components/UpdateOs/RawChangelogRenderer.vue', () => ({
|
||||
default: { template: '<div />', props: ['changelog', 'version', 'date', 't', 'changelogPretty'] },
|
||||
default: { template: '<div />', props: ['changelog', 'version', 'date', 'changelogPretty'] },
|
||||
}));
|
||||
|
||||
vi.mock('pinia', async () => {
|
||||
@@ -94,7 +95,6 @@ describe('ChangelogModal iframeSrc', () => {
|
||||
const mountWithChangelog = (changelogPretty: string | null) =>
|
||||
mount(ChangelogModal, {
|
||||
props: {
|
||||
t: (key: string) => key,
|
||||
open: true,
|
||||
release: {
|
||||
version: '6.12.0',
|
||||
@@ -104,6 +104,9 @@ describe('ChangelogModal iframeSrc', () => {
|
||||
date: '2024-01-01',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -3,31 +3,8 @@ import { mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import CheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
|
||||
|
||||
const translate: ComposerTranslation = ((key: string, params?: unknown) => {
|
||||
if (Array.isArray(params) && params.length > 0) {
|
||||
return params.reduce<string>(
|
||||
(result, value, index) => result.replace(`{${index}}`, String(value)),
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
if (params && typeof params === 'object') {
|
||||
return Object.entries(params as Record<string, unknown>).reduce<string>(
|
||||
(result, [placeholder, value]) => result.replace(`{${placeholder}}`, String(value)),
|
||||
key
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof params === 'number') {
|
||||
return key.replace('{0}', String(params));
|
||||
}
|
||||
|
||||
return key;
|
||||
}) as ComposerTranslation;
|
||||
import { createTestI18n, testTranslate } from '../utils/i18n';
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
BrandButton: {
|
||||
@@ -181,7 +158,9 @@ const mountModal = () =>
|
||||
mount(CheckUpdateResponseModal, {
|
||||
props: {
|
||||
open: true,
|
||||
t: translate,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -208,6 +187,9 @@ describe('CheckUpdateResponseModal', () => {
|
||||
});
|
||||
|
||||
it('renders loading state while checking for updates', () => {
|
||||
expect(testTranslate('updateOs.checkUpdateResponseModal.checkingForOsUpdates')).toBe(
|
||||
'Checking for OS updates...'
|
||||
);
|
||||
checkForUpdatesLoading.value = true;
|
||||
|
||||
const wrapper = mountModal();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DowngradeOs from '~/components/DowngradeOs.standalone.vue';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { createTestI18n, testTranslate } from '../utils/i18n';
|
||||
|
||||
vi.mock('crypto-js/aes', () => ({
|
||||
default: {},
|
||||
@@ -30,11 +31,15 @@ vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof import('vue-i18n');
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: testTranslate,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const PageContainerStub = {
|
||||
template: '<div><slot /></div>',
|
||||
@@ -71,6 +76,7 @@ describe('DowngradeOs', () => {
|
||||
rebootVersion: rebootVersionProp,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
PageContainer: PageContainerStub,
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
@@ -87,6 +93,7 @@ describe('DowngradeOs', () => {
|
||||
it('renders UpdateOsStatus with initial props', () => {
|
||||
const wrapper = mount(DowngradeOs, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
PageContainer: PageContainerStub,
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
@@ -114,6 +121,7 @@ describe('DowngradeOs', () => {
|
||||
restoreReleaseDate: '2023-01-01',
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
PageContainer: PageContainerStub,
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
@@ -139,6 +147,7 @@ describe('DowngradeOs', () => {
|
||||
const wrapper = mount(DowngradeOs, {
|
||||
props: {},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
PageContainer: PageContainerStub,
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
|
||||
@@ -9,19 +9,24 @@ import { createTestingPinia } from '@pinia/testing';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
|
||||
import { createTestI18n, testTranslate } from '../utils/i18n';
|
||||
|
||||
vi.mock('~/helpers/urls', () => ({
|
||||
CONNECT_FORUMS: new URL('http://mock-forums.local'),
|
||||
CONTACT: new URL('http://mock-contact.local'),
|
||||
DISCORD: new URL('http://mock-discord.local'),
|
||||
WEBGUI_GRAPHQL: new URL('http://mock-webgui.local'),
|
||||
WEBGUI_GRAPHQL: '/graphql',
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof import('vue-i18n');
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: testTranslate,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('DownloadApiLogs', () => {
|
||||
beforeEach(() => {
|
||||
@@ -33,7 +38,7 @@ describe('DownloadApiLogs', () => {
|
||||
it('provides a download button with the correct URL', () => {
|
||||
const wrapper = mount(DownloadApiLogs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
ArrowDownTrayIcon: true,
|
||||
ArrowTopRightOnSquareIcon: true,
|
||||
@@ -42,25 +47,24 @@ describe('DownloadApiLogs', () => {
|
||||
});
|
||||
|
||||
// Expected download URL
|
||||
const expectedUrl = new URL('/graphql/api/logs', 'http://mock-webgui.local');
|
||||
expectedUrl.searchParams.append('csrf_token', 'mock-csrf-token');
|
||||
const expectedUrl = '/graphql/api/logs?csrf_token=mock-csrf-token';
|
||||
|
||||
// Find the download button
|
||||
const downloadButton = wrapper.findComponent(BrandButton);
|
||||
|
||||
// Verify download button exists and has correct attributes
|
||||
expect(downloadButton.exists()).toBe(true);
|
||||
expect(downloadButton.attributes('href')).toBe(expectedUrl.toString());
|
||||
expect(downloadButton.attributes('href')).toBe(expectedUrl);
|
||||
expect(downloadButton.attributes('download')).toBe('');
|
||||
expect(downloadButton.attributes('target')).toBe('_blank');
|
||||
expect(downloadButton.attributes('rel')).toBe('noopener noreferrer');
|
||||
expect(downloadButton.text()).toContain('Download unraid-api Logs');
|
||||
expect(downloadButton.text()).toContain(testTranslate('downloadApiLogs.downloadUnraidApiLogs'));
|
||||
});
|
||||
|
||||
it('displays support links to documentation and help resources', () => {
|
||||
const wrapper = mount(DownloadApiLogs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
ArrowDownTrayIcon: true,
|
||||
ArrowTopRightOnSquareIcon: true,
|
||||
@@ -72,13 +76,13 @@ describe('DownloadApiLogs', () => {
|
||||
expect(links.length).toBe(4);
|
||||
|
||||
expect(links[1].attributes('href')).toBe('http://mock-forums.local/');
|
||||
expect(links[1].text()).toContain('Unraid Connect Forums');
|
||||
expect(links[1].text()).toContain(testTranslate('downloadApiLogs.unraidConnectForums'));
|
||||
|
||||
expect(links[2].attributes('href')).toBe('http://mock-discord.local/');
|
||||
expect(links[2].text()).toContain('Unraid Discord');
|
||||
expect(links[2].text()).toContain(testTranslate('downloadApiLogs.unraidDiscord'));
|
||||
|
||||
expect(links[3].attributes('href')).toBe('http://mock-contact.local/');
|
||||
expect(links[3].text()).toContain('Unraid Contact Page');
|
||||
expect(links[3].text()).toContain(testTranslate('downloadApiLogs.unraidContactPage'));
|
||||
|
||||
links.slice(1).forEach((link) => {
|
||||
expect(link.attributes('target')).toBe('_blank');
|
||||
@@ -89,7 +93,7 @@ describe('DownloadApiLogs', () => {
|
||||
it('displays instructions about log usage and privacy', () => {
|
||||
const wrapper = mount(DownloadApiLogs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
ArrowDownTrayIcon: true,
|
||||
ArrowTopRightOnSquareIcon: true,
|
||||
@@ -99,10 +103,8 @@ describe('DownloadApiLogs', () => {
|
||||
|
||||
const text = wrapper.text();
|
||||
|
||||
expect(text).toContain(
|
||||
'The primary method of support for Unraid Connect is through our forums and Discord'
|
||||
);
|
||||
expect(text).toContain('If you are asked to supply logs');
|
||||
expect(text).toContain('The logs may contain sensitive information so do not post them publicly');
|
||||
expect(text).toContain(testTranslate('downloadApiLogs.thePrimaryMethodOfSupportFor'));
|
||||
expect(text).toContain(testTranslate('downloadApiLogs.ifYouAreAskedToSupply'));
|
||||
expect(text).toContain(testTranslate('downloadApiLogs.theLogsMayContainSensitiveInformation'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ServerUpdateOsResponse } from '~/types/server';
|
||||
import HeaderOsVersion from '~/components/HeaderOsVersion.standalone.vue';
|
||||
import { useErrorsStore } from '~/store/errors';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { createTestI18n, testTranslate } from '../utils/i18n';
|
||||
|
||||
vi.mock('crypto-js/aes', () => ({ default: {} }));
|
||||
vi.mock('@unraid/shared-callbacks', () => ({
|
||||
@@ -60,31 +61,15 @@ vi.mock('~/helpers/urls', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: unknown) => {
|
||||
if (params && Array.isArray(params)) {
|
||||
let result = key;
|
||||
params.forEach((val, index) => {
|
||||
result = result.replace(`{${index}}`, String(val));
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const keyMap: Record<string, string> = {
|
||||
'Reboot Required for Update': 'Reboot Required for Update',
|
||||
'Reboot Required for Downgrade': 'Reboot Required for Downgrade',
|
||||
'Updating 3rd party drivers': 'Updating 3rd party drivers',
|
||||
'Update Available': 'Update Available',
|
||||
'Update Released': 'Update Released',
|
||||
'View release notes': 'View release notes',
|
||||
};
|
||||
|
||||
return keyMap[key] ?? key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as typeof import('vue-i18n');
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: testTranslate,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('HeaderOsVersion', () => {
|
||||
let wrapper: VueWrapper<unknown>;
|
||||
@@ -113,7 +98,7 @@ describe('HeaderOsVersion', () => {
|
||||
|
||||
wrapper = mount(HeaderOsVersion, {
|
||||
global: {
|
||||
plugins: [testingPinia],
|
||||
plugins: [testingPinia, createTestI18n()],
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -168,7 +153,7 @@ describe('HeaderOsVersion', () => {
|
||||
// Mount component
|
||||
const newWrapper = mount(HeaderOsVersion, {
|
||||
global: {
|
||||
plugins: [testingPinia],
|
||||
plugins: [testingPinia, createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ServerStateDataAction, ServerStateDataActionType } from '~/types/server';
|
||||
|
||||
import KeyActions from '~/components/KeyActions.vue';
|
||||
import { createTestI18n } from '../utils/i18n';
|
||||
|
||||
import '../mocks/ui-components';
|
||||
|
||||
@@ -26,8 +27,6 @@ vi.mock('@unraid/shared-callbacks', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const t = (key: string) => `translated_${key}`;
|
||||
|
||||
describe('KeyActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -40,25 +39,26 @@ describe('KeyActions', () => {
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
t,
|
||||
actions,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
const buttons = wrapper.findAllComponents(BrandButton);
|
||||
|
||||
expect(buttons.length).toBe(1);
|
||||
expect(buttons[0].text()).toContain('translated_Custom Action 1');
|
||||
expect(buttons[0].text()).toContain('Custom Action 1');
|
||||
});
|
||||
|
||||
it('renders an empty list container when actions array is empty', () => {
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
t,
|
||||
actions: [],
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,9 +74,11 @@ describe('KeyActions', () => {
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
t,
|
||||
actions,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.findComponent(BrandButton).trigger('click');
|
||||
@@ -96,9 +98,11 @@ describe('KeyActions', () => {
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
t,
|
||||
actions,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.findComponent(BrandButton).trigger('click');
|
||||
@@ -114,17 +118,19 @@ describe('KeyActions', () => {
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
t,
|
||||
actions,
|
||||
filterBy: ['purchase', 'upgrade'],
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
const buttons = wrapper.findAllComponents(BrandButton);
|
||||
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons[0].text()).toContain('translated_Action 1');
|
||||
expect(buttons[1].text()).toContain('translated_Action 3');
|
||||
expect(buttons[0].text()).toContain('Action 1');
|
||||
expect(buttons[1].text()).toContain('Action 3');
|
||||
});
|
||||
|
||||
it('filters out actions using filterOut prop', () => {
|
||||
@@ -136,17 +142,19 @@ describe('KeyActions', () => {
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
t,
|
||||
actions,
|
||||
filterOut: ['redeem'],
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
const buttons = wrapper.findAllComponents(BrandButton);
|
||||
|
||||
expect(buttons.length).toBe(2);
|
||||
expect(buttons[0].text()).toContain('translated_Action 1');
|
||||
expect(buttons[1].text()).toContain('translated_Action 3');
|
||||
expect(buttons[0].text()).toContain('Action 1');
|
||||
expect(buttons[1].text()).toContain('Action 3');
|
||||
});
|
||||
|
||||
it('applies maxWidth styling when maxWidth prop is true', () => {
|
||||
@@ -156,10 +164,12 @@ describe('KeyActions', () => {
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
t,
|
||||
actions,
|
||||
maxWidth: true,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
const button = wrapper.findComponent(BrandButton);
|
||||
@@ -183,15 +193,17 @@ describe('KeyActions', () => {
|
||||
|
||||
const wrapper = mount(KeyActions, {
|
||||
props: {
|
||||
t,
|
||||
actions,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
const button = wrapper.findComponent(BrandButton);
|
||||
|
||||
expect(button.props('text')).toBe('translated_Test Action');
|
||||
expect(button.props('title')).toBe('translated_Action Title');
|
||||
expect(button.props('text')).toBe('Test Action');
|
||||
expect(button.props('title')).toBe('Action Title');
|
||||
expect(button.props('href')).toBe('/test-link');
|
||||
expect(button.props('external')).toBe(true);
|
||||
expect(button.props('disabled')).toBe(true);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SingleLogViewer from '~/components/Logs/SingleLogViewer.vue';
|
||||
import { createMockLogFileQuery, createMockUseQuery } from '../../helpers/apollo-mocks';
|
||||
import { createTestI18n } from '../../utils/i18n';
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
@@ -176,6 +177,7 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
autoScroll: false,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
Button: true,
|
||||
Tooltip: true,
|
||||
@@ -217,6 +219,9 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
lineCount: 100,
|
||||
autoScroll: false,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for the component to mount and process initial data
|
||||
@@ -270,6 +275,9 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
lineCount: 100,
|
||||
autoScroll: false,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for mount and trigger the watcher
|
||||
@@ -318,6 +326,9 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
autoScroll: false,
|
||||
clientFilter: 'ERROR',
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for mount and trigger the watcher
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { MountingOptions, VueWrapper } from '@vue/test-utils';
|
||||
import type { Props as ModalProps } from '~/components/Modal.vue';
|
||||
|
||||
import Modal from '~/components/Modal.vue';
|
||||
import { createTestI18n } from '../utils/i18n';
|
||||
|
||||
const mockSetProperty = vi.fn();
|
||||
const mockRemoveProperty = vi.fn();
|
||||
@@ -24,8 +25,6 @@ Object.defineProperty(document.body.style, 'removeProperty', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe('Modal', () => {
|
||||
let wrapper: VueWrapper<unknown>;
|
||||
|
||||
@@ -44,7 +43,6 @@ describe('Modal', () => {
|
||||
|
||||
return mount(Modal, {
|
||||
props: {
|
||||
t,
|
||||
open: true,
|
||||
...(restOptions.props || {}),
|
||||
},
|
||||
@@ -60,6 +58,7 @@ describe('Modal', () => {
|
||||
},
|
||||
...(restOptions.global?.stubs || {}),
|
||||
},
|
||||
plugins: [createTestI18n()],
|
||||
...(restOptions.global || {}),
|
||||
},
|
||||
attachTo: restOptions.attachTo,
|
||||
@@ -69,9 +68,11 @@ describe('Modal', () => {
|
||||
it('applies and removes body scroll lock based on open prop', async () => {
|
||||
wrapper = mount(Modal, {
|
||||
props: {
|
||||
t,
|
||||
open: false,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
// Initially hidden
|
||||
@@ -95,7 +96,7 @@ describe('Modal', () => {
|
||||
it('renders description in main content', async () => {
|
||||
const testDescription = 'This is the modal description.';
|
||||
|
||||
wrapper = mountModal({ props: { t, description: testDescription } });
|
||||
wrapper = mountModal({ props: { description: testDescription } });
|
||||
|
||||
const main = wrapper.find('[class*="max-h-"]');
|
||||
|
||||
@@ -104,7 +105,7 @@ describe('Modal', () => {
|
||||
});
|
||||
|
||||
it('does not emit close event on overlay click when disableOverlayClose is true', async () => {
|
||||
wrapper = mountModal({ props: { t, disableOverlayClose: true } });
|
||||
wrapper = mountModal({ props: { disableOverlayClose: true } });
|
||||
|
||||
const overlay = wrapper.find('[class*="fixed inset-0 z-0"]');
|
||||
|
||||
@@ -126,10 +127,12 @@ describe('Modal', () => {
|
||||
|
||||
wrapper = mount(Modal, {
|
||||
props: {
|
||||
t,
|
||||
open: true,
|
||||
maxWidth,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
@@ -140,10 +143,12 @@ describe('Modal', () => {
|
||||
it('applies error and success classes correctly', async () => {
|
||||
wrapper = mount(Modal, {
|
||||
props: {
|
||||
t,
|
||||
open: true,
|
||||
error: true,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
@@ -166,10 +171,12 @@ describe('Modal', () => {
|
||||
it('disables shadow-sm when disableShadow is true', async () => {
|
||||
wrapper = mount(Modal, {
|
||||
props: {
|
||||
t,
|
||||
open: true,
|
||||
disableShadow: true,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
@@ -183,10 +190,12 @@ describe('Modal', () => {
|
||||
it('applies header justification class based on headerJustifyCenter prop', async () => {
|
||||
wrapper = mount(Modal, {
|
||||
props: {
|
||||
t,
|
||||
open: true,
|
||||
headerJustifyCenter: false,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
@@ -208,11 +217,13 @@ describe('Modal', () => {
|
||||
|
||||
wrapper = mount(Modal, {
|
||||
props: {
|
||||
t,
|
||||
open: true,
|
||||
overlayColor,
|
||||
overlayOpacity,
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
},
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useUpdateOsStore } from '~/store/updateOs';
|
||||
vi.mock('~/components/Activation/ActivationModal.vue', () => ({
|
||||
default: {
|
||||
name: 'ActivationModal',
|
||||
props: ['t'],
|
||||
props: [],
|
||||
template: '<div>ActivationModal</div>',
|
||||
},
|
||||
}));
|
||||
@@ -24,7 +24,7 @@ vi.mock('~/components/Activation/ActivationModal.vue', () => ({
|
||||
vi.mock('~/components/UpdateOs/ChangelogModal.vue', () => ({
|
||||
default: {
|
||||
name: 'UpdateOsChangelogModal',
|
||||
props: ['t', 'open'],
|
||||
props: ['open'],
|
||||
template: '<div v-if="open">ChangelogModal</div>',
|
||||
},
|
||||
}));
|
||||
@@ -32,7 +32,7 @@ vi.mock('~/components/UpdateOs/ChangelogModal.vue', () => ({
|
||||
vi.mock('~/components/UpdateOs/CheckUpdateResponseModal.vue', () => ({
|
||||
default: {
|
||||
name: 'UpdateOsCheckUpdateResponseModal',
|
||||
props: ['t', 'open'],
|
||||
props: ['open'],
|
||||
template: '<div v-if="open">CheckUpdateResponseModal</div>',
|
||||
},
|
||||
}));
|
||||
@@ -40,7 +40,7 @@ vi.mock('~/components/UpdateOs/CheckUpdateResponseModal.vue', () => ({
|
||||
vi.mock('~/components/UserProfile/CallbackFeedback.vue', () => ({
|
||||
default: {
|
||||
name: 'UpcCallbackFeedback',
|
||||
props: ['t', 'open'],
|
||||
props: ['open'],
|
||||
template: '<div v-if="open">CallbackFeedback</div>',
|
||||
},
|
||||
}));
|
||||
@@ -48,7 +48,7 @@ vi.mock('~/components/UserProfile/CallbackFeedback.vue', () => ({
|
||||
vi.mock('~/components/UserProfile/Trial.vue', () => ({
|
||||
default: {
|
||||
name: 'UpcTrial',
|
||||
props: ['t', 'open'],
|
||||
props: ['open'],
|
||||
template: '<div v-if="open">Trial</div>',
|
||||
},
|
||||
}));
|
||||
@@ -160,19 +160,19 @@ describe('Modals.standalone.vue', () => {
|
||||
expect(changelogModal.props('open')).toBe(false);
|
||||
});
|
||||
|
||||
it('should pass translation function to all modals', () => {
|
||||
it('should render all modal components without t props (using useI18n)', () => {
|
||||
const components = [
|
||||
'UpcCallbackFeedback',
|
||||
'UpcTrial',
|
||||
'UpdateOsCheckUpdateResponseModal',
|
||||
'UpdateOsChangelogModal',
|
||||
'ActivationModal',
|
||||
];
|
||||
|
||||
components.forEach((componentName) => {
|
||||
const component = wrapper.findComponent({ name: componentName });
|
||||
expect(component.props('t')).toBeDefined();
|
||||
expect(typeof component.props('t')).toBe('function');
|
||||
expect(component.exists()).toBe(true);
|
||||
// Components now use useI18n internally, so no t prop should be passed
|
||||
expect(component.props('t')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import Registration from '~/components/Registration.standalone.vue';
|
||||
import { usePurchaseStore } from '~/store/purchase';
|
||||
import { useReplaceRenewStore } from '~/store/replaceRenew';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { createTestI18n, testTranslate } from '../utils/i18n';
|
||||
|
||||
vi.mock('crypto-js/aes.js', () => ({ default: {} }));
|
||||
|
||||
@@ -26,6 +27,17 @@ vi.mock('@unraid/shared-callbacks', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock vue-i18n for store tests
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>();
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: testTranslate,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useQuery: () => ({
|
||||
result: { value: {} },
|
||||
@@ -112,11 +124,7 @@ vi.mock('~/composables/dateTime', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t }),
|
||||
}));
|
||||
const t = testTranslate;
|
||||
|
||||
describe('Registration.standalone.vue', () => {
|
||||
let wrapper: VueWrapper<unknown>;
|
||||
@@ -167,7 +175,7 @@ describe('Registration.standalone.vue', () => {
|
||||
// Mount after store setup
|
||||
wrapper = mount(Registration, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
plugins: [pinia, createTestI18n()],
|
||||
stubs: {
|
||||
ShieldCheckIcon: { template: '<div class="shield-check-icon"/>' },
|
||||
ShieldExclamationIcon: { template: '<div class="shield-exclamation-icon"/>' },
|
||||
|
||||
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mock, MockInstance } from 'vitest';
|
||||
|
||||
import SsoButtons from '~/components/sso/SsoButtons.vue';
|
||||
import { createTestI18n } from '../utils/i18n';
|
||||
|
||||
// Mock the child components
|
||||
const SsoProviderButtonStub = {
|
||||
@@ -28,12 +29,6 @@ vi.mock('@vue/apollo-composable', () => ({
|
||||
useQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock vue-i18n
|
||||
const t = (key: string) => key;
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t }),
|
||||
}));
|
||||
|
||||
// Mock the GraphQL query
|
||||
vi.mock('~/components/queries/public-oidc-providers.query.js', () => ({
|
||||
PUBLIC_OIDC_PROVIDERS: 'PUBLIC_OIDC_PROVIDERS_QUERY',
|
||||
@@ -151,6 +146,7 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
@@ -175,6 +171,7 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
@@ -203,6 +200,7 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
@@ -240,6 +238,7 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
@@ -288,6 +287,7 @@ describe('SsoButtons', () => {
|
||||
// Mount the component so that onMounted hook is called
|
||||
mount(SsoButtons, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
@@ -327,6 +327,7 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
@@ -370,6 +371,7 @@ describe('SsoButtons', () => {
|
||||
|
||||
mount(SsoButtons, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
@@ -408,6 +410,7 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
@@ -462,6 +465,7 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
plugins: [createTestI18n()],
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createTestingPinia } from '@pinia/testing';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import UpdateOs from '~/components/UpdateOs.standalone.vue';
|
||||
import { createTestI18n } from '../utils/i18n';
|
||||
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
PageContainer: { template: '<div><slot /></div>' },
|
||||
@@ -32,13 +33,6 @@ vi.mock('~/store/server', () => ({
|
||||
useServerStore: () => mockServerStore,
|
||||
}));
|
||||
|
||||
const mockT = vi.fn((key: string) => key);
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: mockT,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
@@ -49,7 +43,7 @@ Object.defineProperty(window, 'location', {
|
||||
});
|
||||
|
||||
vi.mock('~/helpers/urls', () => ({
|
||||
WEBGUI_TOOLS_UPDATE: { pathname: '/Tools/Update' },
|
||||
WEBGUI_TOOLS_UPDATE: '/Tools/Update',
|
||||
}));
|
||||
|
||||
const UpdateOsStatusStub = {
|
||||
@@ -67,7 +61,6 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
mockRebootType.value = '';
|
||||
mockSetRebootVersion.mockClear();
|
||||
mockAccountStore.updateOs.mockClear();
|
||||
mockT.mockClear().mockImplementation((key: string) => key);
|
||||
window.location.pathname = '/some/other/path';
|
||||
});
|
||||
|
||||
@@ -76,7 +69,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
mount(UpdateOs, {
|
||||
props: { rebootVersion: testVersion },
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
// Rely on @unraid/ui mock for PageContainer
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
@@ -91,7 +84,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
it('calls setRebootVersion with empty string if prop not provided', () => {
|
||||
mount(UpdateOs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
// Rely on @unraid/ui mock for PageContainer
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
@@ -110,7 +103,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
|
||||
const wrapper = mount(UpdateOs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
// Rely on @unraid/ui mock for PageContainer & BrandLoading
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
@@ -140,7 +133,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
|
||||
const wrapper = mount(UpdateOs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
// Rely on @unraid/ui mock for PageContainer & BrandLoading
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
@@ -163,7 +156,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
|
||||
const wrapper = mount(UpdateOs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
// Rely on @unraid/ui mock for PageContainer & BrandLoading
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
@@ -186,7 +179,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
mockRebootType.value = 'downgrade';
|
||||
const wrapper = mount(UpdateOs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
|
||||
@@ -206,7 +199,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
mockRebootType.value = 'thirdPartyDriversDownloading';
|
||||
const wrapper = mount(UpdateOs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
|
||||
@@ -223,7 +216,7 @@ describe('UpdateOs.standalone.vue', () => {
|
||||
mockRebootType.value = 'update';
|
||||
const wrapper = mount(UpdateOs, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
||||
stubs: {
|
||||
UpdateOsStatus: UpdateOsStatusStub,
|
||||
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
|
||||
|
||||
@@ -29,14 +29,6 @@ vi.mock('~/components/Wrapper/component-registry', () => ({
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
const mockI18n = {
|
||||
global: {},
|
||||
install: vi.fn(),
|
||||
};
|
||||
vi.mock('vue-i18n', () => ({
|
||||
createI18n: vi.fn(() => mockI18n),
|
||||
}));
|
||||
|
||||
const mockApolloClient = { query: vi.fn(), mutate: vi.fn() };
|
||||
vi.mock('~/helpers/create-apollo-client', () => ({
|
||||
client: mockApolloClient,
|
||||
@@ -54,12 +46,22 @@ vi.mock('~/store/globalPinia', () => ({
|
||||
globalPinia: mockGlobalPinia,
|
||||
}));
|
||||
|
||||
vi.mock('~/locales/en_US.json', () => ({
|
||||
default: { test: 'Test Message' },
|
||||
}));
|
||||
const mockI18n = {
|
||||
global: {
|
||||
locale: { value: 'en_US' },
|
||||
availableLocales: ['en_US'],
|
||||
setLocaleMessage: vi.fn(),
|
||||
},
|
||||
install: vi.fn(),
|
||||
};
|
||||
const mockCreateI18nInstance = vi.fn(() => mockI18n);
|
||||
const mockEnsureLocale = vi.fn().mockResolvedValue('en_US');
|
||||
const mockGetWindowLocale = vi.fn<() => string | undefined>(() => undefined);
|
||||
|
||||
vi.mock('~/helpers/i18n-utils', () => ({
|
||||
createHtmlEntityDecoder: vi.fn(() => (str: string) => str),
|
||||
vi.mock('~/helpers/i18n-loader', () => ({
|
||||
createI18nInstance: mockCreateI18nInstance,
|
||||
ensureLocale: mockEnsureLocale,
|
||||
getWindowLocale: mockGetWindowLocale,
|
||||
}));
|
||||
|
||||
describe('mount-engine', () => {
|
||||
@@ -75,6 +77,14 @@ describe('mount-engine', () => {
|
||||
|
||||
// Import fresh module
|
||||
vi.resetModules();
|
||||
mockCreateI18nInstance.mockClear();
|
||||
mockEnsureLocale.mockClear();
|
||||
mockGetWindowLocale.mockReset();
|
||||
mockGetWindowLocale.mockReturnValue(undefined);
|
||||
mockI18n.install.mockClear();
|
||||
mockI18n.global.locale.value = 'en_US';
|
||||
mockI18n.global.availableLocales = ['en_US'];
|
||||
mockI18n.global.setLocaleMessage.mockClear();
|
||||
const module = await import('~/components/Wrapper/mount-engine');
|
||||
mountUnifiedApp = module.mountUnifiedApp;
|
||||
autoMountAllComponents = module.autoMountAllComponents;
|
||||
@@ -121,7 +131,7 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
const app = mountUnifiedApp();
|
||||
const app = await mountUnifiedApp();
|
||||
|
||||
expect(app).toBeTruthy();
|
||||
expect(mockI18n.install).toHaveBeenCalled();
|
||||
@@ -150,7 +160,7 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
// Wait for async component to render
|
||||
await vi.waitFor(() => {
|
||||
@@ -158,7 +168,7 @@ describe('mount-engine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle JSON props from attributes', () => {
|
||||
it('should handle JSON props from attributes', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'test-app';
|
||||
element.setAttribute('message', '{"text": "JSON Message"}');
|
||||
@@ -170,13 +180,13 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
// The component receives the parsed JSON object
|
||||
expect(element.getAttribute('message')).toBe('{"text": "JSON Message"}');
|
||||
});
|
||||
|
||||
it('should handle HTML-encoded JSON in attributes', () => {
|
||||
it('should handle HTML-encoded JSON in attributes', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'test-app';
|
||||
element.setAttribute('message', '{"text": "Encoded"}');
|
||||
@@ -188,7 +198,7 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
expect(element.getAttribute('message')).toBe('{"text": "Encoded"}');
|
||||
});
|
||||
@@ -209,7 +219,7 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
// Wait for async component to render
|
||||
await vi.waitFor(() => {
|
||||
@@ -235,7 +245,7 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
// Wait for component to mount
|
||||
await vi.waitFor(() => {
|
||||
@@ -243,7 +253,7 @@ describe('mount-engine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip already mounted elements', () => {
|
||||
it('should skip already mounted elements', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'already-mounted';
|
||||
element.setAttribute('data-vue-mounted', 'true');
|
||||
@@ -255,20 +265,20 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
// Should not mount to already mounted element
|
||||
expect(element.querySelector('.test-component')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle missing elements gracefully', () => {
|
||||
it('should handle missing elements gracefully', async () => {
|
||||
mockComponentMappings.push({
|
||||
selector: '#non-existent',
|
||||
appId: 'non-existent',
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
const app = mountUnifiedApp();
|
||||
const app = await mountUnifiedApp();
|
||||
|
||||
// Should still create the app successfully
|
||||
expect(app).toBeTruthy();
|
||||
@@ -287,7 +297,7 @@ describe('mount-engine', () => {
|
||||
appId: 'invalid-app',
|
||||
} as ComponentMapping);
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
// Should log error for missing component
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
@@ -299,21 +309,21 @@ describe('mount-engine', () => {
|
||||
expect(element.getAttribute('data-vue-mounted')).toBeNull();
|
||||
});
|
||||
|
||||
it('should create hidden root element if not exists', () => {
|
||||
mountUnifiedApp();
|
||||
it('should create hidden root element if not exists', async () => {
|
||||
await mountUnifiedApp();
|
||||
|
||||
const rootElement = document.getElementById('unraid-unified-root');
|
||||
expect(rootElement).toBeTruthy();
|
||||
expect(rootElement?.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('should reuse existing root element', () => {
|
||||
it('should reuse existing root element', async () => {
|
||||
// Create root element first
|
||||
const existingRoot = document.createElement('div');
|
||||
existingRoot.id = 'unraid-unified-root';
|
||||
document.body.appendChild(existingRoot);
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
const rootElement = document.getElementById('unraid-unified-root');
|
||||
expect(rootElement).toBe(existingRoot);
|
||||
@@ -330,7 +340,7 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
// Wait for async component to render
|
||||
await vi.waitFor(() => {
|
||||
@@ -363,7 +373,7 @@ describe('mount-engine', () => {
|
||||
}
|
||||
);
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
// Wait for async components to render
|
||||
await vi.waitFor(() => {
|
||||
@@ -390,7 +400,7 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
autoMountAllComponents();
|
||||
await autoMountAllComponents();
|
||||
|
||||
// Wait for async component to render
|
||||
await vi.waitFor(() => {
|
||||
@@ -400,35 +410,19 @@ describe('mount-engine', () => {
|
||||
});
|
||||
|
||||
describe('i18n setup', () => {
|
||||
it('should setup i18n with default locale', () => {
|
||||
mountUnifiedApp();
|
||||
it('should setup i18n with default locale', async () => {
|
||||
await mountUnifiedApp();
|
||||
expect(mockCreateI18nInstance).toHaveBeenCalled();
|
||||
expect(mockEnsureLocale).toHaveBeenCalledWith(mockI18n, undefined);
|
||||
expect(mockI18n.install).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should parse window locale data', () => {
|
||||
const localeData = {
|
||||
fr_FR: { test: 'Message de test' },
|
||||
};
|
||||
(window as unknown as Record<string, unknown>).LOCALE_DATA = encodeURIComponent(
|
||||
JSON.stringify(localeData)
|
||||
);
|
||||
it('should request window locale when available', async () => {
|
||||
mockGetWindowLocale.mockReturnValue('ja_JP');
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
|
||||
});
|
||||
|
||||
it('should handle locale data parsing errors', () => {
|
||||
(window as unknown as Record<string, unknown>).LOCALE_DATA = 'invalid json';
|
||||
|
||||
mountUnifiedApp();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] error parsing messages',
|
||||
expect.any(Error)
|
||||
);
|
||||
|
||||
delete (window as unknown as Record<string, unknown>).LOCALE_DATA;
|
||||
expect(mockEnsureLocale).toHaveBeenCalledWith(mockI18n, 'ja_JP');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -440,7 +434,7 @@ describe('mount-engine', () => {
|
||||
});
|
||||
|
||||
describe('performance debugging', () => {
|
||||
it('should not log timing by default', () => {
|
||||
it('should not log timing by default', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'perf-app';
|
||||
document.body.appendChild(element);
|
||||
@@ -451,7 +445,7 @@ describe('mount-engine', () => {
|
||||
component: TestComponent,
|
||||
});
|
||||
|
||||
mountUnifiedApp();
|
||||
await mountUnifiedApp();
|
||||
|
||||
// Should not log timing information when PERF_DEBUG is false
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalledWith(expect.stringContaining('[UnifiedMount] Mounted'));
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock mount-engine module first to ensure proper hoisting
|
||||
const mockAutoMountAllComponents = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const mockMountUnifiedApp = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
|
||||
vi.mock('~/components/Wrapper/mount-engine', () => ({
|
||||
autoMountAllComponents: mockAutoMountAllComponents,
|
||||
mountUnifiedApp: mockMountUnifiedApp,
|
||||
}));
|
||||
|
||||
// Mock all the component imports
|
||||
vi.mock('~/components/Auth.standalone.vue', () => ({
|
||||
default: { name: 'MockAuth', template: '<div>Auth</div>' },
|
||||
@@ -56,15 +65,6 @@ vi.mock('~/components/UnraidToaster.vue', () => ({
|
||||
default: { name: 'MockUnraidToaster', template: '<div>UnraidToaster</div>' },
|
||||
}));
|
||||
|
||||
// Mock mount-engine module
|
||||
const mockAutoMountAllComponents = vi.fn();
|
||||
const mockMountUnifiedApp = vi.fn();
|
||||
|
||||
vi.mock('~/components/Wrapper/mount-engine', () => ({
|
||||
autoMountAllComponents: mockAutoMountAllComponents,
|
||||
mountUnifiedApp: mockMountUnifiedApp,
|
||||
}));
|
||||
|
||||
// Mock theme initializer
|
||||
const mockInitializeTheme = vi.fn(() => Promise.resolve());
|
||||
vi.mock('~/store/themeInitializer', () => ({
|
||||
@@ -138,13 +138,13 @@ describe('component-registry', () => {
|
||||
expect(mockProvideApolloClient).toHaveBeenCalledWith(mockApolloClient);
|
||||
});
|
||||
|
||||
it('should initialize theme once', async () => {
|
||||
it.skip('should initialize theme once', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
expect(mockInitializeTheme).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mount unified app with components', async () => {
|
||||
it.skip('should mount unified app with components', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
// The unified app architecture no longer requires teleport container setup per component
|
||||
@@ -154,7 +154,7 @@ describe('component-registry', () => {
|
||||
});
|
||||
|
||||
describe('component auto-mounting', () => {
|
||||
it('should auto-mount components when DOM elements exist', async () => {
|
||||
it.skip('should auto-mount components when DOM elements exist', async () => {
|
||||
// Create DOM elements for components to mount to
|
||||
const authElement = document.createElement('div');
|
||||
authElement.setAttribute('id', 'unraid-auth');
|
||||
@@ -180,7 +180,7 @@ describe('component-registry', () => {
|
||||
});
|
||||
|
||||
describe('global exports', () => {
|
||||
it('should expose utility functions globally', async () => {
|
||||
it.skip('should expose utility functions globally', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
// With unified app architecture, these are exposed instead:
|
||||
@@ -190,7 +190,7 @@ describe('component-registry', () => {
|
||||
// The unified app itself is exposed via window.__unifiedApp after mounting
|
||||
});
|
||||
|
||||
it('should not expose legacy mount functions', async () => {
|
||||
it.skip('should not expose legacy mount functions', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
// These functions are no longer exposed in the unified app architecture
|
||||
@@ -199,7 +199,7 @@ describe('component-registry', () => {
|
||||
expect(window.autoMountComponent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should expose apollo client and graphql utilities', async () => {
|
||||
it.skip('should expose apollo client and graphql utilities', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
// Check that Apollo client and GraphQL utilities are exposed
|
||||
|
||||
@@ -19,6 +19,14 @@ import type {
|
||||
|
||||
import { WebguiState } from '~/composables/services/webgui';
|
||||
import { useServerStore } from '~/store/server';
|
||||
import { testTranslate } from '../utils/i18n';
|
||||
|
||||
// Mock vue-i18n for store tests
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: testTranslate,
|
||||
}),
|
||||
}));
|
||||
|
||||
type MockServerStore = ReturnType<typeof useServerStore> & Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ import type { Release } from '~/store/updateOsActions';
|
||||
import { useUpdateOsActionsStore } from '~/store/updateOsActions';
|
||||
|
||||
vi.mock('~/helpers/urls', () => ({
|
||||
WEBGUI_TOOLS_UPDATE: {
|
||||
toString: () => 'https://webgui/tools/update',
|
||||
},
|
||||
WEBGUI_TOOLS_UPDATE: 'https://webgui/tools/update',
|
||||
}));
|
||||
|
||||
const mockUpdateOs = vi.fn();
|
||||
|
||||
58
web/__test__/utils/i18n.ts
Normal file
58
web/__test__/utils/i18n.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import enUS from '~/locales/en.json';
|
||||
|
||||
const DEFAULT_LOCALE = 'en_US';
|
||||
|
||||
type AnyObject = Record<string, unknown>;
|
||||
|
||||
const flatMessages = enUS as unknown as Record<string, string>;
|
||||
|
||||
function resolveMessage(key: string): string | undefined {
|
||||
return flatMessages[key];
|
||||
}
|
||||
|
||||
function replaceParams(template: string, params?: unknown): string {
|
||||
if (params === undefined || params === null) {
|
||||
return template;
|
||||
}
|
||||
|
||||
let result = template;
|
||||
|
||||
if (Array.isArray(params)) {
|
||||
params.forEach((value, index) => {
|
||||
result = result.replace(new RegExp(`\\{${index}\\}`, 'g'), String(value));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof params === 'object') {
|
||||
Object.entries(params as AnyObject).forEach(([placeholder, value]) => {
|
||||
result = result.replace(new RegExp(`\\{${placeholder}\\}`, 'g'), String(value));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
if (typeof params === 'number' || typeof params === 'string' || typeof params === 'boolean') {
|
||||
return result.replace(/\{0\}/g, String(params));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const testTranslate = ((key: string, params?: unknown) => {
|
||||
const message = resolveMessage(key);
|
||||
const template = message ?? key;
|
||||
return replaceParams(template, params);
|
||||
}) as unknown as import('vue-i18n').ComposerTranslation;
|
||||
|
||||
export function createTestI18n() {
|
||||
return createI18n({
|
||||
legacy: false,
|
||||
locale: DEFAULT_LOCALE,
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages: {
|
||||
[DEFAULT_LOCALE]: enUS,
|
||||
},
|
||||
});
|
||||
}
|
||||
1
web/components.d.ts
vendored
1
web/components.d.ts
vendored
@@ -68,6 +68,7 @@ declare module 'vue' {
|
||||
Keyline: typeof import('./src/components/UserProfile/Keyline.vue')['default']
|
||||
KeyLinkedStatus: typeof import('./src/components/Registration/KeyLinkedStatus.vue')['default']
|
||||
List: typeof import('./src/components/Notifications/List.vue')['default']
|
||||
LocaleSwitcher: typeof import('./src/components/LocaleSwitcher.vue')['default']
|
||||
LogFilterInput: typeof import('./src/components/Logs/LogFilterInput.vue')['default']
|
||||
Logo: typeof import('./src/components/Brand/Logo.vue')['default']
|
||||
Logs: typeof import('./src/components/Docker/Logs.vue')['default']
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
"// GraphQL Codegen": "",
|
||||
"codegen": "graphql-codegen --config codegen.ts -r dotenv/config",
|
||||
"codegen:watch": "graphql-codegen --config codegen.ts --watch -r dotenv/config",
|
||||
"// Internationalization": "",
|
||||
"i18n:extract": "node ./scripts/extract-translations.mjs && pnpm run i18n:sort",
|
||||
"i18n:sort": "node ./scripts/sort-translations.mjs",
|
||||
"// Testing": "",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -57,6 +60,7 @@
|
||||
"@vitejs/plugin-vue": "6.0.1",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vue/apollo-util": "4.2.2",
|
||||
"@vue/compiler-sfc": "3.5.20",
|
||||
"@vue/test-utils": "2.4.6",
|
||||
"@vueuse/core": "13.8.0",
|
||||
"eslint": "9.34.0",
|
||||
@@ -67,6 +71,7 @@
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"eslint-plugin-storybook": "9.1.3",
|
||||
"eslint-plugin-vue": "10.4.0",
|
||||
"glob": "11.0.3",
|
||||
"globals": "16.3.0",
|
||||
"happy-dom": "18.0.1",
|
||||
"kebab-case": "2.0.2",
|
||||
@@ -84,6 +89,7 @@
|
||||
"vitest": "3.2.4",
|
||||
"vue": "3.5.20",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-i18n-extract": "2.0.4",
|
||||
"vue-tsc": "3.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -107,6 +113,7 @@
|
||||
"ansi_up": "6.0.6",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"convert": "5.12.0",
|
||||
"crypto-js": "4.2.0",
|
||||
"dayjs": "1.11.14",
|
||||
"focus-trap": "7.6.5",
|
||||
|
||||
@@ -240,6 +240,11 @@
|
||||
<!-- Test Controls -->
|
||||
<div class="category-header">🎮 Test Controls</div>
|
||||
<div style="background: white; padding: 20px; border-radius: 8px; margin-top: 15px;">
|
||||
<h3>Language Selection</h3>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<unraid-locale-switcher></unraid-locale-switcher>
|
||||
</div>
|
||||
|
||||
<h3>jQuery Interaction Tests</h3>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px;">
|
||||
<button id="test-notification" class="test-btn">Trigger Notification</button>
|
||||
|
||||
566
web/scripts/extract-translations.mjs
Normal file
566
web/scripts/extract-translations.mjs
Normal file
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
import { parse } from '@vue/compiler-sfc';
|
||||
|
||||
import { glob } from 'glob';
|
||||
import ts from 'typescript';
|
||||
|
||||
async function loadExtractor() {
|
||||
const module = await import('vue-i18n-extract');
|
||||
if (typeof module.createI18NReport === 'function') {
|
||||
return module.createI18NReport;
|
||||
}
|
||||
if (module.default && typeof module.default.createI18NReport === 'function') {
|
||||
return module.default.createI18NReport;
|
||||
}
|
||||
throw new Error('createI18NReport export not found');
|
||||
}
|
||||
|
||||
async function readJson(filePath) {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
return raw.trim() ? JSON.parse(raw) : {};
|
||||
}
|
||||
|
||||
async function writeJson(filePath, data) {
|
||||
const json = JSON.stringify(data, null, 2) + '\n';
|
||||
await writeFile(filePath, json, 'utf8');
|
||||
}
|
||||
|
||||
function expandJsonFormsKey(key) {
|
||||
const expanded = new Set();
|
||||
|
||||
// Preserve explicit keys for shared error translations
|
||||
if (key.startsWith('jsonforms.errors')) {
|
||||
expanded.add(key);
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// Don't add .label to keys that already have specific suffixes
|
||||
if (key.endsWith('.title') || key.endsWith('.description')) {
|
||||
expanded.add(key);
|
||||
return expanded;
|
||||
}
|
||||
|
||||
expanded.add(key.endsWith('.label') ? key : `${key}.label`);
|
||||
|
||||
return expanded;
|
||||
}
|
||||
|
||||
function stripAsExpressions(node) {
|
||||
let current = node;
|
||||
while (current && (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current))) {
|
||||
current = current.expression;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function getPropertyName(node) {
|
||||
if (!node) return undefined;
|
||||
if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) {
|
||||
return node.text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function objectLiteralToObject(node) {
|
||||
const result = {};
|
||||
for (const prop of node.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) {
|
||||
continue;
|
||||
}
|
||||
const name = getPropertyName(prop.name);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const value = literalToValue(prop.initializer);
|
||||
if (value !== undefined) {
|
||||
result[name] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function literalToValue(node) {
|
||||
const stripped = stripAsExpressions(node);
|
||||
if (!stripped) return undefined;
|
||||
|
||||
if (ts.isStringLiteralLike(stripped)) {
|
||||
return stripped.text;
|
||||
}
|
||||
|
||||
if (ts.isObjectLiteralExpression(stripped)) {
|
||||
return objectLiteralToObject(stripped);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePropertyAccess(constantMap, expression) {
|
||||
const segments = [];
|
||||
let current = expression;
|
||||
while (ts.isPropertyAccessExpression(current)) {
|
||||
segments.unshift(current.name.text);
|
||||
current = current.expression;
|
||||
}
|
||||
if (!ts.isIdentifier(current)) {
|
||||
return undefined;
|
||||
}
|
||||
const root = current.text;
|
||||
let value = constantMap.get(root);
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
for (const segment of segments) {
|
||||
if (value && typeof value === 'object' && segment in value) {
|
||||
value = value[segment];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function resolveI18nString(constantMap, expression) {
|
||||
const stripped = stripAsExpressions(expression);
|
||||
if (!stripped) return undefined;
|
||||
|
||||
if (ts.isStringLiteralLike(stripped)) {
|
||||
return stripped.text;
|
||||
}
|
||||
|
||||
if (ts.isPropertyAccessExpression(stripped)) {
|
||||
return resolvePropertyAccess(constantMap, stripped);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const translationFunctionNames = new Set(['t', 'tc']);
|
||||
function createSourceFileFromContent(fileName, content, scriptKind = ts.ScriptKind.TSX) {
|
||||
return ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true, scriptKind);
|
||||
}
|
||||
|
||||
function collectTranslationKeysFromSource(sourceFile, keys) {
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node) && node.arguments.length > 0) {
|
||||
let functionName;
|
||||
|
||||
const expression = node.expression;
|
||||
if (ts.isIdentifier(expression)) {
|
||||
functionName = expression.text;
|
||||
} else if (ts.isPropertyAccessExpression(expression)) {
|
||||
functionName = expression.name.text;
|
||||
}
|
||||
|
||||
if (functionName && translationFunctionNames.has(functionName)) {
|
||||
const firstArg = stripAsExpressions(node.arguments[0]);
|
||||
if (firstArg && ts.isStringLiteralLike(firstArg)) {
|
||||
keys.add(firstArg.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
}
|
||||
|
||||
function detectScriptKind(filePath) {
|
||||
if (filePath.endsWith('.tsx')) return ts.ScriptKind.TSX;
|
||||
if (filePath.endsWith('.ts')) return ts.ScriptKind.TS;
|
||||
if (filePath.endsWith('.jsx')) return ts.ScriptKind.JSX;
|
||||
return ts.ScriptKind.JS;
|
||||
}
|
||||
|
||||
async function collectTsTranslationKeys() {
|
||||
const sourceRoot = path.resolve(process.cwd(), 'src');
|
||||
const ignorePatterns = [
|
||||
'**/__tests__/**',
|
||||
'**/__test__/**',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
'**/*.spec.js',
|
||||
'**/*.spec.jsx',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.test.js',
|
||||
'**/*.test.jsx',
|
||||
];
|
||||
|
||||
let scriptFiles = [];
|
||||
try {
|
||||
scriptFiles = await glob('**/*.{ts,tsx,js,jsx}', {
|
||||
cwd: sourceRoot,
|
||||
ignore: ignorePatterns,
|
||||
absolute: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[i18n] Failed to enumerate TS source files for translation keys.', error);
|
||||
return new Set();
|
||||
}
|
||||
|
||||
let vueFiles = [];
|
||||
try {
|
||||
vueFiles = await glob('**/*.vue', {
|
||||
cwd: sourceRoot,
|
||||
ignore: ignorePatterns,
|
||||
absolute: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[i18n] Failed to enumerate Vue files for translation keys.', error);
|
||||
}
|
||||
|
||||
const keys = new Set();
|
||||
|
||||
await Promise.all(
|
||||
scriptFiles.map(async (file) => {
|
||||
try {
|
||||
const content = await readFile(file, 'utf8');
|
||||
const kind = detectScriptKind(file);
|
||||
const sourceFile = createSourceFileFromContent(file, content, kind);
|
||||
collectTranslationKeysFromSource(sourceFile, keys);
|
||||
} catch (error) {
|
||||
console.warn(`[i18n] Failed to process ${file} for translation keys.`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
vueFiles.map(async (file) => {
|
||||
try {
|
||||
const content = await readFile(file, 'utf8');
|
||||
const { descriptor } = parse(content, { filename: file });
|
||||
|
||||
if (descriptor.script) {
|
||||
const lang = descriptor.script.lang || 'ts';
|
||||
const kind = detectScriptKind(
|
||||
`file.${lang === 'tsx' ? 'tsx' : lang === 'ts' ? 'ts' : lang === 'jsx' ? 'jsx' : 'js'}`
|
||||
);
|
||||
const sourceFile = createSourceFileFromContent(file, descriptor.script.content, kind);
|
||||
collectTranslationKeysFromSource(sourceFile, keys);
|
||||
}
|
||||
|
||||
if (descriptor.scriptSetup) {
|
||||
const lang = descriptor.scriptSetup.lang || 'ts';
|
||||
const kind = detectScriptKind(
|
||||
`file.${lang === 'tsx' ? 'tsx' : lang === 'ts' ? 'ts' : lang === 'jsx' ? 'jsx' : 'js'}`
|
||||
);
|
||||
const sourceFile = createSourceFileFromContent(
|
||||
`${file}?setup`,
|
||||
descriptor.scriptSetup.content,
|
||||
kind
|
||||
);
|
||||
collectTranslationKeysFromSource(sourceFile, keys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[i18n] Failed to process ${file} for Vue translation keys.`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function collectJsonFormsKeys() {
|
||||
const apiSourceRoot = path.resolve(process.cwd(), '../api/src');
|
||||
const ignorePatterns = [
|
||||
'**/__tests__/**',
|
||||
'**/__test__/**',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.js',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.js',
|
||||
];
|
||||
|
||||
let files = [];
|
||||
try {
|
||||
files = await glob('**/*.ts', {
|
||||
cwd: apiSourceRoot,
|
||||
ignore: ignorePatterns,
|
||||
absolute: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[i18n] Failed to enumerate API source files for jsonforms keys.', error);
|
||||
return { keys: new Set(), descriptions: new Map() };
|
||||
}
|
||||
|
||||
const keys = new Set();
|
||||
const descriptionValues = new Map();
|
||||
const labelValues = new Map();
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const content = await readFile(file, 'utf8');
|
||||
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
|
||||
|
||||
const constantMap = new Map();
|
||||
|
||||
const recordConstants = (node) => {
|
||||
if (ts.isVariableStatement(node)) {
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) {
|
||||
continue;
|
||||
}
|
||||
const stripped = stripAsExpressions(declaration.initializer);
|
||||
if (!stripped) {
|
||||
continue;
|
||||
}
|
||||
if (ts.isObjectLiteralExpression(stripped)) {
|
||||
const obj = objectLiteralToObject(stripped);
|
||||
if (obj && Object.keys(obj).length > 0) {
|
||||
constantMap.set(declaration.name.text, obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, recordConstants);
|
||||
};
|
||||
|
||||
recordConstants(sourceFile);
|
||||
|
||||
const visit = (node) => {
|
||||
if (ts.isPropertyAssignment(node) && getPropertyName(node.name) === 'i18n') {
|
||||
const key = resolveI18nString(constantMap, node.initializer);
|
||||
if (key && key.startsWith('jsonforms.')) {
|
||||
expandJsonFormsKey(key).forEach((expandedKey) => keys.add(expandedKey));
|
||||
|
||||
const parent = node.parent;
|
||||
if (ts.isObjectLiteralExpression(parent)) {
|
||||
let labelCandidate;
|
||||
let titleCandidate;
|
||||
let descriptionCandidate;
|
||||
const allowDescriptionExtraction = !key.endsWith('.description');
|
||||
|
||||
for (const prop of parent.properties) {
|
||||
if (!ts.isPropertyAssignment(prop)) {
|
||||
continue;
|
||||
}
|
||||
const propName = getPropertyName(prop.name);
|
||||
if (propName === 'description' && allowDescriptionExtraction) {
|
||||
const descriptionValue = resolveI18nString(constantMap, prop.initializer);
|
||||
if (typeof descriptionValue === 'string' && descriptionValue.length > 0) {
|
||||
descriptionCandidate = descriptionValue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (propName === 'title') {
|
||||
const titleValue = resolveI18nString(constantMap, prop.initializer);
|
||||
if (typeof titleValue === 'string' && titleValue.length > 0) {
|
||||
titleCandidate = titleValue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!labelCandidate && (propName === 'label' || propName === 'text')) {
|
||||
const resolved = resolveI18nString(constantMap, prop.initializer);
|
||||
if (typeof resolved === 'string' && resolved.length > 0) {
|
||||
labelCandidate = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add title key if we found a title value
|
||||
if (typeof titleCandidate === 'string' && titleCandidate.length > 0) {
|
||||
const titleKey = `${key}.title`;
|
||||
keys.add(titleKey);
|
||||
labelValues.set(titleKey, titleCandidate);
|
||||
}
|
||||
|
||||
// Add description key if we found a description value
|
||||
if (typeof descriptionCandidate === 'string' && descriptionCandidate.length > 0) {
|
||||
const descriptionKey = `${key}.description`;
|
||||
keys.add(descriptionKey);
|
||||
descriptionValues.set(descriptionKey, descriptionCandidate);
|
||||
}
|
||||
|
||||
// Add label key if we found a label value
|
||||
if (typeof labelCandidate === 'string' && labelCandidate.length > 0) {
|
||||
const labelKey = key.endsWith('.label') ? key : `${key}.label`;
|
||||
keys.add(labelKey);
|
||||
labelValues.set(labelKey, labelCandidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ts.isStringLiteralLike(node)) {
|
||||
const text = node.text;
|
||||
if (text.startsWith('jsonforms.')) {
|
||||
expandJsonFormsKey(text).forEach((key) => keys.add(key));
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
|
||||
visit(sourceFile);
|
||||
} catch (error) {
|
||||
console.warn(`[i18n] Failed to process ${file} for jsonforms keys.`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return { keys, descriptions: descriptionValues, labels: labelValues };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const createI18NReport = await loadExtractor();
|
||||
|
||||
const root = process.cwd();
|
||||
const localesDir = path.resolve(root, 'src/locales');
|
||||
const localeFiles = (await readdir(localesDir)).filter((file) => file.endsWith('.json'));
|
||||
|
||||
if (localeFiles.length === 0) {
|
||||
console.log('[i18n] No locale files found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const englishLocale = 'en_US';
|
||||
const englishFileName = 'en.json';
|
||||
|
||||
const localeDescriptors = localeFiles.map((file) => {
|
||||
const rawLocale = file.replace(/\.json$/, '');
|
||||
const locale = rawLocale === 'en' ? englishLocale : rawLocale;
|
||||
return {
|
||||
locale,
|
||||
file,
|
||||
absPath: path.resolve(localesDir, file),
|
||||
};
|
||||
});
|
||||
|
||||
const missingByLocale = new Map(localeDescriptors.map(({ locale }) => [locale, new Set()]));
|
||||
|
||||
let report;
|
||||
const originalLog = console.log;
|
||||
const originalTable = console.table;
|
||||
const originalInfo = console.info;
|
||||
const originalWarn = console.warn;
|
||||
try {
|
||||
console.log = () => {};
|
||||
console.table = () => {};
|
||||
console.info = () => {};
|
||||
console.warn = () => {};
|
||||
report = await createI18NReport({
|
||||
vueFiles: 'src/**/*.{vue,ts,js}',
|
||||
languageFiles: 'src/locales/*.json',
|
||||
});
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
console.table = originalTable;
|
||||
console.info = originalInfo;
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
for (const entry of report.missingKeys ?? []) {
|
||||
const rawLocale = path.basename(entry.language, '.json');
|
||||
const normalizedLocale = rawLocale === 'en' ? englishLocale : rawLocale;
|
||||
const target = missingByLocale.get(normalizedLocale);
|
||||
if (target) {
|
||||
target.add(entry.path);
|
||||
}
|
||||
}
|
||||
|
||||
const englishDescriptor = localeDescriptors.find((descriptor) => descriptor.file === englishFileName);
|
||||
if (!englishDescriptor) {
|
||||
throw new Error(`Source locale file ${englishFileName} not found in ${localesDir}`);
|
||||
}
|
||||
|
||||
const englishData = await readJson(englishDescriptor.absPath);
|
||||
const englishMissing = missingByLocale.get(englishLocale) ?? new Set();
|
||||
|
||||
const {
|
||||
keys: jsonFormsKeys,
|
||||
descriptions: jsonFormsDescriptions,
|
||||
labels: jsonFormsLabels,
|
||||
} = await collectJsonFormsKeys();
|
||||
jsonFormsKeys.forEach((key) => englishMissing.add(key));
|
||||
|
||||
const tsTranslationKeys = await collectTsTranslationKeys();
|
||||
tsTranslationKeys.forEach((key) => englishMissing.add(key));
|
||||
|
||||
const missingValuePlaceholder = null;
|
||||
|
||||
let englishUpdated = false;
|
||||
let addedEnglish = 0;
|
||||
for (const key of englishMissing) {
|
||||
if (!(key in englishData)) {
|
||||
let value = missingValuePlaceholder;
|
||||
|
||||
if (key.endsWith('.label')) {
|
||||
const baseKey = key.slice(0, -'.label'.length);
|
||||
const baseValue = englishData[baseKey];
|
||||
if (typeof baseValue === 'string' && baseValue.length > 0) {
|
||||
value = baseValue;
|
||||
} else if (jsonFormsLabels.has(key)) {
|
||||
value = jsonFormsLabels.get(key);
|
||||
}
|
||||
} else if (jsonFormsDescriptions.has(key)) {
|
||||
value = jsonFormsDescriptions.get(key);
|
||||
}
|
||||
|
||||
englishData[key] = value;
|
||||
addedEnglish += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (addedEnglish > 0) {
|
||||
englishUpdated = true;
|
||||
}
|
||||
|
||||
const protectedKeys = new Set([
|
||||
...jsonFormsKeys,
|
||||
...jsonFormsDescriptions.keys(),
|
||||
...jsonFormsLabels.keys(),
|
||||
...tsTranslationKeys,
|
||||
]);
|
||||
const maybeDynamicKeys = new Set((report?.maybeDynamicKeys ?? []).map((entry) => entry.path));
|
||||
const englishLanguageKey = 'en';
|
||||
const englishUnusedKeys = new Set(
|
||||
(report?.unusedKeys ?? [])
|
||||
.filter((entry) => entry.language === englishLanguageKey)
|
||||
.map((entry) => entry.path)
|
||||
);
|
||||
|
||||
let removedEnglish = 0;
|
||||
if (englishUnusedKeys.size > 0) {
|
||||
for (const key of Object.keys(englishData)) {
|
||||
if (!englishUnusedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (protectedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (maybeDynamicKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
delete englishData[key];
|
||||
removedEnglish += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedEnglish > 0) {
|
||||
englishUpdated = true;
|
||||
}
|
||||
|
||||
if (englishUpdated) {
|
||||
await writeJson(englishDescriptor.absPath, englishData);
|
||||
}
|
||||
|
||||
if (addedEnglish === 0 && removedEnglish === 0) {
|
||||
console.log('[i18n] No translation updates required for English locale.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (addedEnglish > 0) {
|
||||
console.log(`[i18n] Added ${addedEnglish} key(s) to ${englishFileName}.`);
|
||||
}
|
||||
|
||||
if (removedEnglish > 0) {
|
||||
console.log(`[i18n] Removed ${removedEnglish} unused key(s) from ${englishFileName}.`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[i18n] Failed to extract translations.', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
59
web/scripts/sort-translations.mjs
Normal file
59
web/scripts/sort-translations.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import url from 'url';
|
||||
|
||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const LOCALES_DIR = path.join(__dirname, '..', 'src', 'locales');
|
||||
|
||||
// Create a shared collator for consistent sorting across machines and locales
|
||||
const collator = new Intl.Collator('en', { sensitivity: 'base' });
|
||||
|
||||
function sortValue(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(sortValue);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value)
|
||||
.sort((a, b) => collator.compare(a, b))
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = sortValue(value[key]);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function sortLocaleFile(filePath) {
|
||||
const original = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(original);
|
||||
const sorted = sortValue(parsed);
|
||||
const normalized = JSON.stringify(sorted, null, 2) + '\n';
|
||||
if (normalized !== original) {
|
||||
await fs.writeFile(filePath, normalized, 'utf8');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const entries = await fs.readdir(LOCALES_DIR, { withFileTypes: true });
|
||||
let changed = false;
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||
const localePath = path.join(LOCALES_DIR, entry.name);
|
||||
const updated = await sortLocaleFile(localePath);
|
||||
changed = changed || updated;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
console.log('[i18n] Sorted locale files.');
|
||||
} else {
|
||||
console.log('[i18n] Locale files already sorted.');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[i18n] Failed to sort locale files.', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
||||
@@ -7,7 +8,6 @@ import { BrandButton, Dialog } from '@unraid/ui';
|
||||
import { DOCS_URL_ACCOUNT, DOCS_URL_LICENSING_FAQ } from '~/consts';
|
||||
|
||||
import type { BrandButtonProps } from '@unraid/ui';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import ActivationPartnerLogo from '~/components/Activation/ActivationPartnerLogo.vue';
|
||||
import ActivationSteps from '~/components/Activation/ActivationSteps.vue';
|
||||
@@ -16,11 +16,7 @@ import { useActivationCodeModalStore } from '~/components/Activation/store/activ
|
||||
import { usePurchaseStore } from '~/store/purchase';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
|
||||
export interface Props {
|
||||
t: ComposerTranslation;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
const modalStore = useActivationCodeModalStore();
|
||||
const { isVisible, isHidden } = storeToRefs(modalStore);
|
||||
@@ -29,11 +25,9 @@ const purchaseStore = usePurchaseStore();
|
||||
|
||||
useThemeStore();
|
||||
|
||||
const title = computed<string>(() => props.t("Let's activate your Unraid OS License"));
|
||||
const title = computed<string>(() => t('activation.activationModal.letSActivateYourUnraidOs'));
|
||||
const description = computed<string>(() =>
|
||||
props.t(
|
||||
`On the following screen, your license will be activated. You'll then create an Unraid.net Account to manage your license going forward.`
|
||||
)
|
||||
t('activation.activationModal.onTheFollowingScreenYourLicense')
|
||||
);
|
||||
const docsButtons = computed<BrandButtonProps[]>(() => {
|
||||
return [
|
||||
@@ -43,7 +37,7 @@ const docsButtons = computed<BrandButtonProps[]>(() => {
|
||||
href: DOCS_URL_LICENSING_FAQ,
|
||||
iconRight: ArrowTopRightOnSquareIcon,
|
||||
size: '14px',
|
||||
text: props.t('More about Licensing'),
|
||||
text: t('activation.activationModal.moreAboutLicensing'),
|
||||
},
|
||||
{
|
||||
variant: 'underline',
|
||||
@@ -51,7 +45,7 @@ const docsButtons = computed<BrandButtonProps[]>(() => {
|
||||
href: DOCS_URL_ACCOUNT,
|
||||
iconRight: ArrowTopRightOnSquareIcon,
|
||||
size: '14px',
|
||||
text: props.t('More about Unraid.net Accounts'),
|
||||
text: t('activation.activationModal.moreAboutUnraidNetAccounts'),
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -81,7 +75,7 @@ const docsButtons = computed<BrandButtonProps[]>(() => {
|
||||
<div class="flex flex-col">
|
||||
<div class="mx-auto mb-10">
|
||||
<BrandButton
|
||||
:text="t('Activate Now')"
|
||||
:text="t('activation.activationModal.activateNow')"
|
||||
:icon-right="ArrowTopRightOnSquareIcon"
|
||||
@click="purchaseStore.activate"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { CheckIcon, KeyIcon, ServerStackIcon } from '@heroicons/vue/24/outline';
|
||||
import {
|
||||
KeyIcon as KeyIconSolid,
|
||||
@@ -37,11 +40,14 @@ interface Step {
|
||||
completed: Component;
|
||||
};
|
||||
}
|
||||
const steps: readonly Step[] = [
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const steps = computed<Step[]>(() => [
|
||||
{
|
||||
step: 1,
|
||||
title: 'Create Device Password',
|
||||
description: 'Secure your device',
|
||||
title: t('activation.activationSteps.createDevicePassword'),
|
||||
description: t('activation.activationSteps.secureYourDevice'),
|
||||
icon: {
|
||||
inactive: LockClosedIcon,
|
||||
active: LockClosedIcon,
|
||||
@@ -50,8 +56,8 @@ const steps: readonly Step[] = [
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: 'Activate License',
|
||||
description: 'Create an Unraid.net account and activate your key',
|
||||
title: t('activation.activationSteps.activateLicense'),
|
||||
description: t('activation.activationSteps.createAnUnraidNetAccountAnd'),
|
||||
icon: {
|
||||
inactive: KeyIcon,
|
||||
active: KeyIconSolid,
|
||||
@@ -60,15 +66,15 @@ const steps: readonly Step[] = [
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: 'Unleash Your Hardware',
|
||||
description: 'Device is ready to configure',
|
||||
title: t('activation.activationSteps.unleashYourHardware'),
|
||||
description: t('activation.activationSteps.deviceIsReadyToConfigure'),
|
||||
icon: {
|
||||
inactive: ServerStackIcon,
|
||||
active: ServerStackIconSolid,
|
||||
completed: CheckIcon,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -31,15 +31,11 @@ const { setTheme } = useThemeStore();
|
||||
|
||||
const title = computed<string>(() =>
|
||||
partnerInfo.value?.partnerName
|
||||
? t(`Welcome to your new {0} system, powered by Unraid!`, [partnerInfo.value?.partnerName])
|
||||
: t('Welcome to Unraid!')
|
||||
? t('activation.welcomeModal.welcomeToYourNewSystemPowered', [partnerInfo.value?.partnerName])
|
||||
: t('activation.welcomeModal.welcomeToUnraid')
|
||||
);
|
||||
|
||||
const description = computed<string>(() =>
|
||||
t(
|
||||
`First, you'll create your device's login credentials, then you'll activate your Unraid license—your device's operating system (OS).`
|
||||
)
|
||||
);
|
||||
const description = computed<string>(() => t('activation.welcomeModal.firstYouLlCreateYourDevice'));
|
||||
|
||||
const isLoginPage = computed(() => window.location.pathname.includes('login'));
|
||||
|
||||
@@ -105,7 +101,11 @@ defineExpose({
|
||||
|
||||
<div class="flex flex-col">
|
||||
<div class="mx-auto mb-10">
|
||||
<BrandButton :text="t('Create a password')" :disabled="loading" @click="dropdownHide" />
|
||||
<BrandButton
|
||||
:text="t('activation.welcomeModal.createAPassword')"
|
||||
:disabled="loading"
|
||||
@click="dropdownHide"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActivationSteps :active-step="1" class="mt-6" />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@unraid/ui';
|
||||
import { JsonForms } from '@jsonforms/vue';
|
||||
import { extractGraphQLErrorMessage } from '~/helpers/functions';
|
||||
import { useJsonFormsI18n } from '~/helpers/jsonforms-i18n';
|
||||
|
||||
import type { ApolloError } from '@apollo/client/errors';
|
||||
import type { FragmentType } from '~/composables/gql/fragment-masking';
|
||||
@@ -103,6 +104,7 @@ const formData = ref<FormData>({
|
||||
roles: [],
|
||||
} as FormData);
|
||||
const formValid = ref(false);
|
||||
const jsonFormsI18n = useJsonFormsI18n();
|
||||
|
||||
// Use clipboard for copying
|
||||
const { copyWithNotification, copied } = useClipboardWithToast();
|
||||
@@ -433,10 +435,10 @@ const copyApiKey = async () => {
|
||||
? 'Authorize API Key Access'
|
||||
: editingKey
|
||||
? t
|
||||
? t('Edit API Key')
|
||||
? t('apiKey.apiKeyCreate.editApiKey')
|
||||
: 'Edit API Key'
|
||||
: t
|
||||
? t('Create API Key')
|
||||
? t('apiKey.apiKeyCreate.createApiKey')
|
||||
: 'Create API Key'
|
||||
}}
|
||||
</ResponsiveModalTitle>
|
||||
@@ -465,6 +467,7 @@ const copyApiKey = async () => {
|
||||
:renderers="jsonFormsRenderers"
|
||||
:data="formData"
|
||||
:ajv="jsonFormsAjv"
|
||||
:i18n="jsonFormsI18n"
|
||||
@change="
|
||||
({ data, errors }) => {
|
||||
formData = data;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
// import { useI18n } from 'vue-i18n';
|
||||
|
||||
// const { t } = useI18n();
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import { watchDebounced } from '@vueuse/core';
|
||||
|
||||
import { BrandButton, jsonFormsAjv, jsonFormsRenderers, Label, SettingsGrid } from '@unraid/ui';
|
||||
import { JsonForms } from '@jsonforms/vue';
|
||||
import { useJsonFormsI18n } from '~/helpers/jsonforms-i18n';
|
||||
|
||||
import Auth from '~/components/Auth.standalone.vue';
|
||||
// unified settings values are returned as JSON, so use a generic record type
|
||||
@@ -61,6 +59,8 @@ const {
|
||||
const isUpdating = ref(false);
|
||||
const actualRestartRequired = ref(false);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// prevent ui flash if loading finishes too fast
|
||||
watchDebounced(
|
||||
mutateSettingsLoading,
|
||||
@@ -75,8 +75,10 @@ watchDebounced(
|
||||
// show a toast when the update is done
|
||||
onMutateSettingsDone((result) => {
|
||||
actualRestartRequired.value = result.data?.updateSettings?.restartRequired ?? false;
|
||||
globalThis.toast.success('Updated API Settings', {
|
||||
description: actualRestartRequired.value ? 'The API is restarting...' : undefined,
|
||||
globalThis.toast.success(t('connectSettings.updatedApiSettingsToast'), {
|
||||
description: actualRestartRequired.value
|
||||
? t('connectSettings.apiRestartingToastDescription')
|
||||
: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +92,7 @@ const jsonFormsConfig = {
|
||||
};
|
||||
|
||||
const renderers = [...jsonFormsRenderers];
|
||||
const jsonFormsI18n = useJsonFormsI18n();
|
||||
|
||||
/** Called when the user clicks the "Apply" button */
|
||||
const submitSettingsUpdate = async () => {
|
||||
@@ -109,10 +112,10 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
<!-- common api-related actions -->
|
||||
<SettingsGrid>
|
||||
<template v-if="connectPluginInstalled">
|
||||
<Label>Account Status:</Label>
|
||||
<Label>{{ t('connectSettings.accountStatusLabel') }}</Label>
|
||||
<Auth />
|
||||
</template>
|
||||
<Label>Download Unraid API Logs:</Label>
|
||||
<Label>{{ t('downloadApiLogs.downloadUnraidApiLogs') }}:</Label>
|
||||
<DownloadApiLogs />
|
||||
</SettingsGrid>
|
||||
<!-- auto-generated settings form -->
|
||||
@@ -125,6 +128,7 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
:data="formState"
|
||||
:config="jsonFormsConfig"
|
||||
:ajv="jsonFormsAjv"
|
||||
:i18n="jsonFormsI18n"
|
||||
:readonly="isUpdating"
|
||||
@change="onChange"
|
||||
/>
|
||||
@@ -134,14 +138,15 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
||||
<!-- form submission & fallback reaction message -->
|
||||
<div class="grid-cols-settings mt-6 grid items-baseline gap-y-6">
|
||||
<div class="text-end text-sm">
|
||||
<p v-if="isUpdating">Applying Settings...</p>
|
||||
<p v-if="isUpdating">{{ t('connectSettings.applyingSettings') }}</p>
|
||||
</div>
|
||||
<div class="col-start-2 max-w-3xl space-y-4">
|
||||
<BrandButton padding="lean" size="12px" class="leading-normal" @click="submitSettingsUpdate">
|
||||
Apply
|
||||
{{ t('connectSettings.apply') }}
|
||||
</BrandButton>
|
||||
<p v-if="mutateSettingsError" class="text-unraid-red-500 text-sm">
|
||||
✕ Error: {{ mutateSettingsError.message }}
|
||||
<span aria-hidden="true">✕</span>
|
||||
{{ t('common.error') }}: {{ mutateSettingsError.message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user