Compare commits

...

14 Commits

Author SHA1 Message Date
github-actions[bot]
84f4a7221d chore(main): release 4.25.1 (#1732)
🤖 I have created a release *beep* *boop*
---


## [4.25.1](https://github.com/unraid/api/compare/v4.25.0...v4.25.1)
(2025-09-30)


### Bug Fixes

* add cache busting to web component extractor
([#1731](https://github.com/unraid/api/issues/1731))
([0d165a6](0d165a6087))
* Connect won't appear within Apps - Previous Apps
([#1727](https://github.com/unraid/api/issues/1727))
([d73953f](d73953f8ff))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-30 13:01:57 -04:00
Squidly271
d73953f8ff fix: Connect won't appear within Apps - Previous Apps (#1727)
Manual removal of the .plg is never necessary. plugin script will
automatically move the .plg to /config/plugins-removed

Manual removal results in PHP errors and possible indeterminate state

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

- Bug Fixes
- Updated plugin removal on Unraid 7.2+ to no longer delete the plugin
file during boot. You’ll now be clearly prompted to reboot to complete
uninstallation, reducing unexpected removals and improving guidance.
Behavior on earlier Unraid versions remains unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 12:32:16 -04:00
Eli Bosley
0d165a6087 fix: add cache busting to web component extractor (#1731)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- Bug Fixes
- Ensures UI assets use content-hashed filenames so browsers load the
latest scripts and styles after updates, reducing stale-cache issues.
- Keeps scripts and their related styles in sync for consistent
rendering and fewer cache-related glitches.
- Ignores non-asset manifest entries to prevent accidental inclusion of
invalid items and ensure correct asset loading.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-30 12:27:48 -04:00
github-actions[bot]
f4f3e3c44b chore(main): release 4.25.0 (#1725)
🤖 I have created a release *beep* *boop*
---


## [4.25.0](https://github.com/unraid/api/compare/v4.24.1...v4.25.0)
(2025-09-26)


### Features

* add Tailwind scoping plugin and integrate into Vite config
([#1722](https://github.com/unraid/api/issues/1722))
([b7afaf4](b7afaf4632))
* notification filter controls pill buttons
([#1718](https://github.com/unraid/api/issues/1718))
([661865f](661865f976))


### Bug Fixes

* enable auth guard for nested fields - thanks
[@ingel81](https://github.com/ingel81)
([7bdeca8](7bdeca8338))
* enhance user context validation in auth module
([#1726](https://github.com/unraid/api/issues/1726))
([cd5eff1](cd5eff11bc))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-26 12:58:08 -04:00
Eli Bosley
cd5eff11bc fix: enhance user context validation in auth module (#1726)
Fixes #1723

- Improved error handling in the auth module to ensure user context is
present and valid.
- Added checks for user roles and identifiers, throwing appropriate
exceptions for missing or invalid data.
- Introduced a new integration test suite for AuthZGuard, validating
role-based access control for various actions in the application.
- Tests cover scenarios for viewer and admin roles, ensuring correct
permissions are enforced.

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

## Summary by CodeRabbit

* **Bug Fixes**
* Hardened authorization: properly rejects requests with missing users
or invalid roles and ensures a valid subject is derived for permission
checks, improving reliability and security of access control responses.

* **Tests**
* Added comprehensive integration tests for authorization, covering
admin/viewer role behaviors, API key permissions, and various resource
actions to verify expected allow/deny outcomes across scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-26 12:50:31 -04:00
Eli Bosley
7bdeca8338 fix: enable auth guard for nested fields - thanks @ingel81 2025-09-26 11:12:17 -04:00
Eli Bosley
661865f976 feat: notification filter controls pill buttons (#1718)
## Summary
- replace the notification type dropdown with inline pill buttons for
quick filtering
- expose accessible role and pressed state on the new filter buttons

## Testing
- pnpm --filter @unraid/web lint

------
https://chatgpt.com/codex/tasks/task_e_68d184ad60348323b60c9b8e19146025

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

* **New Features**
* Notifications sidebar now uses a pill-style button group instead of a
dropdown for filtering by importance/type.
  * One-tap switching applies filters instantly for faster navigation.
* Active filters are more visible, improving clarity and accessibility.
* No changes to existing workflows or public behavior; settings and
filtering semantics remain unchanged.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-26 10:15:27 -04:00
Eli Bosley
b7afaf4632 feat: add Tailwind scoping plugin and integrate into Vite config (#1722)
- Introduced a new PostCSS plugin, `scopeTailwindToUnapi`, to scope
Tailwind CSS classes to specific elements.
- Updated Vite configuration to include the new PostCSS plugin for CSS
processing.
- Enhanced theme management in the theme store to apply scoped classes
and dynamic CSS variables to multiple targets, including the document
root and elements with the `.unapi` class.

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

* **New Features**
* Scoped styling for embedded (.unapi) contexts and a PostCSS plugin to
automate it.
* Theme refresh after mount to propagate CSS variables to embedded
roots.
  * Exposed idempotent restart action for the Unraid API when offline.

* **Bug Fixes**
* Consistent dark mode and theme variable application across main and
embedded views.
  * Interactive element and SSO styles now apply in embedded contexts.
* Simplified changelog iframe with a reliable fallback renderer;
improved logs styling scope.

* **Tests**
* New unit tests for the scoping plugin, changelog iframe, and related
components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-26 09:56:27 -04:00
github-actions[bot]
b3ca40c639 chore(main): release 4.24.1 (#1721)
🤖 I have created a release *beep* *boop*
---


## [4.24.1](https://github.com/unraid/api/compare/v4.24.0...v4.24.1)
(2025-09-23)


### Bug Fixes

* cleanup leftover removed packages on upgrade
([#1719](https://github.com/unraid/api/issues/1719))
([9972a5f](9972a5f178))
* enhance version comparison logic in installation script
([d9c561b](d9c561bfeb))
* issue with incorrect permissions on viewer / other roles
([378cdb7](378cdb7f10))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-23 16:06:18 -04:00
Eli Bosley
378cdb7f10 fix: issue with incorrect permissions on viewer / other roles 2025-09-23 15:46:22 -04:00
Eli Bosley
d9c561bfeb fix: enhance version comparison logic in installation script
- Added normalization for version strings to improve semantic versioning comparisons.
- Updated the version comparison function to handle leading 'v' and ignore build metadata, ensuring accurate version checks during installation.
2025-09-23 11:40:13 -04:00
Eli Bosley
9972a5f178 fix: cleanup leftover removed packages on upgrade (#1719)
---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211428391025524

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

* **New Features**
* Adds API version awareness for Unraid Connect: detects server vs.
connector API versions, notifies users, and skips installation to avoid
downgrades.

* **Bug Fixes**
* Enhanced pre-install cleanup removing stale files and leftovers to
improve install/upgrade reliability and clearer status reporting.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-23 11:35:06 -04:00
Eli Bosley
a44473c1d1 chore(api): update API version and enhance installation script (#1685)
- Enhanced the installation script in `dynamix.unraid.net.plg` to
include version comparison logic, preventing downgrades if a newer API
version is already installed.
- Added functionality to notify users of version conflicts during
installation.

This update improves the robustness of the installation process and
ensures compatibility with existing API versions.

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

* **New Features**
* Version-aware installation for the Connect API to prevent downgrades
when the server API is newer.
  * Web GUI notification when a version conflict is detected.

* **Improvements**
* Clearer install messaging when API installation is skipped or
proceeds.
* Safer, guarded install flow that only performs cleanup and
installation when appropriate.
  * Preserves existing behavior for README updates when applicable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-23 11:21:25 -04:00
github-actions[bot]
ed9a5c5ff9 chore(main): release 4.24.0 (#1717)
🤖 I have created a release *beep* *boop*
---


## [4.24.0](https://github.com/unraid/api/compare/v4.23.1...v4.24.0)
(2025-09-18)


### Features

* improve dom content loading by being more efficient about component
mounting ([#1716](https://github.com/unraid/api/issues/1716))
([d8b166e](d8b166e4b6))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-18 12:59:58 -04:00
38 changed files with 1531 additions and 398 deletions

View File

@@ -1,64 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@beta
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
# model: "claude-opus-4-20250514"
# Optional: Customize the trigger phrase (default: @claude)
# trigger_phrase: "/claude"
# Optional: Trigger when specific user is assigned to an issue
# assignee_trigger: "claude-bot"
# Optional: Allow Claude to run specific commands
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
# Optional: Add custom instructions for Claude to customize its behavior for your project
# custom_instructions: |
# Follow our coding standards
# Ensure all new code has tests
# Use TypeScript for new files
# Optional: Custom environment variables for Claude
# claude_env: |
# NODE_ENV: test

View File

@@ -310,9 +310,6 @@ jobs:
- name: Type Check
run: pnpm run type-check
- name: Test
run: pnpm run test:ci
- name: Build
run: pnpm run build

View File

@@ -1 +1 @@
{".":"4.23.1"}
{".":"4.25.1"}

View File

@@ -1,7 +1,8 @@
@custom-variant dark (&:where(.dark, .dark *));
/* Utility defaults for web components (when we were using shadow DOM) */
:host {
:host,
.unapi {
--tw-divide-y-reverse: 0;
--tw-border-style: solid;
--tw-font-weight: initial;
@@ -61,7 +62,7 @@
}
*/
body {
.unapi {
--color-alpha: #1c1b1b;
--color-beta: #f2f2f2;
--color-gamma: #999999;
@@ -73,13 +74,14 @@ body {
--ring-shadow: 0 0 var(--color-beta);
}
button:not(:disabled),
[role='button']:not(:disabled) {
.unapi button:not(:disabled),
.unapi [role='button']:not(:disabled) {
cursor: pointer;
}
/* Font size overrides for SSO button component */
unraid-sso-button {
.unapi unraid-sso-button,
unraid-sso-button.unapi {
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
@@ -93,4 +95,4 @@ unraid-sso-button {
--text-7xl: 4.5rem;
--text-8xl: 6rem;
--text-9xl: 8rem;
}
}

View File

@@ -1,5 +1,43 @@
# Changelog
## [4.25.1](https://github.com/unraid/api/compare/v4.25.0...v4.25.1) (2025-09-30)
### Bug Fixes
* add cache busting to web component extractor ([#1731](https://github.com/unraid/api/issues/1731)) ([0d165a6](https://github.com/unraid/api/commit/0d165a608740505bdc505dcf69fb615225969741))
* Connect won't appear within Apps - Previous Apps ([#1727](https://github.com/unraid/api/issues/1727)) ([d73953f](https://github.com/unraid/api/commit/d73953f8ff3d7425c0aed32d16236ededfd948e1))
## [4.25.0](https://github.com/unraid/api/compare/v4.24.1...v4.25.0) (2025-09-26)
### Features
* add Tailwind scoping plugin and integrate into Vite config ([#1722](https://github.com/unraid/api/issues/1722)) ([b7afaf4](https://github.com/unraid/api/commit/b7afaf463243b073e1ab1083961a16a12ac6c4a3))
* notification filter controls pill buttons ([#1718](https://github.com/unraid/api/issues/1718)) ([661865f](https://github.com/unraid/api/commit/661865f97611cf802f239fde8232f3109281dde6))
### Bug Fixes
* enable auth guard for nested fields - thanks [@ingel81](https://github.com/ingel81) ([7bdeca8](https://github.com/unraid/api/commit/7bdeca8338a3901f15fde06fd7aede3b0c16e087))
* enhance user context validation in auth module ([#1726](https://github.com/unraid/api/issues/1726)) ([cd5eff1](https://github.com/unraid/api/commit/cd5eff11bcb4398581472966cb7ec124eac7ad0a))
## [4.24.1](https://github.com/unraid/api/compare/v4.24.0...v4.24.1) (2025-09-23)
### Bug Fixes
* cleanup leftover removed packages on upgrade ([#1719](https://github.com/unraid/api/issues/1719)) ([9972a5f](https://github.com/unraid/api/commit/9972a5f178f9a251e6c129d85c5f11cfd25e6281))
* enhance version comparison logic in installation script ([d9c561b](https://github.com/unraid/api/commit/d9c561bfebed0c553fe4bfa26b088ae71ca59755))
* issue with incorrect permissions on viewer / other roles ([378cdb7](https://github.com/unraid/api/commit/378cdb7f102f63128dd236c13f1a3745902d5a2c))
## [4.24.0](https://github.com/unraid/api/compare/v4.23.1...v4.24.0) (2025-09-18)
### Features
* improve dom content loading by being more efficient about component mounting ([#1716](https://github.com/unraid/api/issues/1716)) ([d8b166e](https://github.com/unraid/api/commit/d8b166e4b6a718e07783d9c8ac8393b50ec89ae3))
## [4.23.1](https://github.com/unraid/api/compare/v4.23.0...v4.23.1) (2025-09-17)

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.23.1",
"version": "4.25.1",
"main": "src/cli/index.ts",
"type": "module",
"corepack": {

View File

@@ -8,6 +8,7 @@ import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { CasbinModule } from '@app/unraid-api/auth/casbin/casbin.module.js';
import { CasbinService } from '@app/unraid-api/auth/casbin/casbin.service.js';
import { BASE_POLICY, CASBIN_MODEL } from '@app/unraid-api/auth/casbin/index.js';
import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.js';
import { CookieService, SESSION_COOKIE_CONFIG } from '@app/unraid-api/auth/cookie.service.js';
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
@@ -28,6 +29,7 @@ import { getRequest } from '@app/utils.js';
CasbinModule,
AuthZModule.register({
imports: [CasbinModule],
enablePossession: false,
enforcerProvider: {
provide: AUTHZ_ENFORCER,
useFactory: async (casbinService: CasbinService) => {
@@ -40,13 +42,7 @@ import { getRequest } from '@app/utils.js';
try {
const request = getRequest(ctx);
const roles = request?.user?.roles || [];
if (!Array.isArray(roles)) {
throw new UnauthorizedException('User roles must be an array');
}
return roles.join(',');
return resolveSubjectFromUser(request?.user);
} catch (error) {
logger.error('Failed to extract user context', error);
throw new UnauthorizedException('Failed to authenticate user');

View File

@@ -0,0 +1,133 @@
import { ExecutionContext, Type } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js';
import type { Enforcer } from 'casbin';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { AuthZGuard, BatchApproval } from 'nest-authz';
import { beforeAll, describe, expect, it } from 'vitest';
import { CasbinService } from '@app/unraid-api/auth/casbin/casbin.service.js';
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.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 { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js';
import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
import { getRequest } from '@app/utils.js';
type Handler = (...args: any[]) => unknown;
type TestUser = {
id?: string;
roles?: Role[];
};
type TestRequest = {
user?: TestUser;
};
function createExecutionContext(
handler: Handler,
classRef: Type<unknown> | null,
roles: Role[],
userId = 'api-key-viewer'
): ExecutionContext {
const request: TestRequest = {
user: {
id: userId,
roles: [...roles],
},
};
const graphqlContextHost = new ExecutionContextHost(
[undefined, undefined, { req: request }, undefined],
classRef,
handler
);
graphqlContextHost.setType('graphql');
return graphqlContextHost as unknown as ExecutionContext;
}
describe('AuthZGuard + Casbin policies', () => {
let guard: AuthZGuard;
let enforcer: Enforcer;
beforeAll(async () => {
const casbinService = new CasbinService();
enforcer = await casbinService.initializeEnforcer(CASBIN_MODEL, BASE_POLICY);
await enforcer.addGroupingPolicy('api-key-viewer', Role.VIEWER);
await enforcer.addGroupingPolicy('api-key-admin', Role.ADMIN);
guard = new AuthZGuard(new Reflector(), enforcer, {
enablePossession: false,
batchApproval: BatchApproval.ALL,
userFromContext: (ctx: ExecutionContext) => {
const request = getRequest(ctx) as TestRequest | undefined;
return resolveSubjectFromUser(request?.user);
},
});
});
it('denies viewer role from stopping docker containers', async () => {
const context = createExecutionContext(
DockerMutationsResolver.prototype.stop,
DockerMutationsResolver,
[Role.VIEWER],
'api-key-viewer'
);
await expect(guard.canActivate(context)).resolves.toBe(false);
});
it('allows admin role to stop docker containers', async () => {
const context = createExecutionContext(
DockerMutationsResolver.prototype.stop,
DockerMutationsResolver,
[Role.ADMIN],
'api-key-admin'
);
await expect(guard.canActivate(context)).resolves.toBe(true);
});
it('denies viewer role from stopping virtual machines', async () => {
const context = createExecutionContext(
VmMutationsResolver.prototype.stop,
VmMutationsResolver,
[Role.VIEWER],
'api-key-viewer'
);
await expect(guard.canActivate(context)).resolves.toBe(false);
});
it('allows viewer role to read docker data', async () => {
const context = createExecutionContext(
DockerResolver.prototype.containers,
DockerResolver,
[Role.VIEWER],
'api-key-viewer'
);
await expect(guard.canActivate(context)).resolves.toBe(true);
});
it('allows API key with explicit permission to access ME resource', async () => {
await enforcer.addPolicy('api-key-custom', Resource.ME, AuthAction.READ_ANY);
const context = createExecutionContext(
MeResolver.prototype.me,
MeResolver,
[],
'api-key-custom'
);
await expect(guard.canActivate(context)).resolves.toBe(true);
});
});

View File

@@ -0,0 +1,43 @@
import { UnauthorizedException } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { resolveSubjectFromUser } from '@app/unraid-api/auth/casbin/resolve-subject.util.js';
describe('resolveSubjectFromUser', () => {
it('returns trimmed user id when available', () => {
const subject = resolveSubjectFromUser({ id: ' user-123 ', roles: ['viewer'] });
expect(subject).toBe('user-123');
});
it('falls back to a single non-empty role', () => {
const subject = resolveSubjectFromUser({ roles: [' viewer '] });
expect(subject).toBe('viewer');
});
it('throws when role list is empty', () => {
expect(() => resolveSubjectFromUser({ roles: [] })).toThrow(UnauthorizedException);
});
it('throws when multiple roles are present', () => {
expect(() => resolveSubjectFromUser({ roles: ['viewer', 'admin'] })).toThrow(
UnauthorizedException
);
});
it('throws when roles is not an array', () => {
expect(() => resolveSubjectFromUser({ roles: 'viewer' as unknown })).toThrow(
UnauthorizedException
);
});
it('throws when role subject is blank', () => {
expect(() => resolveSubjectFromUser({ roles: [' '] })).toThrow(UnauthorizedException);
});
it('throws when user is missing', () => {
expect(() => resolveSubjectFromUser(undefined)).toThrow(UnauthorizedException);
});
});

View File

@@ -0,0 +1,46 @@
import { UnauthorizedException } from '@nestjs/common';
type CasbinUser = {
id?: unknown;
roles?: unknown;
};
/**
* Determine the Casbin subject for a request user.
*
* Prefers a non-empty `user.id`, otherwise falls back to a single non-empty role.
* Throws when the subject cannot be resolved.
*/
export function resolveSubjectFromUser(user: CasbinUser | undefined): string {
if (!user) {
throw new UnauthorizedException('Request user context missing');
}
const roles = user.roles ?? [];
if (!Array.isArray(roles)) {
throw new UnauthorizedException('User roles must be an array');
}
const userId = typeof user.id === 'string' ? user.id.trim() : '';
if (userId.length > 0) {
return userId;
}
if (roles.length === 1) {
const [role] = roles;
if (typeof role === 'string') {
const trimmedRole = role.trim();
if (trimmedRole.length > 0) {
return trimmedRole;
}
}
throw new UnauthorizedException('Role subject must be a non-empty string');
}
throw new UnauthorizedException('Unable to determine subject from user context');
}

View File

@@ -49,6 +49,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
extra,
};
},
fieldResolverEnhancers: ['guards'],
plugins: [
createDynamicIntrospectionPlugin(isSandboxEnabled),
createSandboxPlugin(),

View File

@@ -1,7 +1,7 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.23.1",
"version": "4.25.1",
"scripts": {
"build": "pnpm -r build",
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/connect-plugin",
"version": "4.23.1",
"version": "4.25.1",
"private": true,
"dependencies": {
"commander": "14.0.0",

View File

@@ -304,13 +304,6 @@ exit 0
-d "Unraid Connect plugin has been marked for removal. Please reboot your server to complete the uninstallation." \
-i "warning"
# Remove the plugin file so it won't be installed on reboot
PLUGIN_FILE="/boot/config/plugins/${MAINNAME}.plg"
if [ -f "$PLUGIN_FILE" ]; then
echo "Removing plugin file: $PLUGIN_FILE"
rm -f "$PLUGIN_FILE"
fi
echo "Plugin marked for removal. Reboot required to complete uninstallation."
else
# Original removal method for older versions
@@ -409,42 +402,118 @@ exit 0
PKG_FILE="&source;" # Full path to the package file including .txz extension
PKG_URL="&txz_url;" # URL where package was downloaded from
PKG_NAME="&txz_name;" # Name of the package file
CONNECT_API_VERSION="&api_version;" # Version of API included with Connect
<![CDATA[
# Install the Slackware package
echo "Installing package..."
# Clean up any old package txz files if they don't match our current version
for txz_file in /boot/config/plugins/dynamix.my.servers/dynamix.unraid.net-*.txz; do
if [ -f "$txz_file" ] && [ "$txz_file" != "${PKG_FILE}" ]; then
echo "Removing old package file: $txz_file"
rm -f "$txz_file"
# Function to compare version numbers using PHP's version_compare
# Returns 0 if version1 > version2, 1 if version1 < version2, 2 if equal
compare_versions() {
local ver1="$1"
local ver2="$2"
# Normalize versions: drop leading 'v' and ignore build metadata (+...) for semver parity
local norm_ver1="${ver1#v}"
norm_ver1="${norm_ver1%%+*}"
local norm_ver2="${ver2#v}"
norm_ver2="${norm_ver2%%+*}"
if [ "$norm_ver1" = "$norm_ver2" ]; then
return 2
fi
done
# Remove existing node_modules directory
echo "Cleaning up existing node_modules directory..."
if [ -d "/usr/local/unraid-api/node_modules" ]; then
echo "Removing: /usr/local/unraid-api/node_modules"
rm -rf "/usr/local/unraid-api/node_modules"
# Use PHP's version_compare which handles semantic versioning properly
result=$(PHP_VER1="$norm_ver1" PHP_VER2="$norm_ver2" php -r "
\$v1 = getenv('PHP_VER1');
\$v2 = getenv('PHP_VER2');
\$cmp = version_compare(\$v1, \$v2);
if (\$cmp > 0) echo '0';
elseif (\$cmp < 0) echo '1';
else echo '2';
")
return $result
}
# Check if API is already installed and get its version
CURRENT_API_VERSION=""
if [ -f "/usr/local/share/dynamix.unraid.net/config/vendor_archive.json" ] && command -v jq >/dev/null 2>&1; then
CURRENT_API_VERSION=$(jq -r '.api_version' "/usr/local/share/dynamix.unraid.net/config/vendor_archive.json" 2>/dev/null)
fi
# Clear existing unraid-components directory contents to ensure clean installation
echo "Cleaning up existing unraid-components directory..."
DIR="/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components"
if [ -d "$DIR" ]; then
echo "Clearing contents of: $DIR"
rm -rf "$DIR"/*
# If we have both versions, compare them
SKIP_API_INSTALL=false
if [ -n "$CURRENT_API_VERSION" ] && [ "$CURRENT_API_VERSION" != "null" ] && [ -n "$CONNECT_API_VERSION" ]; then
echo "Current API version on server: $CURRENT_API_VERSION"
echo "Connect wants to install API version: $CONNECT_API_VERSION"
compare_versions "$CURRENT_API_VERSION" "$CONNECT_API_VERSION"
result=$?
if [ $result -eq 0 ]; then
echo "⚠️ WARNING: Server has a newer API version ($CURRENT_API_VERSION) than Connect ($CONNECT_API_VERSION)"
echo "Skipping API package installation to prevent downgrade"
# Send notification to user
/usr/local/emhttp/webGui/scripts/notify \
-e "Unraid Connect" \
-s "API Version Conflict Detected" \
-d "Your server has API version $CURRENT_API_VERSION installed, which is newer than the version included with Connect ($CONNECT_API_VERSION). The API installation has been skipped to prevent a downgrade. Connect remains installed but may have limited functionality." \
-i "warning"
SKIP_API_INSTALL=true
elif [ $result -eq 2 ]; then
echo "API versions match - proceeding with installation"
else
echo "Connect has a newer API version - proceeding with upgrade"
fi
fi
# Install the package using the explicit file path
upgradepkg --install-new --reinstall "${PKG_FILE}"
if [ $? -ne 0 ]; then
echo "⚠️ Package installation failed"
exit 1
fi
if [ "$SKIP_API_INSTALL" = false ]; then
# Install the Slackware package
echo "Installing package..."
# Clean up any old package txz files if they don't match our current version
for txz_file in /boot/config/plugins/dynamix.my.servers/dynamix.unraid.net-*.txz; do
if [ -f "$txz_file" ] && [ "$txz_file" != "${PKG_FILE}" ]; then
echo "Removing old package file: $txz_file"
rm -f "$txz_file"
fi
done
if [[ -n "$TAG" && "$TAG" != "" ]]; then
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG"
sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
# Remove existing node_modules directory
echo "Cleaning up existing node_modules directory..."
if [ -d "/usr/local/unraid-api/node_modules" ]; then
echo "Removing: /usr/local/unraid-api/node_modules"
rm -rf "/usr/local/unraid-api/node_modules"
fi
# Clean up pkgtools removal logs left behind by prior uninstall operations
REMOVE_PKG_LOG_DIR="/var/log/pkgtools/removed_packages/dynamix.unraid.net"
if [ -d "$REMOVE_PKG_LOG_DIR" ]; then
echo "Cleaning up pkgtools removed_packages logs..."
find "$REMOVE_PKG_LOG_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
fi
# Clear existing unraid-components directory contents to ensure clean installation
echo "Cleaning up existing unraid-components directory..."
DIR="/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components"
if [ -d "$DIR" ]; then
echo "Clearing contents of: $DIR"
rm -rf "$DIR"/*
fi
# Install the package using the explicit file path
upgradepkg --install-new --reinstall "${PKG_FILE}"
if [ $? -ne 0 ]; then
echo "⚠️ Package installation failed"
exit 1
fi
if [[ -n "$TAG" && "$TAG" != "" ]]; then
printf -v sedcmd 's@^\*\*Unraid Connect\*\*@**Unraid Connect (%s)**@' "$TAG"
sed -i "${sedcmd}" "/usr/local/emhttp/plugins/dynamix.unraid.net/README.md"
fi
else
echo "API package installation skipped due to version conflict"
echo "Connect plugin remains installed but API was not modified"
fi
exit 0

View File

@@ -69,8 +69,45 @@ switch ($command) {
response_complete(200, array('result' => $output), $output);
break;
case 'restart':
$lockFilePath = '/var/run/unraid-api-restart.lock';
$lockHandle = @fopen($lockFilePath, 'c');
if ($lockHandle === false) {
response_complete(500, array('error' => 'Unable to open restart lock file'), 'Unable to open restart lock file');
}
// Use a lockfile to avoid concurrently running restart commands
$wouldBlock = null;
error_clear_last();
$acquiredLock = flock($lockHandle, LOCK_EX | LOCK_NB, $wouldBlock);
if (!$acquiredLock) {
if (!empty($wouldBlock)) {
fclose($lockHandle);
response_complete(200, array('success' => true, 'result' => 'Unraid API restart already in progress'), 'Restart already in progress');
}
$lastError = error_get_last();
$errorMessage = 'Unable to acquire restart lock';
if (!empty($lastError['message'])) {
$errorMessage .= ': ' . $lastError['message'];
}
fclose($lockHandle);
response_complete(500, array('error' => $errorMessage), $errorMessage);
}
$pid = getmypid();
if ($pid !== false) {
ftruncate($lockHandle, 0);
fwrite($lockHandle, (string)$pid);
fflush($lockHandle);
}
exec('/etc/rc.d/rc.unraid-api restart 2>&1', $output, $retval);
$output = implode(PHP_EOL, $output);
flock($lockHandle, LOCK_UN);
fclose($lockHandle);
response_complete(200, array('success' => ($retval === 0), 'result' => $output, 'error' => ($retval !== 0 ? $output : null)), $output);
break;
case 'status':
@@ -100,4 +137,4 @@ switch ($command) {
break;
}
exit;
?>
?>

View File

@@ -63,6 +63,9 @@ class WebComponentsExtractor
// Process each entry in the manifest
foreach ($manifest as $key => $entry) {
if ($key === 'ts') {
continue;
}
// Skip if not an array with a 'file' key
if (!is_array($entry) || !isset($entry['file']) || empty($entry['file'])) {
continue;

View File

@@ -14,6 +14,8 @@ class ExtractorTest {
private $passed = 0;
private $failed = 0;
private $verbose = false;
private $standaloneJsFile = 'standalone-apps-AbCdEf12.js';
private $standaloneCssFile = 'standalone-apps-ZyXwVuTs.css';
// Color codes for terminal output
const RED = "\033[0;31m";
@@ -46,13 +48,13 @@ class ExtractorTest {
// Create test manifest files
file_put_contents($this->componentDir . '/standalone-apps/standalone.manifest.json', json_encode([
'standalone-apps-RlN0czLV.css' => [
'file' => 'standalone-apps-RlN0czLV.css',
'src' => 'standalone-apps-RlN0czLV.css'
$this->standaloneCssFile => [
'file' => $this->standaloneCssFile,
'src' => $this->standaloneCssFile
],
'standalone-apps.js' => [
'file' => 'standalone-apps.js',
'src' => 'standalone-apps.js',
$this->standaloneJsFile => [
'file' => $this->standaloneJsFile,
'src' => $this->standaloneJsFile,
'css' => ['app-styles.css', 'theme.css']
],
'ts' => 1234567890
@@ -144,8 +146,8 @@ class ExtractorTest {
echo "Test: Script Tag Generation\n";
echo "----------------------------\n";
$this->test(
"Generates script tag for standalone-apps.js",
strpos($output, 'script id="unraid-standalone-apps-standalone-apps-js"') !== false
"Generates script tag for hashed standalone JS",
strpos($output, 'script id="unraid-standalone-apps-' . $this->sanitizeForExpectedId($this->standaloneJsFile) . '"') !== false
);
$this->test(
"Generates script tag for components.mjs",
@@ -160,8 +162,8 @@ class ExtractorTest {
echo "\nTest: CSS Link Generation\n";
echo "--------------------------\n";
$this->test(
"Generates link tag for standalone CSS",
strpos($output, 'link id="unraid-standalone-apps-standalone-apps-RlN0czLV-css"') !== false
"Generates link tag for hashed standalone CSS",
strpos($output, 'link id="unraid-standalone-apps-' . $this->sanitizeForExpectedId($this->standaloneCssFile) . '"') !== false
);
$this->test(
"Generates link tag for UI styles",
@@ -209,7 +211,7 @@ class ExtractorTest {
echo "------------------------\n";
$this->test(
"Correctly constructs standalone-apps path",
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/standalone-apps.js') !== false
strpos($output, '/plugins/dynamix.my.servers/unraid-components/standalone-apps/' . $this->standaloneJsFile) !== false
);
$this->test(
"Correctly constructs ui-components path",
@@ -274,11 +276,11 @@ class ExtractorTest {
echo "--------------------------------\n";
$this->test(
"Loads CSS from JS entry css array (app-styles.css)",
strpos($output, 'id="unraid-standalone-apps-standalone-apps-js-css-app-styles-css"') !== false
strpos($output, 'id="unraid-standalone-apps-' . $this->sanitizeForExpectedId($this->standaloneJsFile) . '-css-app-styles-css"') !== false
);
$this->test(
"Loads CSS from JS entry css array (theme.css)",
strpos($output, 'id="unraid-standalone-apps-standalone-apps-js-css-theme-css"') !== false
strpos($output, 'id="unraid-standalone-apps-' . $this->sanitizeForExpectedId($this->standaloneJsFile) . '-css-theme-css"') !== false
);
$this->test(
"CSS from manifest has correct href path (app-styles.css)",
@@ -344,6 +346,11 @@ class ExtractorTest {
}
rmdir($dir);
}
private function sanitizeForExpectedId(string $input): string
{
return preg_replace('/[^a-zA-Z0-9-]/', '-', $input);
}
private function reportResults() {
echo "\n";
@@ -366,4 +373,4 @@ class ExtractorTest {
// Run tests
$test = new ExtractorTest();
exit($test->run());
exit($test->run());

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.23.1",
"version": "4.25.1",
"private": true,
"license": "GPL-2.0-or-later",
"type": "module",

View File

@@ -0,0 +1,171 @@
import { ref } from 'vue';
import { mount } from '@vue/test-utils';
import { DOCS } from '~/helpers/urls';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
vi.mock('@unraid/ui', () => ({
BrandButton: { template: '<button><slot /></button>' },
BrandLoading: { template: '<div class="brand-loading" />' },
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
ResponsiveModal: { template: '<div><slot /></div>', props: ['open'] },
ResponsiveModalFooter: { template: '<div><slot /></div>' },
ResponsiveModalHeader: { template: '<div><slot /></div>' },
ResponsiveModalTitle: { template: '<div><slot /></div>' },
}));
vi.mock('@heroicons/vue/24/solid', () => ({
ArrowRightIcon: { template: '<svg />' },
ArrowTopRightOnSquareIcon: { template: '<svg />' },
KeyIcon: { template: '<svg />' },
ServerStackIcon: { template: '<svg />' },
}));
vi.mock('~/components/UpdateOs/RawChangelogRenderer.vue', () => ({
default: { template: '<div />', props: ['changelog', 'version', 'date', 't', 'changelogPretty'] },
}));
vi.mock('pinia', async () => {
const actual = await vi.importActual<typeof import('pinia')>('pinia');
const isActualStore = (candidate: unknown): candidate is Parameters<typeof actual.storeToRefs>[0] =>
Boolean(candidate && typeof candidate === 'object' && '$id' in candidate);
const isRefLike = (input: unknown): input is { value: unknown } =>
Boolean(input && typeof input === 'object' && 'value' in input);
return {
...actual,
storeToRefs: (store: unknown) => {
if (isActualStore(store)) {
return actual.storeToRefs(store);
}
if (!store || typeof store !== 'object') {
return {};
}
const refs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(store)) {
if (isRefLike(value)) {
refs[key] = value;
}
}
return refs;
},
};
});
const mockRenew = vi.fn();
vi.mock('~/store/purchase', () => ({
usePurchaseStore: () => ({
renew: mockRenew,
}),
}));
const mockAvailableWithRenewal = ref(false);
const mockReleaseForUpdate = ref(null);
const mockChangelogModalVisible = ref(false);
const mockSetReleaseForUpdate = vi.fn();
const mockFetchAndConfirmInstall = vi.fn();
vi.mock('~/store/updateOs', () => ({
useUpdateOsStore: () => ({
availableWithRenewal: mockAvailableWithRenewal,
releaseForUpdate: mockReleaseForUpdate,
changelogModalVisible: mockChangelogModalVisible,
setReleaseForUpdate: mockSetReleaseForUpdate,
fetchAndConfirmInstall: mockFetchAndConfirmInstall,
}),
}));
const mockDarkMode = ref(false);
const mockTheme = ref({ name: 'default' });
vi.mock('~/store/theme', () => ({
useThemeStore: () => ({
darkMode: mockDarkMode,
theme: mockTheme,
}),
}));
describe('ChangelogModal iframeSrc', () => {
const mountWithChangelog = (changelogPretty: string | null) =>
mount(ChangelogModal, {
props: {
t: (key: string) => key,
open: true,
release: {
version: '6.12.0',
changelogPretty: changelogPretty ?? undefined,
changelog: 'Raw changelog markdown',
name: 'Unraid OS 6.12.0',
date: '2024-01-01',
},
},
});
beforeEach(() => {
mockRenew.mockClear();
mockAvailableWithRenewal.value = false;
mockReleaseForUpdate.value = null;
mockChangelogModalVisible.value = false;
mockSetReleaseForUpdate.mockClear();
mockFetchAndConfirmInstall.mockClear();
mockDarkMode.value = false;
mockTheme.value = { name: 'default' };
});
it('sanitizes absolute docs URLs to embed within DOCS origin', () => {
const entry = `${DOCS.origin}/go/release-notes/?foo=bar#section`;
const wrapper = mountWithChangelog(entry);
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeTruthy();
const iframeUrl = new URL(iframeSrc!);
expect(iframeUrl.origin).toBe(DOCS.origin);
expect(iframeUrl.pathname).toBe('/go/release-notes/');
expect(iframeUrl.searchParams.get('embed')).toBe('1');
expect(iframeUrl.searchParams.get('theme')).toBe('light');
expect(iframeUrl.searchParams.get('entry')).toBe('/go/release-notes/?foo=bar#section');
});
it('builds DOCS-relative URL when provided a path entry', () => {
const wrapper = mountWithChangelog('updates/6.12?tab=notes#overview');
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeTruthy();
const iframeUrl = new URL(iframeSrc!);
expect(iframeUrl.origin).toBe(DOCS.origin);
expect(iframeUrl.pathname).toBe('/updates/6.12');
expect(iframeUrl.searchParams.get('entry')).toBe('/updates/6.12?tab=notes#overview');
});
it('applies dark theme when current UI theme requires it', () => {
mockTheme.value = { name: 'azure' };
const wrapper = mountWithChangelog(`${DOCS.origin}/release/6.12`);
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeTruthy();
const iframeUrl = new URL(iframeSrc!);
expect(iframeUrl.searchParams.get('theme')).toBe('dark');
});
it('rejects non-docs origins and returns null', () => {
const wrapper = mountWithChangelog('https://example.com/bad');
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeNull();
});
it('rejects non-http(s) protocols', () => {
const wrapper = mountWithChangelog('javascript:alert(1)');
const iframeSrc = (wrapper.vm as unknown as { iframeSrc: string | null }).iframeSrc;
expect(iframeSrc).toBeNull();
});
});

View File

@@ -0,0 +1,271 @@
import { nextTick, ref } from 'vue';
import { mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ComposerTranslation } from 'vue-i18n';
import CheckUpdateResponseModal from '~/components/UpdateOs/CheckUpdateResponseModal.vue';
const translate: ComposerTranslation = ((key: string, params?: unknown) => {
if (Array.isArray(params) && params.length > 0) {
return params.reduce<string>(
(result, value, index) => result.replace(`{${index}}`, String(value)),
key
);
}
if (params && typeof params === 'object') {
return Object.entries(params as Record<string, unknown>).reduce<string>(
(result, [placeholder, value]) => result.replace(`{${placeholder}}`, String(value)),
key
);
}
if (typeof params === 'number') {
return key.replace('{0}', String(params));
}
return key;
}) as ComposerTranslation;
vi.mock('@unraid/ui', () => ({
BrandButton: {
name: 'BrandButton',
props: {
text: {
type: String,
default: undefined,
},
},
emits: ['click'],
template: '<button class="brand-button" @click="$emit(\'click\')"><slot>{{ text }}</slot></button>',
},
BrandLoading: { template: '<div class="brand-loading" />' },
Button: { template: '<button class="ui-button"><slot /></button>' },
cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
DialogDescription: { template: '<div class="dialog-description"><slot /></div>' },
Label: { template: '<label><slot /></label>' },
ResponsiveModal: {
name: 'ResponsiveModal',
props: ['open', 'dialogClass', 'sheetClass', 'showCloseButton'],
template: '<div class="responsive-modal"><slot /></div>',
},
ResponsiveModalFooter: { template: '<div class="responsive-modal-footer"><slot /></div>' },
ResponsiveModalHeader: { template: '<div class="responsive-modal-header"><slot /></div>' },
ResponsiveModalTitle: { template: '<div class="responsive-modal-title"><slot /></div>' },
Switch: { name: 'Switch', props: ['modelValue'], template: '<div class="switch" />' },
Tooltip: { template: '<div class="tooltip"><slot /></div>' },
TooltipTrigger: { template: '<div class="tooltip-trigger"><slot /></div>' },
TooltipContent: { template: '<div class="tooltip-content"><slot /></div>' },
TooltipProvider: { template: '<div class="tooltip-provider"><slot /></div>' },
}));
vi.mock('@heroicons/vue/24/solid', () => ({
ArrowTopRightOnSquareIcon: { template: '<svg />' },
CheckCircleIcon: { template: '<svg />' },
CogIcon: { template: '<svg />' },
EyeIcon: { template: '<svg />' },
IdentificationIcon: { template: '<svg />' },
KeyIcon: { template: '<svg />' },
}));
vi.mock('@heroicons/vue/24/outline', () => ({
ArrowDownTrayIcon: { template: '<svg />' },
}));
vi.mock('~/components/UpdateOs/IgnoredRelease.vue', () => ({
default: { template: '<div class="ignored-release" />', props: ['label'] },
}));
vi.mock('~/composables/dateTime', () => ({
default: () => ({
outputDateTimeFormatted: ref('2024-01-01'),
outputDateTimeReadableDiff: ref('today'),
}),
}));
vi.mock('pinia', async () => {
const actual = await vi.importActual<typeof import('pinia')>('pinia');
const isActualStore = (candidate: unknown): candidate is Parameters<typeof actual.storeToRefs>[0] =>
Boolean(candidate && typeof candidate === 'object' && '$id' in candidate);
const isRefLike = (input: unknown): input is { value: unknown } =>
Boolean(input && typeof input === 'object' && 'value' in input);
return {
...actual,
storeToRefs: (store: unknown) => {
if (isActualStore(store)) {
return actual.storeToRefs(store);
}
if (!store || typeof store !== 'object') {
return {};
}
const refs: Record<string, unknown> = {};
for (const [key, value] of Object.entries(store)) {
if (isRefLike(value)) {
refs[key] = value;
}
}
return refs;
},
};
});
const mockAccountUpdateOs = vi.fn();
vi.mock('~/store/account', () => ({
useAccountStore: () => ({
updateOs: mockAccountUpdateOs,
}),
}));
const mockRenew = vi.fn();
vi.mock('~/store/purchase', () => ({
usePurchaseStore: () => ({
renew: mockRenew,
}),
}));
const mockSetReleaseForUpdate = vi.fn();
const mockSetModalOpen = vi.fn();
const mockFetchAndConfirmInstall = vi.fn();
const available = ref<string | null>(null);
const availableWithRenewal = ref<string | null>(null);
const availableReleaseDate = ref<number | null>(null);
const availableRequiresAuth = ref(false);
const checkForUpdatesLoading = ref(false);
vi.mock('~/store/updateOs', () => ({
useUpdateOsStore: () => ({
available,
availableWithRenewal,
availableReleaseDate,
availableRequiresAuth,
checkForUpdatesLoading,
setReleaseForUpdate: mockSetReleaseForUpdate,
setModalOpen: mockSetModalOpen,
fetchAndConfirmInstall: mockFetchAndConfirmInstall,
}),
}));
const regExp = ref<number | null>(null);
const regUpdatesExpired = ref(false);
const dateTimeFormat = ref('YYYY-MM-DD');
const osVersion = ref<string | null>(null);
const updateOsIgnoredReleases = ref<string[]>([]);
const updateOsNotificationsEnabled = ref(true);
const updateOsResponse = ref<{ changelog?: string | null } | null>(null);
const mockUpdateOsIgnoreRelease = vi.fn();
vi.mock('~/store/server', () => ({
useServerStore: () => ({
regExp,
regUpdatesExpired,
dateTimeFormat,
osVersion,
updateOsIgnoredReleases,
updateOsNotificationsEnabled,
updateOsResponse,
updateOsIgnoreRelease: mockUpdateOsIgnoreRelease,
}),
}));
const mountModal = () =>
mount(CheckUpdateResponseModal, {
props: {
open: true,
t: translate,
},
});
describe('CheckUpdateResponseModal', () => {
beforeEach(() => {
available.value = null;
availableWithRenewal.value = null;
availableReleaseDate.value = null;
availableRequiresAuth.value = false;
checkForUpdatesLoading.value = false;
regExp.value = null;
regUpdatesExpired.value = false;
osVersion.value = null;
updateOsIgnoredReleases.value = [];
updateOsNotificationsEnabled.value = true;
updateOsResponse.value = null;
mockAccountUpdateOs.mockClear();
mockRenew.mockClear();
mockSetModalOpen.mockClear();
mockSetReleaseForUpdate.mockClear();
mockFetchAndConfirmInstall.mockClear();
mockUpdateOsIgnoreRelease.mockClear();
});
it('renders loading state while checking for updates', () => {
checkForUpdatesLoading.value = true;
const wrapper = mountModal();
expect(wrapper.find('.responsive-modal-title').text()).toBe('Checking for OS updates...');
expect(wrapper.find('.brand-loading').exists()).toBe(true);
expect(wrapper.find('.ui-button').text()).toBe('More Options');
});
it('shows up-to-date messaging when no updates are available', async () => {
osVersion.value = '6.12.3';
updateOsNotificationsEnabled.value = false;
const wrapper = mountModal();
await nextTick();
expect(wrapper.find('.responsive-modal-title').text()).toBe('Unraid OS is up-to-date');
expect(wrapper.text()).toContain('Current Version 6.12.3');
expect(wrapper.text()).toContain(
'Go to Settings > Notifications to enable automatic OS update notifications for future releases.'
);
expect(wrapper.find('.ui-button').text()).toBe('More Options');
expect(wrapper.text()).toContain('Enable update notifications');
});
it('displays update actions when a new release is available', async () => {
available.value = '6.13.0';
osVersion.value = '6.12.3';
updateOsResponse.value = { changelog: '### New release' };
const wrapper = mountModal();
await nextTick();
const actionButtons = wrapper.findAll('.brand-button');
const viewChangelogButton = actionButtons.find((button) =>
button.text().includes('View Changelog to Start Update')
);
expect(viewChangelogButton).toBeDefined();
await viewChangelogButton!.trigger('click');
expect(mockSetReleaseForUpdate).toHaveBeenCalledWith({ changelog: '### New release' });
});
it('includes renew option when update requires license renewal', async () => {
available.value = '6.14.0';
availableWithRenewal.value = '6.14.0';
updateOsResponse.value = { changelog: '### Renewal release' };
const wrapper = mountModal();
await nextTick();
const actionButtons = wrapper.findAll('.brand-button');
const labels = actionButtons.map((button) => button.text());
expect(labels).toContain('View Changelog');
expect(labels).toContain('Extend License');
await actionButtons.find((btn) => btn.text() === 'Extend License')?.trigger('click');
expect(mockRenew).toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,7 @@
* ColorSwitcher Component Test Coverage
*/
import { nextTick } from 'vue';
import { nextTick, ref } from 'vue';
import { setActivePinia } from 'pinia';
import { mount } from '@vue/test-utils';
@@ -15,6 +15,15 @@ import type { MockInstance } from 'vitest';
import ColorSwitcher from '~/components/ColorSwitcher.standalone.vue';
import { useThemeStore } from '~/store/theme';
vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}),
}));
// Explicitly mock @unraid/ui to ensure we use the actual components
vi.mock('@unraid/ui', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;

View File

@@ -27,6 +27,8 @@ vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: { value: {} },
loading: { value: false },
onResult: vi.fn(),
onError: vi.fn(),
}),
useLazyQuery: () => ({
result: { value: {} },

View File

@@ -30,6 +30,8 @@ vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: { value: {} },
loading: { value: false },
onResult: vi.fn(),
onError: vi.fn(),
}),
useLazyQuery: () => ({
result: { value: {} },

View File

@@ -2,7 +2,7 @@
* Theme store test coverage
*/
import { nextTick } from 'vue';
import { nextTick, ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { defaultColors } from '~/themes/default';
@@ -11,6 +11,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useThemeStore } from '~/store/theme';
vi.mock('@vue/apollo-composable', () => ({
useQuery: () => ({
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}),
}));
vi.mock('hex-to-rgba', () => ({
default: vi.fn((hex, opacity) => `rgba(mock-${hex}-${opacity})`),
}));

View File

@@ -126,12 +126,18 @@ describe('UnraidApi Store', () => {
store.unraidApiStatus = 'offline';
await nextTick();
expect(mockErrorsStore.removeErrorByRef).toHaveBeenCalledWith('unraidApiOffline');
expect(mockErrorsStore.setError).toHaveBeenCalledWith({
heading: 'Warning: API is offline!',
message: 'The Unraid API is currently offline.',
ref: 'unraidApiOffline',
level: 'warning',
type: 'unraidApiState',
actions: [
expect.objectContaining({
text: 'Restart unraid-api',
}),
],
});
});
@@ -211,6 +217,28 @@ describe('UnraidApi Store', () => {
expect(store.unraidApiStatus).toBe('restarting');
});
it('should reuse existing restart promise when restart is already running', async () => {
const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui');
const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand);
let resolveCommand: (() => void) | undefined;
const commandPromise = new Promise<void>((resolve) => {
resolveCommand = resolve;
});
mockWebguiCommand.mockReturnValueOnce(commandPromise);
store.unraidApiStatus = 'online';
const firstCallPromise = store.restartUnraidApiClient();
const secondCallPromise = store.restartUnraidApiClient();
expect(mockWebguiCommand).toHaveBeenCalledTimes(1);
resolveCommand?.();
await Promise.all([firstCallPromise, secondCallPromise]);
});
it('should handle error during restart', async () => {
const { WebguiUnraidApiCommand } = await import('~/composables/services/webgui');
const mockWebguiCommand = vi.mocked(WebguiUnraidApiCommand);

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.23.1",
"version": "4.25.1",
"private": true,
"type": "module",
"license": "GPL-2.0-or-later",

View File

@@ -0,0 +1,165 @@
interface Container {
type: string;
parent?: Container;
}
interface Rule extends Container {
selector?: string;
selectors?: string[];
}
interface AtRule extends Container {
name: string;
params: string;
}
type PostcssPlugin = {
postcssPlugin: string;
Rule?(rule: Rule): void;
};
type PluginCreator<T> = {
(opts?: T): PostcssPlugin;
postcss?: boolean;
};
export interface ScopeOptions {
scope?: string;
layers?: string[];
includeRoot?: boolean;
}
const DEFAULT_SCOPE = '.unapi';
const DEFAULT_LAYERS = ['*'];
const DEFAULT_INCLUDE_ROOT = true;
const KEYFRAME_AT_RULES = new Set(['keyframes']);
const NON_SCOPED_AT_RULES = new Set(['font-face', 'page']);
const MERGE_WITH_SCOPE_PATTERNS: RegExp[] = [/^\.theme-/, /^\.has-custom-/, /^\.dark\b/];
function shouldScopeRule(rule: Rule, targetLayers: Set<string>, includeRootRules: boolean): boolean {
const hasSelectorString = typeof rule.selector === 'string' && rule.selector.length > 0;
const hasSelectorArray = Array.isArray(rule.selectors) && rule.selectors.length > 0;
// Skip rules without selectors (e.g. @font-face) or nested keyframe steps
if (!hasSelectorString && !hasSelectorArray) {
return false;
}
const directParent = rule.parent;
if (directParent?.type === 'atrule') {
const parentAtRule = directParent as AtRule;
const parentAtRuleName = parentAtRule.name.toLowerCase();
if (KEYFRAME_AT_RULES.has(parentAtRuleName) || parentAtRuleName.endsWith('keyframes')) {
return false;
}
if (NON_SCOPED_AT_RULES.has(parentAtRuleName)) {
return false;
}
}
const includeAllLayers = targetLayers.has('*');
// Traverse ancestors to find the enclosing @layer declaration
let current: Container | undefined = rule.parent ?? undefined;
while (current) {
if (current.type === 'atrule') {
const currentAtRule = current as AtRule;
if (currentAtRule.name === 'layer') {
const layerNames = currentAtRule.params
.split(',')
.map((name: string) => name.trim())
.filter(Boolean);
if (includeAllLayers) {
return true;
}
return layerNames.some((name) => targetLayers.has(name));
}
}
current = current.parent ?? undefined;
}
// If the rule is not inside any @layer, treat it as root-level CSS
return includeRootRules;
}
function hasScope(selector: string, scope: string): boolean {
return selector.includes(scope);
}
function prefixSelector(selector: string, scope: string): string {
const trimmed = selector.trim();
if (!trimmed) {
return selector;
}
if (hasScope(trimmed, scope)) {
return trimmed;
}
// Do not prefix :host selectors they are only valid at the top level
if (trimmed.startsWith(':host')) {
return trimmed;
}
if (trimmed === ':root') {
return scope;
}
if (trimmed.startsWith(':root')) {
return `${scope}${trimmed.slice(':root'.length)}`;
}
const firstToken = trimmed.split(/[\s>+~]/, 1)[0] ?? '';
const shouldMergeWithScope =
!firstToken.includes('\\:') && MERGE_WITH_SCOPE_PATTERNS.some((pattern) => pattern.test(firstToken));
if (shouldMergeWithScope) {
return `${scope}${trimmed}`;
}
return `${scope} ${trimmed}`;
}
export const scopeTailwindToUnapi: PluginCreator<ScopeOptions> = (options: ScopeOptions = {}) => {
const scope = options.scope ?? DEFAULT_SCOPE;
const layers = options.layers ?? DEFAULT_LAYERS;
const includeRootRules = options.includeRoot ?? DEFAULT_INCLUDE_ROOT;
const targetLayers = new Set<string>(layers);
return {
postcssPlugin: 'scope-tailwind-to-unapi',
Rule(rule: Rule) {
if (!shouldScopeRule(rule, targetLayers, includeRootRules)) {
return;
}
const hasSelectorArray = Array.isArray(rule.selectors);
let selectors: string[] = [];
if (hasSelectorArray && rule.selectors) {
selectors = rule.selectors;
} else if (rule.selector) {
selectors = [rule.selector];
}
if (!selectors.length) {
return;
}
const scopedSelectors = selectors.map((selector: string) => prefixSelector(selector, scope));
if (hasSelectorArray) {
rule.selectors = scopedSelectors;
} else {
rule.selector = scopedSelectors.join(', ');
}
},
};
};
scopeTailwindToUnapi.postcss = true;
export default scopeTailwindToUnapi;

View File

@@ -0,0 +1,86 @@
import { performance } from 'node:perf_hooks';
import { describe, expect, it } from 'vitest';
import scopeTailwindToUnapi from '../../postcss/scopeTailwindToUnapi';
type LayerAtRule = {
type: string;
name: string;
params: string;
parent?: LayerAtRule;
};
type MutableRule = {
type: string;
selector?: string;
selectors?: string[];
parent?: LayerAtRule;
};
function createRule(selectors: string[], layer = 'utilities'): MutableRule {
return {
type: 'rule',
selector: selectors.join(', '),
selectors: [...selectors],
parent: {
type: 'atrule',
name: 'layer',
params: layer,
},
};
}
describe('scopeTailwindToUnapi plugin', () => {
it('prefixes simple selectors with .unapi scope', () => {
const plugin = scopeTailwindToUnapi();
const rule = createRule(['.btn-primary']);
plugin.Rule?.(rule);
expect(rule.selectors).toEqual(['.unapi .btn-primary']);
});
it('merges variant class selectors into the scope', () => {
const plugin = scopeTailwindToUnapi();
const rule = createRule(['.dark .btn-secondary']);
plugin.Rule?.(rule);
expect(rule.selectors).toEqual(['.unapi.dark .btn-secondary']);
});
it('handles rules expressed with selector strings only', () => {
const plugin = scopeTailwindToUnapi();
const rule: MutableRule = {
type: 'rule',
selector: '.card',
parent: {
type: 'atrule',
name: 'layer',
params: 'components',
},
};
plugin.Rule?.(rule);
expect(rule.selector).toBe('.unapi .card');
});
it('processes large rule sets within the target budget', () => {
const plugin = scopeTailwindToUnapi();
const totalRules = 10_000;
const start = performance.now();
for (let index = 0; index < totalRules; index += 1) {
const rule = createRule([`.test-${index}`]);
plugin.Rule?.(rule);
}
const durationMs = performance.now() - start;
// Ensure we stay well under 1 second even on slower CI hosts.
expect(durationMs).toBeLessThan(1_000);
});
});

View File

@@ -325,7 +325,7 @@ defineExpose({ refreshLogContent });
</script>
<template>
<div class="flex h-full max-h-full flex-col overflow-hidden">
<div class="log-viewer flex h-full max-h-full flex-col overflow-hidden">
<div
class="bg-muted text-muted-foreground flex shrink-0 items-center justify-between px-4 py-2 text-xs"
>
@@ -412,9 +412,8 @@ defineExpose({ refreshLogContent });
<style scoped>
/* Define CSS variables for both light and dark themes */
:root {
.log-viewer {
/* Light theme colors (default) - adjusted for better readability */
--log-background: transparent;
--log-keyword-color: hsl(var(--destructive) / 0.9); /* Slightly dimmed */
--log-string-color: hsl(var(--primary) / 0.7); /* Dimmed primary color */
--log-comment-color: hsl(var(--muted-foreground));
@@ -431,7 +430,6 @@ defineExpose({ refreshLogContent });
/* Dark theme colors - use slightly different color combinations for better visibility */
.theme-dark {
--log-background: transparent;
--log-keyword-color: hsl(var(--destructive) / 0.9);
--log-string-color: hsl(var(--primary) / 0.9);
--log-comment-color: hsl(var(--muted-foreground) / 0.9);
@@ -446,11 +444,6 @@ defineExpose({ refreshLogContent });
--log-success-bg: hsl(120, 100%, 40% / 0.15);
}
/* Add some basic styling for the highlighted logs */
.hljs {
background: var(--log-background);
}
/* Style for error messages */
.hljs .hljs-keyword,
.hljs .hljs-selector-tag,

View File

@@ -4,7 +4,6 @@ import { useMutation, useQuery, useSubscription } from '@vue/apollo-composable';
import {
Button,
Select,
Sheet,
SheetContent,
SheetHeader,
@@ -46,9 +45,8 @@ const { mutate: recalculateOverview } = useMutation(resetOverview);
const { confirm } = useConfirm();
const importance = ref<Importance | undefined>(undefined);
const filterItems = [
{ type: 'label' as const, label: 'Notification Types' },
{ label: 'All Types', value: 'all' },
const filterOptions: Array<{ label: string; value?: Importance }> = [
{ label: 'All Types' },
{ label: 'Alert', value: Importance.ALERT },
{ label: 'Info', value: Importance.INFO },
{ label: 'Warning', value: Importance.WARNING },
@@ -99,8 +97,6 @@ onNotificationAdded(({ data }) => {
if (notif.timestamp) {
latestNotificationTimestamp.value = notif.timestamp;
}
// probably smart to leave this log outside the if-block for the initial release
console.log('incoming notification', notif);
if (!globalThis.toast) {
return;
}
@@ -203,32 +199,46 @@ const prepareToViewNotifications = () => {
</TabsContent>
</div>
<div class="mt-2 flex items-center justify-between gap-2 px-3">
<Select
:items="filterItems"
placeholder="Filter By"
class="h-8 px-3 text-sm"
@update:model-value="
(val: unknown) => {
const strVal = String(val);
importance = strVal === 'all' || !strVal ? undefined : (strVal as Importance);
}
"
/>
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger as-child>
<a href="/Settings/Notifications">
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Settings class="h-4 w-4" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p>Edit Notification Settings</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div class="mt-3 flex items-start justify-between gap-3 px-3">
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div
class="border-border/60 bg-muted/60 flex flex-wrap items-center gap-1 rounded-xl border p-1"
role="group"
>
<Button
v-for="option in filterOptions"
:key="option.label"
variant="ghost"
size="sm"
class="h-8 rounded-lg border border-transparent px-3 text-xs font-medium transition-colors"
:class="
importance === option.value
? 'border-border bg-background text-foreground'
: 'text-muted-foreground hover:border-border/60 hover:bg-muted/40 hover:text-foreground'
"
:aria-pressed="importance === option.value"
@click="importance = option.value"
>
{{ option.label }}
</Button>
</div>
</div>
<div class="shrink-0">
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger as-child>
<a href="/Settings/Notifications">
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<Settings class="h-4 w-4" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p>Edit Notification Settings</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<TabsContent value="unread" class="min-h-0 flex-1 flex-col">

View File

@@ -1,9 +1,8 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import {
ArrowLeftIcon,
ArrowRightIcon,
ArrowTopRightOnSquareIcon,
KeyIcon,
@@ -18,7 +17,7 @@ import {
ResponsiveModalHeader,
ResponsiveModalTitle,
} from '@unraid/ui';
import { allowedDocsOriginRegex, allowedDocsUrlRegex } from '~/helpers/urls';
import { DOCS } from '~/helpers/urls';
import type { ComposerTranslation } from 'vue-i18n';
@@ -87,93 +86,55 @@ const handleClose = () => {
};
// iframe navigation handling
const iframeRef = ref<HTMLIFrameElement | null>(null);
const hasNavigated = ref(false);
const currentIframeUrl = ref<string | null>(null);
const docsChangelogUrl = computed(() => {
return currentRelease.value?.changelogPretty ?? null;
});
const actualIframeSrc = ref<string | null>(docsChangelogUrl.value);
const iframeSrc = computed(() => {
if (!docsChangelogUrl.value) {
return null;
}
const showRawChangelog = computed<boolean>(() => {
return Boolean(!docsChangelogUrl.value && currentRelease.value?.changelog);
});
try {
const entryTarget = docsChangelogUrl.value.trim();
if (!entryTarget) {
return null;
}
const handleDocsPostMessages = (event: MessageEvent) => {
// Common checks for all iframe messages
if (
event.data &&
iframeRef.value &&
event.source === iframeRef.value.contentWindow &&
(allowedDocsOriginRegex.test(event.origin) || event.origin === 'http://localhost:3000')
) {
// Handle navigation events
if (event.data.type === 'unraid-docs-navigation') {
if (typeof event.data.url === 'string' && allowedDocsUrlRegex.test(event.data.url)) {
hasNavigated.value = event.data.url !== docsChangelogUrl.value;
currentIframeUrl.value = event.data.url;
let entryUrl: URL;
try {
entryUrl = new URL(entryTarget);
const protocol = entryUrl.protocol.toLowerCase();
if (protocol !== 'http:' && protocol !== 'https:') {
return null;
}
if (entryUrl.origin !== DOCS.origin) {
return null;
}
} catch (error) {
entryUrl = new URL(entryTarget, DOCS);
if (entryUrl.origin !== DOCS.origin) {
return null;
}
}
// Handle theme ready events
else if (event.data.type === 'theme-ready') {
sendThemeToIframe();
}
const entryValue = `${entryUrl.pathname}${entryUrl.search}${entryUrl.hash}`;
const url = new URL(entryValue, DOCS);
url.searchParams.set('embed', '1');
url.searchParams.set('theme', isDarkMode.value ? 'dark' : 'light');
url.searchParams.set('entry', entryValue);
return url.toString();
} catch (error) {
console.error('Failed to construct docs iframe URL:', error);
return null;
}
};
// Keep this function just for the watch handler
const sendThemeToIframe = () => {
if (iframeRef.value && iframeRef.value.contentWindow) {
try {
const message = { type: 'theme-update', theme: isDarkMode.value ? 'dark' : 'light' };
iframeRef.value.contentWindow.postMessage(message, '*');
} catch (error) {
console.error('Failed to send theme to iframe:', error);
}
}
};
// Attach event listener right away instead of waiting for mount
onMounted(() => {
// Set initial values only
window.addEventListener('message', handleDocsPostMessages);
currentIframeUrl.value = docsChangelogUrl.value;
});
onBeforeUnmount(() => {
window.removeEventListener('message', handleDocsPostMessages);
});
const revertToInitialChangelog = () => {
if (iframeRef.value && docsChangelogUrl.value) {
iframeRef.value.src = docsChangelogUrl.value;
hasNavigated.value = false;
currentIframeUrl.value = docsChangelogUrl.value;
}
};
watch(
docsChangelogUrl,
(newUrl) => {
currentIframeUrl.value = newUrl;
hasNavigated.value = false;
if (newUrl) {
actualIframeSrc.value = newUrl;
} else {
actualIframeSrc.value = null;
}
},
{ immediate: true }
);
// Only need to watch for theme changes
watch(isDarkMode, () => {
// The iframe will only pick up the message if it has sent theme-ready
sendThemeToIframe();
const showRawChangelog = computed<boolean>(() => {
return Boolean(!iframeSrc.value && currentRelease.value?.changelog);
});
</script>
@@ -196,13 +157,13 @@ watch(isDarkMode, () => {
<div class="flex-1 px-3">
<div class="flex flex-col gap-4 sm:min-w-[40rem]">
<!-- iframe for changelog if available -->
<div v-if="docsChangelogUrl" class="h-[calc(100vh-15rem)] w-full overflow-hidden sm:h-[45rem]">
<div v-if="iframeSrc" class="h-[calc(100vh-15rem)] w-full overflow-hidden sm:h-[45rem]">
<iframe
v-if="actualIframeSrc"
ref="iframeRef"
:src="actualIframeSrc"
:src="iframeSrc"
class="h-full w-full rounded-md border-0"
sandbox="allow-scripts allow-same-origin"
allow="fullscreen"
referrerpolicy="no-referrer"
title="Unraid Changelog"
/>
</div>
@@ -231,21 +192,12 @@ watch(isDarkMode, () => {
<ResponsiveModalFooter>
<div :class="cn('flex w-full flex-wrap justify-between gap-3 md:gap-4')">
<div :class="cn('flex flex-wrap justify-start gap-3 md:gap-4')">
<!-- Back to changelog button (when navigated away) -->
<BrandButton
v-if="hasNavigated && docsChangelogUrl"
variant="underline"
:icon="ArrowLeftIcon"
aria-label="Back to Changelog"
@click="revertToInitialChangelog"
/>
<!-- View on docs button -->
<BrandButton
v-if="currentIframeUrl || currentRelease?.changelogPretty"
v-if="docsChangelogUrl"
variant="underline"
:external="true"
:href="currentIframeUrl || currentRelease?.changelogPretty"
:href="docsChangelogUrl"
:icon-right="ArrowTopRightOnSquareIcon"
aria-label="View on Docs"
target="_blank"

View File

@@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia';
import { ArrowDownTrayIcon } from '@heroicons/vue/24/outline';
import {
ArrowTopRightOnSquareIcon,
CheckCircleIcon,
CogIcon,
EyeIcon,
IdentificationIcon,
@@ -56,6 +57,7 @@ const {
regExp,
regUpdatesExpired,
dateTimeFormat,
osVersion,
updateOsIgnoredReleases,
updateOsNotificationsEnabled,
updateOsResponse,
@@ -162,7 +164,7 @@ const extraLinks = computed((): BrandButtonProps[] => {
return buttons;
});
const actionButtons = computed((): BrandButtonProps[] | null => {
const actionButtons = computed((): BrandButtonProps[] => {
// If ignoring release, show close button as primary action
if (ignoreThisRelease.value && (available.value || availableWithRenewal.value)) {
return [
@@ -173,13 +175,13 @@ const actionButtons = computed((): BrandButtonProps[] | null => {
];
}
// update not available or no action buttons default closing
if (!available.value && !availableWithRenewal.value) {
return null;
}
const buttons: BrandButtonProps[] = [];
// update not available or no action buttons default to empty array
if (!available.value && !availableWithRenewal.value) {
return buttons;
}
// update available but not stable branch - should link out to account update callback
// if availableWithRenewal.value is true, then we need to renew the license before we can update so don't show the verify button
if (availableRequiresAuth.value && !availableWithRenewal.value) {
@@ -219,6 +221,10 @@ const actionButtons = computed((): BrandButtonProps[] | null => {
return buttons;
});
const showNoUpdateContent = computed(() => {
return !checkForUpdatesLoading.value && !available.value && !availableWithRenewal.value;
});
const close = () => {
// close it
updateOsStore.setModalOpen(false);
@@ -231,12 +237,14 @@ const close = () => {
};
const renderMainSlot = computed(() => {
return !!(
checkForUpdatesLoading.value ||
available.value ||
availableWithRenewal.value ||
extraLinks.value?.length > 0 ||
updateOsIgnoredReleases.value.length > 0
return (
!!(
checkForUpdatesLoading.value ||
available.value ||
availableWithRenewal.value ||
extraLinks.value?.length > 0 ||
updateOsIgnoredReleases.value.length > 0
) || showNoUpdateContent.value
);
});
@@ -330,6 +338,22 @@ const modalWidth = computed(() => {
</div>
</div>
<div v-if="showNoUpdateContent" class="flex flex-col items-center gap-4 py-6 text-center">
<div class="bg-primary/10 flex items-center justify-center rounded-full p-4">
<CheckCircleIcon class="text-primary h-10 w-10" />
</div>
<div class="space-y-2">
<p v-if="osVersion" class="text-muted-foreground text-center text-sm font-semibold">
{{ t('Current Version {0}', [osVersion]) }}
</p>
<p
v-if="modalCopy?.description"
class="text-muted-foreground text-xs sm:text-sm"
v-html="modalCopy.description"
/>
</div>
</div>
<div
v-if="extraLinks.length > 0"
:class="cn('xs:!flex-row flex flex-col justify-center gap-2')"
@@ -370,14 +394,13 @@ const modalWidth = computed(() => {
:class="
cn(
'mx-auto flex w-full gap-2',
actionButtons ? 'xs:!flex-row flex-col-reverse justify-between' : 'justify-center'
actionButtons.length > 0
? 'xs:!flex-row flex-col-reverse justify-between'
: 'justify-center'
)
"
>
<div
v-if="actionButtons"
:class="cn('xs:!flex-row flex flex-col-reverse justify-start gap-3')"
>
<div :class="cn('xs:!flex-row mt-2 flex flex-col-reverse justify-start gap-3')">
<TooltipProvider>
<Tooltip :delay-duration="0">
<TooltipTrigger as-child>
@@ -403,7 +426,10 @@ const modalWidth = computed(() => {
</Tooltip>
</TooltipProvider>
</div>
<div v-if="actionButtons" :class="cn('xs:!flex-row flex flex-col justify-end gap-3')">
<div
v-if="actionButtons.length > 0"
:class="cn('xs:!flex-row flex flex-col justify-end gap-3')"
>
<template v-for="item in actionButtons" :key="item.text">
<TooltipProvider v-if="ignoreThisRelease && item.text === localizedCloseText">
<Tooltip :delay-duration="300">

View File

@@ -12,6 +12,7 @@ import en_US from '~/locales/en_US.json';
// Import Pinia for use in Vue apps
import { globalPinia } from '~/store/globalPinia';
import { useThemeStore } from '~/store/theme';
// Ensure Apollo client is singleton
const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || client;
@@ -118,6 +119,8 @@ export function mountUnifiedApp() {
app.use(ui);
app.provide(DefaultApolloClient, apolloClient);
const themeStore = useThemeStore();
// Mount the app to establish context
let rootElement = document.getElementById('unraid-unified-root');
if (!rootElement) {
@@ -214,6 +217,9 @@ export function mountUnifiedApp() {
});
});
// Re-apply theme classes/variables now that new scoped roots exist
themeStore.setCssVars();
console.debug(`[UnifiedMount] Mounted ${mountedComponents.length} components`);
return app;

View File

@@ -5,6 +5,9 @@ import { OBJ_TO_STR } from '~/helpers/functions';
import type { BrandButtonProps } from '@unraid/ui';
import type { Server } from '~/types/server';
import type { UserProfileLink } from '~/types/userProfile';
export type ErrorAction = BrandButtonProps | UserProfileLink;
export type ErrorType =
| 'account'
@@ -17,7 +20,7 @@ export type ErrorType =
| 'unraidApiState';
export interface Error {
actions?: BrandButtonProps[];
actions?: ErrorAction[];
debugServer?: Server;
forumLink?: boolean;
heading: string; // if adding new errors be sure to add translations key value pairs

View File

@@ -1,10 +1,11 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { useLazyQuery } from '@vue/apollo-composable';
import { useQuery } from '@vue/apollo-composable';
import { defaultColors } from '~/themes/default';
import hexToRgba from 'hex-to-rgba';
import type { GetThemeQuery } from '~/composables/gql/graphql';
import type { Theme, ThemeVariables } from '~/themes/types';
import { graphql } from '~/composables/gql/gql';
@@ -26,21 +27,69 @@ export const GET_THEME_QUERY = graphql(`
}
`);
const DEFAULT_THEME: Theme = {
name: 'white',
banner: false,
bannerGradient: false,
bgColor: '',
descriptionShow: false,
metaColor: '',
textColor: '',
};
const DYNAMIC_VAR_KEYS = [
'--custom-header-text-primary',
'--custom-header-text-secondary',
'--custom-header-background-color',
'--custom-header-gradient-start',
'--custom-header-gradient-end',
'--banner-gradient',
] as const;
type DynamicVarKey = (typeof DYNAMIC_VAR_KEYS)[number];
export const useThemeStore = defineStore('theme', () => {
// State
const theme = ref<Theme>({
name: 'white',
banner: false,
bannerGradient: false,
bgColor: '',
descriptionShow: false,
metaColor: '',
textColor: '',
});
const { load } = useLazyQuery(GET_THEME_QUERY);
const theme = ref<Theme>({ ...DEFAULT_THEME });
const activeColorVariables = ref<ThemeVariables>(defaultColors.white);
const hasServerTheme = ref(false);
const { result, onResult, onError } = useQuery<GetThemeQuery>(GET_THEME_QUERY, null, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
});
const applyThemeFromQuery = (publicTheme?: GetThemeQuery['publicTheme'] | null) => {
if (!publicTheme) {
return;
}
hasServerTheme.value = true;
theme.value = {
name: publicTheme.name?.toLowerCase() ?? DEFAULT_THEME.name,
banner: publicTheme.showBannerImage ?? DEFAULT_THEME.banner,
bannerGradient: publicTheme.showBannerGradient ?? DEFAULT_THEME.bannerGradient,
bgColor: publicTheme.headerBackgroundColor ?? DEFAULT_THEME.bgColor,
descriptionShow: publicTheme.showHeaderDescription ?? DEFAULT_THEME.descriptionShow,
metaColor: publicTheme.headerSecondaryTextColor ?? DEFAULT_THEME.metaColor,
textColor: publicTheme.headerPrimaryTextColor ?? DEFAULT_THEME.textColor,
};
};
onResult(({ data }) => {
if (data?.publicTheme) {
applyThemeFromQuery(data.publicTheme);
}
});
if (result.value?.publicTheme) {
applyThemeFromQuery(result.value.publicTheme);
}
onError((err) => {
console.warn('Failed to load theme from server, keeping existing theme:', err);
});
// Getters
// Apply dark mode for gray and black themes
@@ -56,38 +105,17 @@ export const useThemeStore = defineStore('theme', () => {
const end = theme.value?.bgColor ? 'var(--header-gradient-end)' : 'var(--header-background-color)';
return `background-image: linear-gradient(90deg, ${start} 0, ${end} 90%);`;
});
// Actions
const setTheme = async (data?: Theme) => {
const setTheme = (data?: Partial<Theme>) => {
if (data) {
theme.value = data;
} else {
try {
const result = await load();
if (result && result.publicTheme) {
theme.value = {
name: result.publicTheme.name?.toLowerCase() || 'white',
banner: result.publicTheme.showBannerImage ?? false,
bannerGradient: result.publicTheme.showBannerGradient ?? false,
bgColor: result.publicTheme.headerBackgroundColor || '',
descriptionShow: result.publicTheme.showHeaderDescription ?? false,
metaColor: result.publicTheme.headerSecondaryTextColor || '',
textColor: result.publicTheme.headerPrimaryTextColor || '',
};
return;
}
} catch (error) {
console.warn('Failed to load theme from server, using default:', error);
if (hasServerTheme.value) {
return;
}
// Single fallback for both no data and error cases
theme.value = {
name: 'white',
banner: false,
bannerGradient: false,
bgColor: '',
descriptionShow: false,
metaColor: '',
textColor: '',
...theme.value,
...data,
};
}
};
@@ -109,7 +137,7 @@ export const useThemeStore = defineStore('theme', () => {
// Only set CSS variables for dynamic/user-configured values from GraphQL
// Static theme values are handled by Tailwind v4 theme classes in @tailwind-shared
const dynamicVars: Record<string, string> = {};
const dynamicVars: Partial<Record<DynamicVarKey, string>> = {};
// User-configured colors from webGUI @ /Settings/DisplaySettings
if (theme.value.textColor) {
@@ -141,19 +169,31 @@ export const useThemeStore = defineStore('theme', () => {
}
requestAnimationFrame(() => {
// Apply theme classes to documentElement for Tailwind v4
const root = document.documentElement;
const scopedTargets: HTMLElement[] = [
document.documentElement,
...Array.from(document.querySelectorAll<HTMLElement>('.unapi')),
];
// Remove all existing theme and custom classes
root.className = root.className
.split(' ')
.filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-'))
.join(' ');
const cleanClassList = (classList: string) =>
classList
.split(' ')
.filter((c) => !c.startsWith('theme-') && c !== 'dark' && !c.startsWith('has-custom-'))
.filter(Boolean)
.join(' ');
// Add new theme classes
[...themeClasses, ...customClasses].forEach((cls) => root.classList.add(cls));
// Apply theme and custom classes to html element and all .unapi roots
scopedTargets.forEach((target) => {
target.className = cleanClassList(target.className);
[...themeClasses, ...customClasses].forEach((cls) => target.classList.add(cls));
// Also apply dark class to body for compatibility
if (darkMode.value) {
target.classList.add('dark');
} else {
target.classList.remove('dark');
}
});
// Maintain dark mode flag on body for legacy components
if (darkMode.value) {
document.body.classList.add('dark');
} else {
@@ -162,12 +202,22 @@ export const useThemeStore = defineStore('theme', () => {
// Only apply dynamic CSS variables for custom user values
// All theme defaults are handled by classes in @tailwind-shared/theme-variants.css
if (Object.keys(dynamicVars).length > 0) {
// Apply to root element for global availability
Object.entries(dynamicVars).forEach(([key, value]) => {
document.documentElement.style.setProperty(key, value);
const activeDynamicKeys = Object.keys(dynamicVars) as DynamicVarKey[];
scopedTargets.forEach((target) => {
activeDynamicKeys.forEach((key) => {
const value = dynamicVars[key];
if (value !== undefined) {
target.style.setProperty(key, value);
}
});
}
DYNAMIC_VAR_KEYS.forEach((key) => {
if (!Object.prototype.hasOwnProperty.call(dynamicVars, key)) {
target.style.removeProperty(key);
}
});
});
// Store active variables for reference (from defaultColors for compatibility)
const customTheme = { ...defaultColors[selectedTheme] };

View File

@@ -16,35 +16,52 @@ export const useUnraidApiStore = defineStore('unraidApi', () => {
const errorsStore = useErrorsStore();
const serverStore = useServerStore();
const unraidApiClient = ref<ApolloClientType<NormalizedCacheObject> | null>(client);
let pendingRestartPromise: Promise<void> | null = null;
// const unraidApiErrors = ref<any[]>([]);
const unraidApiStatus = ref<'connecting' | 'offline' | 'online' | 'restarting'>('connecting');
const prioritizeCorsError = ref(false); // Ensures we don't overwrite this specific error message with a non-descriptive network error message
const offlineError = computed(() => {
if (unraidApiStatus.value === 'offline') {
return new Error('The Unraid API is currently offline.');
/**
* Can both start and restart the unraid-api depending on its current status
*/
const restartUnraidApiClient = () => {
if (pendingRestartPromise) {
return pendingRestartPromise;
}
});
// maintains an error in global store while api is offline
watch(
offlineError,
(error) => {
const errorId = 'unraidApiOffline';
if (error) {
errorsStore.setError({
heading: 'Warning: API is offline!',
message: error.message,
ref: errorId,
level: 'warning',
type: 'unraidApiState',
const command = unraidApiStatus.value === 'offline' ? 'start' : 'restart';
unraidApiStatus.value = 'restarting';
const restartTask = (async () => {
try {
await WebguiUnraidApiCommand({
csrf_token: serverStore.csrf,
command,
});
} catch (error) {
let errorMessage = 'Unknown error';
if (typeof error === 'string') {
errorMessage = error.toUpperCase();
} else if (error instanceof Error) {
errorMessage = error.message;
}
errorsStore.setError({
heading: 'Error: unraid-api restart',
message: errorMessage,
level: 'error',
ref: 'restartUnraidApiClient',
type: 'request',
});
} else {
errorsStore.removeErrorByRef(errorId);
}
},
{ immediate: true }
);
})();
pendingRestartPromise = restartTask.finally(() => {
pendingRestartPromise = null;
});
return pendingRestartPromise;
};
const unraidApiRestartAction = computed((): UserProfileLink | undefined => {
const { connectPluginInstalled, stateDataError } = serverStore;
@@ -59,6 +76,34 @@ export const useUnraidApiStore = defineStore('unraidApi', () => {
};
});
const offlineError = computed(() => {
if (unraidApiStatus.value === 'offline') {
return new Error('The Unraid API is currently offline.');
}
return undefined;
});
// maintains an error in global store while api is offline
watch(
[offlineError, unraidApiRestartAction],
([error, restartAction]) => {
const errorId = 'unraidApiOffline';
errorsStore.removeErrorByRef(errorId);
if (error) {
errorsStore.setError({
heading: 'Warning: API is offline!',
message: error.message,
ref: errorId,
level: 'warning',
type: 'unraidApiState',
actions: restartAction ? [restartAction] : undefined,
});
}
},
{ immediate: true }
);
/**
* Automatically called when an apiKey is unset in the serverStore
*/
@@ -74,33 +119,6 @@ export const useUnraidApiStore = defineStore('unraidApi', () => {
unraidApiClient.value = null;
unraidApiStatus.value = 'offline';
};
/**
* Can both start and restart the unraid-api depending on it's current status
*/
const restartUnraidApiClient = async () => {
const command = unraidApiStatus.value === 'offline' ? 'start' : 'restart';
unraidApiStatus.value = 'restarting';
try {
await WebguiUnraidApiCommand({
csrf_token: serverStore.csrf,
command,
});
} catch (error) {
let errorMessage = 'Unknown error';
if (typeof error === 'string') {
errorMessage = error.toUpperCase();
} else if (error instanceof Error) {
errorMessage = error.message;
}
errorsStore.setError({
heading: 'Error: unraid-api restart',
message: errorMessage,
level: 'error',
ref: 'restartUnraidApiClient',
type: 'request',
});
}
};
return {
unraidApiClient,

View File

@@ -8,6 +8,7 @@ import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import removeConsole from 'vite-plugin-remove-console';
import scopeTailwindToUnapi from './postcss/scopeTailwindToUnapi';
import { serveStaticHtml } from './vite-plugin-serve-static';
const dropConsole = process.env.VITE_ALLOW_CONSOLE_LOGS !== 'true';
@@ -83,6 +84,12 @@ export default defineConfig({
: []),
],
css: {
postcss: {
plugins: [scopeTailwindToUnapi()],
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -121,12 +128,11 @@ export default defineConfig({
rollupOptions: {
output: {
format: 'es',
entryFileNames: 'standalone-apps.js',
entryFileNames: 'standalone-apps-[hash].js',
chunkFileNames: '[name]-[hash].js',
assetFileNames: (assetInfo) => {
// Keep CSS files with predictable names
if (assetInfo.name?.endsWith('.css')) {
return 'standalone-apps.css';
return 'standalone-apps-[hash][extname]';
}
return '[name]-[hash][extname]';
},

View File

@@ -1,7 +1,25 @@
import { ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, vi } from 'vitest';
vi.mock('@vue/apollo-composable', async () => {
const actual =
await vi.importActual<typeof import('@vue/apollo-composable')>('@vue/apollo-composable');
const useQueryMock = vi.fn(() => ({
result: ref(null),
loading: ref(false),
onResult: vi.fn(),
onError: vi.fn(),
}));
return {
...actual,
useQuery: useQueryMock,
};
});
// Mock WebSocket for test environment
if (!global.WebSocket) {
const mockWebSocket = vi.fn().mockImplementation(() => ({