mirror of
https://github.com/unraid/api.git
synced 2026-01-04 07:29:48 -06:00
Compare commits
40 Commits
feat/trans
...
4.20.3-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24e834bcbe | ||
|
|
6ed2f5ce8e | ||
|
|
e1bbe0ca47 | ||
|
|
b79b44e95c | ||
|
|
5b33e90ed5 | ||
|
|
ca22285a26 | ||
|
|
4fa89b95dc | ||
|
|
20986b217b | ||
|
|
74835d1938 | ||
|
|
838be2c52e | ||
|
|
73c1100d0b | ||
|
|
434e331384 | ||
|
|
e76dd65b41 | ||
|
|
28a1ec552e | ||
|
|
34d542fecf | ||
|
|
c128d8e3fc | ||
|
|
473608eba3 | ||
|
|
49189d9bb7 | ||
|
|
7a6806835c | ||
|
|
d1c98495c9 | ||
|
|
fc5fb1a1ba | ||
|
|
05bbe84175 | ||
|
|
99a2103d16 | ||
|
|
240e104cca | ||
|
|
90aecc3df3 | ||
|
|
88b08754ea | ||
|
|
5f728c06f7 | ||
|
|
cf96f14a4b | ||
|
|
aa04064949 | ||
|
|
f9ebcb6155 | ||
|
|
f704143ed3 | ||
|
|
261d6c55ed | ||
|
|
bb6dac2913 | ||
|
|
19fa436287 | ||
|
|
95560c9155 | ||
|
|
17d151a020 | ||
|
|
21466f1b88 | ||
|
|
9655c006fe | ||
|
|
a9e62b4c3b | ||
|
|
81aff48821 |
45
.github/workflows/main.yml
vendored
45
.github/workflows/main.yml
vendored
@@ -11,24 +11,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
name: Release Please
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
# Only run release-please on pushes to main
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
- id: release
|
||||
uses: googleapis/release-please-action@v4
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
outputs:
|
||||
releases_created: ${{ steps.release.outputs.releases_created || 'false' }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name || '' }}
|
||||
test-api:
|
||||
name: Test API
|
||||
defaults:
|
||||
@@ -386,10 +368,32 @@ jobs:
|
||||
name: unraid-wc-rich
|
||||
path: web/dist
|
||||
|
||||
release-please:
|
||||
name: Release Please
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on pushes to main AND after tests pass
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-api
|
||||
- build-api
|
||||
- build-web
|
||||
- build-unraid-ui-webcomponents
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- id: release
|
||||
uses: googleapis/release-please-action@v4
|
||||
outputs:
|
||||
releases_created: ${{ steps.release.outputs.releases_created || 'false' }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name || '' }}
|
||||
|
||||
build-plugin-staging-pr:
|
||||
name: Build and Deploy Plugin
|
||||
needs:
|
||||
- release-please
|
||||
- build-api
|
||||
- build-web
|
||||
- build-unraid-ui-webcomponents
|
||||
@@ -413,9 +417,6 @@ jobs:
|
||||
needs:
|
||||
- release-please
|
||||
- build-api
|
||||
- build-web
|
||||
- build-unraid-ui-webcomponents
|
||||
- test-api
|
||||
uses: ./.github/workflows/build-plugin.yml
|
||||
with:
|
||||
RELEASE_CREATED: true
|
||||
|
||||
95
.github/workflows/push-staging-pr-on-close.yml
vendored
95
.github/workflows/push-staging-pr-on-close.yml
vendored
@@ -1,4 +1,9 @@
|
||||
name: Push Staging Plugin on PR Close
|
||||
name: Replace PR Plugin with Staging Redirect on Merge
|
||||
|
||||
# This workflow runs when a PR is merged and replaces the PR-specific plugin
|
||||
# with a redirect version that points to the main staging URL.
|
||||
# This ensures users who installed the PR version will automatically
|
||||
# update to the staging version on their next update check.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -17,7 +22,7 @@ on:
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
push-staging:
|
||||
push-staging-redirect:
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.merged == true) || (github.event_name == 'workflow_dispatch' && inputs.pr_merged == true)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -45,11 +50,12 @@ jobs:
|
||||
name: unraid-plugin-.*
|
||||
path: connect-files
|
||||
pr: ${{ steps.pr_number.outputs.pr_number }}
|
||||
workflow: main.yml
|
||||
workflow_conclusion: success
|
||||
workflow_search: true
|
||||
search_artifacts: true
|
||||
if_no_artifact_found: fail
|
||||
|
||||
- name: Update Downloaded Staging Plugin to New Date
|
||||
- name: Update Downloaded Plugin to Redirect to Staging
|
||||
run: |
|
||||
# Find the .plg file in the downloaded artifact
|
||||
plgfile=$(find connect-files -name "*.plg" -type f | head -1)
|
||||
@@ -60,23 +66,82 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "Found plugin file: $plgfile"
|
||||
version=$(date +"%Y.%m.%d.%H%M")
|
||||
sed -i -E "s#(<!ENTITY version \").*(\">)#\1${version}\2#g" "${plgfile}" || exit 1
|
||||
|
||||
# Get current version and bump it with current timestamp
|
||||
current_version=$(grep '<!ENTITY version' "${plgfile}" | sed -E 's/.*"(.*)".*/\1/')
|
||||
echo "Current version: ${current_version}"
|
||||
|
||||
# Create new version with current timestamp (ensures it's newer)
|
||||
new_version=$(date +"%Y.%m.%d.%H%M")
|
||||
echo "New redirect version: ${new_version}"
|
||||
|
||||
# Update version to trigger update
|
||||
sed -i -E "s#(<!ENTITY version \").*(\">)#\1${new_version}\2#g" "${plgfile}" || exit 1
|
||||
|
||||
# Change the plugin url to point to staging
|
||||
# Change the plugin url to point to staging - users will switch to staging on next update
|
||||
url="https://preview.dl.unraid.net/unraid-api/dynamix.unraid.net.plg"
|
||||
sed -i -E "s#(<!ENTITY plugin_url \").*?(\">)#\1${url}\2#g" "${plgfile}" || exit 1
|
||||
cat "${plgfile}"
|
||||
|
||||
echo "Modified plugin to redirect to: ${url}"
|
||||
echo "Version bumped from ${current_version} to ${new_version}"
|
||||
|
||||
mkdir -p pr-release
|
||||
mv "${plgfile}" pr-release/dynamix.unraid.net.plg
|
||||
|
||||
- name: Upload to Cloudflare
|
||||
uses: jakejarvis/s3-sync-action@v0.5.1
|
||||
- name: Clean up old PR artifacts from Cloudflare
|
||||
env:
|
||||
AWS_S3_ENDPOINT: ${{ secrets.CF_ENDPOINT }}
|
||||
AWS_S3_BUCKET: ${{ secrets.CF_BUCKET_PREVIEW }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION: "auto"
|
||||
SOURCE_DIR: pr-release
|
||||
DEST_DIR: unraid-api/tag/PR${{ steps.pr_number.outputs.pr_number }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
run: |
|
||||
# Delete all existing files in the PR directory first (txz, plg, etc.)
|
||||
aws s3 rm s3://${{ secrets.CF_BUCKET_PREVIEW }}/unraid-api/tag/PR${{ steps.pr_number.outputs.pr_number }}/ \
|
||||
--recursive \
|
||||
--endpoint-url ${{ secrets.CF_ENDPOINT }}
|
||||
|
||||
echo "✅ Cleaned up old PR artifacts"
|
||||
|
||||
- name: Upload PR Redirect Plugin to Cloudflare
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
run: |
|
||||
# Upload only the redirect plugin file
|
||||
aws s3 cp pr-release/dynamix.unraid.net.plg \
|
||||
s3://${{ secrets.CF_BUCKET_PREVIEW }}/unraid-api/tag/PR${{ steps.pr_number.outputs.pr_number }}/dynamix.unraid.net.plg \
|
||||
--endpoint-url ${{ secrets.CF_ENDPOINT }} \
|
||||
--content-encoding none \
|
||||
--acl public-read
|
||||
|
||||
echo "✅ Uploaded redirect plugin"
|
||||
|
||||
- name: Output redirect information
|
||||
run: |
|
||||
echo "✅ PR plugin replaced with staging redirect version"
|
||||
echo "PR URL remains: https://preview.dl.unraid.net/unraid-api/tag/PR${{ steps.pr_number.outputs.pr_number }}/dynamix.unraid.net.plg"
|
||||
echo "Redirects users to staging: https://preview.dl.unraid.net/unraid-api/dynamix.unraid.net.plg"
|
||||
echo "Users updating from this PR version will automatically switch to staging"
|
||||
|
||||
- name: Comment on PR about staging redirect
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
comment-tag: pr-closed-staging
|
||||
mode: recreate
|
||||
message: |
|
||||
## 🔄 PR Merged - Plugin Redirected to Staging
|
||||
|
||||
This PR has been merged and the preview plugin has been updated to redirect to the staging version.
|
||||
|
||||
**For users testing this PR:**
|
||||
- Your plugin will automatically update to the staging version on the next update check
|
||||
- The staging version includes all merged changes from this PR
|
||||
- No manual intervention required
|
||||
|
||||
**Staging URL:**
|
||||
```
|
||||
https://preview.dl.unraid.net/unraid-api/dynamix.unraid.net.plg
|
||||
```
|
||||
|
||||
Thank you for testing! 🚀
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.20.1"}
|
||||
{".":"4.20.3"}
|
||||
|
||||
@@ -75,18 +75,19 @@
|
||||
/*
|
||||
* Dynamic color variables for user overrides from GraphQL
|
||||
* These are set via JavaScript and override the theme defaults
|
||||
* Using :root with class for higher specificity to override theme classes
|
||||
*/
|
||||
.has-custom-header-text {
|
||||
:root.has-custom-header-text {
|
||||
--header-text-primary: var(--custom-header-text-primary);
|
||||
--color-header-text-primary: var(--custom-header-text-primary);
|
||||
}
|
||||
|
||||
.has-custom-header-meta {
|
||||
:root.has-custom-header-meta {
|
||||
--header-text-secondary: var(--custom-header-text-secondary);
|
||||
--color-header-text-secondary: var(--custom-header-text-secondary);
|
||||
}
|
||||
|
||||
.has-custom-header-bg {
|
||||
:root.has-custom-header-bg {
|
||||
--header-background-color: var(--custom-header-background-color);
|
||||
--color-header-background: var(--custom-header-background-color);
|
||||
--header-gradient-start: var(--custom-header-gradient-start);
|
||||
|
||||
@@ -31,3 +31,4 @@ BYPASS_CORS_CHECKS=true
|
||||
CHOKIDAR_USEPOLLING=true
|
||||
LOG_TRANSPORT=console
|
||||
LOG_LEVEL=trace
|
||||
ENABLE_NEXT_DOCKER_RELEASE=true
|
||||
|
||||
3
api/.gitignore
vendored
3
api/.gitignore
vendored
@@ -93,3 +93,6 @@ dev/local-session
|
||||
|
||||
# local OIDC config for testing - contains secrets
|
||||
dev/configs/oidc.local.json
|
||||
|
||||
# local api keys
|
||||
dev/keys/*
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [4.20.3](https://github.com/unraid/api/compare/v4.20.2...v4.20.3) (2025-09-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* header background color issues fixed on 7.2 - thanks Nick! ([73c1100](https://github.com/unraid/api/commit/73c1100d0ba396fe4342f8ce7561017ab821e68b))
|
||||
|
||||
## [4.20.2](https://github.com/unraid/api/compare/v4.20.1...v4.20.2) (2025-09-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* trigger deployment ([a27453f](https://github.com/unraid/api/commit/a27453fda81e4eeb07f257e60516bebbbc27cf7a))
|
||||
|
||||
## [4.20.1](https://github.com/unraid/api/compare/v4.20.0...v4.20.1) (2025-09-09)
|
||||
|
||||
|
||||
|
||||
247
api/docs/developer/feature-flags.md
Normal file
247
api/docs/developer/feature-flags.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Feature Flags
|
||||
|
||||
Feature flags allow you to conditionally enable or disable functionality in the Unraid API. This is useful for gradually rolling out new features, A/B testing, or keeping experimental code behind flags during development.
|
||||
|
||||
## Setting Up Feature Flags
|
||||
|
||||
### 1. Define the Feature Flag
|
||||
|
||||
Feature flags are defined as environment variables and collected in `src/consts.ts`:
|
||||
|
||||
```typescript
|
||||
// src/environment.ts
|
||||
export const ENABLE_MY_NEW_FEATURE = process.env.ENABLE_MY_NEW_FEATURE === 'true';
|
||||
|
||||
// src/consts.ts
|
||||
export const FeatureFlags = Object.freeze({
|
||||
ENABLE_NEXT_DOCKER_RELEASE,
|
||||
ENABLE_MY_NEW_FEATURE, // Add your new flag here
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Set the Environment Variable
|
||||
|
||||
Set the environment variable when running the API:
|
||||
|
||||
```bash
|
||||
ENABLE_MY_NEW_FEATURE=true unraid-api start
|
||||
```
|
||||
|
||||
Or add it to your `.env` file:
|
||||
|
||||
```env
|
||||
ENABLE_MY_NEW_FEATURE=true
|
||||
```
|
||||
|
||||
## Using Feature Flags in GraphQL
|
||||
|
||||
### Method 1: @UseFeatureFlag Decorator (Schema-Level)
|
||||
|
||||
The `@UseFeatureFlag` decorator conditionally includes or excludes GraphQL fields, queries, and mutations from the schema based on feature flags. When a feature flag is disabled, the field won't appear in the GraphQL schema at all.
|
||||
|
||||
```typescript
|
||||
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
|
||||
import { Query, Mutation, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
@Resolver()
|
||||
export class MyResolver {
|
||||
|
||||
// Conditionally include a query
|
||||
@UseFeatureFlag('ENABLE_MY_NEW_FEATURE')
|
||||
@Query(() => String)
|
||||
async experimentalQuery() {
|
||||
return 'This query only exists when ENABLE_MY_NEW_FEATURE is true';
|
||||
}
|
||||
|
||||
// Conditionally include a mutation
|
||||
@UseFeatureFlag('ENABLE_MY_NEW_FEATURE')
|
||||
@Mutation(() => Boolean)
|
||||
async experimentalMutation() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Conditionally include a field resolver
|
||||
@UseFeatureFlag('ENABLE_MY_NEW_FEATURE')
|
||||
@ResolveField(() => String)
|
||||
async experimentalField() {
|
||||
return 'This field only exists when the flag is enabled';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Clean schema - disabled features don't appear in GraphQL introspection
|
||||
- No runtime overhead for disabled features
|
||||
- Clear feature boundaries
|
||||
|
||||
**Use when:**
|
||||
- You want to completely hide features from the GraphQL schema
|
||||
- The feature is experimental or in beta
|
||||
- You're doing a gradual rollout
|
||||
|
||||
### Method 2: checkFeatureFlag Function (Runtime)
|
||||
|
||||
The `checkFeatureFlag` function provides runtime feature flag checking within resolver methods. It throws a `ForbiddenException` if the feature is disabled.
|
||||
|
||||
```typescript
|
||||
import { checkFeatureFlag } from '@app/unraid-api/utils/feature-flag.helper.js';
|
||||
import { FeatureFlags } from '@app/consts.js';
|
||||
import { Query, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
@Resolver()
|
||||
export class MyResolver {
|
||||
|
||||
@Query(() => String)
|
||||
async myQuery(
|
||||
@Args('useNewAlgorithm', { nullable: true }) useNewAlgorithm?: boolean
|
||||
) {
|
||||
// Conditionally use new logic based on feature flag
|
||||
if (useNewAlgorithm) {
|
||||
checkFeatureFlag(FeatureFlags, 'ENABLE_MY_NEW_FEATURE');
|
||||
return this.newAlgorithm();
|
||||
}
|
||||
|
||||
return this.oldAlgorithm();
|
||||
}
|
||||
|
||||
@ResolveField(() => String)
|
||||
async dataField() {
|
||||
// Check flag at the start of the method
|
||||
checkFeatureFlag(FeatureFlags, 'ENABLE_MY_NEW_FEATURE');
|
||||
|
||||
// Feature-specific logic here
|
||||
return this.computeExperimentalData();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- More granular control within methods
|
||||
- Can conditionally execute parts of a method
|
||||
- Useful for A/B testing scenarios
|
||||
- Good for gradual migration strategies
|
||||
|
||||
**Use when:**
|
||||
- You need conditional logic within a method
|
||||
- The field should exist but behavior changes based on the flag
|
||||
- You're migrating from old to new implementation gradually
|
||||
|
||||
## Feature Flag Patterns
|
||||
|
||||
### Pattern 1: Complete Feature Toggle
|
||||
|
||||
Hide an entire feature behind a flag:
|
||||
|
||||
```typescript
|
||||
@UseFeatureFlag('ENABLE_DOCKER_TEMPLATES')
|
||||
@Resolver(() => DockerTemplate)
|
||||
export class DockerTemplateResolver {
|
||||
// All resolvers in this class are toggled by the flag
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Gradual Migration
|
||||
|
||||
Migrate from old to new implementation:
|
||||
|
||||
```typescript
|
||||
@Query(() => [Container])
|
||||
async getContainers(@Args('version') version?: string) {
|
||||
if (version === 'v2') {
|
||||
checkFeatureFlag(FeatureFlags, 'ENABLE_CONTAINERS_V2');
|
||||
return this.getContainersV2();
|
||||
}
|
||||
|
||||
return this.getContainersV1();
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Beta Features
|
||||
|
||||
Mark features as beta:
|
||||
|
||||
```typescript
|
||||
@UseFeatureFlag('ENABLE_BETA_FEATURES')
|
||||
@ResolveField(() => BetaMetrics, {
|
||||
description: 'BETA: Advanced metrics (requires ENABLE_BETA_FEATURES flag)'
|
||||
})
|
||||
async betaMetrics() {
|
||||
return this.computeBetaMetrics();
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Performance Optimizations
|
||||
|
||||
Toggle expensive operations:
|
||||
|
||||
```typescript
|
||||
@ResolveField(() => Statistics)
|
||||
async statistics() {
|
||||
const basicStats = await this.getBasicStats();
|
||||
|
||||
try {
|
||||
checkFeatureFlag(FeatureFlags, 'ENABLE_ADVANCED_ANALYTICS');
|
||||
const advancedStats = await this.getAdvancedStats();
|
||||
return { ...basicStats, ...advancedStats };
|
||||
} catch {
|
||||
// Feature disabled, return only basic stats
|
||||
return basicStats;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing with Feature Flags
|
||||
|
||||
When writing tests for feature-flagged code, create a mock to control feature flag values:
|
||||
|
||||
```typescript
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock the entire consts module
|
||||
vi.mock('@app/consts.js', async () => {
|
||||
const actual = await vi.importActual('@app/consts.js');
|
||||
return {
|
||||
...actual,
|
||||
FeatureFlags: {
|
||||
ENABLE_MY_NEW_FEATURE: true, // Set your test value
|
||||
ENABLE_NEXT_DOCKER_RELEASE: false,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('MyResolver', () => {
|
||||
it('should execute new logic when feature is enabled', async () => {
|
||||
// Test new behavior with mocked flag
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Naming Convention**: Use `ENABLE_` prefix for boolean feature flags
|
||||
2. **Environment Variables**: Always use uppercase with underscores
|
||||
3. **Documentation**: Document what each feature flag controls
|
||||
4. **Cleanup**: Remove feature flags once features are stable and fully rolled out
|
||||
5. **Default State**: New features should default to `false` (disabled)
|
||||
6. **Granularity**: Keep feature flags focused on a single feature or capability
|
||||
7. **Testing**: Always test both enabled and disabled states
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
- **Experimental Features**: Hide unstable features in production
|
||||
- **Gradual Rollouts**: Enable features for specific environments first
|
||||
- **A/B Testing**: Toggle between different implementations
|
||||
- **Performance**: Disable expensive operations when not needed
|
||||
- **Breaking Changes**: Provide migration path with both old and new behavior
|
||||
- **Debug Features**: Enable additional logging or debugging tools
|
||||
|
||||
## Checking Active Feature Flags
|
||||
|
||||
To see which feature flags are currently active:
|
||||
|
||||
```typescript
|
||||
// Log all feature flags on startup
|
||||
console.log('Active Feature Flags:', FeatureFlags);
|
||||
```
|
||||
|
||||
Or check via GraphQL introspection to see which fields are available based on current flags.
|
||||
@@ -139,6 +139,9 @@ type ArrayDisk implements Node {
|
||||
"""ata | nvme | usb | (others)"""
|
||||
transport: String
|
||||
color: ArrayDiskFsColor
|
||||
|
||||
"""Whether the disk is currently spinning"""
|
||||
isSpinning: Boolean
|
||||
}
|
||||
|
||||
interface Node {
|
||||
@@ -346,6 +349,9 @@ type Disk implements Node {
|
||||
|
||||
"""The partitions on the disk"""
|
||||
partitions: [DiskPartition!]!
|
||||
|
||||
"""Whether the disk is spinning or not"""
|
||||
isSpinning: Boolean!
|
||||
}
|
||||
|
||||
"""The type of interface the disk uses to connect to the system"""
|
||||
@@ -1083,6 +1089,8 @@ type DockerContainer implements Node {
|
||||
networkSettings: JSON
|
||||
mounts: [JSON!]
|
||||
autoStart: Boolean!
|
||||
isUpdateAvailable: Boolean
|
||||
isRebuildReady: Boolean
|
||||
}
|
||||
|
||||
enum ContainerState {
|
||||
@@ -1113,6 +1121,7 @@ type Docker implements Node {
|
||||
containers(skipCache: Boolean! = false): [DockerContainer!]!
|
||||
networks(skipCache: Boolean! = false): [DockerNetwork!]!
|
||||
organizer: ResolvedOrganizerV1!
|
||||
containerUpdateStatuses: JSON!
|
||||
}
|
||||
|
||||
type ResolvedOrganizerView {
|
||||
@@ -2413,6 +2422,7 @@ type Mutation {
|
||||
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
|
||||
deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1!
|
||||
moveDockerEntriesToFolder(sourceEntryIds: [String!]!, destinationFolderId: String!): ResolvedOrganizerV1!
|
||||
refreshDockerDigests: Boolean!
|
||||
|
||||
"""Initiates a flash drive backup using a configured remote."""
|
||||
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.20.1",
|
||||
"version": "4.20.3",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
@@ -94,7 +94,7 @@
|
||||
"command-exists": "1.2.9",
|
||||
"convert": "5.12.0",
|
||||
"cookie": "1.0.2",
|
||||
"cron": "4.3.3",
|
||||
"cron": "4.3.0",
|
||||
"cross-fetch": "4.1.0",
|
||||
"diff": "8.0.2",
|
||||
"dockerode": "4.0.7",
|
||||
@@ -114,7 +114,6 @@
|
||||
"graphql-subscriptions": "3.0.0",
|
||||
"graphql-tag": "2.12.6",
|
||||
"graphql-ws": "6.0.6",
|
||||
"i18next": "^25.5.2",
|
||||
"ini": "5.0.0",
|
||||
"ip": "2.0.1",
|
||||
"jose": "6.0.13",
|
||||
@@ -124,7 +123,6 @@
|
||||
"mustache": "4.2.0",
|
||||
"nest-authz": "2.17.0",
|
||||
"nest-commander": "3.19.0",
|
||||
"nestjs-i18n": "^10.5.1",
|
||||
"nestjs-pino": "4.4.0",
|
||||
"node-cache": "5.1.2",
|
||||
"node-window-polyfill": "1.0.4",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { join } from 'path';
|
||||
|
||||
import type { JSONWebKeySet } from 'jose';
|
||||
|
||||
import { PORT } from '@app/environment.js';
|
||||
import { ENABLE_NEXT_DOCKER_RELEASE, PORT } from '@app/environment.js';
|
||||
|
||||
export const getInternalApiAddress = (isHttp = true, nginxPort = 80) => {
|
||||
const envPort = PORT;
|
||||
@@ -79,3 +79,14 @@ export const KEYSERVER_VALIDATION_ENDPOINT = 'https://keys.lime-technology.com/v
|
||||
|
||||
/** Set the max retries for the GraphQL Client */
|
||||
export const MAX_RETRIES_FOR_LINEAR_BACKOFF = 100;
|
||||
|
||||
/**
|
||||
* Feature flags are used to conditionally enable or disable functionality in the Unraid API.
|
||||
*
|
||||
* Keys are human readable feature flag names -- will be used to construct error messages.
|
||||
*
|
||||
* Values are boolean/truthy values.
|
||||
*/
|
||||
export const FeatureFlags = Object.freeze({
|
||||
ENABLE_NEXT_DOCKER_RELEASE,
|
||||
});
|
||||
|
||||
@@ -110,3 +110,6 @@ export const PATHS_CONFIG_MODULES =
|
||||
|
||||
export const PATHS_LOCAL_SESSION_FILE =
|
||||
process.env.PATHS_LOCAL_SESSION_FILE ?? '/var/run/unraid-api/local-session';
|
||||
|
||||
/** feature flag for the upcoming docker release */
|
||||
export const ENABLE_NEXT_DOCKER_RELEASE = process.env.ENABLE_NEXT_DOCKER_RELEASE === 'true';
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"hello": "Hello",
|
||||
"welcome": "Welcome to Unraid API",
|
||||
"server": {
|
||||
"started": "Server started successfully",
|
||||
"stopped": "Server stopped",
|
||||
"error": "Server error occurred"
|
||||
},
|
||||
"auth": {
|
||||
"unauthorized": "Unauthorized access",
|
||||
"forbidden": "Access forbidden",
|
||||
"invalidToken": "Invalid authentication token",
|
||||
"tokenExpired": "Authentication token expired",
|
||||
"loginSuccess": "Login successful",
|
||||
"logoutSuccess": "Logout successful"
|
||||
},
|
||||
"docker": {
|
||||
"containerStarted": "Container {{name}} started",
|
||||
"containerStopped": "Container {{name}} stopped",
|
||||
"containerRemoved": "Container {{name}} removed",
|
||||
"imageDeleted": "Image {{name}} deleted"
|
||||
},
|
||||
"vm": {
|
||||
"started": "Virtual machine {{name}} started",
|
||||
"stopped": "Virtual machine {{name}} stopped",
|
||||
"paused": "Virtual machine {{name}} paused",
|
||||
"resumed": "Virtual machine {{name}} resumed"
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"notFound": "Resource not found",
|
||||
"internalError": "Internal server error",
|
||||
"badRequest": "Bad request",
|
||||
"validation": {
|
||||
"required": "{{field}} is required",
|
||||
"invalid": "{{field}} is invalid",
|
||||
"minLength": "{{field}} must be at least {{min}} characters",
|
||||
"maxLength": "{{field}} must not exceed {{max}} characters",
|
||||
"email": "Invalid email format",
|
||||
"numeric": "{{field}} must be a number",
|
||||
"range": "{{field}} must be between {{min}} and {{max}}"
|
||||
},
|
||||
"docker": {
|
||||
"containerNotFound": "Container {{id}} not found",
|
||||
"imageNotFound": "Image {{id}} not found",
|
||||
"networkNotFound": "Network {{id}} not found",
|
||||
"volumeNotFound": "Volume {{id}} not found",
|
||||
"operationFailed": "Docker operation failed: {{error}}"
|
||||
},
|
||||
"vm": {
|
||||
"notFound": "Virtual machine {{name}} not found",
|
||||
"invalidState": "Invalid VM state for operation",
|
||||
"operationFailed": "VM operation failed: {{error}}"
|
||||
},
|
||||
"plugin": {
|
||||
"notFound": "Plugin {{name}} not found",
|
||||
"installFailed": "Failed to install plugin {{name}}",
|
||||
"uninstallFailed": "Failed to uninstall plugin {{name}}",
|
||||
"invalidManifest": "Invalid plugin manifest"
|
||||
},
|
||||
"file": {
|
||||
"notFound": "File not found: {{path}}",
|
||||
"accessDenied": "Access denied: {{path}}",
|
||||
"readError": "Failed to read file: {{path}}",
|
||||
"writeError": "Failed to write file: {{path}}"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"isNotEmpty": "{{property}} should not be empty",
|
||||
"isEmail": "{{property}} must be a valid email",
|
||||
"isString": "{{property}} must be a string",
|
||||
"isNumber": "{{property}} must be a number",
|
||||
"isBoolean": "{{property}} must be a boolean",
|
||||
"isArray": "{{property}} must be an array",
|
||||
"isObject": "{{property}} must be an object",
|
||||
"isEnum": "{{property}} must be one of: {{values}}",
|
||||
"minLength": "{{property}} must be at least {{min}} characters",
|
||||
"maxLength": "{{property}} must not exceed {{max}} characters",
|
||||
"min": "{{property}} must be at least {{min}}",
|
||||
"max": "{{property}} must not exceed {{max}}",
|
||||
"matches": "{{property}} format is invalid",
|
||||
"isUUID": "{{property}} must be a valid UUID",
|
||||
"isURL": "{{property}} must be a valid URL",
|
||||
"isIP": "{{property}} must be a valid IP address",
|
||||
"isPort": "{{property}} must be a valid port number",
|
||||
"isPath": "{{property}} must be a valid path"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { HeaderResolver, I18nModule, QueryResolver, AcceptLanguageResolver } from 'nestjs-i18n';
|
||||
import * as path from 'path';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
I18nModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
fallbackLanguage: 'en',
|
||||
loaderOptions: {
|
||||
path: path.join(__dirname),
|
||||
watch: configService.get('NODE_ENV') === 'development',
|
||||
},
|
||||
resolvers: [
|
||||
new QueryResolver(['lang', 'locale', 'l']),
|
||||
new HeaderResolver(['x-locale', 'x-lang']),
|
||||
new AcceptLanguageResolver(),
|
||||
],
|
||||
typesOutputPath: path.join(__dirname, '../../src/generated/i18n.generated.ts'),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [I18nModule],
|
||||
})
|
||||
export class AppI18nModule {}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { I18nService, I18nContext } from 'nestjs-i18n';
|
||||
|
||||
@Injectable()
|
||||
export class ExampleI18nService {
|
||||
constructor(private readonly i18n: I18nService) {}
|
||||
|
||||
// Basic translation
|
||||
getWelcomeMessage(lang?: string): string {
|
||||
return this.i18n.translate('common.welcome', { lang });
|
||||
}
|
||||
|
||||
// Translation with interpolation
|
||||
getContainerStartedMessage(containerName: string, lang?: string): string {
|
||||
return this.i18n.translate('common.docker.containerStarted', {
|
||||
args: { name: containerName },
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
// Using context from request
|
||||
async getErrorMessage(errorKey: string): Promise<string> {
|
||||
const context = I18nContext.current();
|
||||
return this.i18n.translate(`errors.${errorKey}`, {
|
||||
lang: context?.lang,
|
||||
});
|
||||
}
|
||||
|
||||
// Validation message with parameters
|
||||
getValidationMessage(field: string, min: number, max: number, lang?: string): string {
|
||||
return this.i18n.translate('errors.validation.range', {
|
||||
args: { field, min, max },
|
||||
lang,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,12 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { apiLogger } from '@app/core/log.js';
|
||||
import { LOG_LEVEL } from '@app/environment.js';
|
||||
import { AppI18nModule } from '@app/i18n/i18n.module.js';
|
||||
import { PubSubModule } from '@app/unraid-api/app/pubsub.module.js';
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
|
||||
import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js';
|
||||
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
|
||||
import { CronModule } from '@app/unraid-api/cron/cron.module.js';
|
||||
import { JobModule } from '@app/unraid-api/cron/job.module.js';
|
||||
import { GraphModule } from '@app/unraid-api/graph/graph.module.js';
|
||||
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
|
||||
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
|
||||
@@ -24,9 +24,8 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
|
||||
imports: [
|
||||
GlobalDepsModule,
|
||||
LegacyConfigModule,
|
||||
AppI18nModule,
|
||||
PubSubModule,
|
||||
ScheduleModule.forRoot(),
|
||||
JobModule,
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
logger: apiLogger,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
import { JobModule } from '@app/unraid-api/cron/job.module.js';
|
||||
import { LogRotateService } from '@app/unraid-api/cron/log-rotate.service.js';
|
||||
import { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [JobModule],
|
||||
providers: [WriteFlashFileService, LogRotateService],
|
||||
})
|
||||
export class CronModule {}
|
||||
|
||||
13
api/src/unraid-api/cron/job.module.ts
Normal file
13
api/src/unraid-api/cron/job.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
/**
|
||||
* Sets up common dependencies for initializing jobs (e.g. scheduler registry, cron jobs).
|
||||
*
|
||||
* Simplifies testing setup & application dependency tree by ensuring `forRoot` is called only once.
|
||||
*/
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
exports: [ScheduleModule],
|
||||
})
|
||||
export class JobModule {}
|
||||
172
api/src/unraid-api/decorators/omit-if.decorator.spec.ts
Normal file
172
api/src/unraid-api/decorators/omit-if.decorator.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Field, Mutation, ObjectType, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OMIT_IF_METADATA_KEY, OmitIf } from '@app/unraid-api/decorators/omit-if.decorator.js';
|
||||
|
||||
describe('OmitIf Decorator', () => {
|
||||
let reflector: Reflector;
|
||||
|
||||
beforeEach(() => {
|
||||
reflector = new Reflector();
|
||||
});
|
||||
|
||||
describe('OmitIf', () => {
|
||||
it('should set metadata when condition is true', () => {
|
||||
class TestResolver {
|
||||
@OmitIf(true)
|
||||
testMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
|
||||
expect(metadata).toBe(true);
|
||||
});
|
||||
|
||||
it('should not set metadata when condition is false', () => {
|
||||
class TestResolver {
|
||||
@OmitIf(false)
|
||||
testMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should evaluate function conditions', () => {
|
||||
const mockCondition = vi.fn(() => true);
|
||||
|
||||
class TestResolver {
|
||||
@OmitIf(mockCondition)
|
||||
testMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockCondition).toHaveBeenCalledOnce();
|
||||
const instance = new TestResolver();
|
||||
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
|
||||
expect(metadata).toBe(true);
|
||||
});
|
||||
|
||||
it('should evaluate function conditions that return false', () => {
|
||||
const mockCondition = vi.fn(() => false);
|
||||
|
||||
class TestResolver {
|
||||
@OmitIf(mockCondition)
|
||||
testMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
expect(mockCondition).toHaveBeenCalledOnce();
|
||||
const instance = new TestResolver();
|
||||
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should work with environment variables', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
class TestResolver {
|
||||
@OmitIf(process.env.NODE_ENV === 'production')
|
||||
testMethod() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testMethod);
|
||||
expect(metadata).toBe(true);
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with NestJS GraphQL decorators', () => {
|
||||
it('should work with @Query decorator', () => {
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@OmitIf(true)
|
||||
@Query(() => String)
|
||||
omittedQuery() {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
@OmitIf(false)
|
||||
@Query(() => String)
|
||||
includedQuery() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const omittedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.omittedQuery);
|
||||
const includedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.includedQuery);
|
||||
|
||||
expect(omittedMetadata).toBe(true);
|
||||
expect(includedMetadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should work with @Mutation decorator', () => {
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@OmitIf(true)
|
||||
@Mutation(() => String)
|
||||
omittedMutation() {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
@OmitIf(false)
|
||||
@Mutation(() => String)
|
||||
includedMutation() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const omittedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.omittedMutation);
|
||||
const includedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.includedMutation);
|
||||
|
||||
expect(omittedMetadata).toBe(true);
|
||||
expect(includedMetadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should work with @ResolveField decorator', () => {
|
||||
@ObjectType()
|
||||
class TestType {
|
||||
@Field()
|
||||
id: string = '';
|
||||
}
|
||||
|
||||
@Resolver(() => TestType)
|
||||
class TestResolver {
|
||||
@OmitIf(true)
|
||||
@ResolveField(() => String)
|
||||
omittedField() {
|
||||
return 'test';
|
||||
}
|
||||
|
||||
@OmitIf(false)
|
||||
@ResolveField(() => String)
|
||||
includedField() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const omittedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.omittedField);
|
||||
const includedMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.includedField);
|
||||
|
||||
expect(omittedMetadata).toBe(true);
|
||||
expect(includedMetadata).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
80
api/src/unraid-api/decorators/omit-if.decorator.ts
Normal file
80
api/src/unraid-api/decorators/omit-if.decorator.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { Extensions } from '@nestjs/graphql';
|
||||
|
||||
import { MapperKind, mapSchema } from '@graphql-tools/utils';
|
||||
import { GraphQLFieldConfig, GraphQLSchema } from 'graphql';
|
||||
|
||||
export const OMIT_IF_METADATA_KEY = 'omitIf';
|
||||
|
||||
/**
|
||||
* Decorator that conditionally omits a GraphQL field/query/mutation based on a condition.
|
||||
* The field will only be omitted from the schema when the condition evaluates to true.
|
||||
*
|
||||
* @param condition - If the condition evaluates to true, the field will be omitted from the schema
|
||||
* @returns A decorator that wraps the target field/query/mutation
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @OmitIf(process.env.NODE_ENV === 'production')
|
||||
* @Query(() => String)
|
||||
* async debugQuery() {
|
||||
* return 'This query is omitted in production';
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function OmitIf(condition: boolean | (() => boolean)): MethodDecorator & PropertyDecorator {
|
||||
const shouldOmit = typeof condition === 'function' ? condition() : condition;
|
||||
|
||||
return (target: any, propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => {
|
||||
if (shouldOmit) {
|
||||
SetMetadata(OMIT_IF_METADATA_KEY, true)(
|
||||
target,
|
||||
propertyKey as string,
|
||||
descriptor as PropertyDescriptor
|
||||
);
|
||||
Extensions({ omitIf: true })(
|
||||
target,
|
||||
propertyKey as string,
|
||||
descriptor as PropertyDescriptor
|
||||
);
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema transformer that omits fields/queries/mutations based on the OmitIf decorator.
|
||||
* @param schema - The GraphQL schema to transform
|
||||
* @returns The transformed GraphQL schema
|
||||
*/
|
||||
export function omitIfSchemaTransformer(schema: GraphQLSchema): GraphQLSchema {
|
||||
return mapSchema(schema, {
|
||||
[MapperKind.OBJECT_FIELD]: (
|
||||
fieldConfig: GraphQLFieldConfig<any, any>,
|
||||
fieldName: string,
|
||||
typeName: string
|
||||
) => {
|
||||
const extensions = fieldConfig.extensions || {};
|
||||
|
||||
if (extensions.omitIf === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fieldConfig;
|
||||
},
|
||||
[MapperKind.ROOT_FIELD]: (
|
||||
fieldConfig: GraphQLFieldConfig<any, any>,
|
||||
fieldName: string,
|
||||
typeName: string
|
||||
) => {
|
||||
const extensions = fieldConfig.extensions || {};
|
||||
|
||||
if (extensions.omitIf === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fieldConfig;
|
||||
},
|
||||
});
|
||||
}
|
||||
317
api/src/unraid-api/decorators/use-feature-flag.decorator.spec.ts
Normal file
317
api/src/unraid-api/decorators/use-feature-flag.decorator.spec.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
// fixme: types don't sync with mocks, and there's no override to simplify testing.
|
||||
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OMIT_IF_METADATA_KEY } from '@app/unraid-api/decorators/omit-if.decorator.js';
|
||||
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
|
||||
|
||||
// Mock the FeatureFlags
|
||||
vi.mock('@app/consts.js', () => ({
|
||||
FeatureFlags: Object.freeze({
|
||||
ENABLE_NEXT_DOCKER_RELEASE: false,
|
||||
ENABLE_EXPERIMENTAL_FEATURE: true,
|
||||
ENABLE_DEBUG_MODE: false,
|
||||
ENABLE_BETA_FEATURES: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('UseFeatureFlag Decorator', () => {
|
||||
let reflector: Reflector;
|
||||
|
||||
beforeEach(() => {
|
||||
reflector = new Reflector();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
it('should omit field when feature flag is false', () => {
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@Query(() => String)
|
||||
testQuery() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testQuery);
|
||||
expect(metadata).toBe(true); // Should be omitted because flag is false
|
||||
});
|
||||
|
||||
it('should include field when feature flag is true', () => {
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE')
|
||||
@Query(() => String)
|
||||
testQuery() {
|
||||
return 'test';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const metadata = reflector.get(OMIT_IF_METADATA_KEY, instance.testQuery);
|
||||
expect(metadata).toBeUndefined(); // Should not be omitted because flag is true
|
||||
});
|
||||
});
|
||||
|
||||
describe('With different decorator types', () => {
|
||||
it('should work with @Query decorator', () => {
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@UseFeatureFlag('ENABLE_DEBUG_MODE')
|
||||
@Query(() => String)
|
||||
debugQuery() {
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_BETA_FEATURES')
|
||||
@Query(() => String)
|
||||
betaQuery() {
|
||||
return 'beta';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const debugMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.debugQuery);
|
||||
const betaMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.betaQuery);
|
||||
|
||||
expect(debugMetadata).toBe(true); // ENABLE_DEBUG_MODE is false
|
||||
expect(betaMetadata).toBeUndefined(); // ENABLE_BETA_FEATURES is true
|
||||
});
|
||||
|
||||
it('should work with @Mutation decorator', () => {
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@Mutation(() => String)
|
||||
dockerMutation() {
|
||||
return 'docker';
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE')
|
||||
@Mutation(() => String)
|
||||
experimentalMutation() {
|
||||
return 'experimental';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const dockerMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.dockerMutation);
|
||||
const experimentalMetadata = reflector.get(
|
||||
OMIT_IF_METADATA_KEY,
|
||||
instance.experimentalMutation
|
||||
);
|
||||
|
||||
expect(dockerMetadata).toBe(true); // ENABLE_NEXT_DOCKER_RELEASE is false
|
||||
expect(experimentalMetadata).toBeUndefined(); // ENABLE_EXPERIMENTAL_FEATURE is true
|
||||
});
|
||||
|
||||
it('should work with @ResolveField decorator', () => {
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@UseFeatureFlag('ENABLE_DEBUG_MODE')
|
||||
@ResolveField(() => String)
|
||||
debugField() {
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_BETA_FEATURES')
|
||||
@ResolveField(() => String)
|
||||
betaField() {
|
||||
return 'beta';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const debugMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.debugField);
|
||||
const betaMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.betaField);
|
||||
|
||||
expect(debugMetadata).toBe(true); // ENABLE_DEBUG_MODE is false
|
||||
expect(betaMetadata).toBeUndefined(); // ENABLE_BETA_FEATURES is true
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiple decorators on same class', () => {
|
||||
it('should handle multiple feature flags independently', () => {
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@Query(() => String)
|
||||
dockerQuery() {
|
||||
return 'docker';
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE')
|
||||
@Query(() => String)
|
||||
experimentalQuery() {
|
||||
return 'experimental';
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_DEBUG_MODE')
|
||||
@Query(() => String)
|
||||
debugQuery() {
|
||||
return 'debug';
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_BETA_FEATURES')
|
||||
@Query(() => String)
|
||||
betaQuery() {
|
||||
return 'beta';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.dockerQuery)).toBe(true);
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.experimentalQuery)).toBeUndefined();
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.debugQuery)).toBe(true);
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.betaQuery)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type safety', () => {
|
||||
it('should only accept valid feature flag keys', () => {
|
||||
// This test verifies TypeScript compile-time type safety
|
||||
// The following would cause a TypeScript error if uncommented:
|
||||
// @UseFeatureFlag('INVALID_FLAG')
|
||||
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@Query(() => String)
|
||||
validQuery() {
|
||||
return 'valid';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
expect(instance.validQuery).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should work correctly with other decorators', () => {
|
||||
const customDecorator = (
|
||||
target: any,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: PropertyDescriptor
|
||||
) => {
|
||||
Reflect.defineMetadata('custom', true, target, propertyKey);
|
||||
return descriptor;
|
||||
};
|
||||
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@customDecorator
|
||||
@Query(() => String)
|
||||
multiDecoratorQuery() {
|
||||
return 'multi';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new TestResolver();
|
||||
const omitMetadata = reflector.get(OMIT_IF_METADATA_KEY, instance.multiDecoratorQuery);
|
||||
const customMetadata = Reflect.getMetadata('custom', instance, 'multiDecoratorQuery');
|
||||
|
||||
expect(omitMetadata).toBe(true);
|
||||
expect(customMetadata).toBe(true);
|
||||
});
|
||||
|
||||
it('should maintain correct decorator order', () => {
|
||||
const orderTracker: string[] = [];
|
||||
|
||||
const trackingDecorator = (name: string) => {
|
||||
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
|
||||
orderTracker.push(name);
|
||||
return descriptor;
|
||||
};
|
||||
};
|
||||
|
||||
@Resolver()
|
||||
class TestResolver {
|
||||
@trackingDecorator('first')
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@trackingDecorator('last')
|
||||
@Query(() => String)
|
||||
orderedQuery() {
|
||||
return 'ordered';
|
||||
}
|
||||
}
|
||||
|
||||
// Decorators are applied bottom-up
|
||||
expect(orderTracker).toEqual(['last', 'first']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world usage patterns', () => {
|
||||
it('should work with Docker resolver pattern', () => {
|
||||
@Resolver()
|
||||
class DockerResolver {
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@Mutation(() => String)
|
||||
async createDockerFolder(name: string) {
|
||||
return `Created folder: ${name}`;
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@Mutation(() => String)
|
||||
async deleteDockerEntries(entryIds: string[]) {
|
||||
return `Deleted entries: ${entryIds.join(', ')}`;
|
||||
}
|
||||
|
||||
@Query(() => String)
|
||||
async getDockerInfo() {
|
||||
return 'Docker info';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new DockerResolver();
|
||||
|
||||
// Feature flag is false, so these should be omitted
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.createDockerFolder)).toBe(true);
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.deleteDockerEntries)).toBe(true);
|
||||
|
||||
// No feature flag, so this should not be omitted
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.getDockerInfo)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle mixed feature flags in same resolver', () => {
|
||||
@Resolver()
|
||||
class MixedResolver {
|
||||
@UseFeatureFlag('ENABLE_EXPERIMENTAL_FEATURE')
|
||||
@Query(() => String)
|
||||
experimentalQuery() {
|
||||
return 'experimental';
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@Query(() => String)
|
||||
dockerQuery() {
|
||||
return 'docker';
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_BETA_FEATURES')
|
||||
@Mutation(() => String)
|
||||
betaMutation() {
|
||||
return 'beta';
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new MixedResolver();
|
||||
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.experimentalQuery)).toBeUndefined();
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.dockerQuery)).toBe(true);
|
||||
expect(reflector.get(OMIT_IF_METADATA_KEY, instance.betaMutation)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
22
api/src/unraid-api/decorators/use-feature-flag.decorator.ts
Normal file
22
api/src/unraid-api/decorators/use-feature-flag.decorator.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { FeatureFlags } from '@app/consts.js';
|
||||
import { OmitIf } from '@app/unraid-api/decorators/omit-if.decorator.js';
|
||||
|
||||
/**
|
||||
* Decorator that conditionally includes a GraphQL field/query/mutation based on a feature flag.
|
||||
* The field will only be included in the schema when the feature flag is enabled.
|
||||
*
|
||||
* @param flagKey - The key of the feature flag in FeatureFlags
|
||||
* @returns A decorator that wraps OmitIf
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
* @Mutation(() => String)
|
||||
* async experimentalMutation() {
|
||||
* return 'This mutation is only available when ENABLE_NEXT_DOCKER_RELEASE is true';
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function UseFeatureFlag(flagKey: keyof typeof FeatureFlags): MethodDecorator & PropertyDecorator {
|
||||
return OmitIf(!FeatureFlags[flagKey]);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { NoUnusedVariablesRule } from 'graphql';
|
||||
|
||||
import { ENVIRONMENT } from '@app/environment.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { omitIfSchemaTransformer } from '@app/unraid-api/decorators/omit-if.decorator.js';
|
||||
|
||||
// Import enum registrations to ensure they're registered with GraphQL
|
||||
import '@app/unraid-api/graph/auth/auth-action.enum.js';
|
||||
@@ -64,7 +65,12 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
},
|
||||
// Only add transform when not in test environment to avoid GraphQL version conflicts
|
||||
transformSchema:
|
||||
process.env.NODE_ENV === 'test' ? undefined : usePermissionsSchemaTransformer,
|
||||
process.env.NODE_ENV === 'test'
|
||||
? undefined
|
||||
: (schema) => {
|
||||
const schemaWithPermissions = usePermissionsSchemaTransformer(schema);
|
||||
return omitIfSchemaTransformer(schemaWithPermissions);
|
||||
},
|
||||
validationRules: [NoUnusedVariablesRule],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { SchedulerRegistry, Timeout } from '@nestjs/schedule';
|
||||
|
||||
import { CronJob } from 'cron';
|
||||
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class ContainerStatusJob implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(ContainerStatusJob.name);
|
||||
constructor(
|
||||
private readonly dockerManifestService: DockerManifestService,
|
||||
private readonly schedulerRegistry: SchedulerRegistry,
|
||||
private readonly dockerConfigService: DockerConfigService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initialize cron job for refreshing the update status for all containers on a user-configurable schedule.
|
||||
*/
|
||||
onApplicationBootstrap() {
|
||||
if (!this.dockerConfigService.enabled()) return;
|
||||
const cronExpression = this.dockerConfigService.getConfig().updateCheckCronSchedule;
|
||||
const cronJob = CronJob.from({
|
||||
cronTime: cronExpression,
|
||||
onTick: () => {
|
||||
this.dockerManifestService.refreshDigests().catch((error) => {
|
||||
this.logger.warn(error, 'Failed to refresh container update status');
|
||||
});
|
||||
},
|
||||
start: true,
|
||||
});
|
||||
this.schedulerRegistry.addCronJob(ContainerStatusJob.name, cronJob);
|
||||
this.logger.verbose(
|
||||
`Initialized cron job for refreshing container update status: ${ContainerStatusJob.name}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh container digests 5 seconds after application start.
|
||||
*/
|
||||
@Timeout(5_000)
|
||||
async refreshContainerDigestsAfterStartup() {
|
||||
if (!this.dockerConfigService.enabled()) return;
|
||||
await this.dockerManifestService.refreshDigests();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class DockerConfig {
|
||||
@Field(() => String)
|
||||
updateCheckCronSchedule!: string;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CronExpression } from '@nestjs/schedule';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { ValidationError } from 'class-validator';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
|
||||
vi.mock('cron', () => ({
|
||||
validateCronExpression: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@app/unraid-api/graph/resolvers/validation.utils.js', () => ({
|
||||
validateObject: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DockerConfigService - validate', () => {
|
||||
let service: DockerConfigService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DockerConfigService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DockerConfigService>(DockerConfigService);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should validate and return docker config for valid cron expression', async () => {
|
||||
const inputConfig = { updateCheckCronSchedule: '0 6 * * *' };
|
||||
const validatedConfig = { updateCheckCronSchedule: '0 6 * * *' };
|
||||
|
||||
const { validateObject } = await import(
|
||||
'@app/unraid-api/graph/resolvers/validation.utils.js'
|
||||
);
|
||||
const { validateCronExpression } = await import('cron');
|
||||
|
||||
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
|
||||
const result = await service.validate(inputConfig);
|
||||
|
||||
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
|
||||
expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * *');
|
||||
expect(result).toBe(validatedConfig);
|
||||
});
|
||||
|
||||
it('should validate and return docker config for predefined cron expression', async () => {
|
||||
const inputConfig = { updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM };
|
||||
const validatedConfig = { updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM };
|
||||
|
||||
const { validateObject } = await import(
|
||||
'@app/unraid-api/graph/resolvers/validation.utils.js'
|
||||
);
|
||||
const { validateCronExpression } = await import('cron');
|
||||
|
||||
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
|
||||
const result = await service.validate(inputConfig);
|
||||
|
||||
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
|
||||
expect(validateCronExpression).toHaveBeenCalledWith(CronExpression.EVERY_DAY_AT_6AM);
|
||||
expect(result).toBe(validatedConfig);
|
||||
});
|
||||
|
||||
it('should throw AppError for invalid cron expression', async () => {
|
||||
const inputConfig = { updateCheckCronSchedule: 'invalid-cron' };
|
||||
const validatedConfig = { updateCheckCronSchedule: 'invalid-cron' };
|
||||
|
||||
const { validateObject } = await import(
|
||||
'@app/unraid-api/graph/resolvers/validation.utils.js'
|
||||
);
|
||||
const { validateCronExpression } = await import('cron');
|
||||
|
||||
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: false });
|
||||
|
||||
await expect(service.validate(inputConfig)).rejects.toThrow(
|
||||
new AppError('Cron expression not supported: invalid-cron')
|
||||
);
|
||||
|
||||
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
|
||||
expect(validateCronExpression).toHaveBeenCalledWith('invalid-cron');
|
||||
});
|
||||
|
||||
it('should throw AppError for empty cron expression', async () => {
|
||||
const inputConfig = { updateCheckCronSchedule: '' };
|
||||
const validatedConfig = { updateCheckCronSchedule: '' };
|
||||
|
||||
const { validateObject } = await import(
|
||||
'@app/unraid-api/graph/resolvers/validation.utils.js'
|
||||
);
|
||||
const { validateCronExpression } = await import('cron');
|
||||
|
||||
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: false });
|
||||
|
||||
await expect(service.validate(inputConfig)).rejects.toThrow(
|
||||
new AppError('Cron expression not supported: ')
|
||||
);
|
||||
|
||||
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
|
||||
expect(validateCronExpression).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('should throw AppError for malformed cron expression', async () => {
|
||||
const inputConfig = { updateCheckCronSchedule: '* * * *' };
|
||||
const validatedConfig = { updateCheckCronSchedule: '* * * *' };
|
||||
|
||||
const { validateObject } = await import(
|
||||
'@app/unraid-api/graph/resolvers/validation.utils.js'
|
||||
);
|
||||
const { validateCronExpression } = await import('cron');
|
||||
|
||||
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: false });
|
||||
|
||||
await expect(service.validate(inputConfig)).rejects.toThrow(
|
||||
new AppError('Cron expression not supported: * * * *')
|
||||
);
|
||||
|
||||
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
|
||||
expect(validateCronExpression).toHaveBeenCalledWith('* * * *');
|
||||
});
|
||||
|
||||
it('should propagate validation errors from validateObject', async () => {
|
||||
const inputConfig = { updateCheckCronSchedule: '0 6 * * *' };
|
||||
const validationError = new ValidationError();
|
||||
validationError.property = 'updateCheckCronSchedule';
|
||||
|
||||
const { validateObject } = await import(
|
||||
'@app/unraid-api/graph/resolvers/validation.utils.js'
|
||||
);
|
||||
|
||||
vi.mocked(validateObject).mockRejectedValue(validationError);
|
||||
|
||||
await expect(service.validate(inputConfig)).rejects.toThrow();
|
||||
|
||||
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
|
||||
});
|
||||
|
||||
it('should handle complex valid cron expressions', async () => {
|
||||
const inputConfig = { updateCheckCronSchedule: '0 0,12 * * 1-5' };
|
||||
const validatedConfig = { updateCheckCronSchedule: '0 0,12 * * 1-5' };
|
||||
|
||||
const { validateObject } = await import(
|
||||
'@app/unraid-api/graph/resolvers/validation.utils.js'
|
||||
);
|
||||
const { validateCronExpression } = await import('cron');
|
||||
|
||||
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
|
||||
const result = await service.validate(inputConfig);
|
||||
|
||||
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
|
||||
expect(validateCronExpression).toHaveBeenCalledWith('0 0,12 * * 1-5');
|
||||
expect(result).toBe(validatedConfig);
|
||||
});
|
||||
|
||||
it('should handle input with extra properties', async () => {
|
||||
const inputConfig = {
|
||||
updateCheckCronSchedule: '0 6 * * *',
|
||||
extraProperty: 'should be ignored',
|
||||
};
|
||||
const validatedConfig = { updateCheckCronSchedule: '0 6 * * *' };
|
||||
|
||||
const { validateObject } = await import(
|
||||
'@app/unraid-api/graph/resolvers/validation.utils.js'
|
||||
);
|
||||
const { validateCronExpression } = await import('cron');
|
||||
|
||||
vi.mocked(validateObject).mockResolvedValue(validatedConfig);
|
||||
vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
|
||||
|
||||
const result = await service.validate(inputConfig);
|
||||
|
||||
expect(validateObject).toHaveBeenCalledWith(expect.any(Function), inputConfig);
|
||||
expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * *');
|
||||
expect(result).toBe(validatedConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +1,45 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
|
||||
import { validateCronExpression } from 'cron';
|
||||
|
||||
import { FeatureFlags } from '@app/consts.js';
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { DockerConfig } from '@app/unraid-api/graph/resolvers/docker/docker-config.model.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
import {
|
||||
DEFAULT_ORGANIZER_ROOT_ID,
|
||||
DEFAULT_ORGANIZER_VIEW_ID,
|
||||
} from '@app/unraid-api/organizer/organizer.js';
|
||||
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
|
||||
import { validateOrganizerIntegrity } from '@app/unraid-api/organizer/organizer.validation.js';
|
||||
|
||||
@Injectable()
|
||||
export class DockerConfigService extends ConfigFilePersister<OrganizerV1> {
|
||||
export class DockerConfigService extends ConfigFilePersister<DockerConfig> {
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return FeatureFlags.ENABLE_NEXT_DOCKER_RELEASE;
|
||||
}
|
||||
|
||||
configKey(): string {
|
||||
return 'dockerOrganizer';
|
||||
return 'docker';
|
||||
}
|
||||
|
||||
fileName(): string {
|
||||
return 'docker.organizer.json';
|
||||
return 'docker.config.json';
|
||||
}
|
||||
|
||||
defaultConfig(): OrganizerV1 {
|
||||
defaultConfig(): DockerConfig {
|
||||
return {
|
||||
version: 1,
|
||||
resources: {},
|
||||
views: {
|
||||
default: {
|
||||
id: DEFAULT_ORGANIZER_VIEW_ID,
|
||||
name: 'Default',
|
||||
root: DEFAULT_ORGANIZER_ROOT_ID,
|
||||
entries: {
|
||||
root: {
|
||||
type: 'folder',
|
||||
id: DEFAULT_ORGANIZER_ROOT_ID,
|
||||
name: 'Root',
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM,
|
||||
};
|
||||
}
|
||||
|
||||
async validate(config: object): Promise<OrganizerV1> {
|
||||
const organizer = await validateObject(OrganizerV1, config);
|
||||
const { isValid, errors } = await validateOrganizerIntegrity(organizer);
|
||||
if (!isValid) {
|
||||
throw new AppError(`Docker organizer validation failed: ${JSON.stringify(errors, null, 2)}`);
|
||||
async validate(config: object): Promise<DockerConfig> {
|
||||
const dockerConfig = await validateObject(DockerConfig, config);
|
||||
const cronExpression = validateCronExpression(dockerConfig.updateCheckCronSchedule);
|
||||
if (!cronExpression.valid) {
|
||||
throw new AppError(`Cron expression not supported: ${dockerConfig.updateCheckCronSchedule}`);
|
||||
}
|
||||
return organizer;
|
||||
return dockerConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Mutation, Parent, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
|
||||
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
|
||||
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
|
||||
@Resolver(() => DockerContainer)
|
||||
export class DockerContainerResolver {
|
||||
private readonly logger = new Logger(DockerContainerResolver.name);
|
||||
constructor(private readonly dockerManifestService: DockerManifestService) {}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
})
|
||||
@ResolveField(() => Boolean, { nullable: true })
|
||||
public async isUpdateAvailable(@Parent() container: DockerContainer) {
|
||||
try {
|
||||
return await this.dockerManifestService.isUpdateAvailableCached(container.image);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
throw new AppError('Failed to read cached update status. See graphql-api.log for details.');
|
||||
}
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
})
|
||||
@ResolveField(() => Boolean, { nullable: true })
|
||||
public async isRebuildReady(@Parent() container: DockerContainer) {
|
||||
return this.dockerManifestService.isRebuildReady(container.hostConfig?.networkMode);
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@UsePermissions({
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
})
|
||||
@Mutation(() => Boolean)
|
||||
public async refreshDockerDigests() {
|
||||
return this.dockerManifestService.refreshDigests();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { AsyncMutex } from '@unraid/shared/util/processing.js';
|
||||
|
||||
import { docker } from '@app/core/utils/index.js';
|
||||
import {
|
||||
CachedStatusEntry,
|
||||
DockerPhpService,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class DockerManifestService {
|
||||
constructor(private readonly dockerPhpService: DockerPhpService) {}
|
||||
|
||||
private readonly refreshDigestsMutex = new AsyncMutex(() => {
|
||||
return this.dockerPhpService.refreshDigestsViaPhp();
|
||||
});
|
||||
|
||||
/**
|
||||
* Recomputes local/remote docker container digests and writes them to /var/lib/docker/unraid-update-status.json
|
||||
* @param mutex - Optional mutex to use for the operation. If not provided, a default mutex will be used.
|
||||
* @param dockerUpdatePath - Optional path to the DockerUpdate.php file. If not provided, the default path will be used.
|
||||
* @returns True if the digests were refreshed, false if the operation failed
|
||||
*/
|
||||
async refreshDigests(mutex = this.refreshDigestsMutex, dockerUpdatePath?: string) {
|
||||
return mutex.do(() => {
|
||||
return this.dockerPhpService.refreshDigestsViaPhp(dockerUpdatePath);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an update is available for a given container image.
|
||||
* @param imageRef - The image reference to check, e.g. "unraid/baseimage:latest". If no tag is provided, "latest" is assumed, following the webgui's implementation.
|
||||
* @param cacheData read from /var/lib/docker/unraid-update-status.json by default
|
||||
* @returns True if an update is available, false if not, or null if the status is unknown
|
||||
*/
|
||||
async isUpdateAvailableCached(imageRef: string, cacheData?: Record<string, CachedStatusEntry>) {
|
||||
let taggedRef = imageRef;
|
||||
if (!taggedRef.includes(':')) taggedRef += ':latest';
|
||||
|
||||
cacheData ??= await this.dockerPhpService.readCachedUpdateStatus();
|
||||
const containerData = cacheData[taggedRef];
|
||||
if (!containerData) return null;
|
||||
return containerData.status?.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a container is rebuild ready.
|
||||
* @param networkMode - The network mode of the container, e.g. "container:unraid/baseimage:latest".
|
||||
* @returns True if the container is rebuild ready, false if not
|
||||
*/
|
||||
async isRebuildReady(networkMode?: string) {
|
||||
if (!networkMode || !networkMode.startsWith('container:')) return false;
|
||||
const target = networkMode.slice('container:'.length);
|
||||
try {
|
||||
await docker.getContainer(target).inspect();
|
||||
return false;
|
||||
} catch {
|
||||
return true; // unresolved target -> ':???' equivalent
|
||||
}
|
||||
}
|
||||
}
|
||||
132
api/src/unraid-api/graph/resolvers/docker/docker-php.service.ts
Normal file
132
api/src/unraid-api/graph/resolvers/docker/docker-php.service.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { readFile } from 'fs/promises';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { phpLoader } from '@app/core/utils/plugins/php-loader.js';
|
||||
import {
|
||||
ExplicitStatusItem,
|
||||
UpdateStatus,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js';
|
||||
|
||||
type StatusItem = { name: string; updateStatus: 0 | 1 | 2 | 3 };
|
||||
|
||||
/**
|
||||
* These types reflect the structure of the /var/lib/docker/unraid-update-status.json file,
|
||||
* which is not controlled by the Unraid API.
|
||||
*/
|
||||
const CachedStatusEntrySchema = z.object({
|
||||
/** sha256 digest - "sha256:..." */
|
||||
local: z.string(),
|
||||
/** sha256 digest - "sha256:..." */
|
||||
remote: z.string(),
|
||||
/** whether update is available (true), not available (false), or unknown (null) */
|
||||
status: z.enum(['true', 'false']).nullable(),
|
||||
});
|
||||
const CachedStatusSchema = z.record(z.string(), CachedStatusEntrySchema);
|
||||
export type CachedStatusEntry = z.infer<typeof CachedStatusEntrySchema>;
|
||||
|
||||
@Injectable()
|
||||
export class DockerPhpService {
|
||||
private readonly logger = new Logger(DockerPhpService.name);
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Reads JSON from a file containing cached update status.
|
||||
* If the file does not exist, an empty object is returned.
|
||||
* @param cacheFile
|
||||
* @returns
|
||||
*/
|
||||
async readCachedUpdateStatus(
|
||||
cacheFile = '/var/lib/docker/unraid-update-status.json'
|
||||
): Promise<Record<string, CachedStatusEntry>> {
|
||||
try {
|
||||
const cache = await readFile(cacheFile, 'utf8');
|
||||
const cacheData = JSON.parse(cache);
|
||||
const { success, data } = CachedStatusSchema.safeParse(cacheData);
|
||||
if (success) return data;
|
||||
this.logger.warn(cacheData, 'Invalid cached update status');
|
||||
return {};
|
||||
} catch (error) {
|
||||
this.logger.warn(error, 'Failed to read cached update status');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**----------------------
|
||||
* Refresh Container Digests
|
||||
*------------------------**/
|
||||
|
||||
/**
|
||||
* Recomputes local/remote digests by triggering `DockerTemplates->getAllInfo(true)` via DockerUpdate.php
|
||||
* @param dockerUpdatePath - Path to the DockerUpdate.php file
|
||||
* @returns True if the digests were refreshed, false if the file is not found or the operation failed
|
||||
*/
|
||||
async refreshDigestsViaPhp(
|
||||
dockerUpdatePath = '/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerUpdate.php'
|
||||
) {
|
||||
try {
|
||||
await phpLoader({
|
||||
file: dockerUpdatePath,
|
||||
method: 'GET',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// ignore; offline may keep remote as 'undef'
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**----------------------
|
||||
* Parse Container Statuses
|
||||
*------------------------**/
|
||||
|
||||
private parseStatusesFromDockerPush(js: string): ExplicitStatusItem[] {
|
||||
const items: ExplicitStatusItem[] = [];
|
||||
const re = /docker\.push\(\{[^}]*name:'([^']+)'[^}]*update:(\d)[^}]*\}\);/g;
|
||||
for (const m of js.matchAll(re)) {
|
||||
const name = m[1];
|
||||
const updateStatus = Number(m[2]) as StatusItem['updateStatus'];
|
||||
items.push({ name, updateStatus: this.updateStatusToString(updateStatus) });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private updateStatusToString(updateStatus: 0): UpdateStatus.UP_TO_DATE;
|
||||
private updateStatusToString(updateStatus: 1): UpdateStatus.UPDATE_AVAILABLE;
|
||||
private updateStatusToString(updateStatus: 2): UpdateStatus.REBUILD_READY;
|
||||
private updateStatusToString(updateStatus: 3): UpdateStatus.UNKNOWN;
|
||||
// prettier-ignore
|
||||
private updateStatusToString(updateStatus: StatusItem['updateStatus']): ExplicitStatusItem['updateStatus'];
|
||||
private updateStatusToString(
|
||||
updateStatus: StatusItem['updateStatus']
|
||||
): ExplicitStatusItem['updateStatus'] {
|
||||
switch (updateStatus) {
|
||||
case 0:
|
||||
return UpdateStatus.UP_TO_DATE;
|
||||
case 1:
|
||||
return UpdateStatus.UPDATE_AVAILABLE;
|
||||
case 2:
|
||||
return UpdateStatus.REBUILD_READY;
|
||||
default:
|
||||
return UpdateStatus.UNKNOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the update statuses for all containers by triggering `DockerTemplates->getAllInfo(true)` via DockerContainers.php
|
||||
* @param dockerContainersPath - Path to the DockerContainers.php file
|
||||
* @returns The update statuses for all containers
|
||||
*/
|
||||
async getContainerUpdateStatuses(
|
||||
dockerContainersPath = '/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php'
|
||||
): Promise<ExplicitStatusItem[]> {
|
||||
const stdout = await phpLoader({
|
||||
file: dockerContainersPath,
|
||||
method: 'GET',
|
||||
});
|
||||
const parts = stdout.split('\0'); // [html, "docker.push(...)", busyFlag]
|
||||
const js = parts[1] || '';
|
||||
return this.parseStatusesFromDockerPush(js);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
/**
|
||||
* Note that these values propagate down to API consumers, so be aware of breaking changes.
|
||||
*/
|
||||
export enum UpdateStatus {
|
||||
UP_TO_DATE = 'UP_TO_DATE',
|
||||
UPDATE_AVAILABLE = 'UPDATE_AVAILABLE',
|
||||
REBUILD_READY = 'REBUILD_READY',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
registerEnumType(UpdateStatus, {
|
||||
name: 'UpdateStatus',
|
||||
description: 'Update status of a container.',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class ExplicitStatusItem {
|
||||
@Field(() => String)
|
||||
name!: string;
|
||||
|
||||
@Field(() => UpdateStatus)
|
||||
updateStatus!: UpdateStatus;
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js';
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
|
||||
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
|
||||
import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js';
|
||||
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
|
||||
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
|
||||
|
||||
describe('DockerModule', () => {
|
||||
it('should compile the module', async () => {
|
||||
@@ -18,6 +19,8 @@ describe('DockerModule', () => {
|
||||
})
|
||||
.overrideProvider(DockerService)
|
||||
.useValue({ getDockerClient: vi.fn() })
|
||||
.overrideProvider(DockerOrganizerConfigService)
|
||||
.useValue({ getConfig: vi.fn() })
|
||||
.overrideProvider(DockerConfigService)
|
||||
.useValue({ getConfig: vi.fn() })
|
||||
.compile();
|
||||
@@ -61,6 +64,7 @@ describe('DockerModule', () => {
|
||||
DockerResolver,
|
||||
{ provide: DockerService, useValue: {} },
|
||||
{ provide: DockerOrganizerService, useValue: {} },
|
||||
{ provide: DockerPhpService, useValue: { getContainerUpdateStatuses: vi.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { JobModule } from '@app/unraid-api/cron/job.module.js';
|
||||
import { ContainerStatusJob } from '@app/unraid-api/graph/resolvers/docker/container-status.job.js';
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
|
||||
import { DockerContainerResolver } from '@app/unraid-api/graph/resolvers/docker/docker-container.resolver.js';
|
||||
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
|
||||
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
|
||||
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
|
||||
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [JobModule],
|
||||
providers: [
|
||||
// Services
|
||||
DockerService,
|
||||
DockerConfigService,
|
||||
DockerOrganizerConfigService,
|
||||
DockerOrganizerService,
|
||||
DockerManifestService,
|
||||
DockerPhpService,
|
||||
DockerConfigService,
|
||||
// DockerEventService,
|
||||
|
||||
// Jobs
|
||||
ContainerStatusJob,
|
||||
|
||||
// Resolvers
|
||||
DockerResolver,
|
||||
DockerMutationsResolver,
|
||||
DockerContainerResolver,
|
||||
],
|
||||
exports: [DockerService],
|
||||
})
|
||||
|
||||
@@ -3,10 +3,11 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
|
||||
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
|
||||
import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
|
||||
|
||||
describe('DockerResolver', () => {
|
||||
let resolver: DockerResolver;
|
||||
@@ -26,7 +27,13 @@ describe('DockerResolver', () => {
|
||||
{
|
||||
provide: DockerOrganizerService,
|
||||
useValue: {
|
||||
getResolvedOrganizer: vi.fn(),
|
||||
resolveOrganizer: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DockerPhpService,
|
||||
useValue: {
|
||||
getContainerUpdateStatuses: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,21 +3,25 @@ import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
|
||||
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
|
||||
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
|
||||
import { ExplicitStatusItem } from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js';
|
||||
import {
|
||||
Docker,
|
||||
DockerContainer,
|
||||
DockerNetwork,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
|
||||
import { DEFAULT_ORGANIZER_ROOT_ID } from '@app/unraid-api/organizer/organizer.js';
|
||||
import { OrganizerV1, ResolvedOrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
|
||||
import { ResolvedOrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
|
||||
|
||||
@Resolver(() => Docker)
|
||||
export class DockerResolver {
|
||||
constructor(
|
||||
private readonly dockerService: DockerService,
|
||||
private readonly dockerOrganizerService: DockerOrganizerService
|
||||
private readonly dockerOrganizerService: DockerOrganizerService,
|
||||
private readonly dockerPhpService: DockerPhpService
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
@@ -53,6 +57,7 @@ export class DockerResolver {
|
||||
return this.dockerService.getNetworks({ skipCache });
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
@@ -62,6 +67,7 @@ export class DockerResolver {
|
||||
return this.dockerOrganizerService.resolveOrganizer();
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@UsePermissions({
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
@@ -80,6 +86,7 @@ export class DockerResolver {
|
||||
return this.dockerOrganizerService.resolveOrganizer(organizer);
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@UsePermissions({
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
@@ -96,6 +103,7 @@ export class DockerResolver {
|
||||
return this.dockerOrganizerService.resolveOrganizer(organizer);
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@UsePermissions({
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
@@ -108,6 +116,7 @@ export class DockerResolver {
|
||||
return this.dockerOrganizerService.resolveOrganizer(organizer);
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@UsePermissions({
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
@@ -123,4 +132,14 @@ export class DockerResolver {
|
||||
});
|
||||
return this.dockerOrganizerService.resolveOrganizer(organizer);
|
||||
}
|
||||
|
||||
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
})
|
||||
@ResolveField(() => [ExplicitStatusItem])
|
||||
public async containerUpdateStatuses() {
|
||||
return this.dockerPhpService.getContainerUpdateStatuses();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { ConfigFilePersister } from '@unraid/shared/services/config-file.js';
|
||||
|
||||
import { FeatureFlags } from '@app/consts.js';
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
import {
|
||||
DEFAULT_ORGANIZER_ROOT_ID,
|
||||
DEFAULT_ORGANIZER_VIEW_ID,
|
||||
} from '@app/unraid-api/organizer/organizer.js';
|
||||
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
|
||||
import { validateOrganizerIntegrity } from '@app/unraid-api/organizer/organizer.validation.js';
|
||||
|
||||
@Injectable()
|
||||
export class DockerOrganizerConfigService extends ConfigFilePersister<OrganizerV1> {
|
||||
constructor(configService: ConfigService) {
|
||||
super(configService);
|
||||
}
|
||||
|
||||
enabled(): boolean {
|
||||
return FeatureFlags.ENABLE_NEXT_DOCKER_RELEASE;
|
||||
}
|
||||
|
||||
configKey(): string {
|
||||
return 'dockerOrganizer';
|
||||
}
|
||||
|
||||
fileName(): string {
|
||||
return 'docker.organizer.json';
|
||||
}
|
||||
|
||||
defaultConfig(): OrganizerV1 {
|
||||
return {
|
||||
version: 1,
|
||||
resources: {},
|
||||
views: {
|
||||
default: {
|
||||
id: DEFAULT_ORGANIZER_VIEW_ID,
|
||||
name: 'Default',
|
||||
root: DEFAULT_ORGANIZER_ROOT_ID,
|
||||
entries: {
|
||||
root: {
|
||||
type: 'folder',
|
||||
id: DEFAULT_ORGANIZER_ROOT_ID,
|
||||
name: 'Root',
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async validate(config: object): Promise<OrganizerV1> {
|
||||
const organizer = await validateObject(OrganizerV1, config);
|
||||
const { isValid, errors } = await validateOrganizerIntegrity(organizer);
|
||||
if (!isValid) {
|
||||
throw new AppError(`Docker organizer validation failed: ${JSON.stringify(errors, null, 2)}`);
|
||||
}
|
||||
return organizer;
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,17 @@ import { Test } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import {
|
||||
containerToResource,
|
||||
DockerOrganizerService,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
|
||||
import {
|
||||
ContainerPortType,
|
||||
ContainerState,
|
||||
DockerContainer,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
|
||||
import {
|
||||
containerToResource,
|
||||
DockerOrganizerService,
|
||||
} from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
|
||||
import { OrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
|
||||
|
||||
describe('containerToResource', () => {
|
||||
@@ -138,7 +138,7 @@ describe('containerToResource', () => {
|
||||
|
||||
describe('DockerOrganizerService', () => {
|
||||
let service: DockerOrganizerService;
|
||||
let configService: DockerConfigService;
|
||||
let configService: DockerOrganizerConfigService;
|
||||
let dockerService: DockerService;
|
||||
|
||||
const mockOrganizer: OrganizerV1 = {
|
||||
@@ -178,7 +178,7 @@ describe('DockerOrganizerService', () => {
|
||||
providers: [
|
||||
DockerOrganizerService,
|
||||
{
|
||||
provide: DockerConfigService,
|
||||
provide: DockerOrganizerConfigService,
|
||||
useValue: {
|
||||
getConfig: vi.fn().mockImplementation(() => structuredClone(mockOrganizer)),
|
||||
validate: vi.fn().mockImplementation((config) => Promise.resolve(config)),
|
||||
@@ -220,7 +220,7 @@ describe('DockerOrganizerService', () => {
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get<DockerOrganizerService>(DockerOrganizerService);
|
||||
configService = moduleRef.get<DockerConfigService>(DockerConfigService);
|
||||
configService = moduleRef.get<DockerOrganizerConfigService>(DockerOrganizerConfigService);
|
||||
dockerService = moduleRef.get<DockerService>(DockerService);
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { ContainerListOptions } from 'dockerode';
|
||||
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
|
||||
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
|
||||
import {
|
||||
addMissingResourcesToView,
|
||||
createFolderInView,
|
||||
@@ -47,7 +47,7 @@ export function containerListToResourcesObject(containers: DockerContainer[]): O
|
||||
export class DockerOrganizerService {
|
||||
private readonly logger = new Logger(DockerOrganizerService.name);
|
||||
constructor(
|
||||
private readonly dockerConfigService: DockerConfigService,
|
||||
private readonly dockerConfigService: DockerOrganizerConfigService,
|
||||
private readonly dockerService: DockerService
|
||||
) {}
|
||||
|
||||
28
api/src/unraid-api/utils/feature-flag.helper.ts
Normal file
28
api/src/unraid-api/utils/feature-flag.helper.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Checks if a feature flag is enabled and throws an exception if disabled.
|
||||
* Use this at the beginning of resolver methods for immediate feature flag checks.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @ResolveField(() => String)
|
||||
* async organizer() {
|
||||
* checkFeatureFlag(FeatureFlags, 'ENABLE_NEXT_DOCKER_RELEASE');
|
||||
* return this.dockerOrganizerService.resolveOrganizer();
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param flags - The feature flag object containing boolean/truthy values
|
||||
* @param key - The key within the feature flag object to check
|
||||
* @throws ForbiddenException if the feature flag is disabled
|
||||
*/
|
||||
export function checkFeatureFlag<T extends Record<string, any>>(flags: T, key: keyof T): void {
|
||||
const isEnabled = Boolean(flags[key]);
|
||||
|
||||
if (!isEnabled) {
|
||||
throw new ForbiddenException(
|
||||
`Feature "${String(key)}" is currently disabled. This functionality is not available at this time.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
|
||||
import type { ViteUserConfig } from 'vitest/config';
|
||||
import { viteCommonjs } from '@originjs/vite-plugin-commonjs';
|
||||
import nodeResolve from '@rollup/plugin-node-resolve';
|
||||
@@ -70,6 +73,29 @@ export default defineConfig(({ mode }): ViteUserConfig => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Copy PHP files to assets directory
|
||||
{
|
||||
name: 'copy-php-files',
|
||||
buildStart() {
|
||||
const phpFiles = ['src/core/utils/plugins/wrapper.php'];
|
||||
phpFiles.forEach((file) => this.addWatchFile(file));
|
||||
},
|
||||
async generateBundle() {
|
||||
const phpFiles = ['src/core/utils/plugins/wrapper.php'];
|
||||
phpFiles.forEach((file) => {
|
||||
if (!existsSync(file)) {
|
||||
this.warn(`[copy-php-files] PHP file ${file} does not exist`);
|
||||
return;
|
||||
}
|
||||
const content = readFileSync(file);
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: join('assets', basename(file)),
|
||||
source: content,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
define: {
|
||||
// Allows vite to preserve process.env variables and not hardcode them
|
||||
|
||||
35
crowdin.yml
35
crowdin.yml
@@ -1,35 +0,0 @@
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
api_token_env: CROWDIN_API_TOKEN
|
||||
base_path: .
|
||||
preserve_hierarchy: true
|
||||
|
||||
files:
|
||||
# Frontend translations
|
||||
- source: /web/src/locales/en_US.json
|
||||
translation: /web/src/locales/%locale%.json
|
||||
type: json
|
||||
update_option: update_as_unapproved
|
||||
skip_untranslated_strings: false
|
||||
export_only_approved: false
|
||||
|
||||
# Backend translations
|
||||
- source: /api/src/i18n/en/common.json
|
||||
translation: /api/src/i18n/%two_letters_code%/%file_name%.json
|
||||
type: json
|
||||
update_option: update_as_unapproved
|
||||
skip_untranslated_strings: false
|
||||
export_only_approved: false
|
||||
|
||||
- source: /api/src/i18n/en/errors.json
|
||||
translation: /api/src/i18n/%two_letters_code%/%file_name%.json
|
||||
type: json
|
||||
update_option: update_as_unapproved
|
||||
skip_untranslated_strings: false
|
||||
export_only_approved: false
|
||||
|
||||
- source: /api/src/i18n/en/validation.json
|
||||
translation: /api/src/i18n/%two_letters_code%/%file_name%.json
|
||||
type: json
|
||||
update_option: update_as_unapproved
|
||||
skip_untranslated_strings: false
|
||||
export_only_approved: false
|
||||
111
i18n-setup.md
111
i18n-setup.md
@@ -1,111 +0,0 @@
|
||||
# i18n Setup Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This project uses:
|
||||
- **Frontend**: `vue-i18n` for Vue.js components
|
||||
- **Backend**: `nestjs-i18n` for NestJS API
|
||||
- **Translation Management**: Crowdin for collaborative translation
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/web/src/locales/ # Frontend translations
|
||||
en_US.json # Base English translations
|
||||
*.json # Other locale translations
|
||||
|
||||
/api/src/i18n/ # Backend translations
|
||||
en/ # English translations
|
||||
common.json # Common messages
|
||||
errors.json # Error messages
|
||||
validation.json # Validation messages
|
||||
*/ # Other locales
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
Set these environment variables for Crowdin:
|
||||
```bash
|
||||
export CROWDIN_PROJECT_ID=your_project_id
|
||||
export CROWDIN_API_TOKEN=your_api_token
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Frontend (Vue)
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Use translation
|
||||
const message = t('welcome')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>{{ t('hello') }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Backend (NestJS)
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { I18nService } from 'nestjs-i18n';
|
||||
|
||||
@Injectable()
|
||||
export class MyService {
|
||||
constructor(private readonly i18n: I18nService) {}
|
||||
|
||||
async getMessage() {
|
||||
return this.i18n.translate('common.welcome');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Extract Translation Keys
|
||||
```bash
|
||||
# Extract missing keys from Vue components
|
||||
pnpm i18n:extract
|
||||
|
||||
# Check for missing translations
|
||||
pnpm --filter ./web i18n:missing
|
||||
```
|
||||
|
||||
### Crowdin Sync
|
||||
```bash
|
||||
# Upload source files to Crowdin
|
||||
pnpm crowdin:upload
|
||||
|
||||
# Download translations from Crowdin
|
||||
pnpm crowdin:download
|
||||
|
||||
# Full sync (extract, upload, download)
|
||||
pnpm crowdin:sync
|
||||
```
|
||||
|
||||
## Build-time Integration
|
||||
|
||||
Translations are bundled at build time:
|
||||
- Frontend: Included in the Vite build process
|
||||
- Backend: Copied with the NestJS build
|
||||
|
||||
## Adding New Translations
|
||||
|
||||
1. Add key to source files (`en_US.json` or `en/*.json`)
|
||||
2. Run `pnpm crowdin:upload` to sync with Crowdin
|
||||
3. Translators work on Crowdin
|
||||
4. Run `pnpm crowdin:download` to fetch translations
|
||||
5. Build and deploy
|
||||
|
||||
## Locale Detection
|
||||
|
||||
- **Frontend**: Uses browser's Accept-Language header
|
||||
- **Backend**: Detects from (in order):
|
||||
1. Query parameter: `?lang=fr`
|
||||
2. Header: `x-locale` or `x-lang`
|
||||
3. Accept-Language header
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "unraid-monorepo",
|
||||
"private": true,
|
||||
"version": "4.20.1",
|
||||
"version": "4.20.3",
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"build:watch": " pnpm -r --parallel build:watch",
|
||||
@@ -16,11 +16,7 @@
|
||||
"check": "manypkg check",
|
||||
"sync-webgui-repo": "node web/scripts/sync-webgui-repo.js",
|
||||
"preinstall": "npx check-node-version --node 22 || echo '❌ Node.js 22 required. See readme.md Prerequisites section.'",
|
||||
"postinstall": "simple-git-hooks",
|
||||
"i18n:extract": "pnpm --filter ./web i18n:extract",
|
||||
"crowdin:upload": "crowdin upload sources",
|
||||
"crowdin:download": "crowdin download",
|
||||
"crowdin:sync": "pnpm i18n:extract && crowdin upload sources && crowdin download"
|
||||
"postinstall": "simple-git-hooks"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
@@ -60,7 +56,6 @@
|
||||
"ignore": "7.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@crowdin/cli": "^4.11.0",
|
||||
"lint-staged": "16.1.5",
|
||||
"simple-git-hooks": "2.13.1"
|
||||
},
|
||||
|
||||
311
packages/unraid-shared/src/util/__tests__/processing.test.ts
Normal file
311
packages/unraid-shared/src/util/__tests__/processing.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AsyncMutex } from '../processing.js';
|
||||
|
||||
describe('AsyncMutex', () => {
|
||||
|
||||
describe('constructor-based operation', () => {
|
||||
it('should execute the default operation when do() is called without parameters', async () => {
|
||||
const mockOperation = vi.fn().mockResolvedValue('result');
|
||||
const mutex = new AsyncMutex(mockOperation);
|
||||
|
||||
const result = await mutex.do();
|
||||
|
||||
expect(result).toBe('result');
|
||||
expect(mockOperation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return the same promise when multiple calls are made concurrently', async () => {
|
||||
let resolveOperation: (value: string) => void;
|
||||
const operationPromise = new Promise<string>((resolve) => {
|
||||
resolveOperation = resolve;
|
||||
});
|
||||
const mockOperation = vi.fn().mockReturnValue(operationPromise);
|
||||
const mutex = new AsyncMutex(mockOperation);
|
||||
|
||||
const promise1 = mutex.do();
|
||||
const promise2 = mutex.do();
|
||||
const promise3 = mutex.do();
|
||||
|
||||
expect(mockOperation).toHaveBeenCalledTimes(1);
|
||||
expect(promise1).toBe(promise2);
|
||||
expect(promise2).toBe(promise3);
|
||||
|
||||
resolveOperation!('result');
|
||||
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||
|
||||
expect(result1).toBe('result');
|
||||
expect(result2).toBe('result');
|
||||
expect(result3).toBe('result');
|
||||
});
|
||||
|
||||
it('should allow new operations after the first completes', async () => {
|
||||
const mockOperation = vi.fn()
|
||||
.mockResolvedValueOnce('first')
|
||||
.mockResolvedValueOnce('second');
|
||||
const mutex = new AsyncMutex(mockOperation);
|
||||
|
||||
const result1 = await mutex.do();
|
||||
expect(result1).toBe('first');
|
||||
expect(mockOperation).toHaveBeenCalledTimes(1);
|
||||
|
||||
const result2 = await mutex.do();
|
||||
expect(result2).toBe('second');
|
||||
expect(mockOperation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle errors in the default operation', async () => {
|
||||
const error = new Error('Operation failed');
|
||||
const mockOperation = vi.fn().mockRejectedValue(error);
|
||||
const mutex = new AsyncMutex(mockOperation);
|
||||
|
||||
await expect(mutex.do()).rejects.toThrow(error);
|
||||
expect(mockOperation).toHaveBeenCalledTimes(1);
|
||||
|
||||
const secondOperation = vi.fn().mockResolvedValue('success');
|
||||
const mutex2 = new AsyncMutex(secondOperation);
|
||||
const result = await mutex2.do();
|
||||
expect(result).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-call operation', () => {
|
||||
it('should execute the provided operation', async () => {
|
||||
const mutex = new AsyncMutex<number>();
|
||||
const mockOperation = vi.fn().mockResolvedValue(42);
|
||||
|
||||
const result = await mutex.do(mockOperation);
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(mockOperation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return the same promise for concurrent calls with same operation type', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
let resolveOperation: (value: string) => void;
|
||||
const operationPromise = new Promise<string>((resolve) => {
|
||||
resolveOperation = resolve;
|
||||
});
|
||||
const mockOperation = vi.fn().mockReturnValue(operationPromise);
|
||||
|
||||
const promise1 = mutex.do(mockOperation);
|
||||
const promise2 = mutex.do(mockOperation);
|
||||
const promise3 = mutex.do(mockOperation);
|
||||
|
||||
expect(mockOperation).toHaveBeenCalledTimes(1);
|
||||
expect(promise1).toBe(promise2);
|
||||
expect(promise2).toBe(promise3);
|
||||
|
||||
resolveOperation!('shared-result');
|
||||
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
|
||||
|
||||
expect(result1).toBe('shared-result');
|
||||
expect(result2).toBe('shared-result');
|
||||
expect(result3).toBe('shared-result');
|
||||
});
|
||||
|
||||
it('should allow different operations with different types', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
|
||||
const stringOp = vi.fn().mockResolvedValue('string-result');
|
||||
const numberOp = vi.fn().mockResolvedValue(123);
|
||||
|
||||
const stringResult = await mutex.do(stringOp);
|
||||
const numberResult = await mutex.do(numberOp);
|
||||
|
||||
expect(stringResult).toBe('string-result');
|
||||
expect(numberResult).toBe(123);
|
||||
expect(stringOp).toHaveBeenCalledTimes(1);
|
||||
expect(numberOp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle errors in per-call operations', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
const error = new Error('Operation failed');
|
||||
const failingOp = vi.fn().mockRejectedValue(error);
|
||||
|
||||
await expect(mutex.do(failingOp)).rejects.toThrow(error);
|
||||
expect(failingOp).toHaveBeenCalledTimes(1);
|
||||
|
||||
const successOp = vi.fn().mockResolvedValue('success');
|
||||
const result = await mutex.do(successOp);
|
||||
expect(result).toBe('success');
|
||||
expect(successOp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error when no operation is provided and no default is set', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
|
||||
await expect(mutex.do()).rejects.toThrow('No operation provided and no default operation set');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed usage', () => {
|
||||
it('should allow overriding default operation with per-call operation', async () => {
|
||||
const defaultOp = vi.fn().mockResolvedValue('default');
|
||||
const mutex = new AsyncMutex(defaultOp);
|
||||
|
||||
const customOp = vi.fn().mockResolvedValue('custom');
|
||||
|
||||
const customResult = await mutex.do(customOp);
|
||||
expect(customResult).toBe('custom');
|
||||
expect(customOp).toHaveBeenCalledTimes(1);
|
||||
expect(defaultOp).not.toHaveBeenCalled();
|
||||
|
||||
const defaultResult = await mutex.do();
|
||||
expect(defaultResult).toBe('default');
|
||||
expect(defaultOp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should share lock between default and custom operations', async () => {
|
||||
let resolveDefault: (value: string) => void;
|
||||
const defaultPromise = new Promise<string>((resolve) => {
|
||||
resolveDefault = resolve;
|
||||
});
|
||||
const defaultOp = vi.fn().mockReturnValue(defaultPromise);
|
||||
const mutex = new AsyncMutex(defaultOp);
|
||||
|
||||
const customOp = vi.fn().mockResolvedValue('custom');
|
||||
|
||||
const defaultCall = mutex.do();
|
||||
const customCall = mutex.do(customOp);
|
||||
|
||||
expect(defaultOp).toHaveBeenCalledTimes(1);
|
||||
expect(customOp).not.toHaveBeenCalled();
|
||||
expect(customCall).toBe(defaultCall);
|
||||
|
||||
resolveDefault!('default');
|
||||
const [defaultResult, customResult] = await Promise.all([defaultCall, customCall]);
|
||||
|
||||
expect(defaultResult).toBe('default');
|
||||
expect(customResult).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timing and concurrency', () => {
|
||||
it('should handle sequential slow operations', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
let callCount = 0;
|
||||
|
||||
const slowOp = vi.fn().mockImplementation(() => {
|
||||
return new Promise((resolve) => {
|
||||
const currentCall = ++callCount;
|
||||
setTimeout(() => resolve(`result-${currentCall}`), 100);
|
||||
});
|
||||
});
|
||||
|
||||
const result1 = await mutex.do(slowOp);
|
||||
expect(result1).toBe('result-1');
|
||||
|
||||
const result2 = await mutex.do(slowOp);
|
||||
expect(result2).toBe('result-2');
|
||||
|
||||
expect(slowOp).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should deduplicate concurrent slow operations', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
let resolveOperation: (value: string) => void;
|
||||
|
||||
const slowOp = vi.fn().mockImplementation(() => {
|
||||
return new Promise<string>((resolve) => {
|
||||
resolveOperation = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const promises = [
|
||||
mutex.do(slowOp),
|
||||
mutex.do(slowOp),
|
||||
mutex.do(slowOp),
|
||||
mutex.do(slowOp),
|
||||
mutex.do(slowOp)
|
||||
];
|
||||
|
||||
expect(slowOp).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveOperation!('shared-slow-result');
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
expect(results).toEqual([
|
||||
'shared-slow-result',
|
||||
'shared-slow-result',
|
||||
'shared-slow-result',
|
||||
'shared-slow-result',
|
||||
'shared-slow-result'
|
||||
]);
|
||||
});
|
||||
|
||||
it('should properly clean up after operation completes', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
const op1 = vi.fn().mockResolvedValue('first');
|
||||
const op2 = vi.fn().mockResolvedValue('second');
|
||||
|
||||
await mutex.do(op1);
|
||||
expect(op1).toHaveBeenCalledTimes(1);
|
||||
|
||||
await mutex.do(op2);
|
||||
expect(op2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle multiple rapid sequences of operations', async () => {
|
||||
const mutex = new AsyncMutex();
|
||||
const results: string[] = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const op = vi.fn().mockResolvedValue(`result-${i}`);
|
||||
const result = await mutex.do(op);
|
||||
results.push(result as string);
|
||||
}
|
||||
|
||||
expect(results).toEqual(['result-0', 'result-1', 'result-2', 'result-3', 'result-4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle operations that return undefined', async () => {
|
||||
const mutex = new AsyncMutex<undefined>();
|
||||
const op = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await mutex.do(op);
|
||||
expect(result).toBeUndefined();
|
||||
expect(op).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle operations that return null', async () => {
|
||||
const mutex = new AsyncMutex<null>();
|
||||
const op = vi.fn().mockResolvedValue(null);
|
||||
|
||||
const result = await mutex.do(op);
|
||||
expect(result).toBeNull();
|
||||
expect(op).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle nested operations correctly', async () => {
|
||||
const mutex = new AsyncMutex<string>();
|
||||
|
||||
const innerOp = vi.fn().mockResolvedValue('inner');
|
||||
const outerOp = vi.fn().mockImplementation(async () => {
|
||||
return 'outer';
|
||||
});
|
||||
|
||||
const result = await mutex.do(outerOp);
|
||||
expect(result).toBe('outer');
|
||||
expect(outerOp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should maintain type safety with generic operations', async () => {
|
||||
const mutex = new AsyncMutex<string>();
|
||||
|
||||
const stringOp = vi.fn().mockResolvedValue('string');
|
||||
const numberOp = vi.fn().mockResolvedValue(42);
|
||||
const booleanOp = vi.fn().mockResolvedValue(true);
|
||||
|
||||
const stringResult: string = await mutex.do(stringOp);
|
||||
const numberResult: number = await mutex.do(numberOp);
|
||||
const booleanResult: boolean = await mutex.do(booleanOp);
|
||||
|
||||
expect(stringResult).toBe('string');
|
||||
expect(numberResult).toBe(42);
|
||||
expect(booleanResult).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,3 +31,119 @@ export function makeSafeRunner(onError: (error: unknown) => void) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
type AsyncOperation<T> = () => Promise<T>;
|
||||
|
||||
/**
|
||||
* A mutex for asynchronous operations that ensures only one operation runs at a time.
|
||||
*
|
||||
* When multiple callers attempt to execute operations simultaneously, they will all
|
||||
* receive the same promise from the currently running operation, effectively deduplicating
|
||||
* concurrent calls. This is useful for expensive operations like API calls, file operations,
|
||||
* or database queries that should not be executed multiple times concurrently.
|
||||
*
|
||||
* @template T - The default return type for operations when using a default operation
|
||||
*
|
||||
* @example
|
||||
* // Basic usage with explicit operations
|
||||
* const mutex = new AsyncMutex();
|
||||
*
|
||||
* // Multiple concurrent calls will deduplicate
|
||||
* const [result1, result2, result3] = await Promise.all([
|
||||
* mutex.do(() => fetch('/api/data')),
|
||||
* mutex.do(() => fetch('/api/data')), // Same request, will get same promise
|
||||
* mutex.do(() => fetch('/api/data')) // Same request, will get same promise
|
||||
* ]);
|
||||
* // Only one fetch actually happens
|
||||
*
|
||||
* @example
|
||||
* // Usage with a default operation
|
||||
* const dataLoader = new AsyncMutex(() =>
|
||||
* fetch('/api/expensive-data').then(res => res.json())
|
||||
* );
|
||||
*
|
||||
* const data1 = await dataLoader.do(); // Executes the fetch
|
||||
* const data2 = await dataLoader.do(); // If first promise is finished, a new fetch is executed
|
||||
*/
|
||||
export class AsyncMutex<T = unknown> {
|
||||
private currentOperation: Promise<any> | null = null;
|
||||
private defaultOperation?: AsyncOperation<T>;
|
||||
|
||||
/**
|
||||
* Creates a new AsyncMutex instance.
|
||||
*
|
||||
* @param operation - Optional default operation to execute when calling `do()` without arguments.
|
||||
* This is useful when you have a specific operation that should be deduplicated.
|
||||
*
|
||||
* @example
|
||||
* // Without default operation (shared mutex)
|
||||
* const mutex = new AsyncMutex();
|
||||
* const promise1 = mutex.do(() => someAsyncWork());
|
||||
* const promise2 = mutex.do(() => someOtherAsyncWork());
|
||||
*
|
||||
* // Both promises will be the same
|
||||
* expect(await promise1).toBe(await promise2);
|
||||
*
|
||||
* // After the first operation completes, new operations can run
|
||||
* await promise1;
|
||||
* const newPromise = mutex.do(() => someOtherAsyncWork()); // This will execute
|
||||
*
|
||||
* @example
|
||||
* // With default operation (deduplicating a specific operation)
|
||||
* const dataMutex = new AsyncMutex(() => loadExpensiveData());
|
||||
* await dataMutex.do(); // Executes loadExpensiveData()
|
||||
*/
|
||||
constructor(operation?: AsyncOperation<T>) {
|
||||
this.defaultOperation = operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the default operation if one was provided in the constructor.
|
||||
* @returns Promise that resolves with the result of the default operation
|
||||
* @throws Error if no default operation was set in the constructor
|
||||
*/
|
||||
do(): Promise<T>;
|
||||
/**
|
||||
* Executes the provided operation, ensuring only one runs at a time.
|
||||
*
|
||||
* If an operation is already running, all subsequent calls will receive
|
||||
* the same promise from the currently running operation. This effectively
|
||||
* deduplicates concurrent calls to the same expensive operation.
|
||||
*
|
||||
* @param operation - Optional operation to execute. If not provided, uses the default operation.
|
||||
* @returns Promise that resolves with the result of the operation
|
||||
* @throws Error if no operation is provided and no default operation was set
|
||||
*
|
||||
* @example
|
||||
* const mutex = new AsyncMutex();
|
||||
*
|
||||
* // These will all return the same promise
|
||||
* const promise1 = mutex.do(() => fetch('/api/data'));
|
||||
* const promise2 = mutex.do(() => fetch('/api/other')); // Still gets first promise!
|
||||
* const promise3 = mutex.do(() => fetch('/api/another')); // Still gets first promise!
|
||||
*
|
||||
* // After the first operation completes, new operations can run
|
||||
* await promise1;
|
||||
* const newPromise = mutex.do(() => fetch('/api/new')); // This will execute
|
||||
*/
|
||||
do<U>(operation: AsyncOperation<U>): Promise<U>;
|
||||
do<U = T>(operation?: AsyncOperation<U>): Promise<U | T> {
|
||||
if (!operation && !this.defaultOperation) {
|
||||
return Promise.reject(new Error('No operation provided and no default operation set'));
|
||||
}
|
||||
|
||||
if (this.currentOperation) {
|
||||
return this.currentOperation;
|
||||
}
|
||||
|
||||
const op = (operation || this.defaultOperation) as AsyncOperation<U | T>;
|
||||
const promise = Promise.resolve().then(op).finally(() => {
|
||||
if (this.currentOperation === promise) {
|
||||
this.currentOperation = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.currentOperation = promise;
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/connect-plugin",
|
||||
"version": "4.20.1",
|
||||
"version": "4.20.3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
|
||||
195
pnpm-lock.yaml
generated
195
pnpm-lock.yaml
generated
@@ -25,9 +25,6 @@ importers:
|
||||
specifier: 7.0.5
|
||||
version: 7.0.5
|
||||
devDependencies:
|
||||
'@crowdin/cli':
|
||||
specifier: ^4.11.0
|
||||
version: 4.11.0
|
||||
lint-staged:
|
||||
specifier: 16.1.5
|
||||
version: 16.1.5
|
||||
@@ -167,8 +164,8 @@ importers:
|
||||
specifier: 1.0.2
|
||||
version: 1.0.2
|
||||
cron:
|
||||
specifier: 4.3.3
|
||||
version: 4.3.3
|
||||
specifier: 4.3.0
|
||||
version: 4.3.0
|
||||
cross-fetch:
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0
|
||||
@@ -226,9 +223,6 @@ importers:
|
||||
graphql-ws:
|
||||
specifier: 6.0.6
|
||||
version: 6.0.6(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.3)
|
||||
i18next:
|
||||
specifier: ^25.5.2
|
||||
version: 25.5.2(typescript@5.9.2)
|
||||
ini:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
@@ -256,9 +250,6 @@ importers:
|
||||
nest-commander:
|
||||
specifier: 3.19.0
|
||||
version: 3.19.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(@types/inquirer@9.0.9)(@types/node@22.18.0)(typescript@5.9.2)
|
||||
nestjs-i18n:
|
||||
specifier: ^10.5.1
|
||||
version: 10.5.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(class-validator@0.14.2)(rxjs@7.8.2)
|
||||
nestjs-pino:
|
||||
specifier: 4.4.0
|
||||
version: 4.4.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.9.0)(rxjs@7.8.2)
|
||||
@@ -1320,9 +1311,6 @@ importers:
|
||||
vue-eslint-parser:
|
||||
specifier: 10.2.0
|
||||
version: 10.2.0(eslint@9.34.0(jiti@2.5.1))
|
||||
vue-i18n-extract:
|
||||
specifier: github:Spittal/vue-i18n-extract
|
||||
version: https://codeload.github.com/Spittal/vue-i18n-extract/tar.gz/f856484d2f62abbd931d84676519f529062d94da
|
||||
vue-tsc:
|
||||
specifier: 3.0.6
|
||||
version: 3.0.6(typescript@5.9.2)
|
||||
@@ -1913,10 +1901,6 @@ packages:
|
||||
conventional-commits-parser:
|
||||
optional: true
|
||||
|
||||
'@crowdin/cli@4.11.0':
|
||||
resolution: {integrity: sha512-oIOzCHCc9eHOqcQw1bjcnfFUoJDFVobCN5MEZ1rwjYOhPMpIcRNBDnUT/b43LcRWsogU3c0BwcWN/BHvV19DWg==}
|
||||
hasBin: true
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4217,9 +4201,6 @@ packages:
|
||||
'@types/luxon@3.6.2':
|
||||
resolution: {integrity: sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==}
|
||||
|
||||
'@types/luxon@3.7.1':
|
||||
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
|
||||
|
||||
'@types/mdx@2.0.13':
|
||||
resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
|
||||
|
||||
@@ -4985,9 +4966,6 @@ packages:
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
accept-language-parser@1.5.0:
|
||||
resolution: {integrity: sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==}
|
||||
|
||||
accepts@1.3.8:
|
||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -5390,9 +5368,6 @@ packages:
|
||||
bser@2.1.1:
|
||||
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -5678,10 +5653,6 @@ packages:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
command-exists-promise@2.0.2:
|
||||
resolution: {integrity: sha512-T6PB6vdFrwnHXg/I0kivM3DqaCGZLjjYSOe0a5WgFKcz1sOnmOeIjnhQPXVXX3QjVbLyTJ85lJkX6lUpukTzaA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
command-exists@1.2.9:
|
||||
resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==}
|
||||
|
||||
@@ -5707,10 +5678,6 @@ packages:
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
commander@6.2.1:
|
||||
resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
commander@9.5.0:
|
||||
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
|
||||
engines: {node: ^12.20.0 || >=14}
|
||||
@@ -5898,10 +5865,6 @@ packages:
|
||||
resolution: {integrity: sha512-ciiYNLfSlF9MrDqnbMdRWFiA6oizSF7kA1osPP9lRzNu0Uu+AWog1UKy7SkckiDY2irrNjeO6qLyKnXC8oxmrw==}
|
||||
engines: {node: '>=18.x'}
|
||||
|
||||
cron@4.3.3:
|
||||
resolution: {integrity: sha512-B/CJj5yL3sjtlun6RtYHvoSB26EmQ2NUmhq9ZiJSyKIM4K/fqfh9aelDFlIayD2YMeFZqWLi9hHV+c+pq2Djkw==}
|
||||
engines: {node: '>=18.x'}
|
||||
|
||||
croner@4.1.97:
|
||||
resolution: {integrity: sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==}
|
||||
|
||||
@@ -6259,10 +6222,6 @@ packages:
|
||||
dot-case@3.0.4:
|
||||
resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
|
||||
|
||||
dot-object@2.1.5:
|
||||
resolution: {integrity: sha512-xHF8EP4XH/Ba9fvAF2LDd5O3IITVolerVV6xvkxoM8zlGEiCUrggpAnHyOoKJKCrhvPcGATFAUwIujj7bRG5UA==}
|
||||
hasBin: true
|
||||
|
||||
dot-prop@5.3.0:
|
||||
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -7263,11 +7222,6 @@ packages:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
|
||||
glob@8.1.0:
|
||||
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
|
||||
engines: {node: '>=12'}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
engines: {node: '>=10.0'}
|
||||
@@ -7542,14 +7496,6 @@ packages:
|
||||
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
|
||||
i18next@25.5.2:
|
||||
resolution: {integrity: sha512-lW8Zeh37i/o0zVr+NoCHfNnfvVw+M6FQbRp36ZZ/NyHDJ3NJVpp2HhAUyU9WafL5AssymNoOjMRB48mmx2P6Hw==}
|
||||
peerDependencies:
|
||||
typescript: ^5
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7650,10 +7596,6 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
interpret@1.4.0:
|
||||
resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
invariant@2.2.4:
|
||||
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
|
||||
|
||||
@@ -7900,10 +7842,6 @@ packages:
|
||||
is-utf8@0.2.1:
|
||||
resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==}
|
||||
|
||||
is-valid-glob@1.0.0:
|
||||
resolution: {integrity: sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
is-weakmap@2.0.2:
|
||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -8548,10 +8486,6 @@ packages:
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
minimatch@5.1.6:
|
||||
resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
minimatch@7.4.6:
|
||||
resolution: {integrity: sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -8705,15 +8639,6 @@ packages:
|
||||
'@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||
'@types/inquirer': ^8.1.3
|
||||
|
||||
nestjs-i18n@10.5.1:
|
||||
resolution: {integrity: sha512-cJJFz+RUfav23QACpGCq1pdXNLYC3tBesrP14RGoE/YYcD4xosQPX2eyjvDNuo0Ti63Xtn6j57wDNEUKrZqmSw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@nestjs/common': '*'
|
||||
'@nestjs/core': '*'
|
||||
class-validator: '*'
|
||||
rxjs: '*'
|
||||
|
||||
nestjs-pino@4.4.0:
|
||||
resolution: {integrity: sha512-+GMNlcNWDRrMtlQftfcxN+5pV2C25A4wsYIY7cfRJTMW4b8IFKYReDrG1lUp5LGql9fXemmnVJ2Ww10iIkCZPQ==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -9167,9 +9092,6 @@ packages:
|
||||
pause@0.0.1:
|
||||
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
@@ -9595,10 +9517,6 @@ packages:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
rechoir@0.6.2:
|
||||
resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
redent@3.0.0:
|
||||
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -9947,11 +9865,6 @@ packages:
|
||||
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
shelljs@0.8.5:
|
||||
resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
shimmer@1.2.1:
|
||||
resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==}
|
||||
|
||||
@@ -10151,9 +10064,6 @@ packages:
|
||||
string-env-interpolation@1.0.1:
|
||||
resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==}
|
||||
|
||||
string-format@2.0.0:
|
||||
resolution: {integrity: sha512-bbEs3scLeYNXLecRRuk6uJxdXUSj6le/8rNPHChIJTn2V79aXVTR1EH2OH5zLKKoz0V02fOUKZZcw01pLUShZA==}
|
||||
|
||||
string-width@3.1.0:
|
||||
resolution: {integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -11100,11 +11010,6 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0
|
||||
|
||||
vue-i18n-extract@https://codeload.github.com/Spittal/vue-i18n-extract/tar.gz/f856484d2f62abbd931d84676519f529062d94da:
|
||||
resolution: {tarball: https://codeload.github.com/Spittal/vue-i18n-extract/tar.gz/f856484d2f62abbd931d84676519f529062d94da}
|
||||
version: 2.0.8
|
||||
hasBin: true
|
||||
|
||||
vue-i18n@11.1.11:
|
||||
resolution: {integrity: sha512-LvyteQoXeQiuILbzqv13LbyBna/TEv2Ha+4ZWK2AwGHUzZ8+IBaZS0TJkCgn5izSPLcgZwXy9yyTrewCb2u/MA==}
|
||||
engines: {node: '>= 16'}
|
||||
@@ -11440,10 +11345,6 @@ packages:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yauzl@3.2.0:
|
||||
resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -12261,16 +12162,6 @@ snapshots:
|
||||
conventional-commits-filter: 5.0.0
|
||||
conventional-commits-parser: 6.0.0
|
||||
|
||||
'@crowdin/cli@4.11.0':
|
||||
dependencies:
|
||||
command-exists-promise: 2.0.2
|
||||
node-fetch: 2.7.0
|
||||
shelljs: 0.8.5
|
||||
tar: 7.4.3
|
||||
yauzl: 3.2.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@cspotcode/source-map-support@0.8.1':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
@@ -14908,8 +14799,6 @@ snapshots:
|
||||
|
||||
'@types/luxon@3.6.2': {}
|
||||
|
||||
'@types/luxon@3.7.1': {}
|
||||
|
||||
'@types/mdx@2.0.13': {}
|
||||
|
||||
'@types/methods@1.1.4': {}
|
||||
@@ -15794,8 +15683,6 @@ snapshots:
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
accept-language-parser@1.5.0: {}
|
||||
|
||||
accepts@1.3.8:
|
||||
dependencies:
|
||||
mime-types: 2.1.35
|
||||
@@ -16218,8 +16105,6 @@ snapshots:
|
||||
dependencies:
|
||||
node-int64: 0.4.0
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
@@ -16562,8 +16447,6 @@ snapshots:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
command-exists-promise@2.0.2: {}
|
||||
|
||||
command-exists@1.2.9: {}
|
||||
|
||||
commander@10.0.1: {}
|
||||
@@ -16578,8 +16461,6 @@ snapshots:
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@6.2.1: {}
|
||||
|
||||
commander@9.5.0:
|
||||
optional: true
|
||||
|
||||
@@ -16783,11 +16664,6 @@ snapshots:
|
||||
'@types/luxon': 3.6.2
|
||||
luxon: 3.6.1
|
||||
|
||||
cron@4.3.3:
|
||||
dependencies:
|
||||
'@types/luxon': 3.7.1
|
||||
luxon: 3.7.1
|
||||
|
||||
croner@4.1.97: {}
|
||||
|
||||
cross-fetch@3.2.0:
|
||||
@@ -17108,11 +16984,6 @@ snapshots:
|
||||
no-case: 3.0.4
|
||||
tslib: 2.8.1
|
||||
|
||||
dot-object@2.1.5:
|
||||
dependencies:
|
||||
commander: 6.2.1
|
||||
glob: 7.2.3
|
||||
|
||||
dot-prop@5.3.0:
|
||||
dependencies:
|
||||
is-obj: 2.0.0
|
||||
@@ -18347,14 +18218,6 @@ snapshots:
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
glob@8.1.0:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 5.1.6
|
||||
once: 1.4.0
|
||||
|
||||
global-agent@3.0.0:
|
||||
dependencies:
|
||||
boolean: 3.2.0
|
||||
@@ -18643,12 +18506,6 @@ snapshots:
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
i18next@25.5.2(typescript@5.9.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.6
|
||||
optionalDependencies:
|
||||
typescript: 5.9.2
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -18773,8 +18630,6 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
interpret@1.4.0: {}
|
||||
|
||||
invariant@2.2.4:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -18998,8 +18853,6 @@ snapshots:
|
||||
|
||||
is-utf8@0.2.1: {}
|
||||
|
||||
is-valid-glob@1.0.0: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
|
||||
is-weakref@1.1.1:
|
||||
@@ -19601,10 +19454,6 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
|
||||
minimatch@5.1.6:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimatch@7.4.6:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
@@ -19736,19 +19585,6 @@ snapshots:
|
||||
- '@types/node'
|
||||
- typescript
|
||||
|
||||
nestjs-i18n@10.5.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(class-validator@0.14.2)(rxjs@7.8.2):
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
accept-language-parser: 1.5.0
|
||||
chokidar: 3.6.0
|
||||
class-validator: 0.14.2
|
||||
cookie: 0.7.1
|
||||
iterare: 1.2.1
|
||||
js-yaml: 4.1.0
|
||||
rxjs: 7.8.2
|
||||
string-format: 2.0.0
|
||||
|
||||
nestjs-pino@4.4.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(pino-http@10.5.0)(pino@9.9.0)(rxjs@7.8.2):
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
@@ -20238,8 +20074,6 @@ snapshots:
|
||||
|
||||
pause@0.0.1: {}
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@@ -20742,10 +20576,6 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tslib: 2.8.1
|
||||
|
||||
rechoir@0.6.2:
|
||||
dependencies:
|
||||
resolve: 1.22.10
|
||||
|
||||
redent@3.0.0:
|
||||
dependencies:
|
||||
indent-string: 4.0.0
|
||||
@@ -21190,12 +21020,6 @@ snapshots:
|
||||
|
||||
shell-quote@1.8.3: {}
|
||||
|
||||
shelljs@0.8.5:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
interpret: 1.4.0
|
||||
rechoir: 0.6.2
|
||||
|
||||
shimmer@1.2.1: {}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
@@ -21406,8 +21230,6 @@ snapshots:
|
||||
|
||||
string-env-interpolation@1.0.1: {}
|
||||
|
||||
string-format@2.0.0: {}
|
||||
|
||||
string-width@3.1.0:
|
||||
dependencies:
|
||||
emoji-regex: 7.0.3
|
||||
@@ -22439,14 +22261,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-i18n-extract@https://codeload.github.com/Spittal/vue-i18n-extract/tar.gz/f856484d2f62abbd931d84676519f529062d94da:
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
dot-object: 2.1.5
|
||||
glob: 8.1.0
|
||||
is-valid-glob: 1.0.0
|
||||
js-yaml: 4.1.0
|
||||
|
||||
vue-i18n@11.1.11(vue@3.5.20(typescript@5.9.2)):
|
||||
dependencies:
|
||||
'@intlify/core-base': 11.1.11
|
||||
@@ -22802,11 +22616,6 @@ snapshots:
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yauzl@3.2.0:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
pend: 1.2.0
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yocto-queue@1.2.1: {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/ui",
|
||||
"version": "4.20.1",
|
||||
"version": "4.20.3",
|
||||
"private": true,
|
||||
"license": "GPL-2.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
@@ -322,8 +322,8 @@ describe('UserProfile.ce.vue', () => {
|
||||
themeStore.theme!.descriptionShow = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// Look for the description in a span element
|
||||
let descriptionElement = wrapper.find('span.text-center.md\\:text-right');
|
||||
// Look for the description in a span element with v-html directive
|
||||
let descriptionElement = wrapper.find('span.hidden.text-center.text-base');
|
||||
expect(descriptionElement.exists()).toBe(true);
|
||||
expect(descriptionElement.html()).toContain(initialServerData.description);
|
||||
|
||||
@@ -331,13 +331,13 @@ describe('UserProfile.ce.vue', () => {
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
// When descriptionShow is false, the element should not exist
|
||||
descriptionElement = wrapper.find('span.text-center.md\\:text-right');
|
||||
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
|
||||
expect(descriptionElement.exists()).toBe(false);
|
||||
|
||||
themeStore.theme!.descriptionShow = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
descriptionElement = wrapper.find('span.text-center.md\\:text-right');
|
||||
descriptionElement = wrapper.find('span.hidden.text-center.text-base');
|
||||
expect(descriptionElement.exists()).toBe(true);
|
||||
expect(descriptionElement.html()).toContain(initialServerData.description);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/web",
|
||||
"version": "4.20.1",
|
||||
"version": "4.20.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "GPL-2.0-or-later",
|
||||
@@ -34,10 +34,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ci": "vitest run",
|
||||
"test:standalone": "pnpm run build && vite --config vite.test.config.ts",
|
||||
"// i18n": "",
|
||||
"i18n:extract": "vue-i18n-extract report --vueFiles './src/**/*.{vue,ts,js}' --languageFiles './src/locales/*.json'",
|
||||
"i18n:missing": "vue-i18n-extract report --vueFiles './src/**/*.{vue,ts,js}' --languageFiles './src/locales/*.json' --output missing.json && cat missing.json"
|
||||
"test:standalone": "pnpm run build && vite --config vite.test.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.34.0",
|
||||
@@ -87,7 +84,6 @@
|
||||
"vitest": "3.2.4",
|
||||
"vue": "3.5.20",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-i18n-extract": "github:Spittal/vue-i18n-extract",
|
||||
"vue-tsc": "3.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -106,14 +106,14 @@ onMounted(() => {
|
||||
|
||||
<div class="relative z-10 flex h-full flex-row items-center justify-end gap-x-2">
|
||||
<div
|
||||
class="text-header-text-primary relative flex flex-col-reverse items-center border-0 text-base md:flex-row md:items-center"
|
||||
class="text-header-text-primary relative flex flex-col-reverse items-center border-0 text-base md:!flex-row md:!items-center"
|
||||
>
|
||||
<template v-if="description && theme?.descriptionShow">
|
||||
<span
|
||||
class="hidden text-center text-base md:!inline-flex md:items-center md:text-right"
|
||||
class="hidden text-center text-base md:!inline-flex md:!items-center md:!text-right"
|
||||
v-html="description"
|
||||
/>
|
||||
<span class="text-header-text-secondary hidden px-2 md:!inline-flex md:items-center"
|
||||
<span class="text-header-text-secondary hidden px-2 md:!inline-flex md:!items-center"
|
||||
>•</span
|
||||
>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user