mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
13 Commits
4.23.0-bui
...
v4.25.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f3e3c44b | ||
|
|
cd5eff11bc | ||
|
|
7bdeca8338 | ||
|
|
661865f976 | ||
|
|
b7afaf4632 | ||
|
|
b3ca40c639 | ||
|
|
378cdb7f10 | ||
|
|
d9c561bfeb | ||
|
|
9972a5f178 | ||
|
|
a44473c1d1 | ||
|
|
ed9a5c5ff9 | ||
|
|
d8b166e4b6 | ||
|
|
8b862ecef5 |
64
.github/workflows/claude.yml
vendored
64
.github/workflows/claude.yml
vendored
@@ -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
|
||||
|
||||
3
.github/workflows/main.yml
vendored
3
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{".":"4.23.0"}
|
||||
{".":"4.25.0"}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/api",
|
||||
"version": "4.23.0",
|
||||
"version": "4.25.0",
|
||||
"main": "src/cli/index.ts",
|
||||
"type": "module",
|
||||
"corepack": {
|
||||
|
||||
@@ -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');
|
||||
|
||||
133
api/src/unraid-api/auth/casbin/authz.guard.integration.spec.ts
Normal file
133
api/src/unraid-api/auth/casbin/authz.guard.integration.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
api/src/unraid-api/auth/casbin/resolve-subject.util.spec.ts
Normal file
43
api/src/unraid-api/auth/casbin/resolve-subject.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
api/src/unraid-api/auth/casbin/resolve-subject.util.ts
Normal file
46
api/src/unraid-api/auth/casbin/resolve-subject.util.ts
Normal 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');
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { PluginModule } from '@app/unraid-api/plugin/plugin.module.js';
|
||||
extra,
|
||||
};
|
||||
},
|
||||
fieldResolverEnhancers: ['guards'],
|
||||
plugins: [
|
||||
createDynamicIntrospectionPlugin(isSandboxEnabled),
|
||||
createSandboxPlugin(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@unraid/connect-plugin",
|
||||
"version": "4.23.0",
|
||||
"version": "4.25.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
?>
|
||||
?>
|
||||
|
||||
@@ -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",
|
||||
|
||||
171
web/__test__/components/ChangelogModal.test.ts
Normal file
171
web/__test__/components/ChangelogModal.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
271
web/__test__/components/CheckUpdateResponseModal.test.ts
Normal file
271
web/__test__/components/CheckUpdateResponseModal.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -27,6 +27,8 @@ vi.mock('@vue/apollo-composable', () => ({
|
||||
useQuery: () => ({
|
||||
result: { value: {} },
|
||||
loading: { value: false },
|
||||
onResult: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
useLazyQuery: () => ({
|
||||
result: { value: {} },
|
||||
|
||||
@@ -30,6 +30,8 @@ vi.mock('@vue/apollo-composable', () => ({
|
||||
useQuery: () => ({
|
||||
result: { value: {} },
|
||||
loading: { value: false },
|
||||
onResult: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
}),
|
||||
useLazyQuery: () => ({
|
||||
result: { value: {} },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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})`),
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
165
web/postcss/scopeTailwindToUnapi.ts
Normal file
165
web/postcss/scopeTailwindToUnapi.ts
Normal 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;
|
||||
86
web/src/__tests__/scopeTailwindToUnapi.spec.ts
Normal file
86
web/src/__tests__/scopeTailwindToUnapi.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] };
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
web/types/window.d.ts
vendored
4
web/types/window.d.ts
vendored
@@ -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;
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
Reference in New Issue
Block a user