Compare commits

...

31 Commits

Author SHA1 Message Date
Pujit Mehrotra
f981129764 Merge 1f1516735e into ff2906e52a 2025-10-27 10:37:46 -04:00
Simon Fairweather
1f1516735e chore: Fix tests
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
Simon Fairweather
74c53653e8 chore: Fix tests
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
Simon Fairweather
9e04487f13 chore: Fix tests
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
Simon Fairweather
01ecbbdf56 chore: Fix tests
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
Simon Fairweather
5fda8fc1e2 chore: Code tidy
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
Simon Fairweather
fa62d70ed8 chore: Code tidy
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
SimonFair
f494394825 Update api/src/unraid-api/graph/resolvers/info/cpu/cpu.service.spec.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-10-27 10:37:07 -04:00
Simon Fairweather
95adc92cd3 chore: Code tidy & remove cpu-power subscription/functions
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
Pujit Mehrotra
be2d253060 chore: add local deploy script to unraid-shared 2025-10-27 10:37:07 -04:00
Simon Fairweather
9d63220146 Fix typo
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
Simon Fairweather
f3da2a4caf Subscription changes
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
Simon Fairweather
e78819d9b7 Add topology and temps
Signed-off-by: Simon Fairweather <simon.n.fairweather@gmail.com>
2025-10-27 10:37:07 -04:00
Pujit Mehrotra
3e2b1eff18 fix tests 2025-10-27 10:37:07 -04:00
Pujit Mehrotra
c58199b3ed fix types in tests 2025-10-27 10:37:07 -04:00
Pujit Mehrotra
2e8e4baa5a feat: add cpu power query & subscription 2025-10-27 10:37:07 -04:00
Pujit Mehrotra
ff2906e52a chore: fix header capture from changelog in generate-release-notes.yml (#1759)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Updated release notes extraction workflow to improve handling of
version headers in generated release notes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-22 16:19:38 -04:00
Pujit Mehrotra
a0d6cc92c8 chore: decouple manual-release from release-production (#1758) 2025-10-22 15:57:41 -04:00
Pujit Mehrotra
57acfaacf0 chore: avoid altering formatting via jq in manual-release workflow 2025-10-22 15:36:20 -04:00
Pujit Mehrotra
ea816c7a5c chore: use jq to update package versions in manual-release (#1757) 2025-10-22 15:19:28 -04:00
Pujit Mehrotra
cafde72d38 chore: add version check & changelog generation to manual-release (#1756)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Automated release-notes generation with layered fallbacks (use
provided notes, extract from changelog, generate from previous release,
call provider APIs, or default message).
* New version-validation step to ensure package versions are consistent
before publishing.

* **Chores**
* Moved release-notes logic into a reusable workflow and rewired the
manual release process to consume its outputs for more consistent
releases.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-22 15:13:17 -04:00
Pujit Mehrotra
2b481c397c chore: add manual-release workflow and extract build-artifacts workflow (#1755)
Adds a workflow to create or override (github) releases with a release
produced from a specific git ref. Refactors the main build process into
a workflow call for reusability.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Chores**
* Consolidated multi-target build pipeline for API, UI library, and web
app with unified artifact publishing, improved caching, and simplified
downstream wiring.
* **New Features**
* Added a manual, parameterized release workflow to create/update draft
releases with optional prerelease tagging and generated release notes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-22 13:49:15 -04:00
Eli Bosley
8c4e9dd7ae New Crowdin updates (#1750)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* New Features
* Expanded and refined translations across the UI for Arabic, Bengali,
Catalan, Czech, Danish, German, Spanish, French, Hindi, Croatian,
Hungarian, Italian, Japanese, Korean, Latvian, Dutch, Norwegian, Polish,
Portuguese, Romanian, Russian, Swedish, Ukrainian, and Chinese.
* Updated labels, titles, and descriptions for API key management,
OIDC/SSO configuration, buttons, and restrictions to native-language
equivalents.
* Improves readability and consistency in localized interfaces; no
functional changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-21 13:15:11 -04:00
Eli Bosley
f212dce88b fix: use relative URLs in the web links and fix color in PM2 startup (#1752)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Refactor**
* Consolidated URL resolution and handling logic for improved
consistency across the application
* Enhanced GraphQL endpoint configuration with better fallback
mechanisms for more reliable connections
* Optimized platform command execution through improved default
parameter handling

* **Chores**
  * Infrastructure configuration updates and maintenance

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-21 10:10:50 -04:00
Eli Bosley
8cd2a4c124 chore: add translations.php to backup and restore lists 2025-10-15 15:23:39 -04:00
Eli Bosley
10f048ee1f chore: re-add translations.php to prevent breaking uninstalls 2025-10-15 12:05:02 -04:00
Eli Bosley
e9e271ade5 fix(#1729): api key authorize component not mounted when on Unraid OS 2025-10-13 21:37:28 -04:00
Eli Bosley
31c41027fc feat: translations now use crowdin (translate.unraid.net) (#1739)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- App-wide internationalization: dynamic locale detection/loading, many
new locale bundles, and CLI helpers to extract/sort translation keys.

- **Accessibility**
  - Brand button supports keyboard activation (Enter/Space).

- **Documentation**
  - Internationalization guidance added to API and Web READMEs.

- **Refactor**
- UI updated to use centralized i18n keys and a unified locale loading
approach.

- **Tests**
  - Test utilities updated to support i18n and localized assertions.

- **Chores**
- Crowdin config and i18n scripts added; runtime locale exposed for
selection.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-13 16:56:08 -04:00
Eli Bosley
fabe6a2c4b chore: Delete .github/workflows/claude-code-review.yml 2025-10-13 09:27:40 -04:00
Pujit Mehrotra
754966d5d3 fix: api auth from web during local dev (#1743) 2025-10-07 19:45:50 -04:00
Pujit Mehrotra
ed594e9147 chore(readme): add link to Deep Wiki for automated code documentation (#1735)
Deep Wiki from Cognition Labs already has some useful documentation that
they generated for us: https://deepwiki.com/unraid/api. This PR adds
their badge to our readme so contributors can access their documentation
directly.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Documentation
- Added an “Ask DeepWiki” shield badge to the top of the README’s
project shields, giving users quick access to a dedicated Q&A/help
resource.
- Improves discoverability of support and learning materials directly
from the project homepage.
- No functional or behavioral changes to the application; this is an
informational enhancement aimed at easing onboarding and providing
faster guidance for users exploring the project.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-10-02 11:01:12 -04:00
192 changed files with 17926 additions and 2397 deletions

201
.github/workflows/build-artifacts.yml vendored Normal file
View 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

View File

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

View File

@@ -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]')

View 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

View File

@@ -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
View 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 }}

View File

@@ -42,7 +42,10 @@ export default tseslint.config(
'ignorePackages',
{
js: 'always',
ts: 'always',
mjs: 'always',
cjs: 'always',
ts: 'never',
tsx: 'never',
},
],
'no-restricted-globals': [

View File

@@ -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.

View File

@@ -1,5 +1,5 @@
{
"version": "4.22.2",
"version": "4.25.2",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -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!
}

View File

@@ -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/",

View 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
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/bn.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ca.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/cs.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/da.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/de.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/en.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/es.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/fr.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/hi.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/hr.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/hu.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/it.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ja.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ko.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/lv.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/nl.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/no.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/pl.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/pt.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ro.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/ru.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/sv.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/uk.json Normal file
View File

@@ -0,0 +1 @@
{}

1
api/src/i18n/zh.json Normal file
View File

@@ -0,0 +1 @@
{}

40
api/src/types/jsonforms-i18n.d.ts vendored Normal file
View 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;
}
}

View File

@@ -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();
}

View File

@@ -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,
}),
],
},

View File

@@ -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;
}
}

