mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5449e30eed | ||
|
|
dc12656f81 | ||
|
|
f14b74af91 | ||
|
|
e2fa648d1c | ||
|
|
3b00fec5fd | ||
|
|
4ff6a1aaa0 | ||
|
|
86b6c4f85b | ||
|
|
45bd73698b | ||
|
|
fee7d4613e | ||
|
|
b6acf50c0d | ||
|
|
8279531f2b | ||
|
|
0a18b38008 | ||
|
|
23b2b88461 | ||
|
|
f5352e3a26 | ||
|
|
9dfdb8dce7 | ||
|
|
407585cd40 | ||
|
|
05056e7ca1 | ||
|
|
a74d935b56 | ||
|
|
2c62e0ad09 | ||
|
|
1a8da6d92b | ||
|
|
81808ada0f | ||
|
|
eecd9b1017 | ||
|
|
441e1805c1 | ||
|
|
29dcb7d0f0 | ||
|
|
1a7d35d3f6 | ||
|
|
af33e999a0 | ||
|
|
85a35804c1 | ||
|
|
a35c8ff2f1 | ||
|
|
153e7a1e3a | ||
|
|
e73fc356cb | ||
|
|
e1a7a3d22d | ||
|
|
53b05ebe5e | ||
|
|
2ed1308e40 | ||
|
|
6c03df2b97 | ||
|
|
074370c42c | ||
|
|
f34a33bc9f | ||
|
|
c7801a9236 | ||
|
|
dd759d9f0f | ||
|
|
74da8d81ef | ||
|
|
33e0b1ab24 | ||
|
|
ca4e2db1f2 | ||
|
|
ea20d1e211 |
123
.claude/settings.json
Normal file
123
.claude/settings.json
Normal file
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"# Development Commands",
|
||||
"Bash(pnpm install)",
|
||||
"Bash(pnpm dev)",
|
||||
"Bash(pnpm build)",
|
||||
"Bash(pnpm test)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(pnpm lint)",
|
||||
"Bash(pnpm lint:fix)",
|
||||
"Bash(pnpm type-check)",
|
||||
"Bash(pnpm codegen)",
|
||||
"Bash(pnpm storybook)",
|
||||
"Bash(pnpm --filter * dev)",
|
||||
"Bash(pnpm --filter * build)",
|
||||
"Bash(pnpm --filter * test)",
|
||||
"Bash(pnpm --filter * lint)",
|
||||
"Bash(pnpm --filter * codegen)",
|
||||
|
||||
"# Git Commands (read-only)",
|
||||
"Bash(git status)",
|
||||
"Bash(git diff)",
|
||||
"Bash(git log)",
|
||||
"Bash(git branch)",
|
||||
"Bash(git remote -v)",
|
||||
|
||||
"# Search Commands",
|
||||
"Bash(rg *)",
|
||||
|
||||
"# File System (read-only)",
|
||||
"Bash(ls)",
|
||||
"Bash(ls -la)",
|
||||
"Bash(pwd)",
|
||||
"Bash(find . -name)",
|
||||
"Bash(find . -type)",
|
||||
|
||||
"# Node/NPM Commands",
|
||||
"Bash(node --version)",
|
||||
"Bash(pnpm --version)",
|
||||
"Bash(npx --version)",
|
||||
|
||||
"# Environment Commands",
|
||||
"Bash(echo $*)",
|
||||
"Bash(which *)",
|
||||
|
||||
"# Process Commands",
|
||||
"Bash(ps aux | grep)",
|
||||
"Bash(lsof -i)",
|
||||
|
||||
"# Documentation Domains",
|
||||
"WebFetch(domain:tailwindcss.com)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:reka-ui.com)",
|
||||
"WebFetch(domain:nodejs.org)",
|
||||
"WebFetch(domain:pnpm.io)",
|
||||
"WebFetch(domain:vitejs.dev)",
|
||||
"WebFetch(domain:nuxt.com)",
|
||||
"WebFetch(domain:nestjs.com)",
|
||||
|
||||
"# IDE Integration",
|
||||
"mcp__ide__getDiagnostics",
|
||||
|
||||
"# Browser MCP (for testing)",
|
||||
"mcp__browsermcp__browser_navigate",
|
||||
"mcp__browsermcp__browser_click",
|
||||
"mcp__browsermcp__browser_screenshot"
|
||||
],
|
||||
"deny": [
|
||||
"# Dangerous Commands",
|
||||
"Bash(rm -rf)",
|
||||
"Bash(chmod 777)",
|
||||
"Bash(curl)",
|
||||
"Bash(wget)",
|
||||
"Bash(ssh)",
|
||||
"Bash(scp)",
|
||||
"Bash(sudo)",
|
||||
"Bash(su)",
|
||||
"Bash(pkill)",
|
||||
"Bash(kill)",
|
||||
"Bash(killall)",
|
||||
"Bash(python)",
|
||||
"Bash(python3)",
|
||||
"Bash(pip)",
|
||||
"Bash(npm)",
|
||||
"Bash(yarn)",
|
||||
"Bash(apt)",
|
||||
"Bash(brew)",
|
||||
"Bash(systemctl)",
|
||||
"Bash(service)",
|
||||
"Bash(docker)",
|
||||
"Bash(docker-compose)",
|
||||
|
||||
"# File Modification (use Edit/Write tools instead)",
|
||||
"Bash(sed)",
|
||||
"Bash(awk)",
|
||||
"Bash(perl)",
|
||||
"Bash(echo > *)",
|
||||
"Bash(echo >> *)",
|
||||
"Bash(cat > *)",
|
||||
"Bash(cat >> *)",
|
||||
"Bash(tee)",
|
||||
|
||||
"# Git Write Commands (require explicit user action)",
|
||||
"Bash(git add)",
|
||||
"Bash(git commit)",
|
||||
"Bash(git push)",
|
||||
"Bash(git pull)",
|
||||
"Bash(git merge)",
|
||||
"Bash(git rebase)",
|
||||
"Bash(git checkout)",
|
||||
"Bash(git reset)",
|
||||
"Bash(git clean)",
|
||||
|
||||
"# Package Management Write Commands",
|
||||
"Bash(pnpm add)",
|
||||
"Bash(pnpm remove)",
|
||||
"Bash(pnpm update)",
|
||||
"Bash(pnpm upgrade)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": false
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(rg:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(pnpm codegen:*)",
|
||||
"Bash(pnpm dev:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(pnpm type-check:*)",
|
||||
"Bash(pnpm lint:*)",
|
||||
"Bash(pnpm --filter ./api lint)",
|
||||
"Bash(mv:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": false
|
||||
}
|
||||
20
.github/CODEOWNERS
vendored
20
.github/CODEOWNERS
vendored
@@ -1,20 +0,0 @@
|
||||
# Default owners for everything in the repo
|
||||
* @elibosley @pujitm @mdatelle @zackspear
|
||||
|
||||
# API specific files
|
||||
/api/ @elibosley @pujitm @mdatelle
|
||||
|
||||
# Web frontend files
|
||||
/web/ @elibosley @mdatelle @zackspear
|
||||
|
||||
# Plugin related files
|
||||
/plugin/ @elibosley
|
||||
|
||||
# Unraid UI specific files
|
||||
/unraid-ui/ @mdatelle @zackspear @pujitm
|
||||
|
||||
# GitHub workflows and configuration
|
||||
/.github/ @elibosley
|
||||
|
||||
# Documentation
|
||||
*.md @elibosley @pujitm @mdatelle @zackspear
|
||||
78
.github/workflows/claude-code-review.yml
vendored
Normal file
78
.github/workflows/claude-code-review.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
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: |
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Be constructive and helpful in your feedback.
|
||||
|
||||
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
|
||||
# use_sticky_comment: true
|
||||
|
||||
# Optional: Customize review based on file types
|
||||
# direct_prompt: |
|
||||
# Review this PR focusing on:
|
||||
# - For TypeScript files: Type safety and proper interface usage
|
||||
# - For API endpoints: Security, input validation, and error handling
|
||||
# - For React components: Performance, accessibility, and best practices
|
||||
# - For tests: Coverage, edge cases, and test quality
|
||||
|
||||
# Optional: Different prompts for different authors
|
||||
# direct_prompt: |
|
||||
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
|
||||
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
|
||||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
|
||||
|
||||
# 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]')
|
||||
|
||||
64
.github/workflows/claude.yml
vendored
Normal file
64
.github/workflows/claude.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
|
||||
# model: "claude-opus-4-20250514"
|
||||
|
||||
# Optional: Customize the trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
|
||||
# Optional: Trigger when specific user is assigned to an issue
|
||||
# assignee_trigger: "claude-bot"
|
||||
|
||||
# Optional: Allow Claude to run specific commands
|
||||
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||
|
||||
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||
# custom_instructions: |
|
||||
# Follow our coding standards
|
||||
# Ensure all new code has tests
|
||||
# Use TypeScript for new files
|
||||
|
||||
# Optional: Custom environment variables for Claude
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
|
||||
4
.github/workflows/deploy-storybook.yml
vendored
4
.github/workflows/deploy-storybook.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22.17.1'
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
|
||||
version: 1.0
|
||||
|
||||
6
.github/workflows/main.yml
vendored
6
.github/workflows/main.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential libvirt-daemon-system
|
||||
version: 1.0
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential
|
||||
version: 1.0
|
||||
@@ -267,7 +267,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: bash procps python3 libvirt-dev jq zstd git build-essential
|
||||
version: 1.0
|
||||
|
||||
2
.github/workflows/release-production.yml
vendored
2
.github/workflows/release-production.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
prerelease: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.17.0'
|
||||
node-version: '22.17.1'
|
||||
- run: |
|
||||
cat << 'EOF' > release-notes.txt
|
||||
${{ steps.release-info.outputs.body }}
|
||||
|
||||
2
.github/workflows/test-libvirt.yml
vendored
2
.github/workflows/test-libvirt.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
python-version: "3.13.5"
|
||||
|
||||
- name: Cache APT Packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
with:
|
||||
packages: libvirt-dev
|
||||
version: 1.0
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -109,3 +109,6 @@ plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/dat
|
||||
|
||||
# Config file that changes between versions
|
||||
api/dev/Unraid.net/myservers.cfg
|
||||
|
||||
# Claude local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.9.3"}
|
||||
{".":"4.11.0"}
|
||||
|
||||
14
.vscode/settings.json
vendored
14
.vscode/settings.json
vendored
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.page": "php"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "never",
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.keystyle": "flat",
|
||||
"eslint.experimental.useFlatConfig": true,
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
22
.vscode/sftp-template.json
vendored
22
.vscode/sftp-template.json
vendored
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"_comment": "rename this file to .vscode/sftp.json and replace name/host/privateKeyPath for your system",
|
||||
"name": "Tower",
|
||||
"host": "Tower.local",
|
||||
"protocol": "sftp",
|
||||
"port": 22,
|
||||
"username": "root",
|
||||
"privateKeyPath": "C:/Users/username/.ssh/tower",
|
||||
"remotePath": "/",
|
||||
"context": "plugin/source/dynamix.unraid.net/",
|
||||
"uploadOnSave": true,
|
||||
"useTempFile": false,
|
||||
"openSsh": false,
|
||||
"ignore": [
|
||||
"// comment: ignore dot files/dirs in root of repo",
|
||||
".github",
|
||||
".vscode",
|
||||
".git",
|
||||
".DS_Store"
|
||||
]
|
||||
}
|
||||
|
||||
81
@tailwind-shared/base-utilities.css
Normal file
81
@tailwind-shared/base-utilities.css
Normal file
@@ -0,0 +1,81 @@
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@layer utilities {
|
||||
:host {
|
||||
--tw-divide-y-reverse: 0;
|
||||
--tw-border-style: solid;
|
||||
--tw-font-weight: initial;
|
||||
--tw-tracking: initial;
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-translate-z: 0;
|
||||
--tw-rotate-x: rotateX(0);
|
||||
--tw-rotate-y: rotateY(0);
|
||||
--tw-rotate-z: rotateZ(0);
|
||||
--tw-skew-x: skewX(0);
|
||||
--tw-skew-y: skewY(0);
|
||||
--tw-space-x-reverse: 0;
|
||||
--tw-gradient-position: initial;
|
||||
--tw-gradient-from: #0000;
|
||||
--tw-gradient-via: #0000;
|
||||
--tw-gradient-to: #0000;
|
||||
--tw-gradient-stops: initial;
|
||||
--tw-gradient-via-stops: initial;
|
||||
--tw-gradient-from-position: 0%;
|
||||
--tw-gradient-via-position: 50%;
|
||||
--tw-gradient-to-position: 100%;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-color: initial;
|
||||
--tw-inset-shadow: 0 0 #0000;
|
||||
--tw-inset-shadow-color: initial;
|
||||
--tw-ring-color: initial;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-inset-ring-color: initial;
|
||||
--tw-inset-ring-shadow: 0 0 #0000;
|
||||
--tw-ring-inset: initial;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-blur: initial;
|
||||
--tw-brightness: initial;
|
||||
--tw-contrast: initial;
|
||||
--tw-grayscale: initial;
|
||||
--tw-hue-rotate: initial;
|
||||
--tw-invert: initial;
|
||||
--tw-opacity: initial;
|
||||
--tw-saturate: initial;
|
||||
--tw-sepia: initial;
|
||||
--tw-drop-shadow: initial;
|
||||
--tw-duration: initial;
|
||||
--tw-ease: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
|
||||
|
||||
body {
|
||||
--color-alpha: #1c1b1b;
|
||||
--color-beta: #f2f2f2;
|
||||
--color-gamma: #999999;
|
||||
--color-gamma-opaque: rgba(153, 153, 153, 0.5);
|
||||
--color-customgradient-start: rgba(242, 242, 242, 0);
|
||||
--color-customgradient-end: rgba(242, 242, 242, 0.85);
|
||||
--shadow-beta: 0 25px 50px -12px rgba(242, 242, 242, 0.15);
|
||||
--ring-offset-shadow: 0 0 var(--color-beta);
|
||||
--ring-shadow: 0 0 var(--color-beta);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role='button']:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
130
@tailwind-shared/css-variables.css
Normal file
130
@tailwind-shared/css-variables.css
Normal file
@@ -0,0 +1,130 @@
|
||||
/* Hybrid theme system: Native CSS + Theme Store fallback */
|
||||
@layer base {
|
||||
/* Light mode defaults */
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
/* Alternative class-based dark mode support for specific Unraid themes */
|
||||
.dark[data-theme='black'],
|
||||
.dark[data-theme='gray'] {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
}
|
||||
|
||||
/* For web components: inherit CSS variables from the host */
|
||||
:host {
|
||||
--background: inherit;
|
||||
--foreground: inherit;
|
||||
--muted: inherit;
|
||||
--muted-foreground: inherit;
|
||||
--popover: inherit;
|
||||
--popover-foreground: inherit;
|
||||
--card: inherit;
|
||||
--card-foreground: inherit;
|
||||
--border: inherit;
|
||||
--input: inherit;
|
||||
--primary: inherit;
|
||||
--primary-foreground: inherit;
|
||||
--secondary: inherit;
|
||||
--secondary-foreground: inherit;
|
||||
--accent: inherit;
|
||||
--accent-foreground: inherit;
|
||||
--destructive: inherit;
|
||||
--destructive-foreground: inherit;
|
||||
--ring: inherit;
|
||||
--chart-1: inherit;
|
||||
--chart-2: inherit;
|
||||
--chart-3: inherit;
|
||||
--chart-4: inherit;
|
||||
--chart-5: inherit;
|
||||
}
|
||||
|
||||
/* Class-based dark mode support for web components using :host-context */
|
||||
:host-context(.dark) {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
/* Alternative class-based dark mode support for specific Unraid themes */
|
||||
:host-context(.dark[data-theme='black']),
|
||||
:host-context(.dark[data-theme='gray']) {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
}
|
||||
}
|
||||
5
@tailwind-shared/index.css
Normal file
5
@tailwind-shared/index.css
Normal file
@@ -0,0 +1,5 @@
|
||||
/* Tailwind Shared Styles - Single entry point for all shared CSS */
|
||||
@import './css-variables.css';
|
||||
@import './unraid-theme.css';
|
||||
@import './base-utilities.css';
|
||||
@import './sonner.css';
|
||||
@@ -662,4 +662,4 @@
|
||||
.sonner-loader[data-visible='false'] {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
259
@tailwind-shared/unraid-theme.css
Normal file
259
@tailwind-shared/unraid-theme.css
Normal file
@@ -0,0 +1,259 @@
|
||||
@theme static {
|
||||
/* Breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
--breakpoint-2xl: 100rem;
|
||||
--breakpoint-3xl: 120rem;
|
||||
/* Container settings */
|
||||
--container-center: true;
|
||||
--container-padding: 2rem;
|
||||
--container-screen-2xl: 1400px;
|
||||
|
||||
/* Font families */
|
||||
--font-sans:
|
||||
clear-sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
/* Grid template columns */
|
||||
--grid-template-columns-settings: 35% 1fr;
|
||||
|
||||
/* Border color default */
|
||||
--default-border-color: var(--color-border);
|
||||
--ui-border-muted: hsl(var(--border));
|
||||
--ui-radius: 0.5rem;
|
||||
--ui-primary: var(--color-primary-500);
|
||||
--ui-primary-hover: var(--color-primary-600);
|
||||
--ui-primary-active: var(--color-primary-700);
|
||||
|
||||
/* Color palette */
|
||||
--color-inherit: inherit;
|
||||
--color-transparent: transparent;
|
||||
--color-black: #1c1b1b;
|
||||
--color-grey-darkest: #222;
|
||||
--color-grey-darker: #606f7b;
|
||||
--color-grey-dark: #383735;
|
||||
--color-grey-mid: #999999;
|
||||
--color-grey: #e0e0e0;
|
||||
--color-grey-light: #dae1e7;
|
||||
--color-grey-lighter: #f1f5f8;
|
||||
--color-grey-lightest: #f2f2f2;
|
||||
--color-white: #ffffff;
|
||||
|
||||
/* Unraid colors */
|
||||
--color-yellow-accent: #e9bf41;
|
||||
--color-orange-dark: #f15a2c;
|
||||
--color-orange: #ff8c2f;
|
||||
|
||||
/* Unraid red palette */
|
||||
--color-unraid-red: #e22828;
|
||||
--color-unraid-red-50: #fef2f2;
|
||||
--color-unraid-red-100: #ffe1e1;
|
||||
--color-unraid-red-200: #ffc9c9;
|
||||
--color-unraid-red-300: #fea3a3;
|
||||
--color-unraid-red-400: #fc6d6d;
|
||||
--color-unraid-red-500: #f43f3f;
|
||||
--color-unraid-red-600: #e22828;
|
||||
--color-unraid-red-700: #bd1818;
|
||||
--color-unraid-red-800: #9c1818;
|
||||
--color-unraid-red-900: #821a1a;
|
||||
--color-unraid-red-950: #470808;
|
||||
|
||||
/* Unraid green palette */
|
||||
--color-unraid-green: #63a659;
|
||||
--color-unraid-green-50: #f5f9f4;
|
||||
--color-unraid-green-100: #e7f3e5;
|
||||
--color-unraid-green-200: #d0e6cc;
|
||||
--color-unraid-green-300: #aad1a4;
|
||||
--color-unraid-green-400: #7db474;
|
||||
--color-unraid-green-500: #63a659;
|
||||
--color-unraid-green-600: #457b3e;
|
||||
--color-unraid-green-700: #396134;
|
||||
--color-unraid-green-800: #314e2d;
|
||||
--color-unraid-green-900: #284126;
|
||||
--color-unraid-green-950: #122211;
|
||||
|
||||
/* Primary colors (orange) */
|
||||
--color-primary-50: #fff7ed;
|
||||
--color-primary-100: #ffedd5;
|
||||
--color-primary-200: #fed7aa;
|
||||
--color-primary-300: #fdba74;
|
||||
--color-primary-400: #fb923c;
|
||||
--color-primary-500: #ff6600;
|
||||
--color-primary-600: #ea580c;
|
||||
--color-primary-700: #c2410c;
|
||||
--color-primary-800: #9a3412;
|
||||
--color-primary-900: #7c2d12;
|
||||
--color-primary-950: #431407;
|
||||
|
||||
/* Header colors */
|
||||
--color-header-text-primary: var(--header-text-primary);
|
||||
--color-header-text-secondary: var(--header-text-secondary);
|
||||
--color-header-background-color: var(--header-background-color);
|
||||
|
||||
/* Legacy colors */
|
||||
--color-alpha: var(--color-alpha);
|
||||
--color-beta: var(--color-beta);
|
||||
--color-gamma: var(--color-gamma);
|
||||
--color-gamma-opaque: var(--color-gamma-opaque);
|
||||
--color-customgradient-start: var(--color-customgradient-start);
|
||||
--color-customgradient-end: var(--color-customgradient-end);
|
||||
|
||||
/* Gradients */
|
||||
--color-header-gradient-start: var(--header-gradient-start);
|
||||
--color-header-gradient-end: var(--header-gradient-end);
|
||||
--color-banner-gradient: var(--banner-gradient);
|
||||
|
||||
/* Font sizes */
|
||||
--font-10px: 10px;
|
||||
--font-12px: 12px;
|
||||
--font-14px: 14px;
|
||||
--font-16px: 16px;
|
||||
--font-18px: 18px;
|
||||
--font-20px: 20px;
|
||||
--font-24px: 24px;
|
||||
--font-30px: 30px;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-4_5: 1.125rem;
|
||||
--spacing--8px: -8px;
|
||||
--spacing-2px: 2px;
|
||||
--spacing-4px: 4px;
|
||||
--spacing-6px: 6px;
|
||||
--spacing-8px: 8px;
|
||||
--spacing-10px: 10px;
|
||||
--spacing-12px: 12px;
|
||||
--spacing-14px: 14px;
|
||||
--spacing-16px: 16px;
|
||||
--spacing-20px: 20px;
|
||||
--spacing-24px: 24px;
|
||||
--spacing-28px: 28px;
|
||||
--spacing-32px: 32px;
|
||||
--spacing-36px: 36px;
|
||||
--spacing-40px: 40px;
|
||||
--spacing-64px: 64px;
|
||||
--spacing-80px: 80px;
|
||||
--spacing-90px: 90px;
|
||||
--spacing-150px: 150px;
|
||||
--spacing-160px: 160px;
|
||||
--spacing-200px: 200px;
|
||||
--spacing-260px: 260px;
|
||||
--spacing-300px: 300px;
|
||||
--spacing-310px: 310px;
|
||||
--spacing-350px: 350px;
|
||||
--spacing-448px: 448px;
|
||||
--spacing-512px: 512px;
|
||||
--spacing-640px: 640px;
|
||||
--spacing-800px: 800px;
|
||||
|
||||
/* Width and Height values */
|
||||
--width-36px: 36px;
|
||||
--height-36px: 36px;
|
||||
|
||||
/* Min/Max widths */
|
||||
--min-width-86px: 86px;
|
||||
--min-width-160px: 160px;
|
||||
--min-width-260px: 260px;
|
||||
--min-width-300px: 300px;
|
||||
--min-width-310px: 310px;
|
||||
--min-width-350px: 350px;
|
||||
--min-width-800px: 800px;
|
||||
|
||||
--max-width-86px: 86px;
|
||||
--max-width-160px: 160px;
|
||||
--max-width-260px: 260px;
|
||||
--max-width-300px: 300px;
|
||||
--max-width-310px: 310px;
|
||||
--max-width-350px: 350px;
|
||||
--max-width-640px: 640px;
|
||||
--max-width-800px: 800px;
|
||||
--max-width-1024px: 1024px;
|
||||
|
||||
/* Animations */
|
||||
--animate-mark-2: mark-2 1.5s ease infinite;
|
||||
--animate-mark-3: mark-3 1.5s ease infinite;
|
||||
--animate-mark-6: mark-6 1.5s ease infinite;
|
||||
--animate-mark-7: mark-7 1.5s ease infinite;
|
||||
|
||||
/* Radius */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Text Resizing */
|
||||
--text-xs: 1.2rem; /* 12px at 10px base */
|
||||
--text-sm: 1.4rem; /* 14px at 10px base */
|
||||
--text-base: 1.6rem; /* 16px at 10px base */
|
||||
--text-lg: 1.8rem; /* 18px at 10px base */
|
||||
--text-xl: 2rem; /* 20px at 10px base */
|
||||
--text-2xl: 2.4rem; /* 24px at 10px base */
|
||||
--text-3xl: 3rem; /* 30px at 10px base */
|
||||
--text-4xl: 3.6rem; /* 36px at 10px base */
|
||||
--text-5xl: 4.8rem; /* 48px at 10px base */
|
||||
--text-6xl: 6rem; /* 60px at 10px base */
|
||||
--text-7xl: 7.2rem; /* 72px at 10px base */
|
||||
--text-8xl: 9.6rem; /* 96px at 10px base */
|
||||
--text-9xl: 12.8rem; /* 128px at 10px base */
|
||||
--spacing: 0.4rem; /* 4px at 10px base */
|
||||
}
|
||||
|
||||
/* Keyframes */
|
||||
@keyframes mark-2 {
|
||||
50% {
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mark-3 {
|
||||
50% {
|
||||
transform: translateY(-62px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mark-6 {
|
||||
50% {
|
||||
transform: translateY(40px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mark-7 {
|
||||
50% {
|
||||
transform: translateY(62px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme colors that reference CSS variables */
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-ring: hsl(var(--ring));
|
||||
--color-chart-1: hsl(var(--chart-1, 12 76% 61%));
|
||||
--color-chart-2: hsl(var(--chart-2, 173 58% 39%));
|
||||
--color-chart-3: hsl(var(--chart-3, 197 37% 24%));
|
||||
--color-chart-4: hsl(var(--chart-4, 43 74% 66%));
|
||||
--color-chart-5: hsl(var(--chart-5, 27 87% 67%));
|
||||
}
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -46,6 +46,16 @@ cd api && pnpm codegen # Generate GraphQL types
|
||||
pnpm unraid:deploy <SERVER_IP> # Deploy all to Unraid server
|
||||
```
|
||||
|
||||
### Developer Tools
|
||||
|
||||
```bash
|
||||
unraid-api developer # Interactive prompt for tools
|
||||
unraid-api developer --sandbox true # Enable GraphQL sandbox
|
||||
unraid-api developer --sandbox false # Disable GraphQL sandbox
|
||||
unraid-api developer --enable-modal # Enable modal testing tool
|
||||
unraid-api developer --disable-modal # Disable modal testing tool
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### API Structure (NestJS)
|
||||
@@ -135,3 +145,8 @@ Enables GraphQL playground at `http://tower.local/graphql`
|
||||
- Place all mock declarations at the top level
|
||||
- Use factory functions for module mocks to avoid hoisting issues
|
||||
- Clear mocks between tests to ensure isolation
|
||||
|
||||
## Development Memories
|
||||
|
||||
- We are using tailwind v4 we do not need a tailwind config anymore
|
||||
- always search the internet for tailwind v4 documentation when making tailwind related style changes
|
||||
@@ -15,6 +15,7 @@ PATHS_ACTIVATION_BASE=./dev/activation
|
||||
PATHS_PASSWD=./dev/passwd
|
||||
PATHS_RCLONE_SOCKET=./dev/rclone-socket
|
||||
PATHS_LOG_BASE=./dev/log # Where we store logs
|
||||
PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file
|
||||
ENVIRONMENT="development"
|
||||
NODE_ENV="development"
|
||||
PORT="3001"
|
||||
|
||||
105
api/.eslintrc.ts
105
api/.eslintrc.ts
@@ -4,54 +4,59 @@ import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(eslint.configs.recommended, ...tseslint.configs.recommended, {
|
||||
plugins: {
|
||||
'no-relative-import-paths': noRelativeImportPaths,
|
||||
prettier: prettier,
|
||||
import: importPlugin,
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
ignores: ['src/graphql/generated/client/**/*', 'src/**/**/dummy-process.js'],
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/ban-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'no-use-before-define': ['off'],
|
||||
'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }],
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'import/no-absolute-path': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
'no-relative-import-paths/no-relative-import-paths': [
|
||||
'error',
|
||||
{ allowSameFolder: false, rootDir: 'src', prefix: '@app' },
|
||||
],
|
||||
'prettier/prettier': 'error',
|
||||
'import/extensions': [
|
||||
'error',
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'always',
|
||||
ts: 'always',
|
||||
},
|
||||
],
|
||||
'no-restricted-globals': [
|
||||
'error',
|
||||
{
|
||||
name: '__dirname',
|
||||
message: 'Use import.meta.url instead of __dirname in ESM',
|
||||
},
|
||||
{
|
||||
name: '__filename',
|
||||
message: 'Use import.meta.url instead of __filename in ESM',
|
||||
},
|
||||
],
|
||||
'eol-last': ['error', 'always'],
|
||||
},
|
||||
|
||||
ignores: ['src/graphql/generated/client/**/*'],
|
||||
});
|
||||
{
|
||||
plugins: {
|
||||
'no-relative-import-paths': noRelativeImportPaths,
|
||||
prettier: prettier,
|
||||
import: importPlugin,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/ban-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'no-use-before-define': ['off'],
|
||||
'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }],
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'import/no-absolute-path': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
'no-relative-import-paths/no-relative-import-paths': [
|
||||
'error',
|
||||
{ allowSameFolder: false, rootDir: 'src', prefix: '@app' },
|
||||
],
|
||||
'prettier/prettier': 'error',
|
||||
'import/extensions': [
|
||||
'error',
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'always',
|
||||
ts: 'always',
|
||||
},
|
||||
],
|
||||
'no-restricted-globals': [
|
||||
'error',
|
||||
{
|
||||
name: '__dirname',
|
||||
message: 'Use import.meta.url instead of __dirname in ESM',
|
||||
},
|
||||
{
|
||||
name: '__filename',
|
||||
message: 'Use import.meta.url instead of __filename in ESM',
|
||||
},
|
||||
],
|
||||
'eol-last': ['error', 'always'],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
9
api/.vscode/settings.json
vendored
9
api/.vscode/settings.json
vendored
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"eslint.lintTask.options": "--flag unstable_ts_config",
|
||||
"eslint.options": {
|
||||
"flags": ["unstable_ts_config"],
|
||||
"overrideConfigFile": ".eslintrc.ts"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative"
|
||||
}
|
||||
@@ -1,5 +1,63 @@
|
||||
# Changelog
|
||||
|
||||
## [4.11.0](https://github.com/unraid/api/compare/v4.10.0...v4.11.0) (2025-07-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* tailwind v4 ([#1522](https://github.com/unraid/api/issues/1522)) ([2c62e0a](https://github.com/unraid/api/commit/2c62e0ad09c56d2293b76d07833dfb142c898937))
|
||||
* **web:** install and configure nuxt ui ([#1524](https://github.com/unraid/api/issues/1524)) ([407585c](https://github.com/unraid/api/commit/407585cd40c409175d8e7b861f8d61d8cabc11c9))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing breakpoints ([#1535](https://github.com/unraid/api/issues/1535)) ([f5352e3](https://github.com/unraid/api/commit/f5352e3a26a2766e85d19ffb5f74960c536b91b3))
|
||||
* border color incorrect in tailwind ([#1544](https://github.com/unraid/api/issues/1544)) ([f14b74a](https://github.com/unraid/api/commit/f14b74af91783b08640c0949c51ba7f18508f06f))
|
||||
* **connect:** omit extraneous fields during connect config validation ([#1538](https://github.com/unraid/api/issues/1538)) ([45bd736](https://github.com/unraid/api/commit/45bd73698b2bd534a8aff2c6ac73403de6c58561))
|
||||
* **deps:** pin dependencies ([#1528](https://github.com/unraid/api/issues/1528)) ([a74d935](https://github.com/unraid/api/commit/a74d935b566dd7af1a21824c9b7ab562232f9d8b))
|
||||
* **deps:** pin dependency @nuxt/ui to 3.2.0 ([#1532](https://github.com/unraid/api/issues/1532)) ([8279531](https://github.com/unraid/api/commit/8279531f2b86a78e81a77e6c037a0fb752e98062))
|
||||
* **deps:** update all non-major dependencies ([#1510](https://github.com/unraid/api/issues/1510)) ([1a8da6d](https://github.com/unraid/api/commit/1a8da6d92b96d3afa2a8b42446b36f1ee98b64a0))
|
||||
* **deps:** update all non-major dependencies ([#1520](https://github.com/unraid/api/issues/1520)) ([e2fa648](https://github.com/unraid/api/commit/e2fa648d1cf5a6cbe3e55c3f52c203d26bb4d526))
|
||||
* inject Tailwind CSS into client entry point ([#1537](https://github.com/unraid/api/issues/1537)) ([86b6c4f](https://github.com/unraid/api/commit/86b6c4f85b7b30bb4a13d57450a76bf4c28a3fff))
|
||||
* make settings grid responsive ([#1463](https://github.com/unraid/api/issues/1463)) ([9dfdb8d](https://github.com/unraid/api/commit/9dfdb8dce781fa662d6434ee432e4521f905ffa5))
|
||||
* **notifications:** gracefully handle & mask invalid notifications ([#1529](https://github.com/unraid/api/issues/1529)) ([05056e7](https://github.com/unraid/api/commit/05056e7ca1702eb7bf6c507950460b6b15bf7916))
|
||||
* truncate log files when they take up more than 5mb of space ([#1530](https://github.com/unraid/api/issues/1530)) ([0a18b38](https://github.com/unraid/api/commit/0a18b38008dd86a125cde7f684636d5dbb36f082))
|
||||
* use async for primary file read/writes ([#1531](https://github.com/unraid/api/issues/1531)) ([23b2b88](https://github.com/unraid/api/commit/23b2b8846158a27d1c9808bce0cc1506779c4dc3))
|
||||
|
||||
## [4.10.0](https://github.com/unraid/api/compare/v4.9.5...v4.10.0) (2025-07-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* trial extension allowed within 5 days of expiration ([#1490](https://github.com/unraid/api/issues/1490)) ([f34a33b](https://github.com/unraid/api/commit/f34a33bc9f1a7e135d453d9d31888789bfc3f878))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* delay `nginx:reload` file mod effect by 10 seconds ([#1512](https://github.com/unraid/api/issues/1512)) ([af33e99](https://github.com/unraid/api/commit/af33e999a0480a77e3e6b2aa833b17b38b835656))
|
||||
* **deps:** update all non-major dependencies ([#1489](https://github.com/unraid/api/issues/1489)) ([53b05eb](https://github.com/unraid/api/commit/53b05ebe5e2050cb0916fcd65e8d41370aee0624))
|
||||
* ensure no crash if emhttp state configs are missing ([#1514](https://github.com/unraid/api/issues/1514)) ([1a7d35d](https://github.com/unraid/api/commit/1a7d35d3f6972fd8aff58c17b2b0fb79725e660e))
|
||||
* **my.servers:** improve DNS resolution robustness for backup server ([#1518](https://github.com/unraid/api/issues/1518)) ([eecd9b1](https://github.com/unraid/api/commit/eecd9b1017a63651d1dc782feaa224111cdee8b6))
|
||||
* over-eager cloud query from web components ([#1506](https://github.com/unraid/api/issues/1506)) ([074370c](https://github.com/unraid/api/commit/074370c42cdecc4dbc58193ff518aa25735c56b3))
|
||||
* replace myservers.cfg reads in UpdateFlashBackup.php ([#1517](https://github.com/unraid/api/issues/1517)) ([441e180](https://github.com/unraid/api/commit/441e1805c108a6c1cd35ee093246b975a03f8474))
|
||||
* rm short-circuit in `rc.unraid-api` if plugin config dir is absent ([#1515](https://github.com/unraid/api/issues/1515)) ([29dcb7d](https://github.com/unraid/api/commit/29dcb7d0f088937cefc5158055f48680e86e5c36))
|
||||
|
||||
## [4.9.5](https://github.com/unraid/api/compare/v4.9.4...v4.9.5) (2025-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **connect:** rm eager restart on `ERROR_RETYING` connection status ([#1502](https://github.com/unraid/api/issues/1502)) ([dd759d9](https://github.com/unraid/api/commit/dd759d9f0f841b296f8083bc67c6cd3f7a69aa5b))
|
||||
|
||||
## [4.9.4](https://github.com/unraid/api/compare/v4.9.3...v4.9.4) (2025-07-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* backport `<unraid-modals>` upon plg install when necessary ([#1499](https://github.com/unraid/api/issues/1499)) ([33e0b1a](https://github.com/unraid/api/commit/33e0b1ab24bedb6a2c7b376ea73dbe65bc3044be))
|
||||
* DefaultPageLayout patch rollback omits legacy header logo ([#1497](https://github.com/unraid/api/issues/1497)) ([ea20d1e](https://github.com/unraid/api/commit/ea20d1e2116fcafa154090fee78b42ec5d9ba584))
|
||||
* event emitter setup for writing status ([#1496](https://github.com/unraid/api/issues/1496)) ([ca4e2db](https://github.com/unraid/api/commit/ca4e2db1f29126a1fa3784af563832edda64b0ca))
|
||||
|
||||
## [4.9.3](https://github.com/unraid/api/compare/v4.9.2...v4.9.3) (2025-07-09)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
###########################################################
|
||||
# Development/Build Image
|
||||
###########################################################
|
||||
FROM node:22.17.0-bookworm-slim AS development
|
||||
FROM node:22.17.1-bookworm-slim AS development
|
||||
|
||||
# Install build tools and dependencies
|
||||
RUN apt-get update -y && apt-get install -y \
|
||||
|
||||
@@ -27,19 +27,13 @@ const config: CodegenConfig = {
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
// Generate Types for Mothership GraphQL Client
|
||||
'src/graphql/generated/client/': {
|
||||
documents: './src/graphql/mothership/*.ts',
|
||||
schema: {
|
||||
[process.env.MOTHERSHIP_GRAPHQL_LINK as string]: {
|
||||
headers: {
|
||||
origin: 'https://forums.unraid.net',
|
||||
},
|
||||
},
|
||||
},
|
||||
// Generate Types for CLI Internal GraphQL Queries
|
||||
'src/unraid-api/cli/generated/': {
|
||||
documents: ['src/unraid-api/cli/queries/**/*.ts', 'src/unraid-api/cli/mutations/**/*.ts'],
|
||||
schema: './generated-schema.graphql',
|
||||
preset: 'client',
|
||||
presetConfig: {
|
||||
gqlTagName: 'graphql',
|
||||
gqlTagName: 'gql',
|
||||
},
|
||||
config: {
|
||||
useTypeImports: true,
|
||||
@@ -47,21 +41,6 @@ const config: CodegenConfig = {
|
||||
},
|
||||
plugins: [{ add: { content: '/* eslint-disable */' } }],
|
||||
},
|
||||
'src/graphql/generated/client/validators.ts': {
|
||||
schema: {
|
||||
[process.env.MOTHERSHIP_GRAPHQL_LINK as string]: {
|
||||
headers: {
|
||||
origin: 'https://forums.unraid.net',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: ['typescript-validation-schema', { add: { content: '/* eslint-disable */' } }],
|
||||
config: {
|
||||
importFrom: '@app/graphql/generated/client/graphql.js',
|
||||
strictScalars: false,
|
||||
schema: 'zod',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"version": "4.8.0",
|
||||
"version": "4.10.0",
|
||||
"extraOrigins": [
|
||||
"https://google.com",
|
||||
"https://test.com"
|
||||
],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
"plugins": ["unraid-api-plugin-connect"]
|
||||
"plugins": [
|
||||
"unraid-api-plugin-connect"
|
||||
]
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
{
|
||||
"wanaccess": false,
|
||||
"wanport": 0,
|
||||
"wanaccess": true,
|
||||
"wanport": 8443,
|
||||
"upnpEnabled": false,
|
||||
"apikey": "",
|
||||
"localApiKey": "",
|
||||
"email": "",
|
||||
"username": "",
|
||||
"avatar": "",
|
||||
"regWizTime": "",
|
||||
"accesstoken": "",
|
||||
"idtoken": "",
|
||||
"refreshtoken": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"ssoSubIds": []
|
||||
"apikey": "_______________________BIG_API_KEY_HERE_________________________",
|
||||
"localApiKey": "_______________________LOCAL_API_KEY_HERE_________________________",
|
||||
"email": "test@example.com",
|
||||
"username": "zspearmint",
|
||||
"avatar": "https://via.placeholder.com/200",
|
||||
"regWizTime": "1611175408732_0951-1653-3509-FBA155FA23C0",
|
||||
"dynamicRemoteAccessType": "DISABLED"
|
||||
}
|
||||
11
api/dev/keys/fc91da7b-0284-46f4-9018-55aa9759fba9.json
Normal file
11
api/dev/keys/fc91da7b-0284-46f4-9018-55aa9759fba9.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"createdAt": "2025-07-23T17:34:06.301Z",
|
||||
"description": "Internal admin API key used by CLI commands for system operations",
|
||||
"id": "fc91da7b-0284-46f4-9018-55aa9759fba9",
|
||||
"key": "_______SUPER_SECRET_KEY_______",
|
||||
"name": "CliInternal",
|
||||
"permissions": [],
|
||||
"roles": [
|
||||
"ADMIN"
|
||||
]
|
||||
}
|
||||
@@ -62,10 +62,17 @@ Switch between production and staging environments.
|
||||
### Developer Mode
|
||||
|
||||
```bash
|
||||
unraid-api developer
|
||||
unraid-api developer # Interactive prompt for tools
|
||||
unraid-api developer --sandbox true # Enable GraphQL sandbox
|
||||
unraid-api developer --sandbox false # Disable GraphQL sandbox
|
||||
unraid-api developer --enable-modal # Enable modal testing tool
|
||||
unraid-api developer --disable-modal # Disable modal testing tool
|
||||
```
|
||||
|
||||
Configure developer features for the API (e.g., GraphQL sandbox).
|
||||
Configure developer features for the API:
|
||||
|
||||
- **GraphQL Sandbox**: Enable/disable Apollo GraphQL sandbox at `/graphql`
|
||||
- **Modal Testing Tool**: Enable/disable UI modal testing in the Unraid menu
|
||||
|
||||
## API Key Management
|
||||
|
||||
|
||||
@@ -4,13 +4,19 @@ The Unraid API provides a GraphQL interface that allows you to interact with you
|
||||
|
||||
## Enabling the GraphQL Sandbox
|
||||
|
||||
1. First, enable developer mode using the CLI:
|
||||
1. Enable developer mode using the CLI:
|
||||
|
||||
```bash
|
||||
unraid-api developer --sandbox true
|
||||
```
|
||||
|
||||
Or use the interactive mode:
|
||||
|
||||
```bash
|
||||
unraid-api developer
|
||||
```
|
||||
|
||||
2. Follow the prompts to enable the sandbox. This will allow you to access the Apollo Sandbox interface.
|
||||
2. Once enabled, you can access the Apollo Sandbox interface
|
||||
|
||||
3. Access the GraphQL playground by navigating to:
|
||||
|
||||
|
||||
@@ -226,27 +226,6 @@ type Share implements Node {
|
||||
luksStatus: String
|
||||
}
|
||||
|
||||
type AccessUrl {
|
||||
type: URL_TYPE!
|
||||
name: String
|
||||
ipv4: URL
|
||||
ipv6: URL
|
||||
}
|
||||
|
||||
enum URL_TYPE {
|
||||
LAN
|
||||
WIREGUARD
|
||||
WAN
|
||||
MDNS
|
||||
OTHER
|
||||
DEFAULT
|
||||
}
|
||||
|
||||
"""
|
||||
A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt.
|
||||
"""
|
||||
scalar URL
|
||||
|
||||
type DiskPartition {
|
||||
"""The name of the partition"""
|
||||
name: String!
|
||||
@@ -1490,6 +1469,27 @@ type Plugin {
|
||||
hasCliModule: Boolean
|
||||
}
|
||||
|
||||
type AccessUrl {
|
||||
type: URL_TYPE!
|
||||
name: String
|
||||
ipv4: URL
|
||||
ipv6: URL
|
||||
}
|
||||
|
||||
enum URL_TYPE {
|
||||
LAN
|
||||
WIREGUARD
|
||||
WAN
|
||||
MDNS
|
||||
OTHER
|
||||
DEFAULT
|
||||
}
|
||||
|
||||
"""
|
||||
A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt.
|
||||
"""
|
||||
scalar URL
|
||||
|
||||
type AccessUrlObject {
|
||||
ipv4: String
|
||||
ipv6: String
|
||||
@@ -1653,6 +1653,7 @@ type Query {
|
||||
services: [Service!]!
|
||||
shares: [Share!]!
|
||||
vars: Vars!
|
||||
isInitialSetup: Boolean!
|
||||
|
||||
"""Get information about all VMs on the system"""
|
||||
vms: Vms!
|
||||
@@ -1829,7 +1830,6 @@ type Subscription {
|
||||
notificationAdded: Notification!
|
||||
notificationsOverview: NotificationOverview!
|
||||
ownerSubscription: Owner!
|
||||
registrationSubscription: Registration!
|
||||
serversSubscription: Server!
|
||||
parityHistorySubscription: ParityCheck!
|
||||
arraySubscription: UnraidArray!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.9.3",
|
||||
"version": "4.11.0",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
@@ -10,7 +10,7 @@
|
||||
"author": "Lime Technology, Inc. <unraid.net>",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"engines": {
|
||||
"pnpm": "10.12.4"
|
||||
"pnpm": "10.13.1"
|
||||
},
|
||||
"scripts": {
|
||||
"// Development": "",
|
||||
@@ -28,9 +28,8 @@
|
||||
"preunraid:deploy": "pnpm build",
|
||||
"unraid:deploy": "./scripts/deploy-dev.sh",
|
||||
"// GraphQL Codegen": "",
|
||||
"codegen": "MOTHERSHIP_GRAPHQL_LINK='https://staging.mothership.unraid.net/ws' graphql-codegen --config codegen.ts -r dotenv/config './.env.staging'",
|
||||
"codegen:watch": "DOTENV_CONFIG_PATH='./.env.staging' graphql-codegen --config codegen.ts --watch -r dotenv/config",
|
||||
"codegen:local": "NODE_TLS_REJECT_UNAUTHORIZED=0 MOTHERSHIP_GRAPHQL_LINK='https://mothership.localhost/ws' graphql-codegen --config codegen.ts --watch",
|
||||
"codegen": "graphql-codegen --config codegen.ts",
|
||||
"codegen:watch": "graphql-codegen --config codegen.ts --watch",
|
||||
"// Code Quality": "",
|
||||
"lint": "eslint --config .eslintrc.ts src/",
|
||||
"lint:fix": "eslint --fix --config .eslintrc.ts src/",
|
||||
@@ -57,21 +56,21 @@
|
||||
"@as-integrations/fastify": "2.1.1",
|
||||
"@fastify/cookie": "11.0.2",
|
||||
"@fastify/helmet": "13.0.1",
|
||||
"@graphql-codegen/client-preset": "4.8.2",
|
||||
"@graphql-codegen/client-preset": "4.8.3",
|
||||
"@graphql-tools/load-files": "7.0.1",
|
||||
"@graphql-tools/merge": "9.0.24",
|
||||
"@graphql-tools/schema": "10.0.23",
|
||||
"@graphql-tools/utils": "10.8.6",
|
||||
"@graphql-tools/merge": "9.1.1",
|
||||
"@graphql-tools/schema": "10.0.25",
|
||||
"@graphql-tools/utils": "10.9.1",
|
||||
"@jsonforms/core": "3.6.0",
|
||||
"@nestjs/apollo": "13.1.0",
|
||||
"@nestjs/cache-manager": "3.0.1",
|
||||
"@nestjs/common": "11.1.3",
|
||||
"@nestjs/common": "11.1.5",
|
||||
"@nestjs/config": "4.0.2",
|
||||
"@nestjs/core": "11.1.3",
|
||||
"@nestjs/core": "11.1.5",
|
||||
"@nestjs/event-emitter": "3.0.1",
|
||||
"@nestjs/graphql": "13.1.0",
|
||||
"@nestjs/passport": "11.0.5",
|
||||
"@nestjs/platform-fastify": "11.1.3",
|
||||
"@nestjs/platform-fastify": "11.1.5",
|
||||
"@nestjs/schedule": "6.0.0",
|
||||
"@nestjs/throttler": "6.4.0",
|
||||
"@reduxjs/toolkit": "2.8.2",
|
||||
@@ -82,7 +81,7 @@
|
||||
"accesscontrol": "2.2.1",
|
||||
"bycontract": "2.0.11",
|
||||
"bytes": "3.1.2",
|
||||
"cache-manager": "7.0.0",
|
||||
"cache-manager": "7.0.1",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"camelcase-keys": "9.1.3",
|
||||
"casbin": "5.38.0",
|
||||
@@ -94,11 +93,11 @@
|
||||
"command-exists": "1.2.9",
|
||||
"convert": "5.12.0",
|
||||
"cookie": "1.0.2",
|
||||
"cron": "4.3.1",
|
||||
"cron": "4.3.2",
|
||||
"cross-fetch": "4.1.0",
|
||||
"diff": "8.0.2",
|
||||
"dockerode": "4.0.7",
|
||||
"dotenv": "17.1.0",
|
||||
"dotenv": "17.2.1",
|
||||
"execa": "9.6.0",
|
||||
"exit-hook": "4.0.0",
|
||||
"fastify": "5.4.0",
|
||||
@@ -112,16 +111,16 @@
|
||||
"graphql-scalars": "1.24.2",
|
||||
"graphql-subscriptions": "3.0.0",
|
||||
"graphql-tag": "2.12.6",
|
||||
"graphql-ws": "6.0.5",
|
||||
"graphql-ws": "6.0.6",
|
||||
"ini": "5.0.0",
|
||||
"ip": "2.0.1",
|
||||
"jose": "6.0.11",
|
||||
"jose": "6.0.12",
|
||||
"json-bigint-patch": "0.0.8",
|
||||
"lodash-es": "4.17.21",
|
||||
"multi-ini": "2.3.2",
|
||||
"mustache": "4.2.0",
|
||||
"nest-authz": "2.17.0",
|
||||
"nest-commander": "3.17.0",
|
||||
"nest-commander": "3.18.0",
|
||||
"nestjs-pino": "4.4.0",
|
||||
"node-cache": "5.1.2",
|
||||
"node-window-polyfill": "1.0.4",
|
||||
@@ -138,11 +137,11 @@
|
||||
"rxjs": "7.8.2",
|
||||
"semver": "7.7.2",
|
||||
"strftime": "0.10.3",
|
||||
"systeminformation": "5.27.6",
|
||||
"systeminformation": "5.27.7",
|
||||
"uuid": "11.1.0",
|
||||
"ws": "8.18.2",
|
||||
"ws": "8.18.3",
|
||||
"zen-observable-ts": "1.1.0",
|
||||
"zod": "3.25.67"
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"unraid-api-plugin-connect": "workspace:*"
|
||||
@@ -153,71 +152,73 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.29.0",
|
||||
"@eslint/js": "9.32.0",
|
||||
"@graphql-codegen/add": "5.0.3",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/fragment-matcher": "5.1.0",
|
||||
"@graphql-codegen/import-types-preset": "3.0.1",
|
||||
"@graphql-codegen/typed-document-node": "5.1.1",
|
||||
"@graphql-codegen/typed-document-node": "5.1.2",
|
||||
"@graphql-codegen/typescript": "4.1.6",
|
||||
"@graphql-codegen/typescript-operations": "4.6.1",
|
||||
"@graphql-codegen/typescript-resolvers": "4.5.1",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.4.2",
|
||||
"@nestjs/testing": "11.1.3",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.5.1",
|
||||
"@nestjs/testing": "11.1.5",
|
||||
"@originjs/vite-plugin-commonjs": "1.0.3",
|
||||
"@rollup/plugin-node-resolve": "16.0.1",
|
||||
"@swc/core": "1.12.4",
|
||||
"@swc/core": "1.13.2",
|
||||
"@types/async-exit-hook": "2.0.2",
|
||||
"@types/bytes": "3.1.5",
|
||||
"@types/cli-table": "0.3.4",
|
||||
"@types/command-exists": "1.2.3",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/dockerode": "3.3.41",
|
||||
"@types/dockerode": "3.3.42",
|
||||
"@types/graphql-fields": "1.3.9",
|
||||
"@types/graphql-type-uuid": "0.2.6",
|
||||
"@types/ini": "4.1.1",
|
||||
"@types/ip": "1.1.3",
|
||||
"@types/lodash": "4.17.18",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/mustache": "4.2.6",
|
||||
"@types/node": "22.15.32",
|
||||
"@types/node": "22.16.5",
|
||||
"@types/pify": "6.1.0",
|
||||
"@types/semver": "7.7.0",
|
||||
"@types/sendmail": "1.4.7",
|
||||
"@types/stoppable": "1.1.3",
|
||||
"@types/strftime": "0.9.8",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/ws": "8.18.1",
|
||||
"@types/wtfnode": "0.7.3",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"commit-and-tag-version": "9.6.0",
|
||||
"cz-conventional-changelog": "3.3.0",
|
||||
"eslint": "9.29.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-n": "17.20.0",
|
||||
"eslint": "9.32.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-n": "17.21.2",
|
||||
"eslint-plugin-no-relative-import-paths": "1.6.1",
|
||||
"eslint-plugin-prettier": "5.5.0",
|
||||
"eslint-plugin-prettier": "5.5.3",
|
||||
"graphql-codegen-typescript-validation-schema": "0.17.1",
|
||||
"jiti": "2.4.2",
|
||||
"jiti": "2.5.1",
|
||||
"nodemon": "3.1.10",
|
||||
"prettier": "3.5.3",
|
||||
"prettier": "3.6.2",
|
||||
"rollup-plugin-node-externals": "8.0.1",
|
||||
"commit-and-tag-version": "9.5.0",
|
||||
"supertest": "^7.1.4",
|
||||
"tsx": "4.20.3",
|
||||
"type-fest": "4.41.0",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.34.1",
|
||||
"typescript-eslint": "8.38.0",
|
||||
"unplugin-swc": "1.5.5",
|
||||
"vite": "7.0.3",
|
||||
"vite": "7.0.6",
|
||||
"vite-plugin-node": "7.0.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.2.4",
|
||||
"zx": "8.5.5"
|
||||
"zx": "8.7.1"
|
||||
},
|
||||
"overrides": {
|
||||
"eslint": {
|
||||
"jiti": "2.4.2"
|
||||
"jiti": "2.5.1"
|
||||
},
|
||||
"@as-integrations/fastify": {
|
||||
"fastify": "$fastify"
|
||||
@@ -228,5 +229,5 @@
|
||||
}
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.4"
|
||||
"packageManager": "pnpm@10.13.1"
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { loadConfigFile } from '@app/store/modules/config.js';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp.js';
|
||||
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
test('Returns allowed origins', async () => {
|
||||
// Load state files into store
|
||||
await store.dispatch(loadStateFiles()).unwrap();
|
||||
await store.dispatch(loadConfigFile()).unwrap();
|
||||
|
||||
// Get allowed origins
|
||||
const allowedOrigins = getAllowedOrigins();
|
||||
|
||||
// Test that the result is an array
|
||||
expect(Array.isArray(allowedOrigins)).toBe(true);
|
||||
|
||||
// Test that it contains the expected socket paths
|
||||
expect(allowedOrigins).toContain('/var/run/unraid-notifications.sock');
|
||||
expect(allowedOrigins).toContain('/var/run/unraid-php.sock');
|
||||
expect(allowedOrigins).toContain('/var/run/unraid-cli.sock');
|
||||
|
||||
// Test that it contains the expected local URLs
|
||||
expect(allowedOrigins).toContain('http://localhost:8080');
|
||||
expect(allowedOrigins).toContain('https://localhost:4443');
|
||||
|
||||
// Test that it contains the expected connect URLs
|
||||
expect(allowedOrigins).toContain('https://connect.myunraid.net');
|
||||
expect(allowedOrigins).toContain('https://connect-staging.myunraid.net');
|
||||
expect(allowedOrigins).toContain('https://dev-my.myunraid.net:4000');
|
||||
|
||||
// Test that it contains the extra origins from config
|
||||
expect(allowedOrigins).toContain('https://google.com');
|
||||
expect(allowedOrigins).toContain('https://test.com');
|
||||
|
||||
// Test that it contains some of the remote URLs
|
||||
expect(allowedOrigins).toContain('https://tower.local:4443');
|
||||
expect(allowedOrigins).toContain('https://192.168.1.150:4443');
|
||||
|
||||
// Test that there are no duplicates
|
||||
expect(allowedOrigins.length).toBe(new Set(allowedOrigins).size);
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiConfigPersistence } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { ConfigPersistenceHelper } from '@app/unraid-api/config/persistence.helper.js';
|
||||
|
||||
describe('ApiConfigPersistence', () => {
|
||||
let service: ApiConfigPersistence;
|
||||
let configService: ConfigService;
|
||||
let persistenceHelper: ConfigPersistenceHelper;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
} as any;
|
||||
|
||||
persistenceHelper = {} as ConfigPersistenceHelper;
|
||||
service = new ApiConfigPersistence(configService, persistenceHelper);
|
||||
});
|
||||
|
||||
describe('convertLegacyConfig', () => {
|
||||
it('should migrate sandbox from string "yes" to boolean true', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'yes' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.sandbox).toBe(true);
|
||||
});
|
||||
|
||||
it('should migrate sandbox from string "no" to boolean false', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.sandbox).toBe(false);
|
||||
});
|
||||
|
||||
it('should migrate extraOrigins from comma-separated string to array', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: 'https://example.com,https://test.com' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.extraOrigins).toEqual(['https://example.com', 'https://test.com']);
|
||||
});
|
||||
|
||||
it('should filter out non-HTTP origins from extraOrigins', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: {
|
||||
extraOrigins: 'https://example.com,invalid-origin,http://test.com,ftp://bad.com',
|
||||
},
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.extraOrigins).toEqual(['https://example.com', 'http://test.com']);
|
||||
});
|
||||
|
||||
it('should handle empty extraOrigins string', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.extraOrigins).toEqual([]);
|
||||
});
|
||||
|
||||
it('should migrate ssoSubIds from comma-separated string to array', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: 'user1,user2,user3' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.ssoSubIds).toEqual(['user1', 'user2', 'user3']);
|
||||
});
|
||||
|
||||
it('should handle empty ssoSubIds string', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'no' },
|
||||
api: { extraOrigins: '' },
|
||||
remote: { ssoSubIds: '' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.ssoSubIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined config sections', () => {
|
||||
const legacyConfig = {};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.sandbox).toBe(false);
|
||||
expect(result.extraOrigins).toEqual([]);
|
||||
expect(result.ssoSubIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle complete migration with all fields', () => {
|
||||
const legacyConfig = {
|
||||
local: { sandbox: 'yes' },
|
||||
api: { extraOrigins: 'https://app1.example.com,https://app2.example.com' },
|
||||
remote: { ssoSubIds: 'sub1,sub2,sub3' },
|
||||
};
|
||||
|
||||
const result = service.convertLegacyConfig(legacyConfig);
|
||||
|
||||
expect(result.sandbox).toBe(true);
|
||||
expect(result.extraOrigins).toEqual([
|
||||
'https://app1.example.com',
|
||||
'https://app2.example.com',
|
||||
]);
|
||||
expect(result.ssoSubIds).toEqual(['sub1', 'sub2', 'sub3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer.js';
|
||||
import { initialState } from '@app/store/modules/config.js';
|
||||
|
||||
test('it creates a FLASH config with NO OPTIONAL values', () => {
|
||||
const basicConfig = initialState;
|
||||
const config = getWriteableConfig(basicConfig, 'flash');
|
||||
expect(config).toMatchInlineSnapshot(`
|
||||
{
|
||||
"api": {
|
||||
"extraOrigins": "",
|
||||
"version": "",
|
||||
},
|
||||
"local": {
|
||||
"sandbox": "no",
|
||||
},
|
||||
"remote": {
|
||||
"accesstoken": "",
|
||||
"apikey": "",
|
||||
"avatar": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"localApiKey": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"ssoSubIds": "",
|
||||
"upnpEnabled": "",
|
||||
"username": "",
|
||||
"wanaccess": "",
|
||||
"wanport": "",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('it creates a MEMORY config with NO OPTIONAL values', () => {
|
||||
const basicConfig = initialState;
|
||||
const config = getWriteableConfig(basicConfig, 'memory');
|
||||
expect(config).toMatchInlineSnapshot(`
|
||||
{
|
||||
"api": {
|
||||
"extraOrigins": "",
|
||||
"version": "",
|
||||
},
|
||||
"connectionStatus": {
|
||||
"minigraph": "PRE_INIT",
|
||||
"upnpStatus": "",
|
||||
},
|
||||
"local": {
|
||||
"sandbox": "no",
|
||||
},
|
||||
"remote": {
|
||||
"accesstoken": "",
|
||||
"allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000",
|
||||
"apikey": "",
|
||||
"avatar": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"localApiKey": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"ssoSubIds": "",
|
||||
"upnpEnabled": "",
|
||||
"username": "",
|
||||
"wanaccess": "",
|
||||
"wanport": "",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('it creates a FLASH config with OPTIONAL values', () => {
|
||||
const basicConfig = cloneDeep(initialState);
|
||||
// 2fa & t2fa should be ignored
|
||||
basicConfig.remote['2Fa'] = 'yes';
|
||||
basicConfig.local['2Fa'] = 'yes';
|
||||
|
||||
basicConfig.api.extraOrigins = 'myextra.origins';
|
||||
basicConfig.remote.upnpEnabled = 'yes';
|
||||
basicConfig.connectionStatus.upnpStatus = 'Turned On';
|
||||
const config = getWriteableConfig(basicConfig, 'flash');
|
||||
expect(config).toMatchInlineSnapshot(`
|
||||
{
|
||||
"api": {
|
||||
"extraOrigins": "myextra.origins",
|
||||
"version": "",
|
||||
},
|
||||
"local": {
|
||||
"sandbox": "no",
|
||||
},
|
||||
"remote": {
|
||||
"accesstoken": "",
|
||||
"apikey": "",
|
||||
"avatar": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"localApiKey": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"ssoSubIds": "",
|
||||
"upnpEnabled": "yes",
|
||||
"username": "",
|
||||
"wanaccess": "",
|
||||
"wanport": "",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('it creates a MEMORY config with OPTIONAL values', () => {
|
||||
const basicConfig = cloneDeep(initialState);
|
||||
// 2fa & t2fa should be ignored
|
||||
basicConfig.remote['2Fa'] = 'yes';
|
||||
basicConfig.local['2Fa'] = 'yes';
|
||||
basicConfig.api.extraOrigins = 'myextra.origins';
|
||||
basicConfig.remote.upnpEnabled = 'yes';
|
||||
basicConfig.connectionStatus.upnpStatus = 'Turned On';
|
||||
const config = getWriteableConfig(basicConfig, 'memory');
|
||||
expect(config).toMatchInlineSnapshot(`
|
||||
{
|
||||
"api": {
|
||||
"extraOrigins": "myextra.origins",
|
||||
"version": "",
|
||||
},
|
||||
"connectionStatus": {
|
||||
"minigraph": "PRE_INIT",
|
||||
"upnpStatus": "Turned On",
|
||||
},
|
||||
"local": {
|
||||
"sandbox": "no",
|
||||
},
|
||||
"remote": {
|
||||
"accesstoken": "",
|
||||
"allowedOrigins": "/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000",
|
||||
"apikey": "",
|
||||
"avatar": "",
|
||||
"dynamicRemoteAccessType": "DISABLED",
|
||||
"email": "",
|
||||
"idtoken": "",
|
||||
"localApiKey": "",
|
||||
"refreshtoken": "",
|
||||
"regWizTime": "",
|
||||
"ssoSubIds": "",
|
||||
"upnpEnabled": "yes",
|
||||
"username": "",
|
||||
"wanaccess": "",
|
||||
"wanport": "",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
5
api/src/__test__/core/utils/pm2/dummy-process.js
Normal file
5
api/src/__test__/core/utils/pm2/dummy-process.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/* eslint-disable no-undef */
|
||||
// Dummy process for PM2 testing
|
||||
setInterval(() => {
|
||||
// Keep process alive
|
||||
}, 1000);
|
||||
@@ -0,0 +1,216 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { execa } from 'execa';
|
||||
import pm2 from 'pm2';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running.js';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PROJECT_ROOT = join(__dirname, '../../../../..');
|
||||
const DUMMY_PROCESS_PATH = join(__dirname, 'dummy-process.js');
|
||||
const CLI_PATH = join(PROJECT_ROOT, 'dist/cli.js');
|
||||
const TEST_PROCESS_NAME = 'test-unraid-api';
|
||||
|
||||
// Shared PM2 connection state
|
||||
let pm2Connected = false;
|
||||
|
||||
// Helper function to run CLI command (assumes CLI is built)
|
||||
async function runCliCommand(command: string, options: any = {}) {
|
||||
return await execa('node', [CLI_PATH, command], options);
|
||||
}
|
||||
|
||||
// Helper to ensure PM2 connection is established
|
||||
async function ensurePM2Connection() {
|
||||
if (pm2Connected) return;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
pm2.connect((err) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
pm2Connected = true;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to delete specific test processes (lightweight, reuses connection)
|
||||
async function deleteTestProcesses() {
|
||||
if (!pm2Connected) {
|
||||
// No connection, nothing to clean up
|
||||
return;
|
||||
}
|
||||
|
||||
const deletePromise = new Promise<void>((resolve) => {
|
||||
// Delete specific processes we might have created
|
||||
const processNames = ['unraid-api', TEST_PROCESS_NAME];
|
||||
let deletedCount = 0;
|
||||
|
||||
const deleteNext = () => {
|
||||
if (deletedCount >= processNames.length) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const processName = processNames[deletedCount];
|
||||
pm2.delete(processName, (deleteErr) => {
|
||||
// Ignore errors, process might not exist
|
||||
deletedCount++;
|
||||
deleteNext();
|
||||
});
|
||||
};
|
||||
|
||||
deleteNext();
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => resolve(), 3000); // 3 second timeout
|
||||
});
|
||||
|
||||
return Promise.race([deletePromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
// Helper to ensure PM2 is completely clean (heavy cleanup with daemon kill)
|
||||
async function cleanupAllPM2Processes() {
|
||||
// First delete test processes if we have a connection
|
||||
if (pm2Connected) {
|
||||
await deleteTestProcesses();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
// Always connect fresh for daemon kill (in case we weren't connected)
|
||||
pm2.connect((err) => {
|
||||
if (err) {
|
||||
// If we can't connect, assume PM2 is not running
|
||||
pm2Connected = false;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill the daemon to ensure fresh state
|
||||
pm2.killDaemon((killErr) => {
|
||||
pm2.disconnect();
|
||||
pm2Connected = false;
|
||||
// Small delay to let PM2 fully shutdown
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe.skipIf(!!process.env.CI)('PM2 integration tests', () => {
|
||||
beforeAll(async () => {
|
||||
// Build the CLI if it doesn't exist (only for CLI tests)
|
||||
if (!existsSync(CLI_PATH)) {
|
||||
console.log('Building CLI for integration tests...');
|
||||
try {
|
||||
await execa('pnpm', ['build'], {
|
||||
cwd: PROJECT_ROOT,
|
||||
stdio: 'inherit',
|
||||
timeout: 120000, // 2 minute timeout for build
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to build CLI:', error);
|
||||
throw new Error(
|
||||
'Cannot run CLI integration tests without built CLI. Run `pnpm build` first.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only do a full cleanup once at the beginning
|
||||
await cleanupAllPM2Processes();
|
||||
}, 150000); // 2.5 minute timeout for setup
|
||||
|
||||
afterAll(async () => {
|
||||
// Only do a full cleanup once at the end
|
||||
await cleanupAllPM2Processes();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Lightweight cleanup after each test - just delete our test processes
|
||||
await deleteTestProcesses();
|
||||
}, 5000); // 5 second timeout for cleanup
|
||||
|
||||
describe('isUnraidApiRunning function', () => {
|
||||
it('should return false when PM2 is not running the unraid-api process', async () => {
|
||||
const result = await isUnraidApiRunning();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when PM2 has unraid-api process running', async () => {
|
||||
// Ensure PM2 connection
|
||||
await ensurePM2Connection();
|
||||
|
||||
// Start a dummy process with the name 'unraid-api'
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
pm2.start(
|
||||
{
|
||||
script: DUMMY_PROCESS_PATH,
|
||||
name: 'unraid-api',
|
||||
},
|
||||
(startErr) => {
|
||||
if (startErr) return reject(startErr);
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Give PM2 time to start the process
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
const result = await isUnraidApiRunning();
|
||||
expect(result).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it('should return false when unraid-api process is stopped', async () => {
|
||||
// Ensure PM2 connection
|
||||
await ensurePM2Connection();
|
||||
|
||||
// Start and then stop the process
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
pm2.start(
|
||||
{
|
||||
script: DUMMY_PROCESS_PATH,
|
||||
name: 'unraid-api',
|
||||
},
|
||||
(startErr) => {
|
||||
if (startErr) return reject(startErr);
|
||||
|
||||
// Stop the process after starting
|
||||
setTimeout(() => {
|
||||
pm2.stop('unraid-api', (stopErr) => {
|
||||
if (stopErr) return reject(stopErr);
|
||||
resolve();
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const result = await isUnraidApiRunning();
|
||||
expect(result).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it('should handle PM2 connection errors gracefully', async () => {
|
||||
// Set an invalid PM2_HOME to force connection failure
|
||||
const originalPM2Home = process.env.PM2_HOME;
|
||||
process.env.PM2_HOME = '/invalid/path/that/does/not/exist';
|
||||
|
||||
const result = await isUnraidApiRunning();
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Restore original PM2_HOME
|
||||
if (originalPM2Home) {
|
||||
process.env.PM2_HOME = originalPM2Home;
|
||||
} else {
|
||||
delete process.env.PM2_HOME;
|
||||
}
|
||||
}, 15000); // 15 second timeout to allow for the Promise.race timeout
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,6 @@ exports[`Returns paths 1`] = `
|
||||
"myservers-base",
|
||||
"myservers-config",
|
||||
"myservers-config-states",
|
||||
"myservers-env",
|
||||
"myservers-keepalive",
|
||||
"keyfile-base",
|
||||
"machine-id",
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { MyServersConfigMemory } from '@app/types/my-servers-config.js';
|
||||
|
||||
describe.skip('config tests', () => {
|
||||
// Mock dependencies
|
||||
vi.mock('@app/core/pubsub.js', () => {
|
||||
const mockPublish = vi.fn();
|
||||
return {
|
||||
pubsub: {
|
||||
publish: mockPublish,
|
||||
},
|
||||
PUBSUB_CHANNEL: {
|
||||
OWNER: 'OWNER',
|
||||
SERVERS: 'SERVERS',
|
||||
},
|
||||
__esModule: true,
|
||||
default: {
|
||||
pubsub: {
|
||||
publish: mockPublish,
|
||||
},
|
||||
PUBSUB_CHANNEL: {
|
||||
OWNER: 'OWNER',
|
||||
SERVERS: 'SERVERS',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Get the mock function for pubsub.publish
|
||||
const mockPublish = vi.mocked(pubsub.publish);
|
||||
|
||||
// Clear mock before each test
|
||||
beforeEach(() => {
|
||||
mockPublish.mockClear();
|
||||
});
|
||||
|
||||
vi.mock('@app/mothership/graphql-client.js', () => ({
|
||||
GraphQLClient: {
|
||||
clearInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/mothership/jobs/ping-timeout-jobs.js', () => ({
|
||||
stopPingTimeoutJobs: vi.fn(),
|
||||
}));
|
||||
|
||||
const createConfigMatcher = (specificValues: Partial<MyServersConfigMemory> = {}) => {
|
||||
const defaultMatcher = {
|
||||
api: expect.objectContaining({
|
||||
extraOrigins: expect.any(String),
|
||||
version: expect.any(String),
|
||||
}),
|
||||
connectionStatus: expect.objectContaining({
|
||||
minigraph: expect.any(String),
|
||||
upnpStatus: expect.any(String),
|
||||
}),
|
||||
local: expect.objectContaining({
|
||||
sandbox: expect.any(String),
|
||||
}),
|
||||
nodeEnv: expect.any(String),
|
||||
remote: expect.objectContaining({
|
||||
accesstoken: expect.any(String),
|
||||
allowedOrigins: expect.any(String),
|
||||
apikey: expect.any(String),
|
||||
avatar: expect.any(String),
|
||||
dynamicRemoteAccessType: expect.any(String),
|
||||
email: expect.any(String),
|
||||
idtoken: expect.any(String),
|
||||
localApiKey: expect.any(String),
|
||||
refreshtoken: expect.any(String),
|
||||
regWizTime: expect.any(String),
|
||||
ssoSubIds: expect.any(String),
|
||||
upnpEnabled: expect.any(String),
|
||||
username: expect.any(String),
|
||||
wanaccess: expect.any(String),
|
||||
wanport: expect.any(String),
|
||||
}),
|
||||
status: expect.any(String),
|
||||
};
|
||||
|
||||
return expect.objectContaining({
|
||||
...defaultMatcher,
|
||||
...specificValues,
|
||||
});
|
||||
};
|
||||
|
||||
// test('Before init returns default values for all fields', async () => {
|
||||
// const state = store.getState().config;
|
||||
// expect(state).toMatchSnapshot();
|
||||
// }, 10_000);
|
||||
|
||||
test('After init returns values from cfg file for all fields', async () => {
|
||||
const { loadConfigFile } = await import('@app/store/modules/config.js');
|
||||
|
||||
// Load cfg into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
// Check if store has cfg contents loaded
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(createConfigMatcher());
|
||||
});
|
||||
|
||||
test('updateUserConfig merges in changes to current state', async () => {
|
||||
const { loadConfigFile, updateUserConfig } = await import('@app/store/modules/config.js');
|
||||
|
||||
// Load cfg into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
// Update store
|
||||
store.dispatch(
|
||||
updateUserConfig({
|
||||
remote: { avatar: 'https://via.placeholder.com/200' },
|
||||
})
|
||||
);
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
createConfigMatcher({
|
||||
remote: expect.objectContaining({
|
||||
avatar: 'https://via.placeholder.com/200',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('loginUser updates state and publishes to pubsub', async () => {
|
||||
const { loginUser } = await import('@app/store/modules/config.js');
|
||||
const userInfo = {
|
||||
email: 'test@example.com',
|
||||
avatar: 'https://via.placeholder.com/200',
|
||||
username: 'testuser',
|
||||
apikey: 'test-api-key',
|
||||
localApiKey: 'test-local-api-key',
|
||||
};
|
||||
|
||||
await store.dispatch(loginUser(userInfo));
|
||||
|
||||
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.OWNER, {
|
||||
owner: {
|
||||
username: userInfo.username,
|
||||
url: '',
|
||||
avatar: userInfo.avatar,
|
||||
},
|
||||
});
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
createConfigMatcher({
|
||||
remote: expect.objectContaining(userInfo),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('logoutUser clears state and publishes to pubsub', async () => {
|
||||
const { logoutUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
await store.dispatch(logoutUser({ reason: 'test logout' }));
|
||||
|
||||
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.SERVERS, { servers: [] });
|
||||
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.OWNER, {
|
||||
owner: {
|
||||
username: 'root',
|
||||
url: '',
|
||||
avatar: '',
|
||||
},
|
||||
});
|
||||
// expect(stopPingTimeoutJobs).toHaveBeenCalled();
|
||||
// expect(GraphQLClient.clearInstance).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('updateAccessTokens updates token fields', async () => {
|
||||
const { updateAccessTokens } = await import('@app/store/modules/config.js');
|
||||
const tokens = {
|
||||
accesstoken: 'new-access-token',
|
||||
refreshtoken: 'new-refresh-token',
|
||||
idtoken: 'new-id-token',
|
||||
};
|
||||
|
||||
store.dispatch(updateAccessTokens(tokens));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state).toMatchObject(
|
||||
createConfigMatcher({
|
||||
remote: expect.objectContaining(tokens),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('updateAllowedOrigins updates extraOrigins', async () => {
|
||||
const { updateAllowedOrigins } = await import('@app/store/modules/config.js');
|
||||
const origins = ['https://test1.com', 'https://test2.com'];
|
||||
|
||||
store.dispatch(updateAllowedOrigins(origins));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.api.extraOrigins).toBe(origins.join(', '));
|
||||
});
|
||||
|
||||
test('setUpnpState updates upnp settings', async () => {
|
||||
const { setUpnpState } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setUpnpState({ enabled: 'yes', status: 'active' }));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.upnpEnabled).toBe('yes');
|
||||
expect(state.connectionStatus.upnpStatus).toBe('active');
|
||||
});
|
||||
|
||||
test('setWanPortToValue updates wanport', async () => {
|
||||
const { setWanPortToValue } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setWanPortToValue(8443));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.wanport).toBe('8443');
|
||||
});
|
||||
|
||||
test('setWanAccess updates wanaccess', async () => {
|
||||
const { setWanAccess } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setWanAccess('yes'));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.wanaccess).toBe('yes');
|
||||
});
|
||||
|
||||
// test('addSsoUser adds user to ssoSubIds', async () => {
|
||||
// const { addSsoUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
// store.dispatch(addSsoUser('user1'));
|
||||
// store.dispatch(addSsoUser('user2'));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.remote.ssoSubIds).toBe('user1,user2');
|
||||
// });
|
||||
|
||||
// test('removeSsoUser removes user from ssoSubIds', async () => {
|
||||
// const { addSsoUser, removeSsoUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
// store.dispatch(addSsoUser('user1'));
|
||||
// store.dispatch(addSsoUser('user2'));
|
||||
// store.dispatch(removeSsoUser('user1'));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.remote.ssoSubIds).toBe('user2');
|
||||
// });
|
||||
|
||||
// test('removeSsoUser with null clears all ssoSubIds', async () => {
|
||||
// const { addSsoUser, removeSsoUser } = await import('@app/store/modules/config.js');
|
||||
|
||||
// store.dispatch(addSsoUser('user1'));
|
||||
// store.dispatch(addSsoUser('user2'));
|
||||
// store.dispatch(removeSsoUser(null));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.remote.ssoSubIds).toBe('');
|
||||
// });
|
||||
|
||||
test('setLocalApiKey updates localApiKey', async () => {
|
||||
const { setLocalApiKey } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setLocalApiKey('new-local-api-key'));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.localApiKey).toBe('new-local-api-key');
|
||||
});
|
||||
|
||||
test('setLocalApiKey with null clears localApiKey', async () => {
|
||||
const { setLocalApiKey } = await import('@app/store/modules/config.js');
|
||||
|
||||
store.dispatch(setLocalApiKey(null));
|
||||
|
||||
const state = store.getState().config;
|
||||
expect(state.remote.localApiKey).toBe('');
|
||||
});
|
||||
|
||||
// test('setGraphqlConnectionStatus updates minigraph status', async () => {
|
||||
// store.dispatch(setGraphqlConnectionStatus({ status: MinigraphStatus.CONNECTED, error: null }));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.connectionStatus.minigraph).toBe(MinigraphStatus.CONNECTED);
|
||||
// });
|
||||
|
||||
// test('setupRemoteAccessThunk.fulfilled updates remote access settings', async () => {
|
||||
// const remoteAccessSettings = {
|
||||
// accessType: WAN_ACCESS_TYPE.DYNAMIC,
|
||||
// forwardType: WAN_FORWARD_TYPE.UPNP,
|
||||
// };
|
||||
|
||||
// await store.dispatch(setupRemoteAccessThunk(remoteAccessSettings));
|
||||
|
||||
// const state = store.getState().config;
|
||||
// expect(state.remote).toMatchObject({
|
||||
// wanaccess: 'no',
|
||||
// dynamicRemoteAccessType: 'UPNP',
|
||||
// wanport: '',
|
||||
// upnpEnabled: 'yes',
|
||||
// });
|
||||
// });
|
||||
});
|
||||
@@ -24,7 +24,7 @@ test('Before init returns default values for all fields', async () => {
|
||||
`);
|
||||
});
|
||||
|
||||
test('After init returns values from cfg file for all fields', async () => {
|
||||
test('After init returns values from cfg file for all fields', { timeout: 30000 }, async () => {
|
||||
const { loadStateFiles } = await import('@app/store/modules/emhttp.js');
|
||||
|
||||
// Load state files into store
|
||||
|
||||
@@ -24,7 +24,6 @@ test('Returns paths', async () => {
|
||||
'myservers-base': '/boot/config/plugins/dynamix.my.servers/',
|
||||
'myservers-config': expect.stringContaining('api/dev/Unraid.net/myservers.cfg'),
|
||||
'myservers-config-states': expect.stringContaining('api/dev/states/myservers.cfg'),
|
||||
'myservers-env': '/boot/config/plugins/dynamix.my.servers/env',
|
||||
'myservers-keepalive': './dev/Unraid.net/fb_keepalive',
|
||||
'keyfile-base': expect.stringContaining('api/dev/Unraid.net'),
|
||||
'machine-id': expect.stringContaining('api/dev/data/machine-id'),
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { store } from '@app/store/index.js';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp.js';
|
||||
import { loadRegistrationKey } from '@app/store/modules/registration.js';
|
||||
import { createRegistrationEvent } from '@app/store/sync/registration-sync.js';
|
||||
|
||||
vi.mock('@app/core/pubsub', () => ({
|
||||
pubsub: { publish: vi.fn() },
|
||||
}));
|
||||
|
||||
test('Creates a registration event', async () => {
|
||||
// Load state files into store
|
||||
|
||||
const config = await store.dispatch(loadStateFiles()).unwrap();
|
||||
await store.dispatch(loadRegistrationKey());
|
||||
expect(config.var.regFile).toBe('/app/dev/Unraid.net/Pro.key');
|
||||
|
||||
const state = store.getState();
|
||||
const registrationEvent = createRegistrationEvent(state);
|
||||
expect(registrationEvent).toMatchInlineSnapshot(`
|
||||
{
|
||||
"registration": {
|
||||
"guid": "13FE-4200-C300-58C372A52B19",
|
||||
"keyFile": {
|
||||
"contents": "hVs1tLjvC9FiiQsIwIQ7G1KszAcexf0IneThhnmf22SB0dGs5WzRkqMiSMmt2DtR5HOXFUD32YyxuzGeUXmky3zKpSu6xhZNKVg5atGM1OfvkzHBMldI3SeBLuUFSgejLbpNUMdTrbk64JJdbzle4O8wiQgkIpAMIGxeYLwLBD4zHBcfyzq40QnxG--HcX6j25eE0xqa2zWj-j0b0rCAXahJV2a3ySCbPzr1MvfPRTVb0rr7KJ-25R592hYrz4H7Sc1B3p0lr6QUxHE6o7bcYrWKDRtIVoZ8SMPpd1_0gzYIcl5GsDFzFumTXUh8NEnl0Q8hwW1YE-tRc6Y_rrvd7w",
|
||||
"location": "/app/dev/Unraid.net/Pro.key",
|
||||
},
|
||||
"state": "PRO",
|
||||
"type": "PRO",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type Mapping } from '@runonflux/nat-upnp';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { getWanPortForUpnp } from '@app/upnp/helpers.js';
|
||||
|
||||
test('it successfully gets a wan port given no exclusions', () => {
|
||||
const port = getWanPortForUpnp(null, 36_000, 38_000);
|
||||
expect(port).toBeGreaterThan(35_999);
|
||||
expect(port).toBeLessThan(38_001);
|
||||
});
|
||||
|
||||
test('it fails to get a wan port given exclusions', () => {
|
||||
const port = getWanPortForUpnp([{ public: { port: 36_000 } }] as Mapping[], 36_000, 36_000);
|
||||
expect(port).toBeNull();
|
||||
});
|
||||
|
||||
test('it succeeds in getting a wan port given exclusions', () => {
|
||||
const port = getWanPortForUpnp([{ public: { port: 36_000 } }] as Mapping[], 30_000, 36_000);
|
||||
expect(port).not.toBeNull();
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { uniq } from 'lodash-es';
|
||||
|
||||
import type { RootState } from '@app/store/index.js';
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { GRAPHQL_INTROSPECTION } from '@app/environment.js';
|
||||
import { getServerIps, getUrlForField } from '@app/graphql/resolvers/subscription/network.js';
|
||||
import { getters, store } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
const getAllowedSocks = (): string[] => [
|
||||
// Notifier bridge
|
||||
'/var/run/unraid-notifications.sock',
|
||||
|
||||
// Unraid PHP scripts
|
||||
'/var/run/unraid-php.sock',
|
||||
|
||||
// CLI
|
||||
'/var/run/unraid-cli.sock',
|
||||
];
|
||||
|
||||
const getLocalAccessUrlsForServer = (state: RootState = store.getState()): string[] => {
|
||||
const { emhttp } = state;
|
||||
|
||||
if (emhttp.status !== FileLoadStatus.LOADED) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { nginx } = emhttp;
|
||||
try {
|
||||
return [
|
||||
getUrlForField({
|
||||
url: 'localhost',
|
||||
port: nginx.httpPort,
|
||||
}).toString(),
|
||||
getUrlForField({
|
||||
url: 'localhost',
|
||||
portSsl: nginx.httpsPort,
|
||||
}).toString(),
|
||||
];
|
||||
} catch (error: unknown) {
|
||||
logger.debug('Caught error in getLocalAccessUrlsForServer: \n%o', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getRemoteAccessUrlsForAllowedOrigins = (state: RootState = store.getState()): string[] => {
|
||||
const { urls } = getServerIps(state);
|
||||
|
||||
if (urls) {
|
||||
return urls.reduce<string[]>((acc, curr) => {
|
||||
if ((curr.ipv4 && curr.ipv6) || curr.ipv4) {
|
||||
acc.push(curr.ipv4.toString());
|
||||
} else if (curr.ipv6) {
|
||||
acc.push(curr.ipv6.toString());
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getExtraOrigins = (): string[] => {
|
||||
const { extraOrigins } = getters.config().api;
|
||||
if (extraOrigins) {
|
||||
return extraOrigins
|
||||
.replaceAll(' ', '')
|
||||
.split(',')
|
||||
.filter((origin) => origin.startsWith('http://') || origin.startsWith('https://'));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const getConnectOrigins = (): string[] => {
|
||||
const connectMain = 'https://connect.myunraid.net';
|
||||
const connectStaging = 'https://connect-staging.myunraid.net';
|
||||
const connectDev = 'https://dev-my.myunraid.net:4000';
|
||||
|
||||
return [connectMain, connectStaging, connectDev];
|
||||
};
|
||||
|
||||
const getApolloSandbox = (): string[] => {
|
||||
if (GRAPHQL_INTROSPECTION) {
|
||||
return ['https://studio.apollographql.com'];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getAllowedOrigins = (state: RootState = store.getState()): string[] =>
|
||||
uniq([
|
||||
...getAllowedSocks(),
|
||||
...getLocalAccessUrlsForServer(state),
|
||||
...getRemoteAccessUrlsForAllowedOrigins(state),
|
||||
...getExtraOrigins(),
|
||||
...getConnectOrigins(),
|
||||
...getApolloSandbox(),
|
||||
]).map((url) => (url.endsWith('/') ? url.slice(0, -1) : url));
|
||||
@@ -1,40 +0,0 @@
|
||||
import { isEqual, merge } from 'lodash-es';
|
||||
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
|
||||
import { initialState } from '@app/store/modules/config.js';
|
||||
import {
|
||||
MyServersConfig,
|
||||
MyServersConfigMemory,
|
||||
MyServersConfigMemorySchema,
|
||||
MyServersConfigSchema,
|
||||
} from '@app/types/my-servers-config.js';
|
||||
|
||||
// Define ConfigType and ConfigObject
|
||||
export type ConfigType = 'flash' | 'memory';
|
||||
|
||||
/**
|
||||
* Get a writeable configuration based on the mode ('flash' or 'memory').
|
||||
*/
|
||||
export const getWriteableConfig = <T extends ConfigType>(
|
||||
config: T extends 'memory' ? MyServersConfigMemory : MyServersConfig,
|
||||
mode: T
|
||||
): T extends 'memory' ? MyServersConfigMemory : MyServersConfig => {
|
||||
const schema = mode === 'memory' ? MyServersConfigMemorySchema : MyServersConfigSchema;
|
||||
|
||||
const defaultConfig = schema.parse(initialState);
|
||||
// Use a type assertion for the mergedConfig to include `connectionStatus` only if `mode === 'memory`
|
||||
const mergedConfig = merge<
|
||||
MyServersConfig,
|
||||
T extends 'memory' ? MyServersConfigMemory : MyServersConfig
|
||||
>(defaultConfig, config);
|
||||
|
||||
if (mode === 'memory') {
|
||||
(mergedConfig as MyServersConfigMemory).remote.allowedOrigins = getAllowedOrigins().join(', ');
|
||||
(mergedConfig as MyServersConfigMemory).connectionStatus = {
|
||||
...(defaultConfig as MyServersConfigMemory).connectionStatus,
|
||||
...(config as MyServersConfigMemory).connectionStatus,
|
||||
};
|
||||
}
|
||||
|
||||
return schema.parse(mergedConfig) as T extends 'memory' ? MyServersConfigMemory : MyServersConfig; // Narrowing ensures correct typing
|
||||
};
|
||||
@@ -1,25 +1,40 @@
|
||||
export const isUnraidApiRunning = async (): Promise<boolean | undefined> => {
|
||||
const { connect, describe, disconnect } = await import('pm2');
|
||||
return new Promise((resolve, reject) => {
|
||||
connect(function (err) {
|
||||
const { PM2_HOME } = await import('@app/environment.js');
|
||||
|
||||
// Set PM2_HOME if not already set
|
||||
if (!process.env.PM2_HOME) {
|
||||
process.env.PM2_HOME = PM2_HOME;
|
||||
}
|
||||
|
||||
const pm2Module = await import('pm2');
|
||||
const pm2 = pm2Module.default || pm2Module;
|
||||
|
||||
const pm2Promise = new Promise<boolean>((resolve) => {
|
||||
pm2.connect(function (err) {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
reject('Could not connect to pm2');
|
||||
// Don't reject here, resolve with false since we can't connect to PM2
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
describe('unraid-api', function (err, processDescription) {
|
||||
console.log(err);
|
||||
// Now try to describe unraid-api specifically
|
||||
pm2.describe('unraid-api', function (err, processDescription) {
|
||||
if (err || processDescription.length === 0) {
|
||||
console.log(false); // Service not found or error occurred
|
||||
// Service not found or error occurred
|
||||
resolve(false);
|
||||
} else {
|
||||
const isOnline = processDescription?.[0]?.pm2_env?.status === 'online';
|
||||
console.log(isOnline); // Output true if online, false otherwise
|
||||
resolve(isOnline);
|
||||
}
|
||||
|
||||
disconnect();
|
||||
pm2.disconnect();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<boolean>((resolve) => {
|
||||
setTimeout(() => resolve(false), 10000); // 10 second timeout
|
||||
});
|
||||
|
||||
return Promise.race([pm2Promise, timeoutPromise]);
|
||||
};
|
||||
|
||||
@@ -67,6 +67,7 @@ export const getPackageJsonDependencies = (): string[] | undefined => {
|
||||
|
||||
export const API_VERSION = process.env.npm_package_version ?? getPackageJson().version;
|
||||
|
||||
/** Controls how the app is built/run (i.e. in terms of optimization) */
|
||||
export const NODE_ENV =
|
||||
(process.env.NODE_ENV as 'development' | 'test' | 'staging' | 'production') ?? 'production';
|
||||
export const environment = {
|
||||
@@ -76,6 +77,7 @@ export const CHOKIDAR_USEPOLLING = process.env.CHOKIDAR_USEPOLLING === 'true';
|
||||
export const IS_DOCKER = process.env.IS_DOCKER === 'true';
|
||||
export const DEBUG = process.env.DEBUG === 'true';
|
||||
export const INTROSPECTION = process.env.INTROSPECTION === 'true';
|
||||
/** Determines the app-level & business logic environment (i.e. what data & infrastructure is used) */
|
||||
export const ENVIRONMENT = process.env.ENVIRONMENT
|
||||
? (process.env.ENVIRONMENT as 'production' | 'staging' | 'development')
|
||||
: 'production';
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { ApolloClient, HttpLink, InMemoryCache, split } from '@apollo/client/core/index.js';
|
||||
import { onError } from '@apollo/client/link/error/index.js';
|
||||
import { GraphQLWsLink } from '@apollo/client/link/subscriptions/index.js';
|
||||
import { getMainDefinition } from '@apollo/client/utilities/index.js';
|
||||
import { fetch } from 'cross-fetch';
|
||||
import { createClient } from 'graphql-ws';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
import { getInternalApiAddress } from '@app/consts.js';
|
||||
import { graphqlLogger } from '@app/core/log.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
|
||||
const getWebsocketWithHeaders = () => {
|
||||
return class WebsocketWithOriginHeader extends WebSocket {
|
||||
constructor(address, protocols) {
|
||||
super(address, protocols, {
|
||||
headers: {
|
||||
Origin: '/var/run/unraid-cli.sock',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const getApiApolloClient = ({ localApiKey }: { localApiKey: string }) => {
|
||||
const nginxPort = getters?.emhttp()?.nginx?.httpPort ?? 80;
|
||||
graphqlLogger.debug('Internal GraphQL URL: %s', getInternalApiAddress(true, nginxPort));
|
||||
const httpLink = new HttpLink({
|
||||
uri: getInternalApiAddress(true, nginxPort),
|
||||
fetch,
|
||||
headers: {
|
||||
Origin: '/var/run/unraid-cli.sock',
|
||||
'x-api-key': localApiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Create the subscription websocket link
|
||||
const wsLink = new GraphQLWsLink(
|
||||
createClient({
|
||||
webSocketImpl: getWebsocketWithHeaders(),
|
||||
url: getInternalApiAddress(false, nginxPort),
|
||||
connectionParams: () => {
|
||||
return { 'x-api-key': localApiKey };
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const splitLink = split(
|
||||
({ query }) => {
|
||||
const definition = getMainDefinition(query);
|
||||
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
|
||||
},
|
||||
wsLink,
|
||||
httpLink
|
||||
);
|
||||
|
||||
const errorLink = onError(({ networkError }) => {
|
||||
if (networkError) {
|
||||
graphqlLogger.warn('[GRAPHQL-CLIENT] NETWORK ERROR ENCOUNTERED %o', networkError);
|
||||
}
|
||||
});
|
||||
|
||||
return new ApolloClient({
|
||||
defaultOptions: {
|
||||
query: {
|
||||
fetchPolicy: 'no-cache',
|
||||
},
|
||||
mutate: {
|
||||
fetchPolicy: 'no-cache',
|
||||
},
|
||||
},
|
||||
cache: new InMemoryCache(),
|
||||
link: errorLink.concat(splitLink),
|
||||
});
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
export const GET_CLOUD_OBJECT = /* GraphQL */ `
|
||||
query getCloud {
|
||||
cloud {
|
||||
error
|
||||
apiKey {
|
||||
valid
|
||||
error
|
||||
}
|
||||
minigraphql {
|
||||
status
|
||||
timeout
|
||||
error
|
||||
}
|
||||
cloud {
|
||||
status
|
||||
error
|
||||
ip
|
||||
}
|
||||
allowedOrigins
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_SERVERS = /* GraphQL */ `
|
||||
query getServers {
|
||||
servers {
|
||||
name
|
||||
guid
|
||||
status
|
||||
owner {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,58 +0,0 @@
|
||||
/* eslint-disable */
|
||||
import * as types from './graphql.js';
|
||||
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
|
||||
/**
|
||||
* Map of all GraphQL operations in the project.
|
||||
*
|
||||
* This map has several performance disadvantages:
|
||||
* 1. It is not tree-shakeable, so it will include all operations in the project.
|
||||
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
|
||||
* 3. It does not support dead code elimination, so it will add unused operations.
|
||||
*
|
||||
* Therefore it is highly recommended to use the babel or swc plugin for production.
|
||||
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
|
||||
*/
|
||||
type Documents = {
|
||||
"\n mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {\n remoteGraphQLResponse(input: $input)\n }\n": typeof types.SendRemoteGraphQlResponseDocument,
|
||||
"\n fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {\n remoteGraphQLEventData: data {\n type\n body\n sha256\n }\n }\n": typeof types.RemoteGraphQlEventFragmentFragmentDoc,
|
||||
"\n subscription events {\n events {\n __typename\n ... on ClientConnectedEvent {\n connectedData: data {\n type\n version\n apiKey\n }\n connectedEvent: type\n }\n ... on ClientDisconnectedEvent {\n disconnectedData: data {\n type\n version\n apiKey\n }\n disconnectedEvent: type\n }\n ...RemoteGraphQLEventFragment\n }\n }\n": typeof types.EventsDocument,
|
||||
};
|
||||
const documents: Documents = {
|
||||
"\n mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {\n remoteGraphQLResponse(input: $input)\n }\n": types.SendRemoteGraphQlResponseDocument,
|
||||
"\n fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {\n remoteGraphQLEventData: data {\n type\n body\n sha256\n }\n }\n": types.RemoteGraphQlEventFragmentFragmentDoc,
|
||||
"\n subscription events {\n events {\n __typename\n ... on ClientConnectedEvent {\n connectedData: data {\n type\n version\n apiKey\n }\n connectedEvent: type\n }\n ... on ClientDisconnectedEvent {\n disconnectedData: data {\n type\n version\n apiKey\n }\n disconnectedEvent: type\n }\n ...RemoteGraphQLEventFragment\n }\n }\n": types.EventsDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
|
||||
* ```
|
||||
*
|
||||
* The query argument is unknown!
|
||||
* Please regenerate the types.
|
||||
*/
|
||||
export function graphql(source: string): unknown;
|
||||
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {\n remoteGraphQLResponse(input: $input)\n }\n"): (typeof documents)["\n mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {\n remoteGraphQLResponse(input: $input)\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {\n remoteGraphQLEventData: data {\n type\n body\n sha256\n }\n }\n"): (typeof documents)["\n fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {\n remoteGraphQLEventData: data {\n type\n body\n sha256\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n subscription events {\n events {\n __typename\n ... on ClientConnectedEvent {\n connectedData: data {\n type\n version\n apiKey\n }\n connectedEvent: type\n }\n ... on ClientDisconnectedEvent {\n disconnectedData: data {\n type\n version\n apiKey\n }\n disconnectedEvent: type\n }\n ...RemoteGraphQLEventFragment\n }\n }\n"): (typeof documents)["\n subscription events {\n events {\n __typename\n ... on ClientConnectedEvent {\n connectedData: data {\n type\n version\n apiKey\n }\n connectedEvent: type\n }\n ... on ClientDisconnectedEvent {\n disconnectedData: data {\n type\n version\n apiKey\n }\n disconnectedEvent: type\n }\n ...RemoteGraphQLEventFragment\n }\n }\n"];
|
||||
|
||||
export function graphql(source: string) {
|
||||
return (documents as any)[source] ?? {};
|
||||
}
|
||||
|
||||
export type DocumentType<TDocumentNode extends DocumentNode<any, any>> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;
|
||||
@@ -1,748 +0,0 @@
|
||||
/* eslint-disable */
|
||||
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
export type Maybe<T> = T | null;
|
||||
export type InputMaybe<T> = Maybe<T>;
|
||||
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
||||
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
|
||||
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
|
||||
export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
|
||||
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
|
||||
/** All built-in and custom scalars, mapped to their actual values */
|
||||
export type Scalars = {
|
||||
ID: { input: string; output: string; }
|
||||
String: { input: string; output: string; }
|
||||
Boolean: { input: boolean; output: boolean; }
|
||||
Int: { input: number; output: number; }
|
||||
Float: { input: number; output: number; }
|
||||
/** A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. */
|
||||
DateTime: { input: string; output: string; }
|
||||
/** A field whose value is a IPv4 address: https://en.wikipedia.org/wiki/IPv4. */
|
||||
IPv4: { input: any; output: any; }
|
||||
/** A field whose value is a IPv6 address: https://en.wikipedia.org/wiki/IPv6. */
|
||||
IPv6: { input: any; output: any; }
|
||||
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
|
||||
JSON: { input: Record<string, any>; output: Record<string, any>; }
|
||||
/** The `Long` scalar type represents 52-bit integers */
|
||||
Long: { input: number; output: number; }
|
||||
/** A field whose value is a valid TCP port within the range of 0 to 65535: https://en.wikipedia.org/wiki/Transmission_Control_Protocol#TCP_ports */
|
||||
Port: { input: number; output: number; }
|
||||
/** A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt. */
|
||||
URL: { input: URL; output: URL; }
|
||||
};
|
||||
|
||||
export type AccessUrl = {
|
||||
__typename?: 'AccessUrl';
|
||||
ipv4?: Maybe<Scalars['URL']['output']>;
|
||||
ipv6?: Maybe<Scalars['URL']['output']>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
type: UrlType;
|
||||
};
|
||||
|
||||
export type AccessUrlInput = {
|
||||
ipv4?: InputMaybe<Scalars['URL']['input']>;
|
||||
ipv6?: InputMaybe<Scalars['URL']['input']>;
|
||||
name?: InputMaybe<Scalars['String']['input']>;
|
||||
type: UrlType;
|
||||
};
|
||||
|
||||
export type ArrayCapacity = {
|
||||
__typename?: 'ArrayCapacity';
|
||||
bytes?: Maybe<ArrayCapacityBytes>;
|
||||
};
|
||||
|
||||
export type ArrayCapacityBytes = {
|
||||
__typename?: 'ArrayCapacityBytes';
|
||||
free?: Maybe<Scalars['Long']['output']>;
|
||||
total?: Maybe<Scalars['Long']['output']>;
|
||||
used?: Maybe<Scalars['Long']['output']>;
|
||||
};
|
||||
|
||||
export type ArrayCapacityBytesInput = {
|
||||
free?: InputMaybe<Scalars['Long']['input']>;
|
||||
total?: InputMaybe<Scalars['Long']['input']>;
|
||||
used?: InputMaybe<Scalars['Long']['input']>;
|
||||
};
|
||||
|
||||
export type ArrayCapacityInput = {
|
||||
bytes?: InputMaybe<ArrayCapacityBytesInput>;
|
||||
};
|
||||
|
||||
export type ClientConnectedEvent = {
|
||||
__typename?: 'ClientConnectedEvent';
|
||||
data: ClientConnectionEventData;
|
||||
type: EventType;
|
||||
};
|
||||
|
||||
export type ClientConnectionEventData = {
|
||||
__typename?: 'ClientConnectionEventData';
|
||||
apiKey: Scalars['String']['output'];
|
||||
type: ClientType;
|
||||
version: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type ClientDisconnectedEvent = {
|
||||
__typename?: 'ClientDisconnectedEvent';
|
||||
data: ClientConnectionEventData;
|
||||
type: EventType;
|
||||
};
|
||||
|
||||
export type ClientPingEvent = {
|
||||
__typename?: 'ClientPingEvent';
|
||||
data: PingEventData;
|
||||
type: EventType;
|
||||
};
|
||||
|
||||
export enum ClientType {
|
||||
API = 'API',
|
||||
DASHBOARD = 'DASHBOARD'
|
||||
}
|
||||
|
||||
export type Config = {
|
||||
__typename?: 'Config';
|
||||
error?: Maybe<ConfigErrorState>;
|
||||
valid?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export enum ConfigErrorState {
|
||||
INVALID = 'INVALID',
|
||||
NO_KEY_SERVER = 'NO_KEY_SERVER',
|
||||
UNKNOWN_ERROR = 'UNKNOWN_ERROR',
|
||||
WITHDRAWN = 'WITHDRAWN'
|
||||
}
|
||||
|
||||
export type Dashboard = {
|
||||
__typename?: 'Dashboard';
|
||||
apps?: Maybe<DashboardApps>;
|
||||
array?: Maybe<DashboardArray>;
|
||||
config?: Maybe<DashboardConfig>;
|
||||
display?: Maybe<DashboardDisplay>;
|
||||
id: Scalars['ID']['output'];
|
||||
lastPublish?: Maybe<Scalars['DateTime']['output']>;
|
||||
network?: Maybe<Network>;
|
||||
online?: Maybe<Scalars['Boolean']['output']>;
|
||||
os?: Maybe<DashboardOs>;
|
||||
services?: Maybe<Array<Maybe<DashboardService>>>;
|
||||
twoFactor?: Maybe<DashboardTwoFactor>;
|
||||
vars?: Maybe<DashboardVars>;
|
||||
versions?: Maybe<DashboardVersions>;
|
||||
vms?: Maybe<DashboardVms>;
|
||||
};
|
||||
|
||||
export type DashboardApps = {
|
||||
__typename?: 'DashboardApps';
|
||||
installed?: Maybe<Scalars['Int']['output']>;
|
||||
started?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardAppsInput = {
|
||||
installed: Scalars['Int']['input'];
|
||||
started: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
export type DashboardArray = {
|
||||
__typename?: 'DashboardArray';
|
||||
/** Current array capacity */
|
||||
capacity?: Maybe<ArrayCapacity>;
|
||||
/** Current array state */
|
||||
state?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardArrayInput = {
|
||||
/** Current array capacity */
|
||||
capacity: ArrayCapacityInput;
|
||||
/** Current array state */
|
||||
state: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type DashboardCase = {
|
||||
__typename?: 'DashboardCase';
|
||||
base64?: Maybe<Scalars['String']['output']>;
|
||||
error?: Maybe<Scalars['String']['output']>;
|
||||
icon?: Maybe<Scalars['String']['output']>;
|
||||
url?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardCaseInput = {
|
||||
base64: Scalars['String']['input'];
|
||||
error?: InputMaybe<Scalars['String']['input']>;
|
||||
icon: Scalars['String']['input'];
|
||||
url: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type DashboardConfig = {
|
||||
__typename?: 'DashboardConfig';
|
||||
error?: Maybe<Scalars['String']['output']>;
|
||||
valid?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardConfigInput = {
|
||||
error?: InputMaybe<Scalars['String']['input']>;
|
||||
valid: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export type DashboardDisplay = {
|
||||
__typename?: 'DashboardDisplay';
|
||||
case?: Maybe<DashboardCase>;
|
||||
};
|
||||
|
||||
export type DashboardDisplayInput = {
|
||||
case: DashboardCaseInput;
|
||||
};
|
||||
|
||||
export type DashboardInput = {
|
||||
apps: DashboardAppsInput;
|
||||
array: DashboardArrayInput;
|
||||
config: DashboardConfigInput;
|
||||
display: DashboardDisplayInput;
|
||||
os: DashboardOsInput;
|
||||
services: Array<DashboardServiceInput>;
|
||||
twoFactor?: InputMaybe<DashboardTwoFactorInput>;
|
||||
vars: DashboardVarsInput;
|
||||
versions: DashboardVersionsInput;
|
||||
vms: DashboardVmsInput;
|
||||
};
|
||||
|
||||
export type DashboardOs = {
|
||||
__typename?: 'DashboardOs';
|
||||
hostname?: Maybe<Scalars['String']['output']>;
|
||||
uptime?: Maybe<Scalars['DateTime']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardOsInput = {
|
||||
hostname: Scalars['String']['input'];
|
||||
uptime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
export type DashboardService = {
|
||||
__typename?: 'DashboardService';
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
online?: Maybe<Scalars['Boolean']['output']>;
|
||||
uptime?: Maybe<DashboardServiceUptime>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardServiceInput = {
|
||||
name: Scalars['String']['input'];
|
||||
online: Scalars['Boolean']['input'];
|
||||
uptime?: InputMaybe<DashboardServiceUptimeInput>;
|
||||
version: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type DashboardServiceUptime = {
|
||||
__typename?: 'DashboardServiceUptime';
|
||||
timestamp?: Maybe<Scalars['DateTime']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardServiceUptimeInput = {
|
||||
timestamp: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
export type DashboardTwoFactor = {
|
||||
__typename?: 'DashboardTwoFactor';
|
||||
local?: Maybe<DashboardTwoFactorLocal>;
|
||||
remote?: Maybe<DashboardTwoFactorRemote>;
|
||||
};
|
||||
|
||||
export type DashboardTwoFactorInput = {
|
||||
local: DashboardTwoFactorLocalInput;
|
||||
remote: DashboardTwoFactorRemoteInput;
|
||||
};
|
||||
|
||||
export type DashboardTwoFactorLocal = {
|
||||
__typename?: 'DashboardTwoFactorLocal';
|
||||
enabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardTwoFactorLocalInput = {
|
||||
enabled: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export type DashboardTwoFactorRemote = {
|
||||
__typename?: 'DashboardTwoFactorRemote';
|
||||
enabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardTwoFactorRemoteInput = {
|
||||
enabled: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export type DashboardVars = {
|
||||
__typename?: 'DashboardVars';
|
||||
flashGuid?: Maybe<Scalars['String']['output']>;
|
||||
regState?: Maybe<Scalars['String']['output']>;
|
||||
regTy?: Maybe<Scalars['String']['output']>;
|
||||
serverDescription?: Maybe<Scalars['String']['output']>;
|
||||
serverName?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardVarsInput = {
|
||||
flashGuid: Scalars['String']['input'];
|
||||
regState: Scalars['String']['input'];
|
||||
regTy: Scalars['String']['input'];
|
||||
/** Server description */
|
||||
serverDescription?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Name of the server */
|
||||
serverName?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type DashboardVersions = {
|
||||
__typename?: 'DashboardVersions';
|
||||
unraid?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardVersionsInput = {
|
||||
unraid: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type DashboardVms = {
|
||||
__typename?: 'DashboardVms';
|
||||
installed?: Maybe<Scalars['Int']['output']>;
|
||||
started?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type DashboardVmsInput = {
|
||||
installed: Scalars['Int']['input'];
|
||||
started: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
export type Event = ClientConnectedEvent | ClientDisconnectedEvent | ClientPingEvent | RemoteAccessEvent | RemoteGraphQlEvent | UpdateEvent;
|
||||
|
||||
export enum EventType {
|
||||
CLIENT_CONNECTED_EVENT = 'CLIENT_CONNECTED_EVENT',
|
||||
CLIENT_DISCONNECTED_EVENT = 'CLIENT_DISCONNECTED_EVENT',
|
||||
CLIENT_PING_EVENT = 'CLIENT_PING_EVENT',
|
||||
REMOTE_ACCESS_EVENT = 'REMOTE_ACCESS_EVENT',
|
||||
REMOTE_GRAPHQL_EVENT = 'REMOTE_GRAPHQL_EVENT',
|
||||
UPDATE_EVENT = 'UPDATE_EVENT'
|
||||
}
|
||||
|
||||
export type FullServerDetails = {
|
||||
__typename?: 'FullServerDetails';
|
||||
apiConnectedCount?: Maybe<Scalars['Int']['output']>;
|
||||
apiVersion?: Maybe<Scalars['String']['output']>;
|
||||
connectionTimestamp?: Maybe<Scalars['String']['output']>;
|
||||
dashboard?: Maybe<Dashboard>;
|
||||
lastPublish?: Maybe<Scalars['String']['output']>;
|
||||
network?: Maybe<Network>;
|
||||
online?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export enum Importance {
|
||||
ALERT = 'ALERT',
|
||||
INFO = 'INFO',
|
||||
WARNING = 'WARNING'
|
||||
}
|
||||
|
||||
export type KsServerDetails = {
|
||||
__typename?: 'KsServerDetails';
|
||||
accessLabel: Scalars['String']['output'];
|
||||
accessUrl: Scalars['String']['output'];
|
||||
apiKey?: Maybe<Scalars['String']['output']>;
|
||||
description: Scalars['String']['output'];
|
||||
dnsHash: Scalars['String']['output'];
|
||||
flashBackupDate?: Maybe<Scalars['Int']['output']>;
|
||||
flashBackupUrl: Scalars['String']['output'];
|
||||
flashProduct: Scalars['String']['output'];
|
||||
flashVendor: Scalars['String']['output'];
|
||||
guid: Scalars['String']['output'];
|
||||
ipsId?: Maybe<Scalars['String']['output']>;
|
||||
keyType?: Maybe<Scalars['String']['output']>;
|
||||
licenseKey: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
plgVersion?: Maybe<Scalars['String']['output']>;
|
||||
signedIn: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type LegacyService = {
|
||||
__typename?: 'LegacyService';
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
online?: Maybe<Scalars['Boolean']['output']>;
|
||||
uptime?: Maybe<Scalars['Int']['output']>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Mutation = {
|
||||
__typename?: 'Mutation';
|
||||
remoteGraphQLResponse: Scalars['Boolean']['output'];
|
||||
remoteMutation: Scalars['String']['output'];
|
||||
remoteSession?: Maybe<Scalars['Boolean']['output']>;
|
||||
sendNotification?: Maybe<Notification>;
|
||||
sendPing?: Maybe<Scalars['Boolean']['output']>;
|
||||
updateDashboard: Dashboard;
|
||||
updateNetwork: Network;
|
||||
};
|
||||
|
||||
|
||||
export type MutationRemoteGraphQlResponseArgs = {
|
||||
input: RemoteGraphQlServerInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationRemoteMutationArgs = {
|
||||
input: RemoteGraphQlClientInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationRemoteSessionArgs = {
|
||||
remoteAccess: RemoteAccessInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationSendNotificationArgs = {
|
||||
notification: NotificationInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateDashboardArgs = {
|
||||
data: DashboardInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationUpdateNetworkArgs = {
|
||||
data: NetworkInput;
|
||||
};
|
||||
|
||||
export type Network = {
|
||||
__typename?: 'Network';
|
||||
accessUrls?: Maybe<Array<AccessUrl>>;
|
||||
};
|
||||
|
||||
export type NetworkInput = {
|
||||
accessUrls: Array<AccessUrlInput>;
|
||||
};
|
||||
|
||||
export type Notification = {
|
||||
__typename?: 'Notification';
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
importance?: Maybe<Importance>;
|
||||
link?: Maybe<Scalars['String']['output']>;
|
||||
status: NotificationStatus;
|
||||
subject?: Maybe<Scalars['String']['output']>;
|
||||
title?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type NotificationInput = {
|
||||
description?: InputMaybe<Scalars['String']['input']>;
|
||||
importance: Importance;
|
||||
link?: InputMaybe<Scalars['String']['input']>;
|
||||
subject?: InputMaybe<Scalars['String']['input']>;
|
||||
title?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export enum NotificationStatus {
|
||||
FAILED_TO_SEND = 'FAILED_TO_SEND',
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
PENDING = 'PENDING',
|
||||
SENT = 'SENT'
|
||||
}
|
||||
|
||||
export type PingEvent = {
|
||||
__typename?: 'PingEvent';
|
||||
data?: Maybe<Scalars['String']['output']>;
|
||||
type: EventType;
|
||||
};
|
||||
|
||||
export type PingEventData = {
|
||||
__typename?: 'PingEventData';
|
||||
source: PingEventSource;
|
||||
};
|
||||
|
||||
export enum PingEventSource {
|
||||
API = 'API',
|
||||
MOTHERSHIP = 'MOTHERSHIP'
|
||||
}
|
||||
|
||||
export type ProfileModel = {
|
||||
__typename?: 'ProfileModel';
|
||||
avatar?: Maybe<Scalars['String']['output']>;
|
||||
cognito_id?: Maybe<Scalars['String']['output']>;
|
||||
url?: Maybe<Scalars['String']['output']>;
|
||||
userId?: Maybe<Scalars['ID']['output']>;
|
||||
username?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
apiVersion?: Maybe<Scalars['String']['output']>;
|
||||
dashboard?: Maybe<Dashboard>;
|
||||
ksServers: Array<KsServerDetails>;
|
||||
online?: Maybe<Scalars['Boolean']['output']>;
|
||||
remoteQuery: Scalars['String']['output'];
|
||||
serverStatus: ServerStatusResponse;
|
||||
servers: Array<Maybe<Server>>;
|
||||
status?: Maybe<ServerStatus>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryDashboardArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryRemoteQueryArgs = {
|
||||
input: RemoteGraphQlClientInput;
|
||||
};
|
||||
|
||||
|
||||
export type QueryServerStatusArgs = {
|
||||
apiKey: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export enum RegistrationState {
|
||||
/** Basic */
|
||||
BASIC = 'BASIC',
|
||||
/** BLACKLISTED */
|
||||
EBLACKLISTED = 'EBLACKLISTED',
|
||||
/** BLACKLISTED */
|
||||
EBLACKLISTED1 = 'EBLACKLISTED1',
|
||||
/** BLACKLISTED */
|
||||
EBLACKLISTED2 = 'EBLACKLISTED2',
|
||||
/** Trial Expired */
|
||||
EEXPIRED = 'EEXPIRED',
|
||||
/** GUID Error */
|
||||
EGUID = 'EGUID',
|
||||
/** Multiple License Keys Present */
|
||||
EGUID1 = 'EGUID1',
|
||||
/** Trial Requires Internet Connection */
|
||||
ENOCONN = 'ENOCONN',
|
||||
/** No Flash */
|
||||
ENOFLASH = 'ENOFLASH',
|
||||
ENOFLASH1 = 'ENOFLASH1',
|
||||
ENOFLASH2 = 'ENOFLASH2',
|
||||
ENOFLASH3 = 'ENOFLASH3',
|
||||
ENOFLASH4 = 'ENOFLASH4',
|
||||
ENOFLASH5 = 'ENOFLASH5',
|
||||
ENOFLASH6 = 'ENOFLASH6',
|
||||
ENOFLASH7 = 'ENOFLASH7',
|
||||
/** No Keyfile */
|
||||
ENOKEYFILE = 'ENOKEYFILE',
|
||||
/** No Keyfile */
|
||||
ENOKEYFILE1 = 'ENOKEYFILE1',
|
||||
/** Missing key file */
|
||||
ENOKEYFILE2 = 'ENOKEYFILE2',
|
||||
/** Invalid installation */
|
||||
ETRIAL = 'ETRIAL',
|
||||
/** Plus */
|
||||
PLUS = 'PLUS',
|
||||
/** Pro */
|
||||
PRO = 'PRO',
|
||||
/** Trial */
|
||||
TRIAL = 'TRIAL'
|
||||
}
|
||||
|
||||
export type RemoteAccessEvent = {
|
||||
__typename?: 'RemoteAccessEvent';
|
||||
data: RemoteAccessEventData;
|
||||
type: EventType;
|
||||
};
|
||||
|
||||
/** Defines whether remote access event is the initiation (from connect) or the response (from the server) */
|
||||
export enum RemoteAccessEventActionType {
|
||||
ACK = 'ACK',
|
||||
END = 'END',
|
||||
INIT = 'INIT',
|
||||
PING = 'PING'
|
||||
}
|
||||
|
||||
export type RemoteAccessEventData = {
|
||||
__typename?: 'RemoteAccessEventData';
|
||||
apiKey: Scalars['String']['output'];
|
||||
type: RemoteAccessEventActionType;
|
||||
url?: Maybe<AccessUrl>;
|
||||
};
|
||||
|
||||
export type RemoteAccessInput = {
|
||||
apiKey: Scalars['String']['input'];
|
||||
type: RemoteAccessEventActionType;
|
||||
url?: InputMaybe<AccessUrlInput>;
|
||||
};
|
||||
|
||||
export type RemoteGraphQlClientInput = {
|
||||
apiKey: Scalars['String']['input'];
|
||||
body: Scalars['String']['input'];
|
||||
/** Time in milliseconds to wait for a response from the remote server (defaults to 15000) */
|
||||
timeout?: InputMaybe<Scalars['Int']['input']>;
|
||||
/** How long mothership should cache the result of this query in seconds, only valid on queries */
|
||||
ttl?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type RemoteGraphQlEvent = {
|
||||
__typename?: 'RemoteGraphQLEvent';
|
||||
data: RemoteGraphQlEventData;
|
||||
type: EventType;
|
||||
};
|
||||
|
||||
export type RemoteGraphQlEventData = {
|
||||
__typename?: 'RemoteGraphQLEventData';
|
||||
/** Contains mutation / subscription / query data in the form of body: JSON, variables: JSON */
|
||||
body: Scalars['String']['output'];
|
||||
/** sha256 hash of the body */
|
||||
sha256: Scalars['String']['output'];
|
||||
type: RemoteGraphQlEventType;
|
||||
};
|
||||
|
||||
export enum RemoteGraphQlEventType {
|
||||
REMOTE_MUTATION_EVENT = 'REMOTE_MUTATION_EVENT',
|
||||
REMOTE_QUERY_EVENT = 'REMOTE_QUERY_EVENT',
|
||||
REMOTE_SUBSCRIPTION_EVENT = 'REMOTE_SUBSCRIPTION_EVENT',
|
||||
REMOTE_SUBSCRIPTION_EVENT_PING = 'REMOTE_SUBSCRIPTION_EVENT_PING'
|
||||
}
|
||||
|
||||
export type RemoteGraphQlServerInput = {
|
||||
/** Body - contains an object containing data: (GQL response data) or errors: (GQL Errors) */
|
||||
body: Scalars['String']['input'];
|
||||
/** sha256 hash of the body */
|
||||
sha256: Scalars['String']['input'];
|
||||
type: RemoteGraphQlEventType;
|
||||
};
|
||||
|
||||
export type Server = {
|
||||
__typename?: 'Server';
|
||||
apikey?: Maybe<Scalars['String']['output']>;
|
||||
guid?: Maybe<Scalars['String']['output']>;
|
||||
lanip?: Maybe<Scalars['String']['output']>;
|
||||
localurl?: Maybe<Scalars['String']['output']>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
owner?: Maybe<ProfileModel>;
|
||||
remoteurl?: Maybe<Scalars['String']['output']>;
|
||||
status?: Maybe<ServerStatus>;
|
||||
wanip?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Defines server fields that have a TTL on them, for example last ping */
|
||||
export type ServerFieldsWithTtl = {
|
||||
__typename?: 'ServerFieldsWithTtl';
|
||||
lastPing?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type ServerModel = {
|
||||
apikey: Scalars['String']['output'];
|
||||
guid: Scalars['String']['output'];
|
||||
lanip: Scalars['String']['output'];
|
||||
localurl: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
remoteurl: Scalars['String']['output'];
|
||||
wanip: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export enum ServerStatus {
|
||||
NEVER_CONNECTED = 'never_connected',
|
||||
OFFLINE = 'offline',
|
||||
ONLINE = 'online'
|
||||
}
|
||||
|
||||
export type ServerStatusResponse = {
|
||||
__typename?: 'ServerStatusResponse';
|
||||
id: Scalars['ID']['output'];
|
||||
lastPublish?: Maybe<Scalars['String']['output']>;
|
||||
online: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type Service = {
|
||||
__typename?: 'Service';
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
online?: Maybe<Scalars['Boolean']['output']>;
|
||||
uptime?: Maybe<Uptime>;
|
||||
version?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type Subscription = {
|
||||
__typename?: 'Subscription';
|
||||
events?: Maybe<Array<Event>>;
|
||||
remoteSubscription: Scalars['String']['output'];
|
||||
servers: Array<Server>;
|
||||
};
|
||||
|
||||
|
||||
export type SubscriptionRemoteSubscriptionArgs = {
|
||||
input: RemoteGraphQlClientInput;
|
||||
};
|
||||
|
||||
export type TwoFactorLocal = {
|
||||
__typename?: 'TwoFactorLocal';
|
||||
enabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type TwoFactorRemote = {
|
||||
__typename?: 'TwoFactorRemote';
|
||||
enabled?: Maybe<Scalars['Boolean']['output']>;
|
||||
};
|
||||
|
||||
export type TwoFactorWithToken = {
|
||||
__typename?: 'TwoFactorWithToken';
|
||||
local?: Maybe<TwoFactorLocal>;
|
||||
remote?: Maybe<TwoFactorRemote>;
|
||||
token?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type TwoFactorWithoutToken = {
|
||||
__typename?: 'TwoFactorWithoutToken';
|
||||
local?: Maybe<TwoFactorLocal>;
|
||||
remote?: Maybe<TwoFactorRemote>;
|
||||
};
|
||||
|
||||
export enum UrlType {
|
||||
DEFAULT = 'DEFAULT',
|
||||
LAN = 'LAN',
|
||||
MDNS = 'MDNS',
|
||||
WAN = 'WAN',
|
||||
WIREGUARD = 'WIREGUARD'
|
||||
}
|
||||
|
||||
export type UpdateEvent = {
|
||||
__typename?: 'UpdateEvent';
|
||||
data: UpdateEventData;
|
||||
type: EventType;
|
||||
};
|
||||
|
||||
export type UpdateEventData = {
|
||||
__typename?: 'UpdateEventData';
|
||||
apiKey: Scalars['String']['output'];
|
||||
type: UpdateType;
|
||||
};
|
||||
|
||||
export enum UpdateType {
|
||||
DASHBOARD = 'DASHBOARD',
|
||||
NETWORK = 'NETWORK'
|
||||
}
|
||||
|
||||
export type Uptime = {
|
||||
__typename?: 'Uptime';
|
||||
timestamp?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type UserProfileModelWithServers = {
|
||||
__typename?: 'UserProfileModelWithServers';
|
||||
profile: ProfileModel;
|
||||
servers: Array<Server>;
|
||||
};
|
||||
|
||||
export type Vars = {
|
||||
__typename?: 'Vars';
|
||||
expireTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
flashGuid?: Maybe<Scalars['String']['output']>;
|
||||
regState?: Maybe<RegistrationState>;
|
||||
regTm2?: Maybe<Scalars['String']['output']>;
|
||||
regTy?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type SendRemoteGraphQlResponseMutationVariables = Exact<{
|
||||
input: RemoteGraphQlServerInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type SendRemoteGraphQlResponseMutation = { __typename?: 'Mutation', remoteGraphQLResponse: boolean };
|
||||
|
||||
export type RemoteGraphQlEventFragmentFragment = { __typename?: 'RemoteGraphQLEvent', remoteGraphQLEventData: { __typename?: 'RemoteGraphQLEventData', type: RemoteGraphQlEventType, body: string, sha256: string } } & { ' $fragmentName'?: 'RemoteGraphQlEventFragmentFragment' };
|
||||
|
||||
export type EventsSubscriptionVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type EventsSubscription = { __typename?: 'Subscription', events?: Array<{ __typename: 'ClientConnectedEvent', connectedEvent: EventType, connectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } } | { __typename: 'ClientDisconnectedEvent', disconnectedEvent: EventType, disconnectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } } | { __typename: 'ClientPingEvent' } | { __typename: 'RemoteAccessEvent' } | (
|
||||
{ __typename: 'RemoteGraphQLEvent' }
|
||||
& { ' $fragmentRefs'?: { 'RemoteGraphQlEventFragmentFragment': RemoteGraphQlEventFragmentFragment } }
|
||||
) | { __typename: 'UpdateEvent' }> | null };
|
||||
|
||||
export const RemoteGraphQlEventFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RemoteGraphQLEventFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteGraphQLEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"remoteGraphQLEventData"},"name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"sha256"}}]}}]}}]} as unknown as DocumentNode<RemoteGraphQlEventFragmentFragment, unknown>;
|
||||
export const SendRemoteGraphQlResponseDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"sendRemoteGraphQLResponse"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteGraphQLServerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"remoteGraphQLResponse"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<SendRemoteGraphQlResponseMutation, SendRemoteGraphQlResponseMutationVariables>;
|
||||
export const EventsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"events"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"__typename"}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ClientConnectedEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"connectedData"},"name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"connectedEvent"},"name":{"kind":"Name","value":"type"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ClientDisconnectedEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"disconnectedData"},"name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"disconnectedEvent"},"name":{"kind":"Name","value":"type"}}]}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"RemoteGraphQLEventFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RemoteGraphQLEventFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteGraphQLEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"remoteGraphQLEventData"},"name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"sha256"}}]}}]}}]} as unknown as DocumentNode<EventsSubscription, EventsSubscriptionVariables>;
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./fragment-masking.js";
|
||||
export * from "./gql.js";
|
||||
@@ -1,216 +0,0 @@
|
||||
/* eslint-disable */
|
||||
import { z } from 'zod'
|
||||
import { AccessUrlInput, ArrayCapacityBytesInput, ArrayCapacityInput, ClientType, ConfigErrorState, DashboardAppsInput, DashboardArrayInput, DashboardCaseInput, DashboardConfigInput, DashboardDisplayInput, DashboardInput, DashboardOsInput, DashboardServiceInput, DashboardServiceUptimeInput, DashboardTwoFactorInput, DashboardTwoFactorLocalInput, DashboardTwoFactorRemoteInput, DashboardVarsInput, DashboardVersionsInput, DashboardVmsInput, EventType, Importance, NetworkInput, NotificationInput, NotificationStatus, PingEventSource, RegistrationState, RemoteAccessEventActionType, RemoteAccessInput, RemoteGraphQlClientInput, RemoteGraphQlEventType, RemoteGraphQlServerInput, ServerStatus, UrlType, UpdateType } from '@app/graphql/generated/client/graphql.js'
|
||||
|
||||
type Properties<T> = Required<{
|
||||
[K in keyof T]: z.ZodType<T[K], any, T[K]>;
|
||||
}>;
|
||||
|
||||
type definedNonNullAny = {};
|
||||
|
||||
export const isDefinedNonNullAny = (v: any): v is definedNonNullAny => v !== undefined && v !== null;
|
||||
|
||||
export const definedNonNullAnySchema = z.any().refine((v) => isDefinedNonNullAny(v));
|
||||
|
||||
export const ClientTypeSchema = z.nativeEnum(ClientType);
|
||||
|
||||
export const ConfigErrorStateSchema = z.nativeEnum(ConfigErrorState);
|
||||
|
||||
export const EventTypeSchema = z.nativeEnum(EventType);
|
||||
|
||||
export const ImportanceSchema = z.nativeEnum(Importance);
|
||||
|
||||
export const NotificationStatusSchema = z.nativeEnum(NotificationStatus);
|
||||
|
||||
export const PingEventSourceSchema = z.nativeEnum(PingEventSource);
|
||||
|
||||
export const RegistrationStateSchema = z.nativeEnum(RegistrationState);
|
||||
|
||||
export const RemoteAccessEventActionTypeSchema = z.nativeEnum(RemoteAccessEventActionType);
|
||||
|
||||
export const RemoteGraphQlEventTypeSchema = z.nativeEnum(RemoteGraphQlEventType);
|
||||
|
||||
export const ServerStatusSchema = z.nativeEnum(ServerStatus);
|
||||
|
||||
export const UrlTypeSchema = z.nativeEnum(UrlType);
|
||||
|
||||
export const UpdateTypeSchema = z.nativeEnum(UpdateType);
|
||||
|
||||
export function AccessUrlInputSchema(): z.ZodObject<Properties<AccessUrlInput>> {
|
||||
return z.object({
|
||||
ipv4: z.instanceof(URL).nullish(),
|
||||
ipv6: z.instanceof(URL).nullish(),
|
||||
name: z.string().nullish(),
|
||||
type: UrlTypeSchema
|
||||
})
|
||||
}
|
||||
|
||||
export function ArrayCapacityBytesInputSchema(): z.ZodObject<Properties<ArrayCapacityBytesInput>> {
|
||||
return z.object({
|
||||
free: z.number().nullish(),
|
||||
total: z.number().nullish(),
|
||||
used: z.number().nullish()
|
||||
})
|
||||
}
|
||||
|
||||
export function ArrayCapacityInputSchema(): z.ZodObject<Properties<ArrayCapacityInput>> {
|
||||
return z.object({
|
||||
bytes: z.lazy(() => ArrayCapacityBytesInputSchema().nullish())
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardAppsInputSchema(): z.ZodObject<Properties<DashboardAppsInput>> {
|
||||
return z.object({
|
||||
installed: z.number(),
|
||||
started: z.number()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardArrayInputSchema(): z.ZodObject<Properties<DashboardArrayInput>> {
|
||||
return z.object({
|
||||
capacity: z.lazy(() => ArrayCapacityInputSchema()),
|
||||
state: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardCaseInputSchema(): z.ZodObject<Properties<DashboardCaseInput>> {
|
||||
return z.object({
|
||||
base64: z.string(),
|
||||
error: z.string().nullish(),
|
||||
icon: z.string(),
|
||||
url: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardConfigInputSchema(): z.ZodObject<Properties<DashboardConfigInput>> {
|
||||
return z.object({
|
||||
error: z.string().nullish(),
|
||||
valid: z.boolean()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardDisplayInputSchema(): z.ZodObject<Properties<DashboardDisplayInput>> {
|
||||
return z.object({
|
||||
case: z.lazy(() => DashboardCaseInputSchema())
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardInputSchema(): z.ZodObject<Properties<DashboardInput>> {
|
||||
return z.object({
|
||||
apps: z.lazy(() => DashboardAppsInputSchema()),
|
||||
array: z.lazy(() => DashboardArrayInputSchema()),
|
||||
config: z.lazy(() => DashboardConfigInputSchema()),
|
||||
display: z.lazy(() => DashboardDisplayInputSchema()),
|
||||
os: z.lazy(() => DashboardOsInputSchema()),
|
||||
services: z.array(z.lazy(() => DashboardServiceInputSchema())),
|
||||
twoFactor: z.lazy(() => DashboardTwoFactorInputSchema().nullish()),
|
||||
vars: z.lazy(() => DashboardVarsInputSchema()),
|
||||
versions: z.lazy(() => DashboardVersionsInputSchema()),
|
||||
vms: z.lazy(() => DashboardVmsInputSchema())
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardOsInputSchema(): z.ZodObject<Properties<DashboardOsInput>> {
|
||||
return z.object({
|
||||
hostname: z.string(),
|
||||
uptime: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardServiceInputSchema(): z.ZodObject<Properties<DashboardServiceInput>> {
|
||||
return z.object({
|
||||
name: z.string(),
|
||||
online: z.boolean(),
|
||||
uptime: z.lazy(() => DashboardServiceUptimeInputSchema().nullish()),
|
||||
version: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardServiceUptimeInputSchema(): z.ZodObject<Properties<DashboardServiceUptimeInput>> {
|
||||
return z.object({
|
||||
timestamp: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardTwoFactorInputSchema(): z.ZodObject<Properties<DashboardTwoFactorInput>> {
|
||||
return z.object({
|
||||
local: z.lazy(() => DashboardTwoFactorLocalInputSchema()),
|
||||
remote: z.lazy(() => DashboardTwoFactorRemoteInputSchema())
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardTwoFactorLocalInputSchema(): z.ZodObject<Properties<DashboardTwoFactorLocalInput>> {
|
||||
return z.object({
|
||||
enabled: z.boolean()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardTwoFactorRemoteInputSchema(): z.ZodObject<Properties<DashboardTwoFactorRemoteInput>> {
|
||||
return z.object({
|
||||
enabled: z.boolean()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardVarsInputSchema(): z.ZodObject<Properties<DashboardVarsInput>> {
|
||||
return z.object({
|
||||
flashGuid: z.string(),
|
||||
regState: z.string(),
|
||||
regTy: z.string(),
|
||||
serverDescription: z.string().nullish(),
|
||||
serverName: z.string().nullish()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardVersionsInputSchema(): z.ZodObject<Properties<DashboardVersionsInput>> {
|
||||
return z.object({
|
||||
unraid: z.string()
|
||||
})
|
||||
}
|
||||
|
||||
export function DashboardVmsInputSchema(): z.ZodObject<Properties<DashboardVmsInput>> {
|
||||
return z.object({
|
||||
installed: z.number(),
|
||||
started: z.number()
|
||||
})
|
||||
}
|
||||
|
||||
export function NetworkInputSchema(): z.ZodObject<Properties<NetworkInput>> {
|
||||
return z.object({
|
||||
accessUrls: z.array(z.lazy(() => AccessUrlInputSchema()))
|
||||
})
|
||||
}
|
||||
|
||||
export function NotificationInputSchema(): z.ZodObject<Properties<NotificationInput>> {
|
||||
return z.object({
|
||||
description: z.string().nullish(),
|
||||
importance: ImportanceSchema,
|
||||
link: z.string().nullish(),
|
||||
subject: z.string().nullish(),
|
||||
title: z.string().nullish()
|
||||
})
|
||||
}
|
||||
|
||||
export function RemoteAccessInputSchema(): z.ZodObject<Properties<RemoteAccessInput>> {
|
||||
return z.object({
|
||||
apiKey: z.string(),
|
||||
type: RemoteAccessEventActionTypeSchema,
|
||||
url: z.lazy(() => AccessUrlInputSchema().nullish())
|
||||
})
|
||||
}
|
||||
|
||||
export function RemoteGraphQlClientInputSchema(): z.ZodObject<Properties<RemoteGraphQlClientInput>> {
|
||||
return z.object({
|
||||
apiKey: z.string(),
|
||||
body: z.string(),
|
||||
timeout: z.number().nullish(),
|
||||
ttl: z.number().nullish()
|
||||
})
|
||||
}
|
||||
|
||||
export function RemoteGraphQlServerInputSchema(): z.ZodObject<Properties<RemoteGraphQlServerInput>> {
|
||||
return z.object({
|
||||
body: z.string(),
|
||||
sha256: z.string(),
|
||||
type: RemoteGraphQlEventTypeSchema
|
||||
})
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { FatalAppError } from '@app/core/errors/fatal-error.js';
|
||||
import { modules } from '@app/core/index.js';
|
||||
|
||||
export const getCoreModule = (moduleName: string) => {
|
||||
if (!Object.keys(modules).includes(moduleName)) {
|
||||
throw new FatalAppError(`"${moduleName}" is not a valid core module.`);
|
||||
}
|
||||
|
||||
return modules[moduleName];
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { graphql } from '@app/graphql/generated/client/gql.js';
|
||||
|
||||
export const SEND_REMOTE_QUERY_RESPONSE = graphql(/* GraphQL */ `
|
||||
mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {
|
||||
remoteGraphQLResponse(input: $input)
|
||||
}
|
||||
`);
|
||||
@@ -1,36 +0,0 @@
|
||||
import { graphql } from '@app/graphql/generated/client/gql.js';
|
||||
|
||||
export const RemoteGraphQL_Fragment = graphql(/* GraphQL */ `
|
||||
fragment RemoteGraphQLEventFragment on RemoteGraphQLEvent {
|
||||
remoteGraphQLEventData: data {
|
||||
type
|
||||
body
|
||||
sha256
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const EVENTS_SUBSCRIPTION = graphql(/* GraphQL */ `
|
||||
subscription events {
|
||||
events {
|
||||
__typename
|
||||
... on ClientConnectedEvent {
|
||||
connectedData: data {
|
||||
type
|
||||
version
|
||||
apiKey
|
||||
}
|
||||
connectedEvent: type
|
||||
}
|
||||
... on ClientDisconnectedEvent {
|
||||
disconnectedData: data {
|
||||
type
|
||||
version
|
||||
apiKey
|
||||
}
|
||||
disconnectedEvent: type
|
||||
}
|
||||
...RemoteGraphQLEventFragment
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -1,234 +0,0 @@
|
||||
import { AccessUrl, URL_TYPE } from '@unraid/shared/network.model.js';
|
||||
|
||||
import type { RootState } from '@app/store/index.js';
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { type Nginx } from '@app/core/types/states/nginx.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
|
||||
interface UrlForFieldInput {
|
||||
url: string;
|
||||
port?: number;
|
||||
portSsl?: number;
|
||||
}
|
||||
|
||||
interface UrlForFieldInputSecure extends UrlForFieldInput {
|
||||
url: string;
|
||||
portSsl: number;
|
||||
}
|
||||
interface UrlForFieldInputInsecure extends UrlForFieldInput {
|
||||
url: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export const getUrlForField = ({
|
||||
url,
|
||||
port,
|
||||
portSsl,
|
||||
}: UrlForFieldInputInsecure | UrlForFieldInputSecure) => {
|
||||
let portToUse = '';
|
||||
let httpMode = 'https://';
|
||||
|
||||
if (!url || url === '') {
|
||||
throw new Error('No URL Provided');
|
||||
}
|
||||
|
||||
if (port) {
|
||||
portToUse = port === 80 ? '' : `:${port}`;
|
||||
httpMode = 'http://';
|
||||
} else if (portSsl) {
|
||||
portToUse = portSsl === 443 ? '' : `:${portSsl}`;
|
||||
httpMode = 'https://';
|
||||
} else {
|
||||
throw new Error(`No ports specified for URL: ${url}`);
|
||||
}
|
||||
|
||||
const urlString = `${httpMode}${url}${portToUse}`;
|
||||
|
||||
try {
|
||||
return new URL(urlString);
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`Failed to parse URL: ${urlString}`);
|
||||
}
|
||||
};
|
||||
|
||||
const fieldIsFqdn = (field: keyof Nginx) => field?.toLowerCase().includes('fqdn');
|
||||
|
||||
export type NginxUrlFields = Extract<
|
||||
keyof Nginx,
|
||||
'lanIp' | 'lanIp6' | 'lanName' | 'lanMdns' | 'lanFqdn' | 'wanFqdn' | 'wanFqdn6'
|
||||
>;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param nginx Nginx Config File
|
||||
* @param field The field to build the URL from
|
||||
* @returns a URL, created from the combination of inputs
|
||||
* @throws Error when the URL cannot be created or the URL is invalid
|
||||
*/
|
||||
export const getUrlForServer = ({ nginx, field }: { nginx: Nginx; field: NginxUrlFields }): URL => {
|
||||
if (nginx[field]) {
|
||||
if (fieldIsFqdn(field)) {
|
||||
return getUrlForField({
|
||||
url: nginx[field],
|
||||
portSsl: nginx.httpsPort,
|
||||
});
|
||||
}
|
||||
|
||||
if (!nginx.sslEnabled) {
|
||||
// Use SSL = no
|
||||
return getUrlForField({ url: nginx[field], port: nginx.httpPort });
|
||||
}
|
||||
|
||||
if (nginx.sslMode === 'yes') {
|
||||
return getUrlForField({
|
||||
url: nginx[field],
|
||||
portSsl: nginx.httpsPort,
|
||||
});
|
||||
}
|
||||
|
||||
if (nginx.sslMode === 'auto') {
|
||||
throw new Error(`Cannot get IP Based URL for field: "${field}" SSL mode auto`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`IP URL Resolver: Could not resolve any access URL for field: "${field}", is FQDN?: ${fieldIsFqdn(
|
||||
field
|
||||
)}`
|
||||
);
|
||||
};
|
||||
|
||||
const getUrlTypeFromFqdn = (fqdnType: string): URL_TYPE => {
|
||||
switch (fqdnType) {
|
||||
case 'LAN':
|
||||
return URL_TYPE.LAN;
|
||||
case 'WAN':
|
||||
return URL_TYPE.WAN;
|
||||
case 'WG':
|
||||
return URL_TYPE.WIREGUARD;
|
||||
default:
|
||||
// HACK: This should be added as a new type (e.g. OTHER or CUSTOM)
|
||||
return URL_TYPE.WIREGUARD;
|
||||
}
|
||||
};
|
||||
|
||||
export const getServerIps = (
|
||||
state: RootState = store.getState()
|
||||
): { urls: AccessUrl[]; errors: Error[] } => {
|
||||
const { nginx } = state.emhttp;
|
||||
const {
|
||||
remote: { wanport },
|
||||
} = state.config;
|
||||
if (!nginx || Object.keys(nginx).length === 0) {
|
||||
return { urls: [], errors: [new Error('Nginx Not Loaded')] };
|
||||
}
|
||||
|
||||
const errors: Error[] = [];
|
||||
const urls: AccessUrl[] = [];
|
||||
|
||||
try {
|
||||
// Default URL
|
||||
const defaultUrl = new URL(nginx.defaultUrl);
|
||||
urls.push({
|
||||
name: 'Default',
|
||||
type: URL_TYPE.DEFAULT,
|
||||
ipv4: defaultUrl,
|
||||
ipv6: defaultUrl,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
logger.warn('Uncaught error in network resolver', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Lan IP URL
|
||||
const lanIp4Url = getUrlForServer({ nginx, field: 'lanIp' });
|
||||
urls.push({
|
||||
name: 'LAN IPv4',
|
||||
type: URL_TYPE.LAN,
|
||||
ipv4: lanIp4Url,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
logger.warn('Uncaught error in network resolver', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Lan IP6 URL
|
||||
const lanIp6Url = getUrlForServer({ nginx, field: 'lanIp6' });
|
||||
urls.push({
|
||||
name: 'LAN IPv6',
|
||||
type: URL_TYPE.LAN,
|
||||
ipv4: lanIp6Url,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
logger.warn('Uncaught error in network resolver', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Lan Name URL
|
||||
const lanNameUrl = getUrlForServer({ nginx, field: 'lanName' });
|
||||
urls.push({
|
||||
name: 'LAN Name',
|
||||
type: URL_TYPE.MDNS,
|
||||
ipv4: lanNameUrl,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
logger.warn('Uncaught error in network resolver', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Lan MDNS URL
|
||||
const lanMdnsUrl = getUrlForServer({ nginx, field: 'lanMdns' });
|
||||
urls.push({
|
||||
name: 'LAN MDNS',
|
||||
type: URL_TYPE.MDNS,
|
||||
ipv4: lanMdnsUrl,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
logger.warn('Uncaught error in network resolver', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Now Process the FQDN Urls
|
||||
nginx.fqdnUrls.forEach((fqdnUrl) => {
|
||||
try {
|
||||
const urlType = getUrlTypeFromFqdn(fqdnUrl.interface);
|
||||
const fqdnUrlToUse = getUrlForField({
|
||||
url: fqdnUrl.fqdn,
|
||||
portSsl: urlType === URL_TYPE.WAN ? Number(wanport) : nginx.httpsPort,
|
||||
});
|
||||
|
||||
urls.push({
|
||||
name: `FQDN ${fqdnUrl.interface}${fqdnUrl.id !== null ? ` ${fqdnUrl.id}` : ''}`,
|
||||
type: getUrlTypeFromFqdn(fqdnUrl.interface),
|
||||
ipv4: fqdnUrlToUse,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
errors.push(error);
|
||||
} else {
|
||||
logger.warn('Uncaught error in network resolver', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { urls, errors };
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import { mergeTypeDefs } from '@graphql-tools/merge';
|
||||
|
||||
import { logger } from '@app/core/log.js';
|
||||
|
||||
export const loadTypeDefs = async (additionalTypeDefs: string[] = []) => {
|
||||
// TypeScript now knows this returns Record<string, () => Promise<string>>
|
||||
const typeModules = import.meta.glob('./types/**/*.graphql', { query: '?raw', import: 'default' });
|
||||
|
||||
try {
|
||||
const files = await Promise.all(
|
||||
Object.values(typeModules).map(async (importFn) => {
|
||||
const content = await importFn();
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Invalid GraphQL type definition format');
|
||||
}
|
||||
return content;
|
||||
})
|
||||
);
|
||||
if (!files.length) {
|
||||
throw new Error('No GraphQL type definitions found');
|
||||
}
|
||||
files.push(...additionalTypeDefs);
|
||||
return mergeTypeDefs(files);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load GraphQL type definitions:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { graphqlLogger } from '@app/core/log.js';
|
||||
import { pubsub } from '@app/core/pubsub.js';
|
||||
import { type User } from '@app/core/types/states/user.js';
|
||||
import { ensurePermission } from '@app/core/utils/permissions/ensure-permission.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
import { Server, ServerStatus } from '@app/unraid-api/graph/resolvers/servers/server.model.js';
|
||||
|
||||
export interface Context {
|
||||
user?: User;
|
||||
websocketId: string;
|
||||
}
|
||||
|
||||
type Subscription = {
|
||||
total: number;
|
||||
channels: string[];
|
||||
};
|
||||
|
||||
const subscriptions: Record<string, Subscription> = {};
|
||||
|
||||
/**
|
||||
* Return current ws connection count.
|
||||
*/
|
||||
export const getWsConnectionCount = () =>
|
||||
Object.values(subscriptions).filter((subscription) => subscription.total >= 1).length;
|
||||
|
||||
/**
|
||||
* Return current ws connection count in channel.
|
||||
*/
|
||||
export const getWsConnectionCountInChannel = (channel: string) =>
|
||||
Object.values(subscriptions).filter((subscription) => subscription.channels.includes(channel))
|
||||
.length;
|
||||
|
||||
export const hasSubscribedToChannel = (id: string, channel: string) => {
|
||||
graphqlLogger.debug('Subscribing to %s', channel);
|
||||
|
||||
// Setup initial object
|
||||
if (subscriptions[id] === undefined) {
|
||||
subscriptions[id] = {
|
||||
total: 1,
|
||||
channels: [channel],
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
subscriptions[id].total++;
|
||||
subscriptions[id].channels.push(channel);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a pubsub subscription.
|
||||
* @param channel The pubsub channel to subscribe to.
|
||||
* @param resource The access-control permission resource to check against.
|
||||
*/
|
||||
export const createSubscription = (channel: string, resource?: string) => ({
|
||||
subscribe(_: unknown, __: unknown, context: Context) {
|
||||
if (!context.user) {
|
||||
throw new AppError('<ws> No user found in context.', 500);
|
||||
}
|
||||
|
||||
// Check the user has permission to subscribe to this endpoint
|
||||
ensurePermission(context.user, {
|
||||
resource: resource ?? channel,
|
||||
action: 'read',
|
||||
possession: 'any',
|
||||
});
|
||||
|
||||
hasSubscribedToChannel(context.websocketId, channel);
|
||||
return pubsub.asyncIterableIterator(channel);
|
||||
},
|
||||
});
|
||||
|
||||
export const getLocalServer = (getState = store.getState): Array<Server> => {
|
||||
const { emhttp, config, minigraph } = getState();
|
||||
const guid = emhttp.var.regGuid;
|
||||
const { name } = emhttp.var;
|
||||
const wanip = '';
|
||||
const lanip: string = emhttp.networks[0].ipaddr[0];
|
||||
const port = emhttp.var?.port;
|
||||
const localurl = `http://${lanip}:${port}`;
|
||||
const remoteurl = '';
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'local',
|
||||
owner: {
|
||||
id: 'local',
|
||||
username: config.remote.username ?? 'root',
|
||||
url: '',
|
||||
avatar: '',
|
||||
},
|
||||
guid,
|
||||
apikey: config.remote.apikey ?? '',
|
||||
name: name ?? 'Local Server',
|
||||
status:
|
||||
minigraph.status === MinigraphStatus.CONNECTED
|
||||
? ServerStatus.ONLINE
|
||||
: ServerStatus.OFFLINE,
|
||||
wanip,
|
||||
lanip,
|
||||
localurl,
|
||||
remoteurl,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getServers = (getState = store.getState): Server[] => {
|
||||
// Check if we have the servers already cached, if so return them
|
||||
return getLocalServer(getState) ?? [];
|
||||
};
|
||||
@@ -22,10 +22,8 @@ import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-fi
|
||||
import { shutdownApiEvent } from '@app/store/actions/shutdown-api-event.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { startMiddlewareListeners } from '@app/store/listeners/listener-middleware.js';
|
||||
import { loadConfigFile } from '@app/store/modules/config.js';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp.js';
|
||||
import { loadRegistrationKey } from '@app/store/modules/registration.js';
|
||||
import { startStoreSync } from '@app/store/store-sync.js';
|
||||
import { setupDynamixConfigWatch } from '@app/store/watch/dynamix-config-watch.js';
|
||||
import { setupRegistrationKeyWatch } from '@app/store/watch/registration-watch.js';
|
||||
import { StateManager } from '@app/store/watch/state-watch.js';
|
||||
@@ -71,13 +69,6 @@ export const viteNodeApp = async () => {
|
||||
cacheable.install(http.globalAgent);
|
||||
cacheable.install(https.globalAgent);
|
||||
|
||||
// Start file <-> store sync
|
||||
// Must occur before config is loaded to ensure that the handler can fix broken configs
|
||||
await startStoreSync();
|
||||
|
||||
// Load my servers config file into store
|
||||
await store.dispatch(loadConfigFile());
|
||||
|
||||
// Load emhttp state into store
|
||||
await store.dispatch(loadStateFiles());
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Check is the API Key is the correct length (64 characters)
|
||||
* @param apiKey API Key to validate length
|
||||
* @returns Boolean
|
||||
*/
|
||||
export const isApiKeyCorrectLength = (apiKey: string) => {
|
||||
if (apiKey.length !== 64) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { remoteAccessLogger } from '@app/core/log.js';
|
||||
import { NginxManager } from '@app/core/modules/services/nginx.js';
|
||||
import { UpdateDNSManager } from '@app/core/modules/services/update-dns.js';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
|
||||
export const reloadNginxAndUpdateDNS = createAsyncThunk<
|
||||
void,
|
||||
void,
|
||||
{ state: RootState; dispatch: AppDispatch }
|
||||
>('config/reloadNginxAndUpdateDNS', async () => {
|
||||
remoteAccessLogger.debug('Reloading Nginx and Updating DNS');
|
||||
const manager = new NginxManager();
|
||||
const updateDns = new UpdateDNSManager();
|
||||
await manager.reloadNginx();
|
||||
await updateDns.updateDNS();
|
||||
remoteAccessLogger.debug('Finished Reloading Nginx and Updating DNS');
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { createAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
export const setGraphqlConnectionStatus = createAction<{
|
||||
status: MinigraphStatus;
|
||||
error: string | null;
|
||||
}>('graphql/status');
|
||||
@@ -1,16 +0,0 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { reloadNginxAndUpdateDNS } from '@app/store/actions/reload-nginx-and-update-dns.js';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import { setWanAccess } from '@app/store/modules/config.js';
|
||||
|
||||
type EnableWanAccessArgs = Parameters<typeof setWanAccess>[0];
|
||||
export const setWanAccessAndReloadNginx = createAsyncThunk<
|
||||
void,
|
||||
EnableWanAccessArgs,
|
||||
{ state: RootState; dispatch: AppDispatch }
|
||||
>('config/setWanAccessAndReloadNginx', async (payload, { dispatch }) => {
|
||||
dispatch(setWanAccess(payload));
|
||||
|
||||
await dispatch(reloadNginxAndUpdateDNS());
|
||||
});
|
||||
@@ -1,13 +1,9 @@
|
||||
import { logDestination, logger } from '@app/core/log.js';
|
||||
import { stopListeners } from '@app/store/listeners/stop-listeners.js';
|
||||
import { writeConfigSync } from '@app/store/sync/config-disk-sync.js';
|
||||
|
||||
export const shutdownApiEvent = () => {
|
||||
logger.debug('Running shutdown');
|
||||
stopListeners();
|
||||
logger.debug('Writing final configs');
|
||||
writeConfigSync('flash');
|
||||
writeConfigSync('memory');
|
||||
logger.debug('Shutting down log destination');
|
||||
logDestination.flushSync();
|
||||
logDestination.destroy();
|
||||
|
||||
@@ -16,11 +16,8 @@ export type AppDispatch = typeof store.dispatch;
|
||||
export type ApiStore = typeof store;
|
||||
|
||||
export const getters = {
|
||||
config: () => store.getState().config,
|
||||
dynamix: () => store.getState().dynamix,
|
||||
emhttp: () => store.getState().emhttp,
|
||||
minigraph: () => store.getState().minigraph,
|
||||
paths: () => store.getState().paths,
|
||||
registration: () => store.getState().registration,
|
||||
upnp: () => store.getState().upnp,
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
import type { ConfigType } from '@app/core/utils/files/config-file-normalizer.js';
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer.js';
|
||||
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer.js';
|
||||
import { startAppListening } from '@app/store/listeners/listener-middleware.js';
|
||||
import { configUpdateActionsFlash, configUpdateActionsMemory } from '@app/store/modules/config.js';
|
||||
|
||||
export const enableConfigFileListener = (mode: ConfigType) => () =>
|
||||
startAppListening({
|
||||
matcher: mode === 'flash' ? configUpdateActionsFlash : configUpdateActionsMemory,
|
||||
async effect(_, { getState }) {
|
||||
const { paths, config } = getState();
|
||||
const pathToWrite =
|
||||
mode === 'flash' ? paths['myservers-config'] : paths['myservers-config-states'];
|
||||
const writeableConfig = getWriteableConfig(config, mode);
|
||||
const serializedConfig = safelySerializeObjectToIni(writeableConfig);
|
||||
logger.debug('Writing updated config to %s', pathToWrite);
|
||||
writeFileSync(pathToWrite, serializedConfig);
|
||||
},
|
||||
});
|
||||
@@ -5,9 +5,6 @@ import { addListener, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import { enableArrayEventListener } from '@app/store/listeners/array-event-listener.js';
|
||||
import { enableConfigFileListener } from '@app/store/listeners/config-listener.js';
|
||||
import { enableUpnpListener } from '@app/store/listeners/upnp-listener.js';
|
||||
import { enableVersionListener } from '@app/store/listeners/version-listener.js';
|
||||
|
||||
export const listenerMiddleware = createListenerMiddleware();
|
||||
|
||||
@@ -21,9 +18,5 @@ export const addAppListener = addListener as TypedAddListener<RootState, AppDisp
|
||||
|
||||
export const startMiddlewareListeners = () => {
|
||||
// Begin listening for events
|
||||
enableConfigFileListener('flash')();
|
||||
enableConfigFileListener('memory')();
|
||||
enableUpnpListener();
|
||||
enableVersionListener();
|
||||
enableArrayEventListener();
|
||||
};
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { isAnyOf } from '@reduxjs/toolkit';
|
||||
|
||||
import { upnpLogger } from '@app/core/log.js';
|
||||
import { type RootState } from '@app/store/index.js';
|
||||
import { startAppListening } from '@app/store/listeners/listener-middleware.js';
|
||||
import { loadConfigFile } from '@app/store/modules/config.js';
|
||||
import { loadSingleStateFile, loadStateFiles } from '@app/store/modules/emhttp.js';
|
||||
import { disableUpnp, enableUpnp } from '@app/store/modules/upnp.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
// FLAG for review: make sure we replace this
|
||||
const shouldUpnpBeEnabled = (state: RootState | null): boolean => {
|
||||
if (
|
||||
state?.config.status !== FileLoadStatus.LOADED ||
|
||||
state?.emhttp.status !== FileLoadStatus.LOADED
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { useUpnp } = state.emhttp.var;
|
||||
const { upnpEnabled, wanaccess } = state.config.remote;
|
||||
|
||||
return useUpnp && upnpEnabled === 'yes' && wanaccess === 'yes';
|
||||
};
|
||||
|
||||
const isStateOrConfigUpdate = isAnyOf(
|
||||
loadConfigFile.fulfilled,
|
||||
loadSingleStateFile.fulfilled,
|
||||
loadStateFiles.fulfilled
|
||||
// setupRemoteAccessThunk.fulfilled
|
||||
);
|
||||
|
||||
export const enableUpnpListener = () =>
|
||||
startAppListening({
|
||||
predicate(action, currentState, previousState) {
|
||||
// @TODO: One of our actions is incorrectly configured. Sometimes the action is an anonymous function. We need to fix this.
|
||||
if (
|
||||
(isStateOrConfigUpdate(action) || !action?.type) &&
|
||||
shouldUpnpBeEnabled(currentState) !== shouldUpnpBeEnabled(previousState)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
async effect(_, { getState, dispatch }) {
|
||||
const state = getState();
|
||||
const {
|
||||
config: {
|
||||
remote: { wanport },
|
||||
},
|
||||
emhttp: {
|
||||
var: { portssl },
|
||||
},
|
||||
} = getState();
|
||||
upnpLogger.info(
|
||||
'UPNP Enabled: (%s) Wan Port: [%s]',
|
||||
shouldUpnpBeEnabled(state),
|
||||
wanport === '' ? 'Will Generate New WAN Port' : wanport
|
||||
);
|
||||
|
||||
if (shouldUpnpBeEnabled(state)) {
|
||||
await dispatch(enableUpnp({ wanport, portssl }));
|
||||
} else {
|
||||
await dispatch(disableUpnp());
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { API_VERSION } from '@app/environment.js';
|
||||
import { startAppListening } from '@app/store/listeners/listener-middleware.js';
|
||||
import { updateUserConfig } from '@app/store/modules/config.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
export const enableVersionListener = () =>
|
||||
startAppListening({
|
||||
predicate(_, currentState) {
|
||||
if (
|
||||
currentState.config.status === FileLoadStatus.LOADED &&
|
||||
(currentState.config.api.version === '' ||
|
||||
currentState.config.api.version !== API_VERSION)
|
||||
) {
|
||||
logger.trace('Config Loaded, setting API Version in myservers.cfg to ', API_VERSION);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
async effect(_, { dispatch }) {
|
||||
dispatch(
|
||||
updateUserConfig({
|
||||
api: { version: API_VERSION },
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1,332 +0,0 @@
|
||||
import { F_OK } from 'constants';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk, createSlice, isAnyOf } from '@reduxjs/toolkit';
|
||||
import { isEqual, merge } from 'lodash-es';
|
||||
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer.js';
|
||||
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer.js';
|
||||
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
|
||||
import { NODE_ENV } from '@app/environment.js';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js';
|
||||
// import { setupRemoteAccessThunk } from '@app/store/actions/setup-remote-access.js';
|
||||
import { type RootState } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
import { RecursivePartial } from '@app/types/index.js';
|
||||
import { type MyServersConfig, type MyServersConfigMemory } from '@app/types/my-servers-config.js';
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
// import { DynamicRemoteAccessType } from '@app/unraid-api/graph/resolvers/connect/connect.model.js';
|
||||
import { Owner } from '@app/unraid-api/graph/resolvers/owner/owner.model.js';
|
||||
|
||||
export type SliceState = {
|
||||
status: FileLoadStatus;
|
||||
nodeEnv: string;
|
||||
} & MyServersConfigMemory;
|
||||
|
||||
export const initialState: SliceState = {
|
||||
status: FileLoadStatus.UNLOADED,
|
||||
nodeEnv: NODE_ENV,
|
||||
remote: {
|
||||
wanaccess: '',
|
||||
wanport: '',
|
||||
upnpEnabled: '',
|
||||
apikey: '',
|
||||
localApiKey: '',
|
||||
email: '',
|
||||
username: '',
|
||||
avatar: '',
|
||||
regWizTime: '',
|
||||
accesstoken: '',
|
||||
idtoken: '',
|
||||
refreshtoken: '',
|
||||
allowedOrigins: '',
|
||||
dynamicRemoteAccessType: 'DISABLED',
|
||||
ssoSubIds: '',
|
||||
},
|
||||
local: {
|
||||
sandbox: 'no',
|
||||
},
|
||||
api: {
|
||||
extraOrigins: '',
|
||||
version: '',
|
||||
},
|
||||
connectionStatus: {
|
||||
minigraph: MinigraphStatus.PRE_INIT,
|
||||
upnpStatus: '',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const loginUser = createAsyncThunk<
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey' | 'localApiKey'>,
|
||||
Pick<MyServersConfig['remote'], 'email' | 'avatar' | 'username' | 'apikey' | 'localApiKey'>,
|
||||
{ state: RootState }
|
||||
>('config/login-user', async (userInfo) => {
|
||||
logger.info('Logging in user: %s', userInfo.username);
|
||||
const { pubsub, PUBSUB_CHANNEL } = await import('@app/core/pubsub.js');
|
||||
const owner: Owner = {
|
||||
username: userInfo.username,
|
||||
avatar: userInfo.avatar,
|
||||
url: '',
|
||||
};
|
||||
await pubsub.publish(PUBSUB_CHANNEL.OWNER, { owner });
|
||||
return userInfo;
|
||||
});
|
||||
|
||||
export const logoutUser = createAsyncThunk<void, { reason?: string }, { state: RootState }>(
|
||||
'config/logout-user',
|
||||
async ({ reason }) => {
|
||||
logger.warn('invoked legacy logoutUser. no action taken.');
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Load the myservers.cfg into the store. Returns null if the state after loading doesn't change
|
||||
*
|
||||
* Note: If the file doesn't exist this will fallback to default values.
|
||||
*/
|
||||
enum CONFIG_LOAD_ERROR {
|
||||
CONFIG_EQUAL = 'CONFIG_EQUAL',
|
||||
CONFIG_CORRUPTED = 'CONFIG_CORRUPTED',
|
||||
}
|
||||
|
||||
type LoadFailureWithConfig = {
|
||||
type: CONFIG_LOAD_ERROR.CONFIG_CORRUPTED;
|
||||
error: Error | null;
|
||||
config: MyServersConfig;
|
||||
};
|
||||
type LoadFailureConfigEqual = {
|
||||
type: CONFIG_LOAD_ERROR.CONFIG_EQUAL;
|
||||
};
|
||||
type ConfigRejectedValues = LoadFailureConfigEqual | LoadFailureWithConfig;
|
||||
|
||||
export const loadConfigFile = createAsyncThunk<
|
||||
MyServersConfig,
|
||||
string | undefined,
|
||||
{
|
||||
state: RootState;
|
||||
rejectValue: ConfigRejectedValues;
|
||||
}
|
||||
>('config/load-config-file', async (filePath, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { paths, config } = getState();
|
||||
|
||||
const path = filePath ?? paths['myservers-config'];
|
||||
|
||||
const fileExists = await access(path, F_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!fileExists) {
|
||||
throw new Error('Config File Missing');
|
||||
}
|
||||
|
||||
const newConfigFile = getWriteableConfig(
|
||||
parseConfig<MyServersConfig>({ filePath: path, type: 'ini' }),
|
||||
'flash'
|
||||
);
|
||||
|
||||
const isNewlyLoadedConfigEqual = isEqual(newConfigFile, getWriteableConfig(config, 'flash'));
|
||||
if (isNewlyLoadedConfigEqual) {
|
||||
logger.warn('Not loading config because it is the same as before');
|
||||
return rejectWithValue({
|
||||
type: CONFIG_LOAD_ERROR.CONFIG_EQUAL,
|
||||
});
|
||||
}
|
||||
return newConfigFile;
|
||||
} catch (error: unknown) {
|
||||
logger.warn('Config file is corrupted with error: %o - recreating config', error);
|
||||
const newConfig = getWriteableConfig(initialState, 'flash');
|
||||
newConfig.remote.wanaccess = 'no';
|
||||
const serializedConfig = safelySerializeObjectToIni(newConfig);
|
||||
writeFileSync(getState().paths['myservers-config'], serializedConfig);
|
||||
return rejectWithValue({
|
||||
type: CONFIG_LOAD_ERROR.CONFIG_CORRUPTED,
|
||||
error: error instanceof Error ? error : new Error('Unknown Error'),
|
||||
config: newConfig,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const config = createSlice({
|
||||
name: 'config',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateUserConfig(state, action: PayloadAction<RecursivePartial<MyServersConfig>>) {
|
||||
return merge(state, action.payload);
|
||||
},
|
||||
updateAccessTokens(
|
||||
state,
|
||||
action: PayloadAction<
|
||||
Partial<
|
||||
Pick<
|
||||
Pick<MyServersConfig, 'remote'>['remote'],
|
||||
'accesstoken' | 'refreshtoken' | 'idtoken'
|
||||
>
|
||||
>
|
||||
>
|
||||
) {
|
||||
return merge(state, { remote: action.payload });
|
||||
},
|
||||
updateAllowedOrigins(state, action: PayloadAction<string[]>) {
|
||||
state.api.extraOrigins = action.payload.join(', ');
|
||||
},
|
||||
setUpnpState(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
enabled?: 'no' | 'yes' | 'auto';
|
||||
status?: string | null;
|
||||
}>
|
||||
) {
|
||||
if (action.payload.enabled) {
|
||||
state.remote.upnpEnabled = action.payload.enabled;
|
||||
}
|
||||
|
||||
if (action.payload.status) {
|
||||
state.connectionStatus.upnpStatus = action.payload.status;
|
||||
}
|
||||
},
|
||||
setWanPortToValue(state, action: PayloadAction<number>) {
|
||||
logger.debug('Wan port set to %s', action.payload);
|
||||
state.remote.wanport = String(action.payload);
|
||||
},
|
||||
setWanAccess(state, action: PayloadAction<'yes' | 'no'>) {
|
||||
state.remote.wanaccess = action.payload;
|
||||
},
|
||||
// addSsoUser(state, action: PayloadAction<string>) {
|
||||
// // First check if state already has ID, otherwise append it
|
||||
// if (state.remote.ssoSubIds.includes(action.payload)) {
|
||||
// return;
|
||||
// }
|
||||
// const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== '');
|
||||
// stateAsArray.push(action.payload);
|
||||
// state.remote.ssoSubIds = stateAsArray.join(',');
|
||||
// },
|
||||
setSsoUsers(state, action: PayloadAction<string[]>) {
|
||||
state.remote.ssoSubIds = action.payload.filter((id) => id).join(',');
|
||||
},
|
||||
// removeSsoUser(state, action: PayloadAction<string | null>) {
|
||||
// if (action.payload === null) {
|
||||
// state.remote.ssoSubIds = '';
|
||||
// return;
|
||||
// }
|
||||
// if (!state.remote.ssoSubIds.includes(action.payload)) {
|
||||
// return;
|
||||
// }
|
||||
// const stateAsArray = state.remote.ssoSubIds.split(',').filter((id) => id !== action.payload);
|
||||
// state.remote.ssoSubIds = stateAsArray.join(',');
|
||||
// },
|
||||
setLocalApiKey(state, action: PayloadAction<string | null>) {
|
||||
state.remote.localApiKey = action.payload ?? '';
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(loadConfigFile.pending, (state) => {
|
||||
state.status = FileLoadStatus.LOADING;
|
||||
});
|
||||
|
||||
builder.addCase(loadConfigFile.fulfilled, (state, action) => {
|
||||
if (action.payload) {
|
||||
merge(state, action.payload, { status: FileLoadStatus.LOADED });
|
||||
} else {
|
||||
state.status = FileLoadStatus.LOADED;
|
||||
}
|
||||
});
|
||||
|
||||
builder.addCase(loadConfigFile.rejected, (state, action) => {
|
||||
switch (action.payload?.type) {
|
||||
case CONFIG_LOAD_ERROR.CONFIG_EQUAL:
|
||||
logger.debug('Configs equivalent');
|
||||
state.status = FileLoadStatus.LOADED;
|
||||
break;
|
||||
case CONFIG_LOAD_ERROR.CONFIG_CORRUPTED:
|
||||
logger.debug('Config File Load Failed - %o', action.payload.error);
|
||||
merge(state, action.payload.config);
|
||||
state.status = FileLoadStatus.LOADED;
|
||||
break;
|
||||
default:
|
||||
logger.error('Config File Load Failed', action.error);
|
||||
}
|
||||
});
|
||||
|
||||
builder.addCase(loginUser.fulfilled, (state, action) => {
|
||||
merge(state, {
|
||||
remote: {
|
||||
apikey: action.payload.apikey,
|
||||
localApiKey: action.payload.localApiKey,
|
||||
email: action.payload.email,
|
||||
username: action.payload.username,
|
||||
avatar: action.payload.avatar,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
builder.addCase(logoutUser.fulfilled, (state) => {
|
||||
merge(state, {
|
||||
remote: {
|
||||
apikey: '',
|
||||
localApiKey: '',
|
||||
avatar: '',
|
||||
email: '',
|
||||
username: '',
|
||||
idtoken: '',
|
||||
accessToken: '',
|
||||
refreshToken: '',
|
||||
// dynamicRemoteAccessType: DynamicRemoteAccessType.DISABLED,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
builder.addCase(setGraphqlConnectionStatus, (state, action) => {
|
||||
state.connectionStatus.minigraph = action.payload.status;
|
||||
});
|
||||
|
||||
// builder.addCase(setupRemoteAccessThunk.fulfilled, (state, action) => {
|
||||
// state.remote.wanaccess = action.payload.wanaccess;
|
||||
// state.remote.dynamicRemoteAccessType = action.payload.dynamicRemoteAccessType;
|
||||
// state.remote.wanport = action.payload.wanport;
|
||||
// state.remote.upnpEnabled = action.payload.upnpEnabled;
|
||||
// });
|
||||
},
|
||||
});
|
||||
const { actions, reducer } = config;
|
||||
|
||||
export const {
|
||||
// addSsoUser,
|
||||
setSsoUsers,
|
||||
updateUserConfig,
|
||||
updateAccessTokens,
|
||||
updateAllowedOrigins,
|
||||
setUpnpState,
|
||||
setWanPortToValue,
|
||||
setWanAccess,
|
||||
// removeSsoUser,
|
||||
setLocalApiKey,
|
||||
} = actions;
|
||||
|
||||
/**
|
||||
* Actions that should trigger a flash write
|
||||
*/
|
||||
export const configUpdateActionsFlash = isAnyOf(
|
||||
// addSsoUser,
|
||||
setSsoUsers,
|
||||
updateUserConfig,
|
||||
updateAccessTokens,
|
||||
updateAllowedOrigins,
|
||||
setUpnpState,
|
||||
setWanPortToValue,
|
||||
setWanAccess,
|
||||
// setupRemoteAccessThunk.fulfilled,
|
||||
logoutUser.fulfilled,
|
||||
loginUser.fulfilled,
|
||||
// removeSsoUser,
|
||||
setLocalApiKey
|
||||
);
|
||||
|
||||
/**
|
||||
* Actions that should trigger a memory write
|
||||
*/
|
||||
export const configUpdateActionsMemory = isAnyOf(configUpdateActionsFlash, setGraphqlConnectionStatus);
|
||||
|
||||
export const configReducer = reducer;
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { KEEP_ALIVE_INTERVAL_MS } from '@app/consts.js';
|
||||
import { minigraphLogger } from '@app/core/log.js';
|
||||
import { setGraphqlConnectionStatus } from '@app/store/actions/set-minigraph-status.js';
|
||||
import { loginUser, logoutUser } from '@app/store/modules/config.js';
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
export type MinigraphClientState = {
|
||||
status: MinigraphStatus;
|
||||
error: string | null;
|
||||
lastPing: number | null;
|
||||
selfDisconnectedSince: number | null;
|
||||
timeout: number | null;
|
||||
timeoutStart: number | null;
|
||||
};
|
||||
|
||||
const initialState: MinigraphClientState = {
|
||||
status: MinigraphStatus.PRE_INIT,
|
||||
error: null,
|
||||
lastPing: null,
|
||||
selfDisconnectedSince: null,
|
||||
timeout: null,
|
||||
timeoutStart: null,
|
||||
};
|
||||
|
||||
export const mothership = createSlice({
|
||||
name: 'mothership',
|
||||
initialState,
|
||||
reducers: {
|
||||
setMothershipTimeout(state, action: PayloadAction<number>) {
|
||||
state.timeout = action.payload;
|
||||
state.timeoutStart = Date.now();
|
||||
},
|
||||
receivedMothershipPing(state) {
|
||||
state.lastPing = Date.now();
|
||||
},
|
||||
setSelfDisconnected(state) {
|
||||
minigraphLogger.error(
|
||||
`Received disconnect event for own server, waiting for ${
|
||||
KEEP_ALIVE_INTERVAL_MS / 1_000
|
||||
} seconds before setting disconnected`
|
||||
);
|
||||
state.selfDisconnectedSince = Date.now();
|
||||
},
|
||||
setSelfReconnected(state) {
|
||||
minigraphLogger.error(
|
||||
'Received connected event for own server, clearing disconnection timeout'
|
||||
);
|
||||
state.selfDisconnectedSince = null;
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(setGraphqlConnectionStatus, (state, action) => {
|
||||
minigraphLogger.debug('GraphQL Connection Status: %o', action.payload);
|
||||
state.status = action.payload.status;
|
||||
state.error = action.payload.error;
|
||||
if (
|
||||
[MinigraphStatus.CONNECTED, MinigraphStatus.CONNECTING].includes(action.payload.status)
|
||||
) {
|
||||
state.error = null;
|
||||
state.timeout = null;
|
||||
state.lastPing = null;
|
||||
state.selfDisconnectedSince = null;
|
||||
state.timeoutStart = null;
|
||||
}
|
||||
});
|
||||
builder.addCase(loginUser.pending, (state) => {
|
||||
state.timeout = null;
|
||||
state.timeoutStart = null;
|
||||
state.lastPing = null;
|
||||
state.selfDisconnectedSince = null;
|
||||
state.status = MinigraphStatus.PRE_INIT;
|
||||
state.error = 'Connecting - refresh the page for an updated status.';
|
||||
});
|
||||
builder.addCase(logoutUser.pending, (state) => {
|
||||
state.error = null;
|
||||
state.timeout = null;
|
||||
state.lastPing = null;
|
||||
state.selfDisconnectedSince = null;
|
||||
state.timeoutStart = null;
|
||||
state.status = MinigraphStatus.PRE_INIT;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setMothershipTimeout, receivedMothershipPing, setSelfDisconnected, setSelfReconnected } =
|
||||
mothership.actions;
|
||||
|
||||
export const mothershipReducer = mothership.reducer;
|
||||
@@ -49,7 +49,6 @@ const initialState = {
|
||||
resolvePath(process.env.PATHS_STATES ?? ('/usr/local/emhttp/state/' as const)),
|
||||
'myservers.cfg' as const
|
||||
),
|
||||
'myservers-env': '/boot/config/plugins/dynamix.my.servers/env' as const,
|
||||
'myservers-keepalive':
|
||||
process.env.PATHS_MY_SERVERS_FB ??
|
||||
('/boot/config/plugins/dynamix.my.servers/fb_keepalive' as const),
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { Mapping } from '@runonflux/nat-upnp';
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { upnpLogger } from '@app/core/log.js';
|
||||
import { toNumberOrNull } from '@app/core/utils/casting.js';
|
||||
import { type AppDispatch, type RootState } from '@app/store/index.js';
|
||||
import { setUpnpState, setWanPortToValue } from '@app/store/modules/config.js';
|
||||
import {
|
||||
getUpnpMappings,
|
||||
getWanPortForUpnp,
|
||||
removeUpnpLease,
|
||||
renewUpnpLease,
|
||||
} from '@app/upnp/helpers.js';
|
||||
import { initUpnpJobs, stopUpnpJobs } from '@app/upnp/jobs.js';
|
||||
|
||||
interface UpnpState {
|
||||
upnpEnabled: boolean;
|
||||
wanPortForUpnp: number | null;
|
||||
localPortForUpnp: number | null;
|
||||
errors: {
|
||||
renewal: string | null;
|
||||
removal: string | null;
|
||||
mapping: string | null;
|
||||
};
|
||||
mappings: Mapping[];
|
||||
renewalJobRunning: boolean;
|
||||
}
|
||||
|
||||
export const initialState: UpnpState = {
|
||||
upnpEnabled: false,
|
||||
errors: {
|
||||
removal: null,
|
||||
renewal: null,
|
||||
mapping: null,
|
||||
},
|
||||
wanPortForUpnp: null,
|
||||
localPortForUpnp: null,
|
||||
mappings: [],
|
||||
renewalJobRunning: false,
|
||||
};
|
||||
|
||||
export type LeaseRenewalArgs = { localPortForUpnp: number; wanPortForUpnp: number };
|
||||
export type UpnpEnableReturnValue = Pick<
|
||||
UpnpState,
|
||||
'renewalJobRunning' | 'wanPortForUpnp' | 'localPortForUpnp'
|
||||
>;
|
||||
type EnableUpnpThunkArgs = { portssl: number; wanport?: string } | void;
|
||||
|
||||
/**
|
||||
* Return if the removal or renewal set failed, this indicates that an error was probably fatal
|
||||
* @param errors
|
||||
* @returns
|
||||
*/
|
||||
export const upnpStoreHasFatalError = (errors: UpnpState['errors'] | null): boolean =>
|
||||
errors ? errors.removal !== null || errors.renewal !== null : false;
|
||||
|
||||
/*
|
||||
* Choose port to use - if we pass arguments it means we're re-initing this function, so ignore upnp.wanPortForUpnp
|
||||
* If we don't pass args, use the saved WAN port since that means the job is running
|
||||
*/
|
||||
const getWanPortToUse = async ({
|
||||
leaseRenewalArgs,
|
||||
wanPortArgAsNumber,
|
||||
wanPortForUpnp,
|
||||
dispatch,
|
||||
}: {
|
||||
leaseRenewalArgs: EnableUpnpThunkArgs;
|
||||
wanPortArgAsNumber: number | null;
|
||||
wanPortForUpnp: null | number;
|
||||
dispatch: AppDispatch;
|
||||
}): Promise<number | null> => {
|
||||
if (leaseRenewalArgs) {
|
||||
if (wanPortArgAsNumber) {
|
||||
return wanPortArgAsNumber;
|
||||
}
|
||||
|
||||
const currentMappings = await getUpnpMappings();
|
||||
|
||||
const newPort = getWanPortForUpnp(currentMappings);
|
||||
if (newPort) {
|
||||
dispatch(setWanPortToValue(newPort));
|
||||
}
|
||||
|
||||
return newPort;
|
||||
}
|
||||
|
||||
return wanPortForUpnp;
|
||||
};
|
||||
|
||||
export const enableUpnp = createAsyncThunk<
|
||||
UpnpEnableReturnValue,
|
||||
EnableUpnpThunkArgs,
|
||||
{ state: RootState; dispatch: AppDispatch }
|
||||
>('upnp/enable', async (leaseRenewalArgs, { getState, dispatch }) => {
|
||||
const { upnp, emhttp } = getState();
|
||||
|
||||
const wanPortArgAsNumber = leaseRenewalArgs?.wanport
|
||||
? toNumberOrNull(leaseRenewalArgs?.wanport)
|
||||
: null;
|
||||
|
||||
// If the wan port changes we try to negotiate this by removing the old lease first
|
||||
if (
|
||||
leaseRenewalArgs &&
|
||||
upnp.wanPortForUpnp &&
|
||||
upnp.localPortForUpnp &&
|
||||
(wanPortArgAsNumber !== upnp.wanPortForUpnp ||
|
||||
leaseRenewalArgs.portssl !== upnp.localPortForUpnp)
|
||||
) {
|
||||
try {
|
||||
await removeUpnpLease({
|
||||
wanPortForUpnp: upnp.wanPortForUpnp,
|
||||
localPortForUpnp: upnp.localPortForUpnp,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
upnpLogger.warn(
|
||||
`Caught error [${error instanceof Error ? error.message : 'N/A'}] when removing lease, could be non-fatal, so continuing`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the renewal Job if it's not already running. When run from inside a job this will return true
|
||||
const renewalJobRunning = upnp.renewalJobRunning ? true : initUpnpJobs();
|
||||
|
||||
const wanPortToUse = await getWanPortToUse({
|
||||
leaseRenewalArgs,
|
||||
wanPortForUpnp: upnp.wanPortForUpnp,
|
||||
dispatch,
|
||||
wanPortArgAsNumber,
|
||||
});
|
||||
const localPortToUse = leaseRenewalArgs ? leaseRenewalArgs.portssl : upnp.localPortForUpnp;
|
||||
if (wanPortToUse && localPortToUse) {
|
||||
try {
|
||||
await renewUpnpLease({
|
||||
localPortForUpnp: localPortToUse,
|
||||
wanPortForUpnp: wanPortToUse,
|
||||
serverName: emhttp?.var?.name,
|
||||
});
|
||||
const today = new Date();
|
||||
const todayFormatted = `${today.toLocaleDateString()} ${today.toLocaleTimeString()}`;
|
||||
dispatch(
|
||||
setUpnpState({
|
||||
status: `Success: UPNP Lease Renewed [${todayFormatted}] Public Port [${wanPortToUse}] Local Port [${localPortToUse}]`,
|
||||
})
|
||||
);
|
||||
|
||||
return { renewalJobRunning, wanPortForUpnp: wanPortToUse, localPortForUpnp: localPortToUse };
|
||||
} catch (error: unknown) {
|
||||
const message = `Error: Failed Opening UPNP Public Port [${wanPortToUse}] Local Port [${localPortToUse}] Message: [${error instanceof Error ? error.message : 'N/A'}]`;
|
||||
dispatch(setUpnpState({ enabled: 'no', status: message }));
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('No WAN port found, disabling UPNP');
|
||||
});
|
||||
|
||||
export const disableUpnp = createAsyncThunk<{ renewalJobRunning: boolean }, void, { state: RootState }>(
|
||||
'upnp/disable',
|
||||
async (_, { dispatch, getState }) => {
|
||||
const {
|
||||
upnp: { localPortForUpnp, wanPortForUpnp },
|
||||
} = getState();
|
||||
|
||||
const renewalJobRunning = stopUpnpJobs();
|
||||
if (localPortForUpnp && wanPortForUpnp) {
|
||||
try {
|
||||
await removeUpnpLease({ localPortForUpnp, wanPortForUpnp });
|
||||
dispatch(setUpnpState({ enabled: 'no', status: 'UPNP Disabled' }));
|
||||
} catch (error: unknown) {
|
||||
upnpLogger.warn(
|
||||
`Failed to remove UPNP Binding with Error [${error instanceof Error ? error.message : 'N/A'}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { renewalJobRunning };
|
||||
}
|
||||
);
|
||||
|
||||
export const upnp = createSlice({
|
||||
name: 'upnp',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateMappings(state, action: PayloadAction<Mapping[]>) {
|
||||
state.mappings = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers(builder) {
|
||||
builder.addCase(enableUpnp.pending, (state) => {
|
||||
state.upnpEnabled = true;
|
||||
});
|
||||
builder.addCase(enableUpnp.fulfilled, (state, action) => {
|
||||
state.localPortForUpnp = action.payload.localPortForUpnp;
|
||||
state.wanPortForUpnp = action.payload.wanPortForUpnp;
|
||||
state.renewalJobRunning = action.payload.renewalJobRunning;
|
||||
});
|
||||
builder.addCase(enableUpnp.rejected, (state, action) => {
|
||||
upnpLogger.warn('Failed to renew UPNP Lease with Error %o', action.error);
|
||||
state.errors.renewal = action.error.message ?? 'Undefined Error When Renewing UPNP Lease';
|
||||
});
|
||||
|
||||
builder.addCase(disableUpnp.fulfilled, (state, action) => {
|
||||
state.renewalJobRunning = action.payload.renewalJobRunning;
|
||||
state.wanPortForUpnp = null;
|
||||
state.localPortForUpnp = null;
|
||||
state.upnpEnabled = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { actions, reducer } = upnp;
|
||||
|
||||
export const { updateMappings } = actions;
|
||||
export const upnpReducer = reducer;
|
||||
@@ -1,25 +1,19 @@
|
||||
import { combineReducers, UnknownAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { resetStore } from '@app/store/actions/reset-store.js';
|
||||
import { configReducer } from '@app/store/modules/config.js';
|
||||
import { dynamix } from '@app/store/modules/dynamix.js';
|
||||
import { emhttp } from '@app/store/modules/emhttp.js';
|
||||
import { mothershipReducer } from '@app/store/modules/minigraph.js';
|
||||
import { paths } from '@app/store/modules/paths.js';
|
||||
import { registrationReducer } from '@app/store/modules/registration.js';
|
||||
import { upnp } from '@app/store/modules/upnp.js';
|
||||
|
||||
/**
|
||||
* Root reducer that combines all slice reducers and handles the reset action.
|
||||
* When the reset action is dispatched, all slices will be reset to their initial state.
|
||||
*/
|
||||
const appReducer = combineReducers({
|
||||
config: configReducer,
|
||||
minigraph: mothershipReducer,
|
||||
paths: paths.reducer,
|
||||
emhttp: emhttp.reducer,
|
||||
registration: registrationReducer,
|
||||
upnp: upnp.reducer,
|
||||
dynamix: dynamix.reducer,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import type { RootState } from '@app/store/index.js';
|
||||
import { NODE_ENV } from '@app/environment.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { syncRegistration } from '@app/store/sync/registration-sync.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
export const startStoreSync = async () => {
|
||||
// The last state is stored so we don't end up in a loop of writing -> reading -> writing
|
||||
let lastState: RootState | null = null;
|
||||
|
||||
// Update cfg when store changes
|
||||
store.subscribe(async () => {
|
||||
const state = store.getState();
|
||||
// Config dependent options, wait until config loads to execute
|
||||
if (state.config.status === FileLoadStatus.LOADED) {
|
||||
// Update registration
|
||||
await syncRegistration(lastState);
|
||||
}
|
||||
|
||||
if (
|
||||
NODE_ENV === 'development' &&
|
||||
!isEqual(state, lastState) &&
|
||||
state.paths['myservers-config-states']
|
||||
) {
|
||||
writeFileSync(join(state.paths.states, 'config.log'), JSON.stringify(state.config, null, 2));
|
||||
writeFileSync(
|
||||
join(state.paths.states, 'graphql.log'),
|
||||
JSON.stringify(state.minigraph, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
lastState = state;
|
||||
});
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
import type { ConfigType } from '@app/core/utils/files/config-file-normalizer.js';
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { getWriteableConfig } from '@app/core/utils/files/config-file-normalizer.js';
|
||||
import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
export const writeConfigSync = (mode: ConfigType) => {
|
||||
const { config, paths } = store.getState();
|
||||
|
||||
if (config.status !== FileLoadStatus.LOADED) {
|
||||
logger.warn('Configs not loaded, unable to write sync');
|
||||
return;
|
||||
}
|
||||
|
||||
const writeableConfig = getWriteableConfig(config, mode);
|
||||
const path = mode === 'flash' ? paths['myservers-config'] : paths['myservers-config-states'];
|
||||
const serializedConfig = safelySerializeObjectToIni(writeableConfig);
|
||||
writeFileSync(path, serializedConfig);
|
||||
};
|
||||
@@ -1,69 +0,0 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import type { StoreSubscriptionHandler } from '@app/store/types.js';
|
||||
import { logger } from '@app/core/log.js';
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
|
||||
export type RegistrationEvent = {
|
||||
registration: {
|
||||
guid: string;
|
||||
type: string;
|
||||
state: string;
|
||||
keyFile: {
|
||||
location: string;
|
||||
contents: null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const createRegistrationEvent = (
|
||||
state: Parameters<StoreSubscriptionHandler>[0]
|
||||
): RegistrationEvent | null => {
|
||||
// Var state isn't loaded
|
||||
if (state === null || Object.keys(state.emhttp.var).length === 0) return null;
|
||||
|
||||
const event = {
|
||||
registration: {
|
||||
guid: state.emhttp.var.regGuid,
|
||||
type: state.emhttp.var.regTy.toUpperCase(),
|
||||
state: state.emhttp.var.regState,
|
||||
keyFile: {
|
||||
location: state.emhttp.var.regFile,
|
||||
contents: state.registration.keyFile,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return event;
|
||||
};
|
||||
|
||||
export const syncRegistration: StoreSubscriptionHandler = async (lastState) => {
|
||||
try {
|
||||
// Skip until we have the key and emhttp states loaded
|
||||
const { registration, emhttp } = store.getState();
|
||||
if (registration.status !== FileLoadStatus.LOADED) return;
|
||||
if (emhttp.status !== FileLoadStatus.LOADED) return;
|
||||
|
||||
const lastEvent = createRegistrationEvent(lastState);
|
||||
const currentEvent = createRegistrationEvent(store.getState());
|
||||
|
||||
// Skip if either event resolved to null
|
||||
if (lastEvent === null || currentEvent === null) return;
|
||||
|
||||
// Skip this if it's the same as the last one
|
||||
if (isEqual(lastEvent, currentEvent)) return;
|
||||
|
||||
logger.debug('Registration was updated, publishing event');
|
||||
|
||||
// Publish to graphql
|
||||
await pubsub.publish(PUBSUB_CHANNEL.REGISTRATION, currentEvent);
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof Error))
|
||||
throw new Error(
|
||||
`Failed publishing registration event with unknown error "${String(error)}"`
|
||||
);
|
||||
logger.error('Failed publishing registration event with "%s"', error.message);
|
||||
}
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js';
|
||||
|
||||
// Define Zod schemas
|
||||
const ApiConfigSchema = z.object({
|
||||
version: z.string(),
|
||||
extraOrigins: z.string(),
|
||||
});
|
||||
|
||||
const RemoteConfigSchema = z.object({
|
||||
wanaccess: z.string(),
|
||||
wanport: z.string(),
|
||||
upnpEnabled: z.string(),
|
||||
apikey: z.string(),
|
||||
localApiKey: z.string(),
|
||||
email: z.string(),
|
||||
username: z.string(),
|
||||
avatar: z.string(),
|
||||
regWizTime: z.string(),
|
||||
accesstoken: z.string(),
|
||||
idtoken: z.string(),
|
||||
refreshtoken: z.string(),
|
||||
dynamicRemoteAccessType: z.string(),
|
||||
ssoSubIds: z
|
||||
.string()
|
||||
.transform((val) => {
|
||||
// If valid, return as is
|
||||
if (val === '' || val.split(',').every((id) => id.trim().match(/^[a-zA-Z0-9-]+$/))) {
|
||||
return val;
|
||||
}
|
||||
// Otherwise, replace with an empty string
|
||||
return '';
|
||||
})
|
||||
.refine(
|
||||
(val) => val === '' || val.split(',').every((id) => id.trim().match(/^[a-zA-Z0-9-]+$/)),
|
||||
{
|
||||
message:
|
||||
'ssoSubIds must be empty or a comma-separated list of alphanumeric strings with dashes',
|
||||
}
|
||||
),
|
||||
});
|
||||
|
||||
const LocalConfigSchema = z.object({
|
||||
sandbox: z.enum(['yes', 'no']).default('no'),
|
||||
});
|
||||
|
||||
// Base config schema
|
||||
export const MyServersConfigSchema = z
|
||||
.object({
|
||||
api: ApiConfigSchema,
|
||||
local: LocalConfigSchema,
|
||||
remote: RemoteConfigSchema,
|
||||
})
|
||||
.strip();
|
||||
|
||||
// Memory config schema
|
||||
export const ConnectionStatusSchema = z.object({
|
||||
minigraph: z.nativeEnum(MinigraphStatus),
|
||||
upnpStatus: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const MyServersConfigMemorySchema = MyServersConfigSchema.extend({
|
||||
connectionStatus: ConnectionStatusSchema,
|
||||
remote: RemoteConfigSchema.extend({
|
||||
allowedOrigins: z.string(),
|
||||
}),
|
||||
}).strip();
|
||||
|
||||
// Infer and export types from Zod schemas
|
||||
export type MyServersConfig = z.infer<typeof MyServersConfigSchema>;
|
||||
export type MyServersConfigMemory = z.infer<typeof MyServersConfigMemorySchema>;
|
||||
256
api/src/unraid-api/app/__test__/app.module.integration.spec.ts
Normal file
256
api/src/unraid-api/app/__test__/app.module.integration.spec.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AuthZGuard } from 'nest-authz';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { loadDynamixConfigFile } from '@app/store/actions/load-dynamix-config-file.js';
|
||||
import { store } from '@app/store/index.js';
|
||||
import { loadStateFiles } from '@app/store/modules/emhttp.js';
|
||||
import { AppModule } from '@app/unraid-api/app/app.module.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
|
||||
// Mock external system boundaries that we can't control in tests
|
||||
vi.mock('dockerode', () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
listContainers: vi.fn().mockResolvedValue([
|
||||
{
|
||||
Id: 'test-container-1',
|
||||
Names: ['/test-container'],
|
||||
State: 'running',
|
||||
Status: 'Up 5 minutes',
|
||||
Image: 'test:latest',
|
||||
Command: 'node server.js',
|
||||
Created: Date.now() / 1000,
|
||||
Ports: [
|
||||
{
|
||||
IP: '0.0.0.0',
|
||||
PrivatePort: 3000,
|
||||
PublicPort: 3000,
|
||||
Type: 'tcp',
|
||||
},
|
||||
],
|
||||
Labels: {},
|
||||
HostConfig: {
|
||||
NetworkMode: 'bridge',
|
||||
},
|
||||
NetworkSettings: {
|
||||
Networks: {},
|
||||
},
|
||||
Mounts: [],
|
||||
},
|
||||
]),
|
||||
getContainer: vi.fn().mockImplementation((id) => ({
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
Id: id,
|
||||
Name: '/test-container',
|
||||
State: { Running: true },
|
||||
Config: { Image: 'test:latest' },
|
||||
}),
|
||||
})),
|
||||
listImages: vi.fn().mockResolvedValue([]),
|
||||
listNetworks: vi.fn().mockResolvedValue([]),
|
||||
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock external command execution
|
||||
vi.mock('execa', () => ({
|
||||
execa: vi.fn().mockImplementation((cmd) => {
|
||||
if (cmd === 'whoami') {
|
||||
return Promise.resolve({ stdout: 'testuser' });
|
||||
}
|
||||
return Promise.resolve({ stdout: 'mocked output' });
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock child_process for services that spawn processes
|
||||
vi.mock('node:child_process', () => ({
|
||||
spawn: vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
stdout: { on: vi.fn() },
|
||||
stderr: { on: vi.fn() },
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock file system operations that would fail in test environment
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs/promises')>();
|
||||
return {
|
||||
...actual,
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
stat: vi.fn().mockResolvedValue({ isFile: () => true }),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
rename: vi.fn().mockResolvedValue(undefined),
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs module for synchronous operations
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn().mockReturnValue(false),
|
||||
readFileSync: vi.fn().mockReturnValue(''),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
readdirSync: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
describe('AppModule Integration Tests', () => {
|
||||
let app: NestFastifyApplication;
|
||||
let moduleRef: TestingModule;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Initialize the dynamix config and state files before creating the module
|
||||
await store.dispatch(loadDynamixConfigFile());
|
||||
await store.dispatch(loadStateFiles());
|
||||
|
||||
// Debug: Log the CSRF token from the store
|
||||
const { getters } = await import('@app/store/index.js');
|
||||
console.log('CSRF Token from store:', getters.emhttp().var.csrfToken);
|
||||
|
||||
moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
// Override authentication for tests
|
||||
.overrideGuard(AuthenticationGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
// Override authorization guard
|
||||
.overrideGuard(AuthZGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
// Override AuthService to bypass CSRF validation
|
||||
.overrideProvider(AuthService)
|
||||
.useValue({
|
||||
validateCookiesWithCsrfToken: vi.fn().mockResolvedValue({
|
||||
id: 'test-user',
|
||||
name: 'Test User',
|
||||
roles: ['admin'],
|
||||
}),
|
||||
validateApiKeyCasbin: vi.fn().mockResolvedValue({
|
||||
id: 'test-user',
|
||||
name: 'Test User',
|
||||
roles: ['admin'],
|
||||
}),
|
||||
getSessionUser: vi.fn().mockResolvedValue({
|
||||
id: 'test-user',
|
||||
name: 'Test User',
|
||||
roles: ['admin'],
|
||||
}),
|
||||
})
|
||||
// Override Redis client
|
||||
.overrideProvider('REDIS_CLIENT')
|
||||
.useValue({
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
})
|
||||
.compile();
|
||||
|
||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Module Compilation', () => {
|
||||
it('should successfully compile all modules with proper dependency injection', () => {
|
||||
expect(moduleRef).toBeDefined();
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
|
||||
it('should resolve core services', () => {
|
||||
const dockerService = moduleRef.get(DockerService);
|
||||
|
||||
expect(dockerService).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GraphQL API', () => {
|
||||
it('should expose GraphQL endpoint and handle a simple query', async () => {
|
||||
// Query for a simpler public endpoint that doesn't require permissions
|
||||
const query = `
|
||||
query {
|
||||
isSSOEnabled
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/graphql')
|
||||
.set('x-csrf-token', '0000000000000000') // Add CSRF token from dev/states/var.ini
|
||||
.send({ query })
|
||||
.expect((res) => {
|
||||
// Log the response for debugging
|
||||
if (res.status !== 200 || res.body.errors) {
|
||||
console.error('GraphQL Response:', JSON.stringify(res.body, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.isSSOEnabled).toBeDefined();
|
||||
expect(typeof response.body.data.isSSOEnabled).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should execute public theme query', async () => {
|
||||
const query = `
|
||||
query {
|
||||
publicTheme {
|
||||
name
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/graphql')
|
||||
.send({ query })
|
||||
.expect((res) => {
|
||||
// Log the response for debugging
|
||||
if (res.status !== 200 || res.body.errors) {
|
||||
console.error('GraphQL Response:', JSON.stringify(res.body, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// The query may have errors if theme is not configured, but the GraphQL endpoint should still work
|
||||
expect(response.body).toBeDefined();
|
||||
// Either we get data or errors, but the endpoint should respond
|
||||
expect(response.body.data || response.body.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service Integration', () => {
|
||||
it('should have working service-to-service communication', async () => {
|
||||
const dockerService = moduleRef.get(DockerService);
|
||||
|
||||
// Test that the service can be called and returns expected data structure
|
||||
const containers = await dockerService.getContainers();
|
||||
|
||||
expect(containers).toBeInstanceOf(Array);
|
||||
// The containers might be empty or cached, just verify structure
|
||||
if (containers.length > 0) {
|
||||
expect(containers[0]).toHaveProperty('id');
|
||||
expect(containers[0]).toHaveProperty('names');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
|
||||
|
||||
describe('Module Dependencies Integration', () => {
|
||||
it('should compile RestModule without dependency injection errors', async () => {
|
||||
let module;
|
||||
try {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [RestModule],
|
||||
}).compile();
|
||||
|
||||
expect(module).toBeDefined();
|
||||
} finally {
|
||||
if (module) {
|
||||
await module.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { type CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface.js';
|
||||
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
|
||||
import { apiLogger } from '@app/core/log.js';
|
||||
import { BYPASS_CORS_CHECKS } from '@app/environment.js';
|
||||
import { type CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
|
||||
/**
|
||||
* Returns whether the origin is allowed to access the API.
|
||||
*
|
||||
* @throws GraphQLError if the origin is not in the list of allowed origins
|
||||
* and `BYPASS_CORS_CHECKS` flag is not set.
|
||||
*/
|
||||
// note: don't make this function synchronous. throwing will then crash the server.
|
||||
export async function isOriginAllowed(origin: string | undefined) {
|
||||
const allowedOrigins = getAllowedOrigins();
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
return true;
|
||||
} else {
|
||||
apiLogger.debug(`Origin not in allowed origins: ${origin}`);
|
||||
|
||||
if (BYPASS_CORS_CHECKS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new GraphQLError(
|
||||
'The CORS policy for this site does not allow access from the specified Origin.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**------------------------------------------------------------------------
|
||||
* ? Fastify Cors Config
|
||||
*
|
||||
* The fastify cors configuration function is very different from express,
|
||||
* but Nest.js doesn't have clear docs or types describing this so I'm
|
||||
* documenting it here.
|
||||
*
|
||||
* This takes a fastify app instance and returns a cors config function, instead
|
||||
* of just the cors config function (which is nest's default behavior).
|
||||
*------------------------------------------------------------------------**/
|
||||
|
||||
/**
|
||||
* A wrapper function for the fastify CORS configuration, which
|
||||
* takes a CookieService (i.e. a singleton from Nest.js) and returns a
|
||||
* fastify CORS config function. This function:
|
||||
*
|
||||
* Dynamically determines the CORS config for a request.
|
||||
*
|
||||
* - Expects any cookies to be parsed & available on the `cookies` property of the request.
|
||||
*
|
||||
* If the request contains a valid unraid session cookie, it is allowed to access
|
||||
* the API from any origin. Otherwise, the origin must be explicitly listed in
|
||||
* the `allowedOrigins` config option, or the `BYPASS_PERMISSION_CHECKS` flag
|
||||
* must be set.
|
||||
*/
|
||||
export const configureFastifyCors =
|
||||
(service: CookieService) =>
|
||||
// this is the function that nestApp.enableCors() needs when configured to use fastify
|
||||
() =>
|
||||
/**
|
||||
* Our CORS handler function. It dynamically determines the CORS config for a request.
|
||||
*
|
||||
* @param req the request object
|
||||
* @param callback the callback to call with the CORS options
|
||||
*/
|
||||
(req: FastifyRequest, callback: (error: Error | null, options: CorsOptions) => void) => {
|
||||
const { cookies } = req;
|
||||
if (cookies && typeof cookies === 'object') {
|
||||
service.hasValidAuthCookie(cookies).then((isValid) => {
|
||||
if (isValid) {
|
||||
callback(null, { credentials: true, origin: true });
|
||||
} else {
|
||||
callback(null, { credentials: true, origin: isOriginAllowed });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
callback(null, { credentials: true, origin: isOriginAllowed });
|
||||
}
|
||||
};
|
||||
@@ -8,20 +8,13 @@ import { AuthActionVerb } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { environment } from '@app/environment.js';
|
||||
import { getters, store } from '@app/store/index.js';
|
||||
import { updateUserConfig } from '@app/store/modules/config.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import {
|
||||
ApiKey,
|
||||
ApiKeyWithSecret,
|
||||
Permission,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
// Mock the store and its modules
|
||||
vi.mock('@app/store/index.js', () => ({
|
||||
getters: {
|
||||
config: vi.fn(),
|
||||
paths: vi.fn(),
|
||||
},
|
||||
store: {
|
||||
@@ -30,15 +23,6 @@ vi.mock('@app/store/index.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@app/store/modules/config.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@app/store/modules/config.js')>();
|
||||
return {
|
||||
...actual,
|
||||
updateUserConfig: vi.fn(),
|
||||
setLocalApiKey: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', async () => ({
|
||||
readdir: vi.fn().mockResolvedValue(['key1.json', 'key2.json', 'notakey.txt']),
|
||||
@@ -116,15 +100,6 @@ describe('ApiKeyService', () => {
|
||||
'auth-keys': mockBasePath,
|
||||
} as any);
|
||||
|
||||
// Set up default config mock
|
||||
vi.mocked(getters.config).mockReturnValue({
|
||||
status: FileLoadStatus.LOADED,
|
||||
remote: {
|
||||
apikey: null,
|
||||
localApiKey: null,
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Mock ensureDir
|
||||
vi.mocked(ensureDir).mockResolvedValue();
|
||||
|
||||
@@ -142,7 +117,7 @@ describe('ApiKeyService', () => {
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should ensure directory exists', async () => {
|
||||
const service = new ApiKeyService();
|
||||
new ApiKeyService();
|
||||
expect(ensureDirSync).toHaveBeenCalledWith(mockBasePath);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ import { AuthActionVerb } from 'nest-authz';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { environment } from '@app/environment.js';
|
||||
import { getters, store } from '@app/store/index.js';
|
||||
import { setLocalApiKey } from '@app/store/modules/config.js';
|
||||
import { FileLoadStatus } from '@app/store/types.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import {
|
||||
AddPermissionInput,
|
||||
ApiKey,
|
||||
@@ -117,7 +115,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
overwrite = false,
|
||||
}: {
|
||||
name: string;
|
||||
description: string | undefined;
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[] | AddPermissionInput[];
|
||||
overwrite?: boolean;
|
||||
@@ -359,4 +357,59 @@ export class ApiKeyService implements OnModuleInit {
|
||||
await this.saveApiKey(apiKey);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures an API key exists, creating it if necessary.
|
||||
* Used by internal services like Connect and CLI for automatic key management.
|
||||
*/
|
||||
public async ensureKey(config: {
|
||||
name: string;
|
||||
description: string;
|
||||
roles: Role[];
|
||||
legacyNames?: string[];
|
||||
}): Promise<string> {
|
||||
// Clean up any legacy keys
|
||||
if (config.legacyNames && config.legacyNames.length > 0) {
|
||||
const allKeys = await this.findAll();
|
||||
const legacyKeys = allKeys.filter((key) => config.legacyNames!.includes(key.name));
|
||||
if (legacyKeys.length > 0) {
|
||||
await this.deleteApiKeys(legacyKeys.map((key) => key.id));
|
||||
this.logger.log(`Deleted legacy API keys: ${config.legacyNames.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if key already exists
|
||||
const existingKey = this.findByField('name', config.name);
|
||||
if (existingKey) {
|
||||
return existingKey.key;
|
||||
}
|
||||
|
||||
// Create new key
|
||||
const newApiKey = await this.getOrCreateLocalKey(config.name, config.description, config.roles);
|
||||
this.logger.log(`Created new API key: ${config.name}`);
|
||||
return newApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a local API key with the specified name, description, and roles.
|
||||
*/
|
||||
public async getOrCreateLocalKey(name: string, description: string, roles: Role[]): Promise<string> {
|
||||
try {
|
||||
const apiKey = await this.create({
|
||||
name,
|
||||
description,
|
||||
roles,
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
if (!apiKey?.key) {
|
||||
throw new Error(`Failed to create local API key: ${name}`);
|
||||
}
|
||||
|
||||
return apiKey.key;
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to create local API key ${name}: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { BASE_POLICY, CASBIN_MODEL } from '@app/unraid-api/auth/casbin/index.js'
|
||||
import { CookieService, SESSION_COOKIE_CONFIG } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
|
||||
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { getRequest } from '@app/utils.js';
|
||||
|
||||
@Module({
|
||||
@@ -50,6 +51,7 @@ import { getRequest } from '@app/utils.js';
|
||||
providers: [
|
||||
AuthService,
|
||||
ApiKeyService,
|
||||
AdminKeyService,
|
||||
ServerHeaderStrategy,
|
||||
UserCookieStrategy,
|
||||
CookieService,
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import type { SsoUserService as ISsoUserService } from '@unraid/shared/services/sso.js';
|
||||
import { GraphQLError } from 'graphql/error/GraphQLError.js';
|
||||
|
||||
import type { ApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { UnraidFileModificationService } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class SsoUserService implements ISsoUserService {
|
||||
private readonly logger = new Logger(SsoUserService.name);
|
||||
private ssoSubIdsConfigKey = 'api.ssoSubIds';
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
@Optional() private readonly fileModificationService?: UnraidFileModificationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the current list of SSO user IDs
|
||||
@@ -48,75 +52,29 @@ export class SsoUserService implements ISsoUserService {
|
||||
// Update the config
|
||||
this.configService.set(this.ssoSubIdsConfigKey, userIds);
|
||||
|
||||
// Request a restart if there were no SSO users before
|
||||
return currentUserSet.size === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single SSO user ID
|
||||
* @param userId - The SSO user ID to add
|
||||
* @returns true if a restart is required, false otherwise
|
||||
*/
|
||||
async addSsoUser(userId: string): Promise<boolean> {
|
||||
const currentUsers = await this.getSsoUsers();
|
||||
|
||||
// If user already exists, no need to update
|
||||
if (currentUsers.includes(userId)) {
|
||||
return false;
|
||||
// Handle file modification if available
|
||||
if (this.fileModificationService) {
|
||||
// If going from 0 to 1+ users, apply the SSO modification
|
||||
if (currentUserSet.size === 0 && newUserSet.size > 0) {
|
||||
try {
|
||||
await this.fileModificationService.applyModificationById('sso');
|
||||
this.logger.log('Applied SSO file modification after adding SSO users');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to apply SSO file modification', error);
|
||||
}
|
||||
}
|
||||
// If going from 1+ to 0 users, rollback the SSO modification
|
||||
else if (currentUserSet.size > 0 && newUserSet.size === 0) {
|
||||
try {
|
||||
await this.fileModificationService.rollbackModificationById('sso');
|
||||
this.logger.log('Rolled back SSO file modification after removing all SSO users');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to rollback SSO file modification', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate user ID
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
||||
if (!uuidRegex.test(userId)) {
|
||||
throw new GraphQLError(`Invalid SSO user ID: ${userId}`);
|
||||
}
|
||||
|
||||
// Add the new user
|
||||
const newUsers = [...currentUsers, userId];
|
||||
this.configService.set(this.ssoSubIdsConfigKey, newUsers);
|
||||
|
||||
// Request a restart if there were no SSO users before
|
||||
return currentUsers.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single SSO user ID
|
||||
* @param userId - The SSO user ID to remove
|
||||
* @returns true if a restart is required, false otherwise
|
||||
*/
|
||||
async removeSsoUser(userId: string): Promise<boolean> {
|
||||
const currentUsers = await this.getSsoUsers();
|
||||
|
||||
// If user doesn't exist, no need to update
|
||||
if (!currentUsers.includes(userId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove the user
|
||||
const newUsers = currentUsers.filter((id) => id !== userId);
|
||||
this.configService.set(this.ssoSubIdsConfigKey, newUsers);
|
||||
|
||||
// Request a restart if this was the last SSO user
|
||||
return currentUsers.length === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all SSO users
|
||||
* @returns true if a restart is required, false otherwise
|
||||
*/
|
||||
async removeAllSsoUsers(): Promise<boolean> {
|
||||
const currentUsers = await this.getSsoUsers();
|
||||
|
||||
// If no users exist, no need to update
|
||||
if (currentUsers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove all users
|
||||
this.configService.set(this.ssoSubIdsConfigKey, []);
|
||||
|
||||
// Request a restart if there were any SSO users
|
||||
return true;
|
||||
// No restart required - file modifications are applied immediately
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
154
api/src/unraid-api/cli/__test__/add-sso-user.command.test.ts
Normal file
154
api/src/unraid-api/cli/__test__/add-sso-user.command.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { InquirerService } from 'nest-commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
import { AddSSOUserCommand } from '@app/unraid-api/cli/sso/add-sso-user.command.js';
|
||||
|
||||
// Mock services
|
||||
const mockInternalClient = {
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRestartCommand = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
const mockInquirerService = {
|
||||
prompt: vi.fn(),
|
||||
};
|
||||
|
||||
describe('AddSSOUserCommand', () => {
|
||||
let command: AddSSOUserCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
AddSSOUserCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||
{ provide: InquirerService, useValue: mockInquirerService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<AddSSOUserCommand>(AddSSOUserCommand);
|
||||
|
||||
// Clear mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should add a new SSO user successfully', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['existing-user-id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
updateSettings: {
|
||||
restartRequired: false,
|
||||
values: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
disclaimer: 'y',
|
||||
username: 'new-user-id',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
ssoSubIds: ['existing-user-id', 'new-user-id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('User added: new-user-id');
|
||||
expect(mockLogger.info).not.toHaveBeenCalledWith('Restarting the API');
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add user if disclaimer is not accepted', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
disclaimer: 'n',
|
||||
username: 'new-user-id',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).not.toHaveBeenCalled();
|
||||
expect(mockClient.mutate).not.toHaveBeenCalled();
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not add user if user already exists', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['existing-user-id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
disclaimer: 'y',
|
||||
username: 'existing-user-id',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).not.toHaveBeenCalled();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'User existing-user-id already exists in SSO users'
|
||||
);
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockInternalClient.getClient.mockRejectedValue(new Error('Connection failed'));
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
disclaimer: 'y',
|
||||
username: 'new-user-id',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Error adding user:', expect.any(Error));
|
||||
});
|
||||
});
|
||||
405
api/src/unraid-api/cli/__test__/api-report.service.test.ts
Normal file
405
api/src/unraid-api/cli/__test__/api-report.service.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import {
|
||||
CONNECT_STATUS_QUERY,
|
||||
SERVICES_QUERY,
|
||||
SYSTEM_REPORT_QUERY,
|
||||
} from '@app/unraid-api/cli/queries/system-report.query.js';
|
||||
|
||||
// Mock Apollo Client
|
||||
const mockClient = {
|
||||
query: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock internal client service
|
||||
const mockInternalClientService = {
|
||||
getClient: vi.fn().mockResolvedValue(mockClient),
|
||||
clearClient: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock log service
|
||||
const mockLogService = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ApiReportService', () => {
|
||||
let apiReportService: ApiReportService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiReportService,
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClientService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
apiReportService = module.get<ApiReportService>(ApiReportService);
|
||||
|
||||
// Clear mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('generateReport', () => {
|
||||
const mockSystemData = {
|
||||
info: {
|
||||
id: 'info',
|
||||
machineId: 'test-machine-id',
|
||||
system: {
|
||||
manufacturer: 'Test Manufacturer',
|
||||
model: 'Test Model',
|
||||
version: '1.0',
|
||||
sku: 'TEST-SKU',
|
||||
serial: 'TEST-SERIAL',
|
||||
uuid: 'test-uuid',
|
||||
},
|
||||
versions: {
|
||||
unraid: '6.12.0',
|
||||
kernel: '5.19.17',
|
||||
openssl: '3.0.8',
|
||||
},
|
||||
},
|
||||
config: {
|
||||
id: 'config',
|
||||
valid: true,
|
||||
error: null,
|
||||
},
|
||||
server: {
|
||||
id: 'server',
|
||||
name: 'Test Server',
|
||||
},
|
||||
};
|
||||
|
||||
const mockConnectData = {
|
||||
connect: {
|
||||
id: 'connect',
|
||||
dynamicRemoteAccess: {
|
||||
enabledType: 'STATIC',
|
||||
runningType: 'STATIC',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockServicesData = {
|
||||
services: [
|
||||
{
|
||||
id: 'service-cloud',
|
||||
name: 'cloud',
|
||||
online: true,
|
||||
uptime: { timestamp: '2023-01-01T00:00:00Z' },
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
id: 'service-minigraph',
|
||||
name: 'minigraph',
|
||||
online: false,
|
||||
uptime: null,
|
||||
version: '2.0.0',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('should generate complete report when API is running and all queries succeed', async () => {
|
||||
// Configure mock to return different data based on query
|
||||
mockClient.query.mockImplementation(({ query }) => {
|
||||
if (query === SYSTEM_REPORT_QUERY) {
|
||||
return Promise.resolve({ data: mockSystemData });
|
||||
} else if (query === CONNECT_STATUS_QUERY) {
|
||||
return Promise.resolve({ data: mockConnectData });
|
||||
} else if (query === SERVICES_QUERY) {
|
||||
return Promise.resolve({ data: mockServicesData });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown query'));
|
||||
});
|
||||
|
||||
const result = await apiReportService.generateReport(true);
|
||||
|
||||
// Verify GraphQL client was called for all queries
|
||||
expect(mockInternalClientService.getClient).toHaveBeenCalled();
|
||||
expect(mockClient.query).toHaveBeenCalledWith({
|
||||
query: SYSTEM_REPORT_QUERY,
|
||||
});
|
||||
expect(mockClient.query).toHaveBeenCalledWith({
|
||||
query: CONNECT_STATUS_QUERY,
|
||||
});
|
||||
expect(mockClient.query).toHaveBeenCalledWith({
|
||||
query: SERVICES_QUERY,
|
||||
});
|
||||
|
||||
// Verify report structure
|
||||
expect(result).toMatchObject({
|
||||
timestamp: expect.any(String),
|
||||
connectionStatus: {
|
||||
running: 'yes',
|
||||
},
|
||||
system: {
|
||||
id: 'test-uuid',
|
||||
name: 'Test Server',
|
||||
version: '6.12.0',
|
||||
machineId: 'REDACTED',
|
||||
manufacturer: 'Test Manufacturer',
|
||||
model: 'Test Model',
|
||||
},
|
||||
connect: {
|
||||
installed: true,
|
||||
dynamicRemoteAccess: {
|
||||
enabledType: 'STATIC',
|
||||
runningType: 'STATIC',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
config: {
|
||||
valid: true,
|
||||
error: null,
|
||||
},
|
||||
services: {
|
||||
cloud: {
|
||||
id: 'service-cloud',
|
||||
name: 'cloud',
|
||||
online: true,
|
||||
uptime: { timestamp: '2023-01-01T00:00:00Z' },
|
||||
version: '1.0.0',
|
||||
},
|
||||
minigraph: {
|
||||
id: 'service-minigraph',
|
||||
name: 'minigraph',
|
||||
online: false,
|
||||
uptime: null,
|
||||
version: '2.0.0',
|
||||
},
|
||||
allServices: [
|
||||
{
|
||||
name: 'cloud',
|
||||
online: true,
|
||||
version: '1.0.0',
|
||||
uptime: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
name: 'minigraph',
|
||||
online: false,
|
||||
version: '2.0.0',
|
||||
uptime: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error report when API is not running', async () => {
|
||||
const result = await apiReportService.generateReport(false);
|
||||
|
||||
// Verify GraphQL client was not called
|
||||
expect(mockInternalClientService.getClient).not.toHaveBeenCalled();
|
||||
expect(mockClient.query).not.toHaveBeenCalled();
|
||||
|
||||
// Verify error report structure
|
||||
expect(result).toMatchObject({
|
||||
timestamp: expect.any(String),
|
||||
connectionStatus: {
|
||||
running: 'no',
|
||||
},
|
||||
system: {
|
||||
name: 'Unknown',
|
||||
version: 'Unknown',
|
||||
machineId: 'REDACTED',
|
||||
},
|
||||
connect: {
|
||||
installed: false,
|
||||
reason: 'API is not running',
|
||||
},
|
||||
config: {
|
||||
valid: null,
|
||||
error: 'API is not running',
|
||||
},
|
||||
services: {
|
||||
cloud: null,
|
||||
minigraph: null,
|
||||
allServices: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle connect plugin not available gracefully', async () => {
|
||||
// Mock system and services queries to succeed, connect to fail
|
||||
mockClient.query.mockImplementation(({ query }) => {
|
||||
if (query === SYSTEM_REPORT_QUERY) {
|
||||
return Promise.resolve({ data: mockSystemData });
|
||||
} else if (query === CONNECT_STATUS_QUERY) {
|
||||
return Promise.reject(new Error('Connect plugin not installed'));
|
||||
} else if (query === SERVICES_QUERY) {
|
||||
return Promise.resolve({ data: mockServicesData });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown query'));
|
||||
});
|
||||
|
||||
const result = await apiReportService.generateReport(true);
|
||||
|
||||
// Verify connect error was logged
|
||||
expect(mockLogService.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Connect plugin not available')
|
||||
);
|
||||
|
||||
// Verify connect shows as not installed
|
||||
expect(result.connect).toEqual({
|
||||
installed: false,
|
||||
reason: 'Connect plugin not installed or not available',
|
||||
});
|
||||
|
||||
// Verify other data is still present
|
||||
expect(result.system.name).toBe('Test Server');
|
||||
expect(result.services.cloud).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle services query failure gracefully', async () => {
|
||||
// Mock system and connect queries to succeed, services to fail
|
||||
mockClient.query.mockImplementation(({ query }) => {
|
||||
if (query === SYSTEM_REPORT_QUERY) {
|
||||
return Promise.resolve({ data: mockSystemData });
|
||||
} else if (query === CONNECT_STATUS_QUERY) {
|
||||
return Promise.resolve({ data: mockConnectData });
|
||||
} else if (query === SERVICES_QUERY) {
|
||||
return Promise.reject(new Error('Services query failed'));
|
||||
}
|
||||
return Promise.reject(new Error('Unknown query'));
|
||||
});
|
||||
|
||||
const result = await apiReportService.generateReport(true);
|
||||
|
||||
// Verify services error was logged
|
||||
expect(mockLogService.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error querying services')
|
||||
);
|
||||
|
||||
// Verify services shows empty
|
||||
expect(result.services).toEqual({
|
||||
cloud: null,
|
||||
minigraph: null,
|
||||
allServices: [],
|
||||
});
|
||||
|
||||
// Verify other data is still present
|
||||
expect(result.system.name).toBe('Test Server');
|
||||
expect(result.connect.installed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing server name gracefully', async () => {
|
||||
const mockSystemDataWithoutServer = {
|
||||
...mockSystemData,
|
||||
server: null,
|
||||
};
|
||||
|
||||
mockClient.query.mockImplementation(({ query }) => {
|
||||
if (query === SYSTEM_REPORT_QUERY) {
|
||||
return Promise.resolve({ data: mockSystemDataWithoutServer });
|
||||
} else if (query === CONNECT_STATUS_QUERY) {
|
||||
return Promise.resolve({ data: mockConnectData });
|
||||
} else if (query === SERVICES_QUERY) {
|
||||
return Promise.resolve({ data: mockServicesData });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown query'));
|
||||
});
|
||||
|
||||
const result = await apiReportService.generateReport(true);
|
||||
|
||||
expect(result.system.name).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should handle services without uptime timestamps', async () => {
|
||||
const mockServicesDataNoUptime = {
|
||||
services: [
|
||||
{
|
||||
id: 'service-test',
|
||||
name: 'test-service',
|
||||
online: true,
|
||||
uptime: null,
|
||||
version: '1.0.0',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockClient.query.mockImplementation(({ query }) => {
|
||||
if (query === SYSTEM_REPORT_QUERY) {
|
||||
return Promise.resolve({ data: mockSystemData });
|
||||
} else if (query === CONNECT_STATUS_QUERY) {
|
||||
return Promise.resolve({ data: mockConnectData });
|
||||
} else if (query === SERVICES_QUERY) {
|
||||
return Promise.resolve({ data: mockServicesDataNoUptime });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown query'));
|
||||
});
|
||||
|
||||
const result = await apiReportService.generateReport(true);
|
||||
|
||||
expect(result.services.allServices[0]).toMatchObject({
|
||||
name: 'test-service',
|
||||
online: true,
|
||||
version: '1.0.0',
|
||||
uptime: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should always redact sensitive information', async () => {
|
||||
mockClient.query.mockImplementation(({ query }) => {
|
||||
if (query === SYSTEM_REPORT_QUERY) {
|
||||
return Promise.resolve({ data: mockSystemData });
|
||||
} else if (query === CONNECT_STATUS_QUERY) {
|
||||
return Promise.resolve({ data: mockConnectData });
|
||||
} else if (query === SERVICES_QUERY) {
|
||||
return Promise.resolve({ data: mockServicesData });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown query'));
|
||||
});
|
||||
|
||||
const result = await apiReportService.generateReport(true);
|
||||
|
||||
// Verify all sensitive fields are redacted
|
||||
expect(result.system.machineId).toBe('REDACTED');
|
||||
});
|
||||
|
||||
it('should handle connect with error gracefully', async () => {
|
||||
const mockConnectDataWithError = {
|
||||
connect: {
|
||||
id: 'connect',
|
||||
dynamicRemoteAccess: {
|
||||
enabledType: 'STATIC',
|
||||
runningType: 'DISABLED',
|
||||
error: 'Port forwarding failed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockClient.query.mockImplementation(({ query }) => {
|
||||
if (query === SYSTEM_REPORT_QUERY) {
|
||||
return Promise.resolve({ data: mockSystemData });
|
||||
} else if (query === CONNECT_STATUS_QUERY) {
|
||||
return Promise.resolve({ data: mockConnectDataWithError });
|
||||
} else if (query === SERVICES_QUERY) {
|
||||
return Promise.resolve({ data: mockServicesData });
|
||||
}
|
||||
return Promise.reject(new Error('Unknown query'));
|
||||
});
|
||||
|
||||
const result = await apiReportService.generateReport(true);
|
||||
|
||||
expect(result.connect).toMatchObject({
|
||||
installed: true,
|
||||
dynamicRemoteAccess: {
|
||||
enabledType: 'STATIC',
|
||||
runningType: 'DISABLED',
|
||||
error: 'Port forwarding failed',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
201
api/src/unraid-api/cli/__test__/developer-tools.service.test.ts
Normal file
201
api/src/unraid-api/cli/__test__/developer-tools.service.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { access, readFile, unlink, writeFile } from 'fs/promises';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DeveloperToolsService } from '@app/unraid-api/cli/developer/developer-tools.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
|
||||
vi.mock('fs/promises');
|
||||
|
||||
describe('DeveloperToolsService', () => {
|
||||
let module: TestingModule;
|
||||
let service: DeveloperToolsService;
|
||||
let logService: LogService;
|
||||
let restartCommand: RestartCommand;
|
||||
let internalClient: CliInternalClientService;
|
||||
|
||||
const mockClient = {
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
DeveloperToolsService,
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RestartCommand,
|
||||
useValue: {
|
||||
run: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CliInternalClientService,
|
||||
useValue: {
|
||||
getClient: vi.fn().mockResolvedValue(mockClient),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DeveloperToolsService>(DeveloperToolsService);
|
||||
logService = module.get<LogService>(LogService);
|
||||
restartCommand = module.get<RestartCommand>(RestartCommand);
|
||||
internalClient = module.get<CliInternalClientService>(CliInternalClientService);
|
||||
});
|
||||
|
||||
describe('setSandboxMode', () => {
|
||||
it('should enable sandbox mode and restart when required', async () => {
|
||||
mockClient.mutate.mockResolvedValue({
|
||||
data: {
|
||||
updateSettings: {
|
||||
restartRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.setSandboxMode(true);
|
||||
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.any(Object),
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
sandbox: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(logService.info).toHaveBeenCalledWith('Enabling sandbox mode - restarting API...');
|
||||
expect(restartCommand.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disable sandbox mode without restart', async () => {
|
||||
mockClient.mutate.mockResolvedValue({
|
||||
data: {
|
||||
updateSettings: {
|
||||
restartRequired: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.setSandboxMode(false);
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith('Sandbox mode disabled successfully.');
|
||||
expect(restartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableModalTest', () => {
|
||||
it('should create modal test page file', async () => {
|
||||
vi.mocked(access).mockResolvedValue(undefined);
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(readFile).mockResolvedValue('<html><body></body></html>');
|
||||
|
||||
await service.enableModalTest();
|
||||
|
||||
expect(access).toHaveBeenCalledWith('/usr/local/emhttp/plugins/dynamix.my.servers');
|
||||
expect(writeFile).toHaveBeenCalledWith(
|
||||
'/usr/local/emhttp/plugins/dynamix.my.servers/DevModalTest.page',
|
||||
expect.stringContaining('unraid-dev-modal-test')
|
||||
);
|
||||
expect(logService.info).toHaveBeenCalledWith('✓ Modal test tool ENABLED');
|
||||
expect(logService.info).toHaveBeenCalledWith(
|
||||
'\nAccess the tool at: Menu > UNRAID-OS > Dev Modal Test'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if directory does not exist', async () => {
|
||||
vi.mocked(access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
await expect(service.enableModalTest()).rejects.toThrow(
|
||||
'Directory does not exist: /usr/local/emhttp/plugins/dynamix.my.servers'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableModalTest', () => {
|
||||
it('should remove modal test page file', async () => {
|
||||
vi.mocked(access).mockResolvedValue(undefined);
|
||||
vi.mocked(unlink).mockResolvedValue(undefined);
|
||||
|
||||
await service.disableModalTest();
|
||||
|
||||
expect(unlink).toHaveBeenCalledWith(
|
||||
'/usr/local/emhttp/plugins/dynamix.my.servers/DevModalTest.page'
|
||||
);
|
||||
expect(logService.info).toHaveBeenCalledWith('✓ Modal test tool DISABLED');
|
||||
});
|
||||
|
||||
it('should handle file not existing', async () => {
|
||||
vi.mocked(access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
await service.disableModalTest();
|
||||
|
||||
expect(unlink).not.toHaveBeenCalled();
|
||||
expect(logService.info).toHaveBeenCalledWith('Modal test tool is already disabled.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isModalTestEnabled', () => {
|
||||
it('should return true if file exists', async () => {
|
||||
vi.mocked(access).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.isModalTestEnabled();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if file does not exist', async () => {
|
||||
vi.mocked(access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await service.isModalTestEnabled();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModalTestStatus', () => {
|
||||
it('should return enabled status', async () => {
|
||||
vi.mocked(access).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.getModalTestStatus();
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return disabled status', async () => {
|
||||
vi.mocked(access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await service.getModalTestStatus();
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModalTestingGuide', () => {
|
||||
it('should return modal testing guide', () => {
|
||||
const guide = service.getModalTestingGuide();
|
||||
|
||||
expect(guide).toBeInstanceOf(Array);
|
||||
expect(guide[0]).toBe('Modal Testing Guide');
|
||||
expect(guide).toContainEqual(' - Show/hide the Welcome Modal');
|
||||
});
|
||||
});
|
||||
});
|
||||
119
api/src/unraid-api/cli/__test__/developer.command.test.ts
Normal file
119
api/src/unraid-api/cli/__test__/developer.command.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { InquirerService } from 'nest-commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DeveloperToolsService } from '@app/unraid-api/cli/developer/developer-tools.service.js';
|
||||
import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.command.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
|
||||
describe('DeveloperCommand', () => {
|
||||
let module: TestingModule;
|
||||
let command: DeveloperCommand;
|
||||
let developerToolsService: DeveloperToolsService;
|
||||
let logService: LogService;
|
||||
let inquirerService: InquirerService;
|
||||
|
||||
beforeEach(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
DeveloperCommand,
|
||||
{
|
||||
provide: DeveloperToolsService,
|
||||
useValue: {
|
||||
setSandboxMode: vi.fn(),
|
||||
enableModalTest: vi.fn(),
|
||||
disableModalTest: vi.fn(),
|
||||
getModalTestStatus: vi.fn().mockResolvedValue({ enabled: false }),
|
||||
getModalTestingGuide: vi.fn().mockReturnValue(['test guide']),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: InquirerService,
|
||||
useValue: {
|
||||
prompt: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<DeveloperCommand>(DeveloperCommand);
|
||||
developerToolsService = module.get<DeveloperToolsService>(DeveloperToolsService);
|
||||
logService = module.get<LogService>(LogService);
|
||||
inquirerService = module.get<InquirerService>(InquirerService);
|
||||
});
|
||||
|
||||
it('should handle sandbox option directly', async () => {
|
||||
await command.run([], { sandbox: true });
|
||||
|
||||
expect(developerToolsService.setSandboxMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should handle enable-modal option directly', async () => {
|
||||
await command.run([], { 'enable-modal': true });
|
||||
|
||||
expect(developerToolsService.enableModalTest).toHaveBeenCalled();
|
||||
expect(logService.info).toHaveBeenCalledWith('test guide');
|
||||
});
|
||||
|
||||
it('should handle disable-modal option directly', async () => {
|
||||
await command.run([], { 'disable-modal': true });
|
||||
|
||||
expect(developerToolsService.disableModalTest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show modal test status', async () => {
|
||||
vi.mocked(inquirerService.prompt).mockResolvedValue({
|
||||
tool: 'modal-test',
|
||||
modalAction: 'status',
|
||||
});
|
||||
|
||||
await command.run([], {});
|
||||
|
||||
expect(developerToolsService.getModalTestStatus).toHaveBeenCalled();
|
||||
expect(logService.info).toHaveBeenCalledWith('Modal Test Tool Status');
|
||||
});
|
||||
|
||||
it('should handle sandbox selection in interactive mode', async () => {
|
||||
vi.mocked(inquirerService.prompt).mockResolvedValue({
|
||||
tool: 'sandbox',
|
||||
sandboxEnabled: true,
|
||||
});
|
||||
|
||||
await command.run([], {});
|
||||
|
||||
expect(developerToolsService.setSandboxMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should handle modal test enable in interactive mode', async () => {
|
||||
vi.mocked(inquirerService.prompt).mockResolvedValue({
|
||||
tool: 'modal-test',
|
||||
modalAction: 'enable',
|
||||
});
|
||||
|
||||
await command.run([], {});
|
||||
|
||||
expect(developerToolsService.enableModalTest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
vi.mocked(developerToolsService.setSandboxMode).mockRejectedValue(new Error('Test error'));
|
||||
|
||||
await expect(command.run([], { sandbox: true })).rejects.toThrow('process.exit called');
|
||||
|
||||
expect(mockExit).toHaveBeenCalledWith(1);
|
||||
mockExit.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { ListSSOUserCommand } from '@app/unraid-api/cli/sso/list-sso-user.command.js';
|
||||
|
||||
// Mock services
|
||||
const mockInternalClient = {
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ListSSOUserCommand', () => {
|
||||
let command: ListSSOUserCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ListSSOUserCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<ListSSOUserCommand>(ListSSOUserCommand);
|
||||
|
||||
// Clear mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should list all SSO users', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-2', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalledWith({
|
||||
query: expect.anything(),
|
||||
});
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('user-1\nuser-2\nuser-3');
|
||||
});
|
||||
|
||||
it('should display message when no users found', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('No SSO users found');
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
mockInternalClient.getClient.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await expect(command.run([])).rejects.toThrow('Connection failed');
|
||||
});
|
||||
});
|
||||
274
api/src/unraid-api/cli/__test__/plugin.command.test.ts
Normal file
274
api/src/unraid-api/cli/__test__/plugin.command.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import {
|
||||
InstallPluginCommand,
|
||||
ListPluginCommand,
|
||||
RemovePluginCommand,
|
||||
} from '@app/unraid-api/cli/plugins/plugin.command.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
|
||||
// Mock services
|
||||
const mockInternalClient = {
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
log: vi.fn(),
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRestartCommand = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
describe('Plugin Commands', () => {
|
||||
beforeEach(() => {
|
||||
// Clear mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('InstallPluginCommand', () => {
|
||||
let command: InstallPluginCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
InstallPluginCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<InstallPluginCommand>(InstallPluginCommand);
|
||||
});
|
||||
|
||||
it('should install a plugin successfully', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
addPlugin: false, // No manual restart required
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run(['@unraid/plugin-example'], { bundled: false, restart: true });
|
||||
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
names: ['@unraid/plugin-example'],
|
||||
bundled: false,
|
||||
restart: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Added plugin @unraid/plugin-example');
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled(); // Because addPlugin returned false
|
||||
});
|
||||
|
||||
it('should handle bundled plugin installation', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
addPlugin: true, // Manual restart required
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run(['@unraid/bundled-plugin'], { bundled: true, restart: true });
|
||||
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
names: ['@unraid/bundled-plugin'],
|
||||
bundled: true,
|
||||
restart: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Added bundled plugin @unraid/bundled-plugin');
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled(); // Because addPlugin returned true
|
||||
});
|
||||
|
||||
it('should not restart when restart option is false', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
addPlugin: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run(['@unraid/plugin'], { bundled: false, restart: false });
|
||||
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockInternalClient.getClient.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await command.run(['@unraid/plugin'], { bundled: false, restart: true });
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Failed to add plugin:', expect.any(Error));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should error when no package name provided', async () => {
|
||||
await command.run([], { bundled: false, restart: true });
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Package name is required.');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemovePluginCommand', () => {
|
||||
let command: RemovePluginCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
RemovePluginCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<RemovePluginCommand>(RemovePluginCommand);
|
||||
});
|
||||
|
||||
it('should remove a plugin successfully', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
removePlugin: false, // No manual restart required
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run(['@unraid/plugin-example'], { bundled: false, restart: true });
|
||||
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
names: ['@unraid/plugin-example'],
|
||||
bundled: false,
|
||||
restart: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Removed plugin @unraid/plugin-example');
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle removing bundled plugins', async () => {
|
||||
const mockClient = {
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
removePlugin: true, // Manual restart required
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run(['@unraid/bundled-plugin'], { bundled: true, restart: true });
|
||||
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Removed bundled plugin @unraid/bundled-plugin');
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListPluginCommand', () => {
|
||||
let command: ListPluginCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ListPluginCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<ListPluginCommand>(ListPluginCommand);
|
||||
});
|
||||
|
||||
it('should list installed plugins', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
plugins: [
|
||||
{
|
||||
name: '@unraid/plugin-1',
|
||||
version: '1.0.0',
|
||||
hasApiModule: true,
|
||||
hasCliModule: false,
|
||||
},
|
||||
{
|
||||
name: '@unraid/plugin-2',
|
||||
version: '2.0.0',
|
||||
hasApiModule: true,
|
||||
hasCliModule: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalledWith({
|
||||
query: expect.anything(),
|
||||
});
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('Installed plugins:\n');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-1@1.0.0 [API]');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('☑️ @unraid/plugin-2@2.0.0 [API, CLI]');
|
||||
expect(mockLogger.log).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should handle no plugins installed', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
plugins: [],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(mockLogger.log).toHaveBeenCalledWith('No plugins installed.');
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
mockInternalClient.getClient.mockRejectedValue(new Error('Connection failed'));
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('Failed to list plugins:', expect.any(Error));
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
186
api/src/unraid-api/cli/__test__/remove-sso-user.command.test.ts
Normal file
186
api/src/unraid-api/cli/__test__/remove-sso-user.command.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { InquirerService } from 'nest-commander';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
|
||||
import { RemoveSSOUserCommand } from '@app/unraid-api/cli/sso/remove-sso-user.command.js';
|
||||
|
||||
// Mock services
|
||||
const mockInternalClient = {
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRestartCommand = {
|
||||
run: vi.fn(),
|
||||
};
|
||||
|
||||
const mockInquirerService = {
|
||||
prompt: vi.fn(),
|
||||
};
|
||||
|
||||
describe('RemoveSSOUserCommand', () => {
|
||||
let command: RemoveSSOUserCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
RemoveSSOUserCommand,
|
||||
{ provide: CliInternalClientService, useValue: mockInternalClient },
|
||||
{ provide: LogService, useValue: mockLogger },
|
||||
{ provide: RestartCommand, useValue: mockRestartCommand },
|
||||
{ provide: InquirerService, useValue: mockInquirerService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
command = module.get<RemoveSSOUserCommand>(RemoveSSOUserCommand);
|
||||
|
||||
// Clear mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should remove a specific SSO user successfully', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-2', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
updateSettings: {
|
||||
restartRequired: true,
|
||||
values: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
username: 'user-2',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('User removed: user-2');
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('Restarting the API');
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove all SSO users when "all" is selected', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-2', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
updateSettings: {
|
||||
restartRequired: true,
|
||||
values: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
username: 'all',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).toHaveBeenCalledWith({
|
||||
mutation: expect.anything(),
|
||||
variables: {
|
||||
input: {
|
||||
api: {
|
||||
ssoSubIds: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockLogger.info).toHaveBeenCalledWith('All users removed from SSO');
|
||||
expect(mockRestartCommand.run).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not remove user if user does not exist', async () => {
|
||||
const mockClient = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
settings: {
|
||||
api: {
|
||||
ssoSubIds: ['user-1', 'user-3'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
|
||||
mockInternalClient.getClient.mockResolvedValue(mockClient);
|
||||
mockInquirerService.prompt.mockResolvedValue({
|
||||
username: 'user-2',
|
||||
});
|
||||
|
||||
await command.run([]);
|
||||
|
||||
expect(mockClient.query).toHaveBeenCalled();
|
||||
expect(mockClient.mutate).not.toHaveBeenCalled();
|
||||
expect(mockLogger.error).toHaveBeenCalledWith('User user-2 not found in SSO users');
|
||||
expect(mockRestartCommand.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should exit when no SSO users are found', async () => {
|
||||
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit');
|
||||
});
|
||||
|
||||
const error = new Error('No SSO Users Found');
|
||||
(error as any).name = 'NoSSOUsersFoundError';
|
||||
mockInquirerService.prompt.mockRejectedValue(error);
|
||||
|
||||
try {
|
||||
await command.run([]);
|
||||
} catch (error) {
|
||||
// Expected to throw due to process.exit
|
||||
}
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Failed to fetch SSO users: %s',
|
||||
'No SSO Users Found'
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
153
api/src/unraid-api/cli/__test__/report.command.test.ts
Normal file
153
api/src/unraid-api/cli/__test__/report.command.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
|
||||
|
||||
// Mock log service
|
||||
const mockLogService = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock ApiReportService
|
||||
const mockApiReportService = {
|
||||
generateReport: vi.fn(),
|
||||
};
|
||||
|
||||
// Mock PM2 check
|
||||
const mockIsUnraidApiRunning = vi.fn().mockResolvedValue(true);
|
||||
|
||||
vi.mock('@app/core/utils/pm2/unraid-api-running.js', () => ({
|
||||
isUnraidApiRunning: () => mockIsUnraidApiRunning(),
|
||||
}));
|
||||
|
||||
describe('ReportCommand', () => {
|
||||
let reportCommand: ReportCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ReportCommand,
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: ApiReportService, useValue: mockApiReportService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
reportCommand = module.get<ReportCommand>(ReportCommand);
|
||||
|
||||
// Clear mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset PM2 mock to default
|
||||
mockIsUnraidApiRunning.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('report', () => {
|
||||
it('should generate report using ApiReportService when API is running', async () => {
|
||||
const mockReport = {
|
||||
timestamp: '2023-01-01T00:00:00.000Z',
|
||||
connectionStatus: {
|
||||
running: 'yes' as const,
|
||||
},
|
||||
system: {
|
||||
id: 'test-uuid',
|
||||
name: 'Test Server',
|
||||
version: '6.12.0',
|
||||
machineId: 'REDACTED',
|
||||
manufacturer: 'Test Manufacturer',
|
||||
model: 'Test Model',
|
||||
},
|
||||
connect: {
|
||||
installed: true,
|
||||
dynamicRemoteAccess: {
|
||||
enabledType: 'STATIC',
|
||||
runningType: 'STATIC',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
config: {
|
||||
valid: true,
|
||||
error: null,
|
||||
},
|
||||
services: {
|
||||
cloud: { name: 'cloud', online: true },
|
||||
minigraph: { name: 'minigraph', online: false },
|
||||
allServices: [],
|
||||
},
|
||||
remote: {
|
||||
apikey: 'REDACTED',
|
||||
localApiKey: 'REDACTED',
|
||||
accesstoken: 'REDACTED',
|
||||
idtoken: 'REDACTED',
|
||||
refreshtoken: 'REDACTED',
|
||||
ssoSubIds: 'REDACTED',
|
||||
allowedOrigins: 'REDACTED',
|
||||
email: 'REDACTED',
|
||||
},
|
||||
};
|
||||
|
||||
mockApiReportService.generateReport.mockResolvedValue(mockReport);
|
||||
|
||||
await reportCommand.report();
|
||||
|
||||
// Verify ApiReportService was called with correct parameter
|
||||
expect(mockApiReportService.generateReport).toHaveBeenCalledWith(true);
|
||||
|
||||
// Verify report was logged
|
||||
expect(mockLogService.clear).toHaveBeenCalled();
|
||||
expect(mockLogService.info).toHaveBeenCalledWith(JSON.stringify(mockReport, null, 2));
|
||||
});
|
||||
|
||||
it('should handle API not running gracefully', async () => {
|
||||
mockIsUnraidApiRunning.mockResolvedValue(false);
|
||||
|
||||
await reportCommand.report();
|
||||
|
||||
// Verify ApiReportService was not called
|
||||
expect(mockApiReportService.generateReport).not.toHaveBeenCalled();
|
||||
|
||||
// Verify warning was logged
|
||||
expect(mockLogService.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('API is not running')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle ApiReportService errors gracefully', async () => {
|
||||
const error = new Error('Report generation failed');
|
||||
mockApiReportService.generateReport.mockRejectedValue(error);
|
||||
|
||||
await reportCommand.report();
|
||||
|
||||
// Verify error was logged
|
||||
expect(mockLogService.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error generating report via GraphQL')
|
||||
);
|
||||
expect(mockLogService.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to generate system report')
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass correct apiRunning parameter to ApiReportService', async () => {
|
||||
const mockReport = { timestamp: '2023-01-01T00:00:00.000Z' };
|
||||
mockApiReportService.generateReport.mockResolvedValue(mockReport);
|
||||
|
||||
// Test with API running
|
||||
await reportCommand.report();
|
||||
expect(mockApiReportService.generateReport).toHaveBeenCalledWith(true);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Test with API running but PM2 check returns true
|
||||
mockIsUnraidApiRunning.mockResolvedValue(true);
|
||||
await reportCommand.report();
|
||||
expect(mockApiReportService.generateReport).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
api/src/unraid-api/cli/admin-key.service.ts
Normal file
44
api/src/unraid-api/cli/admin-key.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import type { ApiKeyService } from '@unraid/shared/services/api-key.js';
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { API_KEY_SERVICE_TOKEN } from '@unraid/shared/tokens.js';
|
||||
|
||||
/**
|
||||
* Service that creates and manages the admin API key used by CLI commands.
|
||||
* Uses the standard API key storage location via helper methods in ApiKeyService.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AdminKeyService.name);
|
||||
private static readonly ADMIN_KEY_NAME = 'CliInternal';
|
||||
private static readonly ADMIN_KEY_DESCRIPTION =
|
||||
'Internal admin API key used by CLI commands for system operations';
|
||||
|
||||
constructor(
|
||||
@Inject(API_KEY_SERVICE_TOKEN)
|
||||
private readonly apiKeyService: ApiKeyService
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.getOrCreateLocalAdminKey();
|
||||
this.logger.log('Admin API key initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize admin API key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates a local admin API key for CLI operations.
|
||||
* Uses the standard API key storage location.
|
||||
*/
|
||||
public async getOrCreateLocalAdminKey(): Promise<string> {
|
||||
return this.apiKeyService.ensureKey({
|
||||
name: AdminKeyService.ADMIN_KEY_NAME,
|
||||
description: AdminKeyService.ADMIN_KEY_DESCRIPTION,
|
||||
roles: [Role.ADMIN], // Full admin privileges for CLI operations
|
||||
legacyNames: ['CLI', 'Internal', 'CliAdmin'], // Clean up old keys
|
||||
});
|
||||
}
|
||||
}
|
||||
198
api/src/unraid-api/cli/api-report.service.ts
Normal file
198
api/src/unraid-api/cli/api-report.service.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { ConnectStatusQuery, SystemReportQuery } from '@app/unraid-api/cli/generated/graphql.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import {
|
||||
CONNECT_STATUS_QUERY,
|
||||
SERVICES_QUERY,
|
||||
SYSTEM_REPORT_QUERY,
|
||||
} from '@app/unraid-api/cli/queries/system-report.query.js';
|
||||
|
||||
export interface ServiceInfo {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
online?: boolean | null;
|
||||
version?: string | null;
|
||||
uptime?: {
|
||||
timestamp?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ApiReportData {
|
||||
timestamp: string;
|
||||
connectionStatus: {
|
||||
running: 'yes' | 'no';
|
||||
};
|
||||
system: {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
version: string;
|
||||
machineId: string;
|
||||
manufacturer?: string | null;
|
||||
model?: string | null;
|
||||
};
|
||||
connect: {
|
||||
installed: boolean;
|
||||
dynamicRemoteAccess?: {
|
||||
enabledType: string;
|
||||
runningType: string;
|
||||
error?: string | null;
|
||||
};
|
||||
reason?: string;
|
||||
};
|
||||
config: {
|
||||
valid?: boolean | null;
|
||||
error?: string | null;
|
||||
};
|
||||
services: {
|
||||
cloud: ServiceInfo | null;
|
||||
minigraph: ServiceInfo | null;
|
||||
allServices: Array<{
|
||||
name?: string | null;
|
||||
online?: boolean | null;
|
||||
version?: string | null;
|
||||
uptime?: string | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiReportService {
|
||||
constructor(
|
||||
private readonly internalClient: CliInternalClientService,
|
||||
private readonly logger: LogService
|
||||
) {}
|
||||
|
||||
private createApiReportData(params: {
|
||||
apiRunning: boolean;
|
||||
systemData?: SystemReportQuery;
|
||||
connectData?: ConnectStatusQuery['connect'] | null;
|
||||
servicesData?: ServiceInfo[];
|
||||
errorReason?: string;
|
||||
}): ApiReportData {
|
||||
const { apiRunning, systemData, connectData, servicesData = [], errorReason } = params;
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionStatus: {
|
||||
running: apiRunning ? 'yes' : 'no',
|
||||
},
|
||||
system: systemData
|
||||
? {
|
||||
id: systemData.info.system.uuid,
|
||||
name: systemData.server?.name || 'Unknown',
|
||||
version: systemData.info.versions.unraid || 'Unknown',
|
||||
machineId: 'REDACTED',
|
||||
manufacturer: systemData.info.system.manufacturer,
|
||||
model: systemData.info.system.model,
|
||||
}
|
||||
: {
|
||||
name: 'Unknown',
|
||||
version: 'Unknown',
|
||||
machineId: 'REDACTED',
|
||||
},
|
||||
connect: connectData
|
||||
? {
|
||||
installed: true,
|
||||
dynamicRemoteAccess: {
|
||||
enabledType: connectData.dynamicRemoteAccess.enabledType,
|
||||
runningType: connectData.dynamicRemoteAccess.runningType,
|
||||
error: connectData.dynamicRemoteAccess.error || null,
|
||||
},
|
||||
}
|
||||
: {
|
||||
installed: false,
|
||||
reason: errorReason || 'Connect plugin not installed or not available',
|
||||
},
|
||||
config: systemData
|
||||
? {
|
||||
valid: systemData.config.valid,
|
||||
error: systemData.config.error || null,
|
||||
}
|
||||
: {
|
||||
valid: null,
|
||||
error: errorReason || 'Unable to retrieve config',
|
||||
},
|
||||
services: {
|
||||
cloud: servicesData.find((s) => s.name === 'cloud') || null,
|
||||
minigraph: servicesData.find((s) => s.name === 'minigraph') || null,
|
||||
allServices: servicesData.map((s) => ({
|
||||
name: s.name,
|
||||
online: s.online,
|
||||
version: s.version,
|
||||
uptime: s.uptime?.timestamp || null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async generateReport(apiRunning = true): Promise<ApiReportData> {
|
||||
if (!apiRunning) {
|
||||
return this.createApiReportData({
|
||||
apiRunning: false,
|
||||
errorReason: 'API is not running',
|
||||
});
|
||||
}
|
||||
|
||||
const client = await this.internalClient.getClient();
|
||||
|
||||
// Query system data
|
||||
let systemResult: { data: SystemReportQuery } | null = null;
|
||||
try {
|
||||
systemResult = await Promise.race([
|
||||
client.query({
|
||||
query: SYSTEM_REPORT_QUERY,
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Query timeout after 10 seconds')), 10000)
|
||||
),
|
||||
]);
|
||||
} catch (error) {
|
||||
this.logger.error('Error querying system data: ' + error);
|
||||
return this.createApiReportData({
|
||||
apiRunning,
|
||||
errorReason: 'System query failed',
|
||||
});
|
||||
}
|
||||
|
||||
// Try to query connect status
|
||||
let connectData: ConnectStatusQuery['connect'] | null = null;
|
||||
try {
|
||||
const connectResult = await Promise.race([
|
||||
client.query({
|
||||
query: CONNECT_STATUS_QUERY,
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Connect query timeout after 10 seconds')), 10000)
|
||||
),
|
||||
]);
|
||||
connectData = connectResult.data.connect;
|
||||
} catch (error) {
|
||||
this.logger.debug('Connect plugin not available: ' + error);
|
||||
}
|
||||
|
||||
// Query services
|
||||
let servicesData: ServiceInfo[] = [];
|
||||
try {
|
||||
const servicesResult = await Promise.race([
|
||||
client.query({
|
||||
query: SERVICES_QUERY,
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Services query timeout after 10 seconds')), 10000)
|
||||
),
|
||||
]);
|
||||
servicesData = servicesResult.data.services || [];
|
||||
} catch (error) {
|
||||
this.logger.debug('Error querying services: ' + error);
|
||||
}
|
||||
|
||||
return this.createApiReportData({
|
||||
apiRunning,
|
||||
systemData: systemResult.data,
|
||||
connectData,
|
||||
servicesData,
|
||||
});
|
||||
}
|
||||
}
|
||||
39
api/src/unraid-api/cli/cli-services.module.ts
Normal file
39
api/src/unraid-api/cli/cli-services.module.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
|
||||
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.module.js';
|
||||
|
||||
// This module provides only the services from CliModule without the CLI commands
|
||||
// This avoids dependency issues with InquirerService when used in other modules
|
||||
@Module({
|
||||
imports: [
|
||||
LegacyConfigModule,
|
||||
ApiConfigModule,
|
||||
GlobalDepsModule,
|
||||
PluginCliModule.register(),
|
||||
UnraidFileModifierModule,
|
||||
],
|
||||
providers: [
|
||||
LogService,
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
SsoUserService,
|
||||
DependencyService,
|
||||
AdminKeyService,
|
||||
ApiReportService,
|
||||
CliInternalClientService,
|
||||
],
|
||||
exports: [ApiReportService, LogService, ApiKeyService, SsoUserService, CliInternalClientService],
|
||||
})
|
||||
export class CliServicesModule {}
|
||||
@@ -3,12 +3,16 @@ import { Module } from '@nestjs/common';
|
||||
import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { SsoUserService } from '@app/unraid-api/auth/sso-user.service.js';
|
||||
import { AdminKeyService } from '@app/unraid-api/cli/admin-key.service.js';
|
||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
||||
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
|
||||
import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js';
|
||||
import { DeleteApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
|
||||
import { ConfigCommand } from '@app/unraid-api/cli/config.command.js';
|
||||
import { DeveloperToolsService } from '@app/unraid-api/cli/developer/developer-tools.service.js';
|
||||
import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.command.js';
|
||||
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
|
||||
import { CliInternalClientService } from '@app/unraid-api/cli/internal-client.service.js';
|
||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
||||
import { LogsCommand } from '@app/unraid-api/cli/logs.command.js';
|
||||
import {
|
||||
@@ -34,6 +38,7 @@ import { SwitchEnvCommand } from '@app/unraid-api/cli/switch-env.command.js';
|
||||
import { VersionCommand } from '@app/unraid-api/cli/version.command.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
|
||||
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||
import { PluginCliModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
|
||||
const DEFAULT_COMMANDS = [
|
||||
@@ -68,15 +73,20 @@ const DEFAULT_PROVIDERS = [
|
||||
AddSSOUserQuestionSet,
|
||||
RemoveSSOUserQuestionSet,
|
||||
DeveloperQuestions,
|
||||
DeveloperToolsService,
|
||||
LogService,
|
||||
PM2Service,
|
||||
ApiKeyService,
|
||||
SsoUserService,
|
||||
DependencyService,
|
||||
AdminKeyService,
|
||||
ApiReportService,
|
||||
CliInternalClientService,
|
||||
] as const;
|
||||
|
||||
@Module({
|
||||
imports: [LegacyConfigModule, ApiConfigModule, PluginCliModule.register()],
|
||||
imports: [LegacyConfigModule, ApiConfigModule, GlobalDepsModule, PluginCliModule.register()],
|
||||
providers: [...DEFAULT_COMMANDS, ...DEFAULT_PROVIDERS],
|
||||
exports: [ApiReportService],
|
||||
})
|
||||
export class CliModule {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user