Compare commits

...

13 Commits

Author SHA1 Message Date
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
Eli Bosley
d8b166e4b6 feat: improve dom content loading by being more efficient about component mounting (#1716)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
  - Faster, more scalable component auto-mounting via batch processing.
- More robust prop parsing (handles JSON vs. strings and HTML entities).
  - Improved locale data initialization during setup.

- Bug Fixes
- Prevents duplicate mounts and improves handling of empty/irrelevant
attributes.

- Refactor
  - Consolidated mounting flow and removed legacy runtime debug globals.

- Tests
  - Removed outdated tests tied to previous global exposures.

- Chores
- Updated type declarations; global client is now optional for improved
flexibility.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-18 12:50:24 -04:00
github-actions[bot]
8b862ecef5 chore(main): release 4.23.1 (#1715)
🤖 I have created a release *beep* *boop*
---


## [4.23.1](https://github.com/unraid/api/compare/v4.23.0...v4.23.1)
(2025-09-17)


### Bug Fixes

* cleanup ini parser logic with better fallbacks
([#1713](https://github.com/unraid/api/issues/1713))
([1691362](16913627de))

---
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-17 14:46:58 -04:00
40 changed files with 1597 additions and 473 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.0"}
{".":"4.25.0"}

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,42 @@
# Changelog
## [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)
### Bug Fixes
* cleanup ini parser logic with better fallbacks ([#1713](https://github.com/unraid/api/issues/1713)) ([1691362](https://github.com/unraid/api/commit/16913627de9497a5d2f71edb710cec6e2eb9f890))
## [4.23.0](https://github.com/unraid/api/compare/v4.22.2...v4.23.0) (2025-09-16)

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/api",
"version": "4.23.0",
"version": "4.25.0",
"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.0",
"version": "4.25.0",
"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.0",
"version": "4.25.0",
"private": true,
"dependencies": {
"commander": "14.0.0",

View File

@@ -409,42 +409,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

@@ -1,6 +1,6 @@
{
"name": "@unraid/ui",
"version": "4.23.0",
"version": "4.25.0",
"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

@@ -105,12 +105,7 @@ describe('mount-engine', () => {
vi.restoreAllMocks();
document.body.innerHTML = '';
// Clean up global references
if (window.__unifiedApp) {
delete window.__unifiedApp;
}
if (window.__mountedComponents) {
delete window.__mountedComponents;
}
// Clean up any window references if needed
});
describe('mountUnifiedApp', () => {
@@ -438,29 +433,6 @@ describe('mount-engine', () => {
});
describe('global exposure', () => {
it('should expose unified app globally', () => {
const app = mountUnifiedApp();
expect(window.__unifiedApp).toBe(app);
});
it('should expose mounted components globally', () => {
const element = document.createElement('div');
element.id = 'global-app';
document.body.appendChild(element);
mockComponentMappings.push({
selector: '#global-app',
appId: 'global-app',
component: TestComponent,
});
mountUnifiedApp();
expect(window.__mountedComponents).toBeDefined();
expect(Array.isArray(window.__mountedComponents)).toBe(true);
expect(window.__mountedComponents!.length).toBe(1);
});
it('should expose globalPinia globally', () => {
expect(window.globalPinia).toBeDefined();
expect(window.globalPinia).toBe(mockGlobalPinia);

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.0",
"version": "4.25.0",
"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

@@ -9,7 +9,7 @@
/* Import theme and utilities only - no global preflight */
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@import "@nuxt/ui";
/* @import "@nuxt/ui"; temporarily disabled */
@import 'tw-animate-css';
@import '../../../@tailwind-shared/index.css';

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

@@ -23,9 +23,6 @@ function initializeGlobalDependencies() {
});
// Expose utility functions on window for debugging/external use
// With unified app, these are no longer needed
// Access the unified app via window.__unifiedApp instead
// Expose Apollo client on window for global access
window.apolloClient = apolloClient;

View File

@@ -10,10 +10,9 @@ import { client } from '~/helpers/create-apollo-client';
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
import en_US from '~/locales/en_US.json';
import type { App as VueApp } from 'vue';
// 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;
@@ -22,7 +21,7 @@ const apolloClient = (typeof window !== 'undefined' && window.apolloClient) || c
declare global {
interface Window {
globalPinia: typeof globalPinia;
__unifiedApp?: VueApp;
LOCALE_DATA?: string;
}
}
@@ -38,7 +37,7 @@ function setupI18n() {
// Check for window locale data
if (typeof window !== 'undefined') {
const windowLocaleData = (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA || null;
const windowLocaleData = window.LOCALE_DATA || null;
if (windowLocaleData) {
try {
parsedMessages = JSON.parse(decodeURIComponent(windowLocaleData));
@@ -64,19 +63,26 @@ function setupI18n() {
// Helper function to parse props from HTML attributes
function parsePropsFromElement(element: Element): Record<string, unknown> {
// Early exit if no attributes
if (!element.hasAttributes()) return {};
const props: Record<string, unknown> = {};
// Pre-compile attribute skip list into a Set for O(1) lookup
const skipAttrs = new Set(['class', 'id', 'style', 'data-vue-mounted']);
for (const attr of element.attributes) {
const name = attr.name;
const value = attr.value;
// Skip Vue internal attributes and common HTML attributes
if (name.startsWith('data-v-') || name === 'class' || name === 'id' || name === 'style') {
if (skipAttrs.has(name) || name.startsWith('data-v-')) {
continue;
}
const value = attr.value;
const first = value.trimStart()[0];
// Try to parse JSON values (handles HTML-encoded JSON)
if (value.startsWith('{') || value.startsWith('[')) {
if (first === '{' || first === '[') {
try {
// Decode HTML entities first
const decoded = value
@@ -113,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) {
@@ -126,75 +134,98 @@ export function mountUnifiedApp() {
// Now render components to their locations using the shared context
const mountedComponents: Array<{ element: HTMLElement; unmount: () => void }> = [];
// Components are already in priority order in component-registry
// Batch all selector queries first to identify which components are needed
const componentsToMount: Array<{ mapping: (typeof componentMappings)[0]; element: HTMLElement }> = [];
// Build a map of all selectors to their mappings for quick lookup
const selectorToMapping = new Map<string, (typeof componentMappings)[0]>();
componentMappings.forEach((mapping) => {
const { selector, appId } = mapping;
const selectors = Array.isArray(selector) ? selector : [selector];
const selectors = Array.isArray(mapping.selector) ? mapping.selector : [mapping.selector];
selectors.forEach((sel) => selectorToMapping.set(sel, mapping));
});
// Find first matching element
for (const sel of selectors) {
const element = document.querySelector(sel) as HTMLElement;
if (element && !element.hasAttribute('data-vue-mounted')) {
// Get the async component from mapping
const component = mapping.component;
// Query all selectors at once
const allSelectors = Array.from(selectorToMapping.keys()).join(',');
// Skip if no component is defined
if (!component) {
console.error(`[UnifiedMount] No component defined for ${appId}`);
continue;
// Early exit if no selectors to query
if (!allSelectors) {
console.debug('[UnifiedMount] Mounted 0 components');
return app;
}
const foundElements = document.querySelectorAll(allSelectors);
const processedMappings = new Set<(typeof componentMappings)[0]>();
foundElements.forEach((element) => {
if (!element.hasAttribute('data-vue-mounted')) {
// Find which mapping this element belongs to
for (const [selector, mapping] of selectorToMapping) {
if (element.matches(selector) && !processedMappings.has(mapping)) {
componentsToMount.push({ mapping, element: element as HTMLElement });
processedMappings.add(mapping);
break;
}
// Parse props from element
const props = parsePropsFromElement(element);
// Wrap component in UApp for Nuxt UI support
const wrappedComponent = {
name: `${appId}-wrapper`,
setup() {
return () =>
h(
UApp,
{},
{
default: () => h(component, props),
}
);
},
};
// Create vnode with shared app context
const vnode = createVNode(wrappedComponent);
vnode.appContext = app._context; // Share the app context
// Clear the element and render the component into it
element.innerHTML = '';
render(vnode, element);
// Mark as mounted
element.setAttribute('data-vue-mounted', 'true');
element.classList.add('unapi');
// Store for cleanup
mountedComponents.push({
element,
unmount: () => render(null, element),
});
break;
}
}
});
// Store reference for debugging
if (typeof window !== 'undefined') {
window.__unifiedApp = app;
window.__mountedComponents = mountedComponents;
}
// Now mount only the components that exist
componentsToMount.forEach(({ mapping, element }) => {
const { appId } = mapping;
const component = mapping.component;
// Skip if no component is defined
if (!component) {
console.error(`[UnifiedMount] No component defined for ${appId}`);
return;
}
// Parse props from element
const props = parsePropsFromElement(element);
// Wrap component in UApp for Nuxt UI support
const wrappedComponent = {
name: `${appId}-wrapper`,
setup() {
return () =>
h(
UApp,
{},
{
default: () => h(component, props),
}
);
},
};
// Create vnode with shared app context
const vnode = createVNode(wrappedComponent);
vnode.appContext = app._context; // Share the app context
// Clear the element and render the component into it
element.replaceChildren();
render(vnode, element);
// Mark as mounted
element.setAttribute('data-vue-mounted', 'true');
element.classList.add('unapi');
// Store for cleanup
mountedComponents.push({
element,
unmount: () => render(null, element),
});
});
// Re-apply theme classes/variables now that new scoped roots exist
themeStore.setCssVars();
console.debug(`[UnifiedMount] Mounted ${mountedComponents.length} components`);
return app;
}
// Replace the old autoMountAllComponents with the new unified approach
export function autoMountAllComponents() {
mountUnifiedApp();
return mountUnifiedApp();
}

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

@@ -1,5 +1,5 @@
import type { ApolloClient } from '@apollo/client/core';
import type { autoMountComponent, getMountedApp, mountVueApp } from '~/components/Wrapper/mount-engine';
import type { client as apolloClient } from '~/helpers/create-apollo-client';
import type { parse } from 'graphql';
import type { Component } from 'vue';
@@ -11,7 +11,7 @@ import type { Component } from 'vue';
declare global {
interface Window {
// Apollo GraphQL client and utilities
apolloClient: typeof apolloClient;
apolloClient?: ApolloClient<unknown>;
gql: typeof parse;
graphqlParse: typeof parse;

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

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