View File

@@ -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;
}

View 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 {}

View File

@@ -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],
],
],
});
});

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

View File

@@ -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,
}),
],
};

View File

@@ -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,
}),
],
},

View File

@@ -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;
};
};
}

View File

@@ -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
View 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

View File

@@ -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",

View 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

View File

@@ -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>",

View File

@@ -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",

View File

@@ -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"

View File

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

View File

@@ -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
View File

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

View File

@@ -21,6 +21,8 @@
[![LinkedIn][linkedin-shield]][linkedin-url]
[![Codecov][codecov-shield]][codecov-url]
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/unraid/api)
</div>
<!-- PROJECT LOGO -->
<br />

View File

@@ -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'"

View File

@@ -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 || '';

View File

@@ -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.

View File

@@ -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,
},
});

View File

@@ -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()],
},
});
};

View File

@@ -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 () => {

View File

@@ -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()],
},
});

View File

@@ -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,
},

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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,

View File

@@ -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'));
});
});

View File

@@ -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()],
},
});

View File

@@ -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);

View File

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

View File

@@ -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();

View File

@@ -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();
});
});

View File

@@ -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"/>' },

View File

@@ -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>' },

View File

@@ -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,

View File

@@ -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', '{&quot;text&quot;: &quot;Encoded&quot;}');
@@ -188,7 +198,7 @@ describe('mount-engine', () => {
component: TestComponent,
});
mountUnifiedApp();
await mountUnifiedApp();
expect(element.getAttribute('message')).toBe('{&quot;text&quot;: &quot;Encoded&quot;}');
});
@@ -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'));

View File

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

View File

@@ -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>;

View File

@@ -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();

View 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
View File

@@ -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']

View File

@@ -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",

View File

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

View 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;
});

View 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);
});

View File

@@ -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"
/>

View File

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

View File

@@ -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" />

View File

@@ -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;

View File

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