mirror of
https://github.com/unraid/api.git
synced 2026-05-06 07:00:19 -05:00
Feat/vue (#1655)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Introduced Docker management UI components: Overview, Logs, Console, Preview, and Edit. - Added responsive Card/Detail layouts with grouping, bulk actions, and tabs. - New UnraidToaster component and global toaster configuration. - Component auto-mounting improved with async loading and multi-selector support. - UI/UX - Overhauled theme system (light/dark tokens, primary/orange accents) and added theme variants. - Header OS version now includes integrated changelog modal. - Registration displays warning states; multiple visual polish updates. - API - CPU load now includes percentGuest and percentSteal metrics. - Chores - Migrated web app to Vite; updated artifacts and manifests. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: mdatelle <mike@datelle.net> Co-authored-by: Michael Datelle <mdatelle@icloud.com>
This commit is contained in:
@@ -99,7 +99,7 @@ jobs:
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: unraid-wc-rich
|
||||
path: ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt
|
||||
path: ${{ github.workspace }}/plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone
|
||||
merge-multiple: true
|
||||
- name: Download Unraid API
|
||||
uses: actions/download-artifact@v5
|
||||
|
||||
@@ -333,7 +333,6 @@ jobs:
|
||||
echo VITE_CONNECT=${{ secrets.VITE_CONNECT }} >> .env
|
||||
echo VITE_UNRAID_NET=${{ secrets.VITE_UNRAID_NET }} >> .env
|
||||
echo VITE_CALLBACK_KEY=${{ secrets.VITE_CALLBACK_KEY }} >> .env
|
||||
cat .env
|
||||
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -385,7 +384,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: unraid-wc-rich
|
||||
path: web/.nuxt/standalone-apps
|
||||
path: web/dist
|
||||
|
||||
build-plugin-staging-pr:
|
||||
name: Build and Deploy Plugin
|
||||
|
||||
@@ -29,6 +29,10 @@ unraid-ui/node_modules/
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Auto-generated type declarations for Nuxt UI
|
||||
auto-imports.d.ts
|
||||
components.d.ts
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
@@ -118,3 +122,4 @@ api/dev/Unraid.net/myservers.cfg
|
||||
|
||||
# local Mise settings
|
||||
.mise.toml
|
||||
|
||||
|
||||
@@ -2,9 +2,59 @@
|
||||
|
||||
/* Light mode defaults */
|
||||
:root {
|
||||
/* Override Tailwind v4 global styles to use webgui variables */
|
||||
--ui-bg: var(--background-color) !important;
|
||||
--ui-text: var(--text-color) !important;
|
||||
/* Nuxt UI Color System - Primary (Orange for Unraid) */
|
||||
--ui-color-primary-50: #fff7ed;
|
||||
--ui-color-primary-100: #ffedd5;
|
||||
--ui-color-primary-200: #fed7aa;
|
||||
--ui-color-primary-300: #fdba74;
|
||||
--ui-color-primary-400: #fb923c;
|
||||
--ui-color-primary-500: #ff8c2f;
|
||||
--ui-color-primary-600: #ea580c;
|
||||
--ui-color-primary-700: #c2410c;
|
||||
--ui-color-primary-800: #9a3412;
|
||||
--ui-color-primary-900: #7c2d12;
|
||||
--ui-color-primary-950: #431407;
|
||||
|
||||
/* Nuxt UI Color System - Neutral (True Gray) */
|
||||
--ui-color-neutral-50: #fafafa;
|
||||
--ui-color-neutral-100: #f5f5f5;
|
||||
--ui-color-neutral-200: #e5e5e5;
|
||||
--ui-color-neutral-300: #d4d4d4;
|
||||
--ui-color-neutral-400: #a3a3a3;
|
||||
--ui-color-neutral-500: #737373;
|
||||
--ui-color-neutral-600: #525252;
|
||||
--ui-color-neutral-700: #404040;
|
||||
--ui-color-neutral-800: #262626;
|
||||
--ui-color-neutral-900: #171717;
|
||||
--ui-color-neutral-950: #0a0a0a;
|
||||
|
||||
/* Nuxt UI Default color shades */
|
||||
--ui-primary: var(--ui-color-primary-500);
|
||||
--ui-secondary: var(--ui-color-neutral-500);
|
||||
|
||||
/* Nuxt UI Design Tokens - Text */
|
||||
--ui-text-dimmed: var(--ui-color-neutral-400);
|
||||
--ui-text-muted: var(--ui-color-neutral-500);
|
||||
--ui-text-toned: var(--ui-color-neutral-600);
|
||||
--ui-text: var(--ui-color-neutral-700);
|
||||
--ui-text-highlighted: var(--ui-color-neutral-900);
|
||||
--ui-text-inverted: white;
|
||||
|
||||
/* Nuxt UI Design Tokens - Background */
|
||||
--ui-bg: white;
|
||||
--ui-bg-muted: var(--ui-color-neutral-50);
|
||||
--ui-bg-elevated: var(--ui-color-neutral-100);
|
||||
--ui-bg-accented: var(--ui-color-neutral-200);
|
||||
--ui-bg-inverted: var(--ui-color-neutral-900);
|
||||
|
||||
/* Nuxt UI Design Tokens - Border */
|
||||
--ui-border: var(--ui-color-neutral-200);
|
||||
--ui-border-muted: var(--ui-color-neutral-200);
|
||||
--ui-border-accented: var(--ui-color-neutral-300);
|
||||
--ui-border-inverted: var(--ui-color-neutral-900);
|
||||
|
||||
/* Nuxt UI Radius */
|
||||
--ui-radius: 0.5rem;
|
||||
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
@@ -16,7 +66,7 @@
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary: 24 100% 50%; /* Orange #ff8c2f in HSL */
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
@@ -24,7 +74,7 @@
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--ring: 24 100% 50%; /* Orange ring to match primary */
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
@@ -34,9 +84,30 @@
|
||||
|
||||
/* Dark mode */
|
||||
.dark {
|
||||
/* Override Tailwind v4 global styles to use webgui variables */
|
||||
--ui-bg: var(--background-color) !important;
|
||||
--ui-text: var(--text-color) !important;
|
||||
/* Nuxt UI Default color shades - Dark mode */
|
||||
--ui-primary: var(--ui-color-primary-400);
|
||||
--ui-secondary: var(--ui-color-neutral-400);
|
||||
|
||||
/* Nuxt UI Design Tokens - Text (Dark) */
|
||||
--ui-text-dimmed: var(--ui-color-neutral-500);
|
||||
--ui-text-muted: var(--ui-color-neutral-400);
|
||||
--ui-text-toned: var(--ui-color-neutral-300);
|
||||
--ui-text: var(--ui-color-neutral-200);
|
||||
--ui-text-highlighted: white;
|
||||
--ui-text-inverted: var(--ui-color-neutral-900);
|
||||
|
||||
/* Nuxt UI Design Tokens - Background (Dark) */
|
||||
--ui-bg: var(--ui-color-neutral-900);
|
||||
--ui-bg-muted: var(--ui-color-neutral-800);
|
||||
--ui-bg-elevated: var(--ui-color-neutral-800);
|
||||
--ui-bg-accented: var(--ui-color-neutral-700);
|
||||
--ui-bg-inverted: white;
|
||||
|
||||
/* Nuxt UI Design Tokens - Border (Dark) */
|
||||
--ui-border: var(--ui-color-neutral-800);
|
||||
--ui-border-muted: var(--ui-color-neutral-700);
|
||||
--ui-border-accented: var(--ui-color-neutral-700);
|
||||
--ui-border-inverted: white;
|
||||
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
@@ -48,15 +119,15 @@
|
||||
--card-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--primary: 24 100% 50%; /* Orange #ff8c2f in HSL */
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--ring: 24 100% 50%; /* Orange ring to match primary */
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Tailwind Shared Styles - Single entry point for all shared CSS */
|
||||
@import './css-variables.css';
|
||||
@import './unraid-theme.css';
|
||||
@import './theme-variants.css';
|
||||
@import './base-utilities.css';
|
||||
@import './sonner.css';
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Tailwind v4 Theme Variants
|
||||
* Defines theme-specific CSS variables that can be switched via classes
|
||||
* These are applied dynamically based on the theme selected in GraphQL
|
||||
*/
|
||||
|
||||
/* Default/White Theme */
|
||||
:root,
|
||||
.theme-white {
|
||||
--header-text-primary: #ffffff;
|
||||
--header-text-secondary: #999999;
|
||||
--header-background-color: #1c1b1b;
|
||||
--header-gradient-start: rgba(28, 27, 27, 0);
|
||||
--header-gradient-end: rgba(28, 27, 27, 0.7);
|
||||
--ui-border-muted: hsl(240 5% 20%);
|
||||
--color-border: #383735;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #1c1b1b;
|
||||
--color-gamma: #ffffff;
|
||||
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Black Theme */
|
||||
.theme-black,
|
||||
.theme-black.dark {
|
||||
--header-text-primary: #1c1b1b;
|
||||
--header-text-secondary: #999999;
|
||||
--header-background-color: #f2f2f2;
|
||||
--header-gradient-start: rgba(242, 242, 242, 0);
|
||||
--header-gradient-end: rgba(242, 242, 242, 0.7);
|
||||
--ui-border-muted: hsl(240 5.9% 90%);
|
||||
--color-border: #e0e0e0;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #f2f2f2;
|
||||
--color-gamma: #1c1b1b;
|
||||
--color-gamma-opaque: rgba(28, 27, 27, 0.3);
|
||||
}
|
||||
|
||||
/* Gray Theme */
|
||||
.theme-gray {
|
||||
--header-text-primary: #ffffff;
|
||||
--header-text-secondary: #999999;
|
||||
--header-background-color: #1c1b1b;
|
||||
--header-gradient-start: rgba(28, 27, 27, 0);
|
||||
--header-gradient-end: rgba(28, 27, 27, 0.7);
|
||||
--ui-border-muted: hsl(240 5% 25%);
|
||||
--color-border: #383735;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #383735;
|
||||
--color-gamma: #ffffff;
|
||||
--color-gamma-opaque: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Azure Theme */
|
||||
.theme-azure {
|
||||
--header-text-primary: #1c1b1b;
|
||||
--header-text-secondary: #999999;
|
||||
--header-background-color: #f2f2f2;
|
||||
--header-gradient-start: rgba(242, 242, 242, 0);
|
||||
--header-gradient-end: rgba(242, 242, 242, 0.7);
|
||||
--ui-border-muted: hsl(210 40% 80%);
|
||||
--color-border: #5a8bb8;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #e7f2f8;
|
||||
--color-gamma: #336699;
|
||||
--color-gamma-opaque: rgba(51, 102, 153, 0.3);
|
||||
}
|
||||
|
||||
/* Dark Mode Overrides */
|
||||
.dark {
|
||||
--ui-border-muted: hsl(240 5% 20%);
|
||||
--color-border: #383735;
|
||||
}
|
||||
|
||||
/*
|
||||
* Dynamic color variables for user overrides from GraphQL
|
||||
* These are set via JavaScript and override the theme defaults
|
||||
*/
|
||||
.has-custom-header-text {
|
||||
--header-text-primary: var(--custom-header-text-primary);
|
||||
--color-header-text-primary: var(--custom-header-text-primary);
|
||||
}
|
||||
|
||||
.has-custom-header-meta {
|
||||
--header-text-secondary: var(--custom-header-text-secondary);
|
||||
--color-header-text-secondary: var(--custom-header-text-secondary);
|
||||
}
|
||||
|
||||
.has-custom-header-bg {
|
||||
--header-background-color: var(--custom-header-background-color);
|
||||
--color-header-background: var(--custom-header-background-color);
|
||||
--header-gradient-start: var(--custom-header-gradient-start);
|
||||
--header-gradient-end: var(--custom-header-gradient-end);
|
||||
--color-header-gradient-start: var(--custom-header-gradient-start);
|
||||
--color-header-gradient-end: var(--custom-header-gradient-end);
|
||||
}
|
||||
@@ -85,22 +85,22 @@
|
||||
--color-primary-950: #431407;
|
||||
|
||||
/* Header colors */
|
||||
--color-header-text-primary: var(--header-text-primary);
|
||||
--color-header-text-secondary: var(--header-text-secondary);
|
||||
--color-header-background-color: var(--header-background-color);
|
||||
--color-header-text-primary: #1c1c1c;
|
||||
--color-header-text-secondary: #999999;
|
||||
--color-header-background: #f2f2f2;
|
||||
|
||||
/* Legacy colors */
|
||||
--color-alpha: var(--color-alpha);
|
||||
--color-beta: var(--color-beta);
|
||||
--color-gamma: var(--color-gamma);
|
||||
--color-gamma-opaque: var(--color-gamma-opaque);
|
||||
--color-customgradient-start: var(--color-customgradient-start);
|
||||
--color-customgradient-end: var(--color-customgradient-end);
|
||||
/* Legacy colors - defaults (overridden by theme-variants.css) */
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #f2f2f2;
|
||||
--color-gamma: #999999;
|
||||
--color-gamma-opaque: rgba(153, 153, 153, 0.5);
|
||||
--color-customgradient-start: rgba(242, 242, 242, 0);
|
||||
--color-customgradient-end: rgba(242, 242, 242, 0.85);
|
||||
|
||||
/* Gradients */
|
||||
--color-header-gradient-start: var(--header-gradient-start);
|
||||
--color-header-gradient-end: var(--header-gradient-end);
|
||||
--color-banner-gradient: var(--banner-gradient);
|
||||
/* Gradients - defaults (overridden by theme-variants.css) */
|
||||
--color-header-gradient-start: rgba(242, 242, 242, 0);
|
||||
--color-header-gradient-end: rgba(242, 242, 242, 0.85);
|
||||
--color-banner-gradient: none;
|
||||
|
||||
/* Font sizes */
|
||||
--font-10px: 10px;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.18.2",
|
||||
"version": "4.19.1",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -1361,6 +1361,12 @@ type CpuLoad {
|
||||
|
||||
"""The percentage of time the CPU spent servicing hardware interrupts."""
|
||||
percentIrq: Float!
|
||||
|
||||
"""The percentage of time the CPU spent running virtual machines (guest)."""
|
||||
percentGuest: Float!
|
||||
|
||||
"""The percentage of CPU time stolen by the hypervisor."""
|
||||
percentSteal: Float!
|
||||
}
|
||||
|
||||
type CpuUtilization implements Node {
|
||||
|
||||
@@ -448,20 +448,6 @@ export enum ConfigErrorState {
|
||||
WITHDRAWN = 'WITHDRAWN'
|
||||
}
|
||||
|
||||
export type ConfigFile = {
|
||||
__typename?: 'ConfigFile';
|
||||
content: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
path: Scalars['String']['output'];
|
||||
/** Human-readable file size (e.g., "1.5 KB", "2.3 MB") */
|
||||
sizeReadable: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type ConfigFilesResponse = {
|
||||
__typename?: 'ConfigFilesResponse';
|
||||
files: Array<ConfigFile>;
|
||||
};
|
||||
|
||||
export type Connect = Node & {
|
||||
__typename?: 'Connect';
|
||||
/** The status of dynamic remote access */
|
||||
@@ -553,12 +539,16 @@ export type CoreVersions = {
|
||||
/** CPU load for a single core */
|
||||
export type CpuLoad = {
|
||||
__typename?: 'CpuLoad';
|
||||
/** The percentage of time the CPU spent running virtual machines (guest). */
|
||||
percentGuest: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU was idle. */
|
||||
percentIdle: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent servicing hardware interrupts. */
|
||||
percentIrq: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent on low-priority (niced) user space processes. */
|
||||
percentNice: Scalars['Float']['output'];
|
||||
/** The percentage of CPU time stolen by the hypervisor. */
|
||||
percentSteal: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in kernel space. */
|
||||
percentSystem: Scalars['Float']['output'];
|
||||
/** The total CPU load on a single core, in percent. */
|
||||
@@ -1645,7 +1635,6 @@ export type PublicPartnerInfo = {
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
allConfigFiles: ConfigFilesResponse;
|
||||
apiKey?: Maybe<ApiKey>;
|
||||
/** All possible permissions for API keys */
|
||||
apiKeyPossiblePermissions: Array<Permission>;
|
||||
@@ -1655,7 +1644,6 @@ export type Query = {
|
||||
array: UnraidArray;
|
||||
cloud: Cloud;
|
||||
config: Config;
|
||||
configFile?: Maybe<ConfigFile>;
|
||||
connect: Connect;
|
||||
customization?: Maybe<Customization>;
|
||||
disk: Disk;
|
||||
@@ -1719,11 +1707,6 @@ export type QueryApiKeyArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryConfigFileArgs = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryDiskArgs = {
|
||||
id: Scalars['PrefixedID']['input'];
|
||||
};
|
||||
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
Menu="UserPreferences"
|
||||
Title="Display Settings"
|
||||
Icon="icon-display"
|
||||
Tag="desktop"
|
||||
---
|
||||
<?PHP
|
||||
/* Copyright 2005-2025, Lime Technology
|
||||
* Copyright 2012-2025, Bergware International.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*/
|
||||
?>
|
||||
<?
|
||||
$void = "<img src='/webGui/images/banner.png' id='image' width='330' height='30' onclick='$("#drop").click()' style='cursor:pointer' title='_(Click to select PNG file)_'>";
|
||||
$icon = "<i class='fa fa-trash top' title='_(Restore default image)_' onclick='restore()'></i>";
|
||||
$plugins = '/var/log/plugins';
|
||||
|
||||
require_once "$docroot/plugins/dynamix.plugin.manager/include/PluginHelpers.php";
|
||||
?>
|
||||
<script src="<?autov('/webGui/javascript/jquery.filedrop.js')?>"></script>
|
||||
<script>
|
||||
var path = '/boot/config/plugins/dynamix';
|
||||
var filename = '';
|
||||
var locale = "<?=$locale?>";
|
||||
|
||||
function restore() {
|
||||
// restore original image and activate APPLY button
|
||||
$('#dropbox').html("<?=$void?>");
|
||||
$('select[name="banner"]').trigger('change');
|
||||
filename = 'reset';
|
||||
}
|
||||
function upload(lang) {
|
||||
// save or delete upload when APPLY is pressed
|
||||
if (filename=='reset') {
|
||||
$.post("/webGui/include/FileUpload.php",{cmd:'delete',path:path,filename:'banner.png'});
|
||||
} else if (filename) {
|
||||
$.post("/webGui/include/FileUpload.php",{cmd:'save',path:path,filename:filename,output:'banner.png'});
|
||||
}
|
||||
// reset dashboard tiles when switching language
|
||||
if (lang != locale) {
|
||||
$.removeCookie('db-box1');
|
||||
$.removeCookie('db-box2');
|
||||
$.removeCookie('db-box3');
|
||||
$.removeCookie('inactive_content');
|
||||
$.removeCookie('hidden_content');
|
||||
}
|
||||
}
|
||||
function presetBanner(form) {
|
||||
if (form.banner.selectedIndex == 0) $('.js-bannerSettings').hide(); else $('.js-bannerSettings').show();
|
||||
}
|
||||
function presetRefresh(form) {
|
||||
for (var i=0,item; item=form.refresh.options[i]; i++) item.value *= -1;
|
||||
}
|
||||
function presetPassive(index) {
|
||||
if (index==0) $('#passive').hide(); else $('#passive').show();
|
||||
}
|
||||
function updateDirection(lang) {
|
||||
// var rtl = ['ar_AR','fa_FA'].includes(lang) ? "dir='rtl' " : "";
|
||||
// RTL display is not giving the desired results, we keep LTR
|
||||
var rtl = "";
|
||||
$('input[name="rtl"]').val(rtl);
|
||||
}
|
||||
|
||||
$(function() {
|
||||
var dropbox = $('#dropbox');
|
||||
// attach the drag-n-drop feature to the 'dropbox' element
|
||||
dropbox.filedrop({
|
||||
maxfiles:1,
|
||||
maxfilesize:512, // KB
|
||||
data: {"csrf_token": "<?=$var['csrf_token']?>"},
|
||||
url:'/webGui/include/FileUpload.php',
|
||||
beforeEach:function(file) {
|
||||
if (!file.type.match(/^image\/.*/)) {
|
||||
swal({title:"_(Warning)_",text:"_(Only PNG images are allowed)_!",type:"warning",html:true,confirmButtonText:"_(Ok)_"});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
error: function(err, file, i) {
|
||||
switch (err) {
|
||||
case 'BrowserNotSupported':
|
||||
swal({title:"_(Browser error)_",text:"_(Your browser does not support HTML5 file uploads)_!",type:"error",html:true,confirmButtonText:"_(Ok)_"});
|
||||
break;
|
||||
case 'TooManyFiles':
|
||||
swal({title:"_(Too many files)_",text:"_(Please select one file only)_!",html:true,type:"error"});
|
||||
break;
|
||||
case 'FileTooLarge':
|
||||
swal({title:"_(File too large)_",text:"_(Maximum file upload size is 512K)_ (524,288 _(bytes)_)",type:"error",html:true,confirmButtonText:"_(Ok)_"});
|
||||
break;
|
||||
}
|
||||
},
|
||||
uploadStarted:function(i,file,count) {
|
||||
var image = $('img', $(dropbox));
|
||||
var reader = new FileReader();
|
||||
image.width = 330;
|
||||
image.height = 30;
|
||||
reader.onload = function(e){image.attr('src',e.target.result);};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
uploadFinished:function(i,file,response) {
|
||||
if (response == 'OK 200') {
|
||||
if (!filename || filename=='reset') $(dropbox).append("<?=$icon?>");
|
||||
$('select[name="banner"]').trigger('change');
|
||||
filename = file.name;
|
||||
} else {
|
||||
swal({title:"_(Upload error)_",text:response,type:"error",html:true,confirmButtonText:"_(Ok)_"});
|
||||
}
|
||||
}
|
||||
});
|
||||
// simulate a drop action when manual file selection is done
|
||||
$('#drop').bind('change', function(e) {
|
||||
var files = e.target.files;
|
||||
if ($('#dropbox').triggerHandler({type:'drop',dataTransfer:{files:files}})==false) e.stopImmediatePropagation();
|
||||
});
|
||||
presetBanner(document.display_settings);
|
||||
});
|
||||
</script>
|
||||
|
||||
:display_settings_help:
|
||||
|
||||
<form markdown="1" name="display_settings" method="POST" action="/update.php" target="progressFrame" onsubmit="upload(this.locale.value)">
|
||||
<input type="hidden" name="#file" value="dynamix/dynamix.cfg">
|
||||
<input type="hidden" name="#section" value="display">
|
||||
<input type="hidden" name="rtl" value="<?=$display['rtl']?>">
|
||||
|
||||
_(Display width)_:
|
||||
: <select name="width">
|
||||
<?=mk_option($display['width'], "",_('Boxed'))?>
|
||||
<?=mk_option($display['width'], "1",_('Unlimited'))?>
|
||||
</select>
|
||||
|
||||
:display_width_help:
|
||||
|
||||
_(Language)_:
|
||||
: <select name="locale" class="fixed" onchange="updateDirection(this.value)">
|
||||
<?echo mk_option($display['locale'], "","English");
|
||||
foreach (glob("$plugins/lang-*.xml",GLOB_NOSORT) as $xml_file) {
|
||||
$lang = language('Language', $xml_file);
|
||||
$home = language('LanguageLocal', $xml_file);
|
||||
$name = language('LanguagePack', $xml_file);
|
||||
echo mk_option($display['locale'], $name, "$home ($lang)");
|
||||
}
|
||||
?></select>
|
||||
|
||||
_(Font size)_:
|
||||
: <select name="font" id='font'>
|
||||
<?=mk_option($display['font'], "50",_('Very small'))?>
|
||||
<?=mk_option($display['font'], "56.25",_('Small'))?>
|
||||
<?=mk_option($display['font'], "",_('Normal'))?>
|
||||
<?=mk_option($display['font'], "68.75",_('Large'))?>
|
||||
<?=mk_option($display['font'], "75",_('Very large'))?>
|
||||
<?=mk_option($display['font'], "80",_('Huge'))?>
|
||||
</select>
|
||||
|
||||
:display_font_size_help:
|
||||
|
||||
_(Terminal font size)_:
|
||||
: <select name="tty" id="tty">
|
||||
<?=mk_option($display['tty'], "11",_('Very small'))?>
|
||||
<?=mk_option($display['tty'], "13",_('Small'))?>
|
||||
<?=mk_option($display['tty'], "15",_('Normal'))?>
|
||||
<?=mk_option($display['tty'], "17",_('Large'))?>
|
||||
<?=mk_option($display['tty'], "19",_('Very large'))?>
|
||||
<?=mk_option($display['tty'], "21",_('Huge'))?>
|
||||
</select>
|
||||
|
||||
:display_tty_size_help:
|
||||
|
||||
_(Number format)_:
|
||||
: <select name="number">
|
||||
<?=mk_option($display['number'], ".,",_('[D] dot : [G] comma'))?>
|
||||
<?=mk_option($display['number'], ". ",_('[D] dot : [G] space'))?>
|
||||
<?=mk_option($display['number'], ".",_('[D] dot : [G] none'))?>
|
||||
<?=mk_option($display['number'], ",.",_('[D] comma : [G] dot'))?>
|
||||
<?=mk_option($display['number'], ", ",_('[D] comma : [G] space'))?>
|
||||
<?=mk_option($display['number'], ",",_('[D] comma : [G] none'))?>
|
||||
</select>
|
||||
|
||||
_(Number scaling)_:
|
||||
: <select name="scale">
|
||||
<?=mk_option($display['scale'], "-1",_('Automatic'))?>
|
||||
<?=mk_option($display['scale'], "0",_('Disabled'))?>
|
||||
<?=mk_option($display['scale'], "1",_('KB'))?>
|
||||
<?=mk_option($display['scale'], "2",_('MB'))?>
|
||||
<?=mk_option($display['scale'], "3",_('GB'))?>
|
||||
<?=mk_option($display['scale'], "4",_('TB'))?>
|
||||
<?=mk_option($display['scale'], "5",_('PB'))?>
|
||||
</select>
|
||||
|
||||
_(Page view)_:
|
||||
: <select name="tabs">
|
||||
<?=mk_option($display['tabs'], "0",_('Tabbed'))?>
|
||||
<?=mk_option($display['tabs'], "1",_('Non-tabbed'))?>
|
||||
</select>
|
||||
|
||||
:display_page_view_help:
|
||||
|
||||
_(Placement of Users menu)_:
|
||||
: <select name="users">
|
||||
<?=mk_option($display['users'], "Tasks:3",_('Header menu'))?>
|
||||
<?=mk_option($display['users'], "UserPreferences",_('Settings menu'))?>
|
||||
</select>
|
||||
|
||||
:display_users_menu_help:
|
||||
|
||||
_(Listing height)_:
|
||||
: <select name="resize">
|
||||
<?=mk_option($display['resize'], "0",_('Automatic'))?>
|
||||
<?=mk_option($display['resize'], "1",_('Fixed'))?>
|
||||
</select>
|
||||
|
||||
:display_listing_height_help:
|
||||
|
||||
_(Display device name)_:
|
||||
: <select name="raw">
|
||||
<?=mk_option($display['raw'], "",_('Normalized'))?>
|
||||
<?=mk_option($display['raw'], "1",_('Raw'))?>
|
||||
</select>
|
||||
|
||||
_(Display world-wide-name in device ID)_:
|
||||
: <select name="wwn">
|
||||
<?=mk_option($display['wwn'], "0",_('Disabled'))?>
|
||||
<?=mk_option($display['wwn'], "1",_('Automatic'))?>
|
||||
</select>
|
||||
|
||||
:display_wwn_device_id_help:
|
||||
|
||||
_(Display array totals)_:
|
||||
: <select name="total">
|
||||
<?=mk_option($display['total'], "0",_('No'))?>
|
||||
<?=mk_option($display['total'], "1",_('Yes'))?>
|
||||
</select>
|
||||
|
||||
_(Show array utilization indicator)_:
|
||||
: <select name="usage">
|
||||
<?=mk_option($display['usage'], "0",_('No'))?>
|
||||
<?=mk_option($display['usage'], "1",_('Yes'))?>
|
||||
</select>
|
||||
|
||||
_(Temperature unit)_:
|
||||
: <select name="unit">
|
||||
<?=mk_option($display['unit'], "C",_('Celsius'))?>
|
||||
<?=mk_option($display['unit'], "F",_('Fahrenheit'))?>
|
||||
</select>
|
||||
|
||||
:display_temperature_unit_help:
|
||||
|
||||
_(Dynamix color theme)_:
|
||||
: <select name="theme">
|
||||
<?foreach (glob("$docroot/webGui/styles/themes/*.css") as $themes):?>
|
||||
<?$theme = basename($themes, '.css');?>
|
||||
<?=mk_option($display['theme'], $theme, _(ucfirst($theme)))?>
|
||||
<?endforeach;?>
|
||||
</select>
|
||||
|
||||
_(Used / Free columns)_:
|
||||
: <select name="text">
|
||||
<?=mk_option($display['text'], "0",_('Text'))?>
|
||||
<?=mk_option($display['text'], "1",_('Bar (gray)'))?>
|
||||
<?=mk_option($display['text'], "2",_('Bar (color)'))?>
|
||||
<?=mk_option($display['text'], "10",_('Text - Bar (gray)'))?>
|
||||
<?=mk_option($display['text'], "20",_('Text - Bar (color)'))?>
|
||||
<?=mk_option($display['text'], "11",_('Bar (gray) - Text'))?>
|
||||
<?=mk_option($display['text'], "21",_('Bar (color) - Text'))?>
|
||||
</select>
|
||||
|
||||
_(Header custom text color)_:
|
||||
: <input type="text" class="narrow" name="header" value="<?=$display['header']?>" maxlength="6" pattern="([0-9a-fA-F]{3}){1,2}" title="_(HTML color code of 3 or 6 hexadecimal digits)_">
|
||||
|
||||
:display_custom_text_color_help:
|
||||
|
||||
_(Header custom secondary text color)_:
|
||||
: <input type="text" class="narrow" name="headermetacolor" value="<?=$display['headermetacolor']?>" maxlength="6" pattern="([0-9a-fA-F]{3}){1,2}" title="_(HTML color code of 3 or 6 hexadecimal digits)_">
|
||||
|
||||
_(Header custom background color)_:
|
||||
: <input type="text" class="narrow" name="background" value="<?=$display['background']?>" maxlength="6" pattern="([0-9a-fA-F]{3}){1,2}" title="_(HTML color code of 3 or 6 hexadecimal digits)_">
|
||||
|
||||
:display_custom_background_color_help:
|
||||
|
||||
_(Header show description)_:
|
||||
: <select name="headerdescription">
|
||||
<?=mk_option($display['headerdescription'], "yes",_('Yes'))?>
|
||||
<?=mk_option($display['headerdescription'], "no",_('No'))?>
|
||||
</select>
|
||||
|
||||
_(Show banner)_:
|
||||
: <select name="banner" onchange="presetBanner(this.form)">
|
||||
<?=mk_option($display['banner'], "",_('No'))?>
|
||||
<?=mk_option($display['banner'], "image",_('Yes'))?>
|
||||
</select>
|
||||
|
||||
<div class="js-bannerSettings" markdown="1" style="display:none">
|
||||
_(Custom banner)_:
|
||||
<input type="hidden" name="#custom" value="">
|
||||
: <span id="dropbox">
|
||||
<?if (file_exists($banner)):?>
|
||||
<img src="<?=autov($banner)?>" width="330" height="30" onclick="$('#drop').click()" style="cursor:pointer" title="_(Click to select PNG file)_"><?=$icon?>
|
||||
<?else:?>
|
||||
<?=$void?>
|
||||
<?endif;?>
|
||||
</span><em>_(Drag-n-drop a PNG file or click the image at the left)_.</em><input type="file" id="drop" accept="image/*" style="display:none">
|
||||
|
||||
:display_custom_banner_help:
|
||||
</div>
|
||||
|
||||
<div class="js-bannerSettings" markdown="1" style="display:none">
|
||||
_(Show banner background color fade)_:
|
||||
: <select name="showBannerGradient">
|
||||
<?=mk_option($display['showBannerGradient'], "no",_('No'))?>
|
||||
<?=mk_option($display['showBannerGradient'], "yes",_('Yes'))?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
_(Favorites enabled)_:
|
||||
: <select name="favorites">
|
||||
<?=mk_option($display['favorites'], "yes",_('Yes'))?>
|
||||
<?=mk_option($display['favorites'], "no",_('No'))?>
|
||||
</select>
|
||||
|
||||
:display_favorites_enabled_help:
|
||||
|
||||
_(Allow realtime updates on inactive browsers)_:
|
||||
: <select name='liveUpdate'>
|
||||
<?=mk_option($display['liveUpdate'],"no",_('No'))?>
|
||||
<?=mk_option($display['liveUpdate'],"yes",_('Yes'))?>
|
||||
</select>
|
||||
|
||||
<input type="submit" name="#default" value="_(Default)_" onclick="filename='reset'">
|
||||
: <input type="submit" name="#apply" value="_(Apply)_" disabled><input type="button" value="_(Done)_" onclick="done()">
|
||||
</form>
|
||||
+7
@@ -8,6 +8,7 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification.js';
|
||||
import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/modifications/default-page-layout.modification.js';
|
||||
import DisplaySettingsModification from '@app/unraid-api/unraid-file-modifier/modifications/display-settings.modification.js';
|
||||
import NotificationsPageModification from '@app/unraid-api/unraid-file-modifier/modifications/notifications-page.modification.js';
|
||||
import RcNginxModification from '@app/unraid-api/unraid-file-modifier/modifications/rc-nginx.modification.js';
|
||||
import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification.js';
|
||||
@@ -35,6 +36,12 @@ const patchTestCases: ModificationTestCase[] = [
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/Notifications.page',
|
||||
fileName: 'Notifications.page',
|
||||
},
|
||||
{
|
||||
ModificationClass: DisplaySettingsModification,
|
||||
fileUrl:
|
||||
'https://raw.githubusercontent.com/unraid/webgui/refs/heads/7.1/emhttp/plugins/dynamix/DisplaySettings.page',
|
||||
fileName: 'DisplaySettings.page',
|
||||
},
|
||||
{
|
||||
ModificationClass: SSOFileModification,
|
||||
fileUrl:
|
||||
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
Menu="UserPreferences"
|
||||
Title="Display Settings"
|
||||
Icon="icon-display"
|
||||
Tag="desktop"
|
||||
---
|
||||
<?PHP
|
||||
/* Copyright 2005-2025, Lime Technology
|
||||
* Copyright 2012-2025, Bergware International.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*/
|
||||
?>
|
||||
<?
|
||||
$void = "<img src='/webGui/images/banner.png' id='image' width='330' height='30' onclick='$("#drop").click()' style='cursor:pointer' title='_(Click to select PNG file)_'>";
|
||||
$icon = "<i class='fa fa-trash top' title='_(Restore default image)_' onclick='restore()'></i>";
|
||||
$plugins = '/var/log/plugins';
|
||||
|
||||
require_once "$docroot/plugins/dynamix.plugin.manager/include/PluginHelpers.php";
|
||||
?>
|
||||
<script src="<?autov('/webGui/javascript/jquery.filedrop.js')?>"></script>
|
||||
<script>
|
||||
var path = '/boot/config/plugins/dynamix';
|
||||
var filename = '';
|
||||
var locale = "<?=$locale?>";
|
||||
|
||||
function restore() {
|
||||
// restore original image and activate APPLY button
|
||||
$('#dropbox').html("<?=$void?>");
|
||||
$('select[name="banner"]').trigger('change');
|
||||
filename = 'reset';
|
||||
}
|
||||
function upload(lang) {
|
||||
// save or delete upload when APPLY is pressed
|
||||
if (filename=='reset') {
|
||||
$.post("/webGui/include/FileUpload.php",{cmd:'delete',path:path,filename:'banner.png'});
|
||||
} else if (filename) {
|
||||
$.post("/webGui/include/FileUpload.php",{cmd:'save',path:path,filename:filename,output:'banner.png'});
|
||||
}
|
||||
// reset dashboard tiles when switching language
|
||||
if (lang != locale) {
|
||||
$.removeCookie('db-box1');
|
||||
$.removeCookie('db-box2');
|
||||
$.removeCookie('db-box3');
|
||||
$.removeCookie('inactive_content');
|
||||
$.removeCookie('hidden_content');
|
||||
}
|
||||
}
|
||||
function presetBanner(form) {
|
||||
if (form.banner.selectedIndex == 0) $('.js-bannerSettings').hide(); else $('.js-bannerSettings').show();
|
||||
}
|
||||
function presetRefresh(form) {
|
||||
for (var i=0,item; item=form.refresh.options[i]; i++) item.value *= -1;
|
||||
}
|
||||
function presetPassive(index) {
|
||||
if (index==0) $('#passive').hide(); else $('#passive').show();
|
||||
}
|
||||
function updateDirection(lang) {
|
||||
// var rtl = ['ar_AR','fa_FA'].includes(lang) ? "dir='rtl' " : "";
|
||||
// RTL display is not giving the desired results, we keep LTR
|
||||
var rtl = "";
|
||||
$('input[name="rtl"]').val(rtl);
|
||||
}
|
||||
|
||||
$(function() {
|
||||
var dropbox = $('#dropbox');
|
||||
// attach the drag-n-drop feature to the 'dropbox' element
|
||||
dropbox.filedrop({
|
||||
maxfiles:1,
|
||||
maxfilesize:512, // KB
|
||||
data: {"csrf_token": "<?=$var['csrf_token']?>"},
|
||||
url:'/webGui/include/FileUpload.php',
|
||||
beforeEach:function(file) {
|
||||
if (!file.type.match(/^image\/.*/)) {
|
||||
swal({title:"_(Warning)_",text:"_(Only PNG images are allowed)_!",type:"warning",html:true,confirmButtonText:"_(Ok)_"});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
error: function(err, file, i) {
|
||||
switch (err) {
|
||||
case 'BrowserNotSupported':
|
||||
swal({title:"_(Browser error)_",text:"_(Your browser does not support HTML5 file uploads)_!",type:"error",html:true,confirmButtonText:"_(Ok)_"});
|
||||
break;
|
||||
case 'TooManyFiles':
|
||||
swal({title:"_(Too many files)_",text:"_(Please select one file only)_!",html:true,type:"error"});
|
||||
break;
|
||||
case 'FileTooLarge':
|
||||
swal({title:"_(File too large)_",text:"_(Maximum file upload size is 512K)_ (524,288 _(bytes)_)",type:"error",html:true,confirmButtonText:"_(Ok)_"});
|
||||
break;
|
||||
}
|
||||
},
|
||||
uploadStarted:function(i,file,count) {
|
||||
var image = $('img', $(dropbox));
|
||||
var reader = new FileReader();
|
||||
image.width = 330;
|
||||
image.height = 30;
|
||||
reader.onload = function(e){image.attr('src',e.target.result);};
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
uploadFinished:function(i,file,response) {
|
||||
if (response == 'OK 200') {
|
||||
if (!filename || filename=='reset') $(dropbox).append("<?=$icon?>");
|
||||
$('select[name="banner"]').trigger('change');
|
||||
filename = file.name;
|
||||
} else {
|
||||
swal({title:"_(Upload error)_",text:response,type:"error",html:true,confirmButtonText:"_(Ok)_"});
|
||||
}
|
||||
}
|
||||
});
|
||||
// simulate a drop action when manual file selection is done
|
||||
$('#drop').bind('change', function(e) {
|
||||
var files = e.target.files;
|
||||
if ($('#dropbox').triggerHandler({type:'drop',dataTransfer:{files:files}})==false) e.stopImmediatePropagation();
|
||||
});
|
||||
presetBanner(document.display_settings);
|
||||
});
|
||||
</script>
|
||||
|
||||
:display_settings_help:
|
||||
|
||||
<form markdown="1" name="display_settings" method="POST" action="/update.php" target="progressFrame" onsubmit="upload(this.locale.value)">
|
||||
<input type="hidden" name="#file" value="dynamix/dynamix.cfg">
|
||||
<input type="hidden" name="#section" value="display">
|
||||
<input type="hidden" name="rtl" value="<?=$display['rtl']?>">
|
||||
|
||||
_(Display width)_:
|
||||
: <select name="width">
|
||||
<?=mk_option($display['width'], "",_('Boxed'))?>
|
||||
<?=mk_option($display['width'], "1",_('Unlimited'))?>
|
||||
</select>
|
||||
|
||||
:display_width_help:
|
||||
|
||||
_(Language)_:
|
||||
: <select name="locale" onchange="updateDirection(this.value)">
|
||||
<?echo mk_option($display['locale'], "","English");
|
||||
foreach (glob("$plugins/lang-*.xml",GLOB_NOSORT) as $xml_file) {
|
||||
$lang = language('Language', $xml_file);
|
||||
$home = language('LanguageLocal', $xml_file);
|
||||
$name = language('LanguagePack', $xml_file);
|
||||
echo mk_option($display['locale'], $name, "$home ($lang)");
|
||||
}
|
||||
?></select>
|
||||
|
||||
_(Font size)_:
|
||||
: <select name="font" id='font'>
|
||||
<?=mk_option($display['font'], "50",_('Very small'))?>
|
||||
<?=mk_option($display['font'], "56.25",_('Small'))?>
|
||||
<?=mk_option($display['font'], "",_('Normal'))?>
|
||||
<?=mk_option($display['font'], "68.75",_('Large'))?>
|
||||
<?=mk_option($display['font'], "75",_('Very large'))?>
|
||||
<?=mk_option($display['font'], "80",_('Huge'))?>
|
||||
</select>
|
||||
|
||||
:display_font_size_help:
|
||||
|
||||
_(Terminal font size)_:
|
||||
: <select name="tty" id="tty">
|
||||
<?=mk_option($display['tty'], "11",_('Very small'))?>
|
||||
<?=mk_option($display['tty'], "13",_('Small'))?>
|
||||
<?=mk_option($display['tty'], "15",_('Normal'))?>
|
||||
<?=mk_option($display['tty'], "17",_('Large'))?>
|
||||
<?=mk_option($display['tty'], "19",_('Very large'))?>
|
||||
<?=mk_option($display['tty'], "21",_('Huge'))?>
|
||||
</select>
|
||||
|
||||
:display_tty_size_help:
|
||||
|
||||
_(Number format)_:
|
||||
: <select name="number">
|
||||
<?=mk_option($display['number'], ".,",_('[D] dot : [G] comma'))?>
|
||||
<?=mk_option($display['number'], ". ",_('[D] dot : [G] space'))?>
|
||||
<?=mk_option($display['number'], ".",_('[D] dot : [G] none'))?>
|
||||
<?=mk_option($display['number'], ",.",_('[D] comma : [G] dot'))?>
|
||||
<?=mk_option($display['number'], ", ",_('[D] comma : [G] space'))?>
|
||||
<?=mk_option($display['number'], ",",_('[D] comma : [G] none'))?>
|
||||
</select>
|
||||
|
||||
_(Number scaling)_:
|
||||
: <select name="scale">
|
||||
<?=mk_option($display['scale'], "-1",_('Automatic'))?>
|
||||
<?=mk_option($display['scale'], "0",_('Disabled'))?>
|
||||
<?=mk_option($display['scale'], "1",_('KB'))?>
|
||||
<?=mk_option($display['scale'], "2",_('MB'))?>
|
||||
<?=mk_option($display['scale'], "3",_('GB'))?>
|
||||
<?=mk_option($display['scale'], "4",_('TB'))?>
|
||||
<?=mk_option($display['scale'], "5",_('PB'))?>
|
||||
</select>
|
||||
|
||||
_(Page view)_:
|
||||
: <select name="tabs">
|
||||
<?=mk_option($display['tabs'], "0",_('Tabbed'))?>
|
||||
<?=mk_option($display['tabs'], "1",_('Non-tabbed'))?>
|
||||
</select>
|
||||
|
||||
:display_page_view_help:
|
||||
|
||||
_(Placement of Users menu)_:
|
||||
: <select name="users">
|
||||
<?=mk_option($display['users'], "Tasks:3",_('Header menu'))?>
|
||||
<?=mk_option($display['users'], "UserPreferences",_('Settings menu'))?>
|
||||
</select>
|
||||
|
||||
:display_users_menu_help:
|
||||
|
||||
_(Listing height)_:
|
||||
: <select name="resize">
|
||||
<?=mk_option($display['resize'], "0",_('Automatic'))?>
|
||||
<?=mk_option($display['resize'], "1",_('Fixed'))?>
|
||||
</select>
|
||||
|
||||
:display_listing_height_help:
|
||||
|
||||
_(Display device name)_:
|
||||
: <select name="raw">
|
||||
<?=mk_option($display['raw'], "",_('Normalized'))?>
|
||||
<?=mk_option($display['raw'], "1",_('Raw'))?>
|
||||
</select>
|
||||
|
||||
_(Display world-wide-name in device ID)_:
|
||||
: <select name="wwn">
|
||||
<?=mk_option($display['wwn'], "0",_('Disabled'))?>
|
||||
<?=mk_option($display['wwn'], "1",_('Automatic'))?>
|
||||
</select>
|
||||
|
||||
:display_wwn_device_id_help:
|
||||
|
||||
_(Display array totals)_:
|
||||
: <select name="total">
|
||||
<?=mk_option($display['total'], "0",_('No'))?>
|
||||
<?=mk_option($display['total'], "1",_('Yes'))?>
|
||||
</select>
|
||||
|
||||
_(Show array utilization indicator)_:
|
||||
: <select name="usage">
|
||||
<?=mk_option($display['usage'], "0",_('No'))?>
|
||||
<?=mk_option($display['usage'], "1",_('Yes'))?>
|
||||
</select>
|
||||
|
||||
_(Temperature unit)_:
|
||||
: <select name="unit">
|
||||
<?=mk_option($display['unit'], "C",_('Celsius'))?>
|
||||
<?=mk_option($display['unit'], "F",_('Fahrenheit'))?>
|
||||
</select>
|
||||
|
||||
:display_temperature_unit_help:
|
||||
|
||||
_(Dynamix color theme)_:
|
||||
: <select name="theme">
|
||||
<?foreach (glob("$docroot/webGui/styles/themes/*.css") as $themes):?>
|
||||
<?$theme = basename($themes, '.css');?>
|
||||
<?=mk_option($display['theme'], $theme, _(ucfirst($theme)))?>
|
||||
<?endforeach;?>
|
||||
</select>
|
||||
|
||||
_(Used / Free columns)_:
|
||||
: <select name="text">
|
||||
<?=mk_option($display['text'], "0",_('Text'))?>
|
||||
<?=mk_option($display['text'], "1",_('Bar (gray)'))?>
|
||||
<?=mk_option($display['text'], "2",_('Bar (color)'))?>
|
||||
<?=mk_option($display['text'], "10",_('Text - Bar (gray)'))?>
|
||||
<?=mk_option($display['text'], "20",_('Text - Bar (color)'))?>
|
||||
<?=mk_option($display['text'], "11",_('Bar (gray) - Text'))?>
|
||||
<?=mk_option($display['text'], "21",_('Bar (color) - Text'))?>
|
||||
</select>
|
||||
|
||||
_(Header custom text color)_:
|
||||
: <input type="text" class="narrow" name="header" value="<?=$display['header']?>" maxlength="6" pattern="([0-9a-fA-F]{3}){1,2}" title="_(HTML color code of 3 or 6 hexadecimal digits)_">
|
||||
|
||||
:display_custom_text_color_help:
|
||||
|
||||
_(Header custom secondary text color)_:
|
||||
: <input type="text" class="narrow" name="headermetacolor" value="<?=$display['headermetacolor']?>" maxlength="6" pattern="([0-9a-fA-F]{3}){1,2}" title="_(HTML color code of 3 or 6 hexadecimal digits)_">
|
||||
|
||||
_(Header custom background color)_:
|
||||
: <input type="text" class="narrow" name="background" value="<?=$display['background']?>" maxlength="6" pattern="([0-9a-fA-F]{3}){1,2}" title="_(HTML color code of 3 or 6 hexadecimal digits)_">
|
||||
|
||||
:display_custom_background_color_help:
|
||||
|
||||
_(Header show description)_:
|
||||
: <select name="headerdescription">
|
||||
<?=mk_option($display['headerdescription'], "yes",_('Yes'))?>
|
||||
<?=mk_option($display['headerdescription'], "no",_('No'))?>
|
||||
</select>
|
||||
|
||||
_(Show banner)_:
|
||||
: <select name="banner" onchange="presetBanner(this.form)">
|
||||
<?=mk_option($display['banner'], "",_('No'))?>
|
||||
<?=mk_option($display['banner'], "image",_('Yes'))?>
|
||||
</select>
|
||||
|
||||
<div class="js-bannerSettings" markdown="1" style="display:none">
|
||||
_(Custom banner)_:
|
||||
<input type="hidden" name="#custom" value="">
|
||||
: <span id="dropbox">
|
||||
<?if (file_exists($banner)):?>
|
||||
<img src="<?=autov($banner)?>" width="330" height="30" onclick="$('#drop').click()" style="cursor:pointer" title="_(Click to select PNG file)_"><?=$icon?>
|
||||
<?else:?>
|
||||
<?=$void?>
|
||||
<?endif;?>
|
||||
</span><em>_(Drag-n-drop a PNG file or click the image at the left)_.</em><input type="file" id="drop" accept="image/*" style="display:none">
|
||||
|
||||
:display_custom_banner_help:
|
||||
</div>
|
||||
|
||||
<div class="js-bannerSettings" markdown="1" style="display:none">
|
||||
_(Show banner background color fade)_:
|
||||
: <select name="showBannerGradient">
|
||||
<?=mk_option($display['showBannerGradient'], "no",_('No'))?>
|
||||
<?=mk_option($display['showBannerGradient'], "yes",_('Yes'))?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
_(Favorites enabled)_:
|
||||
: <select name="favorites">
|
||||
<?=mk_option($display['favorites'], "yes",_('Yes'))?>
|
||||
<?=mk_option($display['favorites'], "no",_('No'))?>
|
||||
</select>
|
||||
|
||||
:display_favorites_enabled_help:
|
||||
|
||||
_(Allow realtime updates on inactive browsers)_:
|
||||
: <select name='liveUpdate'>
|
||||
<?=mk_option($display['liveUpdate'],"no",_('No'))?>
|
||||
<?=mk_option($display['liveUpdate'],"yes",_('Yes'))?>
|
||||
</select>
|
||||
|
||||
<input type="submit" name="#default" value="_(Default)_" onclick="filename='reset'">
|
||||
: <input type="submit" name="#apply" value="_(Apply)_" disabled><input type="button" value="_(Done)_" onclick="done()">
|
||||
</form>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
export default class DisplaySettingsModification extends FileModification {
|
||||
id: string = 'display-settings';
|
||||
public readonly filePath: string = '/usr/local/emhttp/plugins/dynamix/DisplaySettings.page';
|
||||
|
||||
private removeFixedClassFromLanguageSelect(source: string): string {
|
||||
// Find lines with locale select and remove class="fixed" from them
|
||||
return source
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
// Check if this line contains the locale select element
|
||||
if (line.includes('<select name="locale"') && line.includes('class="fixed"')) {
|
||||
// Remove class="fixed" from the line, handling potential spacing variations
|
||||
return line.replace(/\s*class="fixed"\s*/, ' ').replace(/\s+/g, ' ');
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private applyToSource(fileContent: string): string {
|
||||
const transformers = [this.removeFixedClassFromLanguageSelect.bind(this)];
|
||||
|
||||
return transformers.reduce((content, transformer) => transformer(content), fileContent);
|
||||
}
|
||||
|
||||
protected async generatePatch(overridePath?: string): Promise<string> {
|
||||
const fileContent = await readFile(this.filePath, 'utf-8');
|
||||
|
||||
const newContent = await this.applyToSource(fileContent);
|
||||
|
||||
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
Index: /usr/local/emhttp/plugins/dynamix/DisplaySettings.page
|
||||
===================================================================
|
||||
--- /usr/local/emhttp/plugins/dynamix/DisplaySettings.page original
|
||||
+++ /usr/local/emhttp/plugins/dynamix/DisplaySettings.page modified
|
||||
@@ -134,11 +134,11 @@
|
||||
</select>
|
||||
|
||||
:display_width_help:
|
||||
|
||||
_(Language)_:
|
||||
-: <select name="locale" class="fixed" onchange="updateDirection(this.value)">
|
||||
+: <select name="locale" onchange="updateDirection(this.value)">
|
||||
<?echo mk_option($display['locale'], "","English");
|
||||
foreach (glob("$plugins/lang-*.xml",GLOB_NOSORT) as $xml_file) {
|
||||
$lang = language('Language', $xml_file);
|
||||
$home = language('LanguageLocal', $xml_file);
|
||||
$name = language('LanguagePack', $xml_file);
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "pnpm -r dev",
|
||||
"unraid:deploy": "pnpm -r unraid:deploy",
|
||||
"test": "pnpm -r test",
|
||||
"test:watch": "pnpm -r --parallel test:watch",
|
||||
"lint": "pnpm -r lint",
|
||||
"lint:fix": "pnpm -r lint:fix",
|
||||
"type-check": "pnpm -r type-check",
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"@nestjs/core": "11.1.6",
|
||||
"@nestjs/graphql": "13.1.0",
|
||||
"nest-authz": "2.17.0",
|
||||
"typescript": "5.9.2"
|
||||
"typescript": "5.9.2",
|
||||
"pify": "^6.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "11.1.6",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"graphql-ws": "6.0.6",
|
||||
"lodash-es": "4.17.21",
|
||||
"nest-authz": "2.17.0",
|
||||
"pify": "^6.1.0",
|
||||
"rimraf": "6.0.1",
|
||||
"type-fest": "4.41.0",
|
||||
"typescript": "5.9.2",
|
||||
|
||||
+53
-34
@@ -10,34 +10,34 @@ import { cleanupTxzFiles } from "./utils/cleanup";
|
||||
import { apiDir } from "./utils/paths";
|
||||
import { getVendorBundleName, getVendorFullPath } from "./build-vendor-store";
|
||||
import { getAssetUrl } from "./utils/bucket-urls";
|
||||
import { validateStandaloneManifest, getStandaloneManifestPath } from "./utils/manifest-validator";
|
||||
|
||||
|
||||
// Recursively search for manifest files
|
||||
// Check for manifest files in expected locations
|
||||
const findManifestFiles = async (dir: string): Promise<string[]> => {
|
||||
const files: string[] = [];
|
||||
|
||||
// Check standalone subdirectory (preferred)
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
|
||||
const standaloneDir = join(dir, "standalone");
|
||||
const entries = await readdir(standaloneDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
try {
|
||||
files.push(...(await findManifestFiles(fullPath)));
|
||||
} catch (error) {
|
||||
// Log and continue if a subdirectory can't be read
|
||||
console.warn(`Warning: Could not read directory ${fullPath}: ${error.message}`);
|
||||
}
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(entry.name === "manifest.json" ||
|
||||
entry.name === "ui.manifest.json" ||
|
||||
entry.name === "standalone.manifest.json")
|
||||
) {
|
||||
files.push(entry.name);
|
||||
if (entry.isFile() && entry.name === "standalone.manifest.json") {
|
||||
files.push("standalone/standalone.manifest.json");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Directory doesn't exist, continue checking other locations
|
||||
}
|
||||
|
||||
// Check root directory for backwards compatibility
|
||||
try {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name === "standalone.manifest.json") {
|
||||
files.push("standalone.manifest.json");
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
console.warn(`Directory does not exist: ${dir}`);
|
||||
@@ -45,6 +45,8 @@ const findManifestFiles = async (dir: string): Promise<string[]> => {
|
||||
}
|
||||
throw error; // Re-throw other errors
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
// Function to store vendor archive information in a recoverable location
|
||||
@@ -125,24 +127,41 @@ const validateSourceDir = async (validatedEnv: TxzEnv) => {
|
||||
}
|
||||
|
||||
const manifestFiles = await findManifestFiles(webcomponentDir);
|
||||
const hasManifest = manifestFiles.includes("manifest.json");
|
||||
const hasStandaloneManifest = manifestFiles.includes("standalone.manifest.json");
|
||||
const hasUiManifest = manifestFiles.includes("ui.manifest.json");
|
||||
const hasStandaloneManifest = manifestFiles.some(file =>
|
||||
file === "standalone.manifest.json" || file === "standalone/standalone.manifest.json"
|
||||
);
|
||||
|
||||
// Accept either manifest.json (old web components) or standalone.manifest.json (new standalone apps)
|
||||
if ((!hasManifest && !hasStandaloneManifest) || !hasUiManifest) {
|
||||
// Only require standalone.manifest.json for new standalone apps
|
||||
if (!hasStandaloneManifest) {
|
||||
console.log("Existing Manifest Files:", manifestFiles);
|
||||
const missingFiles: string[] = [];
|
||||
if (!hasManifest && !hasStandaloneManifest) missingFiles.push("manifest.json or standalone.manifest.json");
|
||||
if (!hasUiManifest) missingFiles.push("ui.manifest.json");
|
||||
|
||||
throw new Error(
|
||||
`Webcomponents missing required file(s): ${missingFiles.join(", ")} - ` +
|
||||
`${!hasUiManifest ? "run 'pnpm build:wc' in unraid-ui for ui.manifest.json" : ""}` +
|
||||
`${(!hasManifest && !hasStandaloneManifest) && !hasUiManifest ? " and " : ""}` +
|
||||
`${(!hasManifest && !hasStandaloneManifest) ? "run 'pnpm build' in web for standalone.manifest.json" : ""}`
|
||||
`Webcomponents missing required file: standalone.manifest.json - ` +
|
||||
`run 'pnpm build' in web to generate standalone.manifest.json in the standalone/ subdirectory`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the manifest contents
|
||||
const manifestPath = getStandaloneManifestPath(webcomponentDir);
|
||||
if (manifestPath) {
|
||||
const validation = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
if (!validation.isValid) {
|
||||
console.error("Standalone manifest validation failed:");
|
||||
validation.errors.forEach(error => console.error(` ❌ ${error}`));
|
||||
if (validation.warnings.length > 0) {
|
||||
console.warn("Warnings:");
|
||||
validation.warnings.forEach(warning => console.warn(` ⚠️ ${warning}`));
|
||||
}
|
||||
throw new Error("Standalone manifest validation failed. See errors above.");
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
console.warn("Standalone manifest validation warnings:");
|
||||
validation.warnings.forEach(warning => console.warn(` ⚠️ ${warning}`));
|
||||
}
|
||||
|
||||
console.log("✅ Standalone manifest validation passed");
|
||||
}
|
||||
|
||||
if (!existsSync(apiDir)) {
|
||||
throw new Error(`API directory ${apiDir} does not exist`);
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdir, writeFile, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import {
|
||||
validateStandaloneManifest,
|
||||
getStandaloneManifestPath,
|
||||
type StandaloneManifest
|
||||
} from "./manifest-validator";
|
||||
|
||||
describe("manifest-validator", () => {
|
||||
let testDir: string;
|
||||
let manifestPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary test directory
|
||||
testDir = join(tmpdir(), `manifest-test-${Date.now()}`);
|
||||
await mkdir(testDir, { recursive: true });
|
||||
manifestPath = join(testDir, "standalone.manifest.json");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test directory
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("validateStandaloneManifest", () => {
|
||||
it("should fail when manifest file does not exist", async () => {
|
||||
const result = await validateStandaloneManifest(join(testDir, "nonexistent.json"));
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toContain("Manifest file does not exist");
|
||||
});
|
||||
|
||||
it("should fail when manifest has invalid JSON", async () => {
|
||||
await writeFile(manifestPath, "{ invalid json");
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toContain("Failed to parse manifest JSON");
|
||||
});
|
||||
|
||||
it("should pass for valid manifest with existing files", async () => {
|
||||
// Create the referenced files
|
||||
await writeFile(join(testDir, "app.js"), "console.log('app');");
|
||||
await writeFile(join(testDir, "app.css"), "body { color: red; }");
|
||||
|
||||
// Create valid manifest
|
||||
const manifest: StandaloneManifest = {
|
||||
"app.js": {
|
||||
file: "app.js",
|
||||
src: "app.js",
|
||||
isEntry: true,
|
||||
},
|
||||
"app.css": {
|
||||
file: "app.css",
|
||||
src: "app.css",
|
||||
},
|
||||
ts: Date.now(),
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should fail when referenced files are missing", async () => {
|
||||
const manifest: StandaloneManifest = {
|
||||
"app.js": {
|
||||
file: "app.js",
|
||||
src: "app.js",
|
||||
},
|
||||
"app.css": {
|
||||
file: "app.css",
|
||||
src: "app.css",
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors).toContain("Missing file referenced in manifest: app.js");
|
||||
expect(result.errors).toContain("Missing file referenced in manifest: app.css");
|
||||
});
|
||||
|
||||
it("should fail when CSS files in array are missing", async () => {
|
||||
await writeFile(join(testDir, "app.js"), "console.log('app');");
|
||||
|
||||
const manifest: StandaloneManifest = {
|
||||
"app.js": {
|
||||
file: "app.js",
|
||||
css: ["style1.css", "style2.css"],
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors).toContain("Missing CSS file referenced in manifest: style1.css");
|
||||
expect(result.errors).toContain("Missing CSS file referenced in manifest: style2.css");
|
||||
});
|
||||
|
||||
it("should fail when asset files are missing", async () => {
|
||||
await writeFile(join(testDir, "app.js"), "console.log('app');");
|
||||
|
||||
const manifest: StandaloneManifest = {
|
||||
"app.js": {
|
||||
file: "app.js",
|
||||
assets: ["image.png", "font.woff2"],
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors).toContain("Missing asset file referenced in manifest: image.png");
|
||||
expect(result.errors).toContain("Missing asset file referenced in manifest: font.woff2");
|
||||
});
|
||||
|
||||
it("should warn for missing imports but not fail", async () => {
|
||||
await writeFile(join(testDir, "app.js"), "console.log('app');");
|
||||
|
||||
const manifest: StandaloneManifest = {
|
||||
"app.js": {
|
||||
file: "app.js",
|
||||
imports: ["virtual-module"],
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain("Missing import file referenced in manifest: virtual-module");
|
||||
});
|
||||
|
||||
it("should skip timestamp field", async () => {
|
||||
await writeFile(join(testDir, "app.js"), "console.log('app');");
|
||||
|
||||
const manifest = {
|
||||
"app.js": {
|
||||
file: "app.js",
|
||||
},
|
||||
ts: 1234567890,
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should warn for non-entry fields", async () => {
|
||||
await writeFile(join(testDir, "app.js"), "console.log('app');");
|
||||
|
||||
const manifest = {
|
||||
"app.js": {
|
||||
file: "app.js",
|
||||
},
|
||||
"invalid": "not an entry",
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toContain("Skipping non-entry field: invalid");
|
||||
});
|
||||
|
||||
it("should fail when no JavaScript entry exists", async () => {
|
||||
await writeFile(join(testDir, "app.css"), "body { color: red; }");
|
||||
|
||||
const manifest: StandaloneManifest = {
|
||||
"app.css": {
|
||||
file: "app.css",
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toContain("Manifest must contain at least one JavaScript entry file");
|
||||
});
|
||||
|
||||
it("should not check duplicate files multiple times", async () => {
|
||||
await writeFile(join(testDir, "app.js"), "console.log('app');");
|
||||
await writeFile(join(testDir, "shared.css"), "body { color: red; }");
|
||||
|
||||
const manifest: StandaloneManifest = {
|
||||
"entry1": {
|
||||
file: "app.js",
|
||||
css: ["shared.css"],
|
||||
},
|
||||
"entry2": {
|
||||
file: "app.js",
|
||||
css: ["shared.css"],
|
||||
},
|
||||
};
|
||||
|
||||
await writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
const result = await validateStandaloneManifest(manifestPath);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStandaloneManifestPath", () => {
|
||||
it("should find manifest in standalone subdirectory (preferred)", async () => {
|
||||
const standaloneDir = join(testDir, "standalone");
|
||||
await mkdir(standaloneDir, { recursive: true });
|
||||
const standaloneManifestPath = join(standaloneDir, "standalone.manifest.json");
|
||||
await writeFile(standaloneManifestPath, "{}");
|
||||
|
||||
const path = getStandaloneManifestPath(testDir);
|
||||
|
||||
expect(path).toBe(standaloneManifestPath);
|
||||
});
|
||||
|
||||
it("should find manifest in root directory", async () => {
|
||||
await writeFile(manifestPath, "{}");
|
||||
|
||||
const path = getStandaloneManifestPath(testDir);
|
||||
|
||||
expect(path).toBe(manifestPath);
|
||||
});
|
||||
|
||||
it("should find manifest in nuxt subdirectory for backwards compatibility", async () => {
|
||||
const nuxtDir = join(testDir, "nuxt");
|
||||
await mkdir(nuxtDir, { recursive: true });
|
||||
const nuxtManifestPath = join(nuxtDir, "standalone.manifest.json");
|
||||
await writeFile(nuxtManifestPath, "{}");
|
||||
|
||||
const path = getStandaloneManifestPath(testDir);
|
||||
|
||||
expect(path).toBe(nuxtManifestPath);
|
||||
});
|
||||
|
||||
it("should prefer standalone subdirectory over root and nuxt", async () => {
|
||||
// Create manifest in all locations
|
||||
const standaloneDir = join(testDir, "standalone");
|
||||
await mkdir(standaloneDir, { recursive: true });
|
||||
const standaloneManifestPath = join(standaloneDir, "standalone.manifest.json");
|
||||
await writeFile(standaloneManifestPath, "{}");
|
||||
|
||||
await writeFile(manifestPath, "{}");
|
||||
|
||||
const nuxtDir = join(testDir, "nuxt");
|
||||
await mkdir(nuxtDir, { recursive: true });
|
||||
await writeFile(join(nuxtDir, "standalone.manifest.json"), "{}");
|
||||
|
||||
const path = getStandaloneManifestPath(testDir);
|
||||
|
||||
expect(path).toBe(standaloneManifestPath);
|
||||
});
|
||||
|
||||
it("should return null when no manifest exists", async () => {
|
||||
const path = getStandaloneManifestPath(testDir);
|
||||
|
||||
expect(path).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { existsSync } from "fs";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
|
||||
export interface ManifestEntry {
|
||||
file: string;
|
||||
src?: string;
|
||||
css?: string[];
|
||||
assets?: string[];
|
||||
imports?: string[];
|
||||
dynamicImports?: string[];
|
||||
isDynamicEntry?: boolean;
|
||||
isEntry?: boolean;
|
||||
}
|
||||
|
||||
export interface StandaloneManifest {
|
||||
[key: string]: ManifestEntry | number;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
manifest?: StandaloneManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a standalone.manifest.json file and checks that all referenced files exist
|
||||
* @param manifestPath - Path to the manifest file
|
||||
* @returns Validation result with errors and warnings
|
||||
*/
|
||||
export async function validateStandaloneManifest(manifestPath: string): Promise<ValidationResult> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check if manifest file exists
|
||||
if (!existsSync(manifestPath)) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [`Manifest file does not exist: ${manifestPath}`],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
let manifest: StandaloneManifest;
|
||||
|
||||
try {
|
||||
const content = await readFile(manifestPath, "utf-8");
|
||||
manifest = JSON.parse(content);
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [`Failed to parse manifest JSON: ${error.message}`],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the directory containing the manifest
|
||||
// Files should be relative to the manifest location
|
||||
const manifestDir = dirname(manifestPath);
|
||||
|
||||
// Track which files were checked to avoid duplicates
|
||||
const checkedFiles = new Set<string>();
|
||||
|
||||
// Validate each entry in the manifest
|
||||
for (const [key, value] of Object.entries(manifest)) {
|
||||
// Skip the timestamp field
|
||||
if (key === "ts" && typeof value === "number") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if not a manifest entry
|
||||
if (typeof value !== "object" || !value || !("file" in value)) {
|
||||
warnings.push(`Skipping non-entry field: ${key}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = value as ManifestEntry;
|
||||
|
||||
// Check main file
|
||||
if (entry.file) {
|
||||
const filePath = join(manifestDir, entry.file);
|
||||
if (!checkedFiles.has(filePath)) {
|
||||
checkedFiles.add(filePath);
|
||||
if (!existsSync(filePath)) {
|
||||
errors.push(`Missing file referenced in manifest: ${entry.file}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check CSS files
|
||||
if (entry.css && Array.isArray(entry.css)) {
|
||||
for (const cssFile of entry.css) {
|
||||
const cssPath = join(manifestDir, cssFile);
|
||||
if (!checkedFiles.has(cssPath)) {
|
||||
checkedFiles.add(cssPath);
|
||||
if (!existsSync(cssPath)) {
|
||||
errors.push(`Missing CSS file referenced in manifest: ${cssFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check asset files
|
||||
if (entry.assets && Array.isArray(entry.assets)) {
|
||||
for (const assetFile of entry.assets) {
|
||||
const assetPath = join(manifestDir, assetFile);
|
||||
if (!checkedFiles.has(assetPath)) {
|
||||
checkedFiles.add(assetPath);
|
||||
if (!existsSync(assetPath)) {
|
||||
errors.push(`Missing asset file referenced in manifest: ${assetFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check imports
|
||||
if (entry.imports && Array.isArray(entry.imports)) {
|
||||
for (const importFile of entry.imports) {
|
||||
const importPath = join(manifestDir, importFile);
|
||||
if (!checkedFiles.has(importPath)) {
|
||||
checkedFiles.add(importPath);
|
||||
if (!existsSync(importPath)) {
|
||||
warnings.push(`Missing import file referenced in manifest: ${importFile} (this may be okay if it's a virtual import)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for required entries
|
||||
const hasJsEntry = Object.values(manifest).some(
|
||||
(entry) => typeof entry === "object" && entry?.file?.endsWith(".js")
|
||||
);
|
||||
|
||||
if (!hasJsEntry) {
|
||||
errors.push("Manifest must contain at least one JavaScript entry file");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the standalone manifest file in a directory
|
||||
* @param dir - Directory to search in
|
||||
* @returns Path to the manifest file or null if not found
|
||||
*/
|
||||
export function getStandaloneManifestPath(dir: string): string | null {
|
||||
// Check standalone subdirectory first (preferred location)
|
||||
const standaloneManifest = join(dir, "standalone", "standalone.manifest.json");
|
||||
if (existsSync(standaloneManifest)) {
|
||||
return standaloneManifest;
|
||||
}
|
||||
|
||||
// Check root directory for backwards compatibility
|
||||
const rootManifest = join(dir, "standalone.manifest.json");
|
||||
if (existsSync(rootManifest)) {
|
||||
return rootManifest;
|
||||
}
|
||||
|
||||
// Check nuxt subdirectory for backwards compatibility
|
||||
const nuxtManifest = join(dir, "nuxt", "standalone.manifest.json");
|
||||
if (existsSync(nuxtManifest)) {
|
||||
return nuxtManifest;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- ./source:/app/source
|
||||
- ./scripts:/app/scripts
|
||||
- ../unraid-ui/dist-wc:/app/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/uui
|
||||
- ../web/.nuxt/nuxt-custom-elements/dist/unraid-components:/app/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/nuxt
|
||||
- ../web/dist:/app/source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/standalone
|
||||
- ../api/deploy/release/:/app/source/dynamix.unraid.net/usr/local/unraid-api # Use the release dir instead of pack to allow watcher to not try to build with node_modules
|
||||
stdin_open: true # equivalent to -i
|
||||
tty: true # equivalent to -t
|
||||
|
||||
@@ -27,10 +27,10 @@ CONTAINER_NAME="plugin-builder"
|
||||
|
||||
# Create the directory if it doesn't exist
|
||||
# This is to prevent errors when mounting volumes in docker compose
|
||||
NUXT_COMPONENTS_DIR="../web/.nuxt/nuxt-custom-elements/dist/unraid-components"
|
||||
if [ ! -d "$NUXT_COMPONENTS_DIR" ]; then
|
||||
echo "Creating directory $NUXT_COMPONENTS_DIR for Docker volume mount..."
|
||||
mkdir -p "$NUXT_COMPONENTS_DIR"
|
||||
WEB_DIST_DIR="../web/dist"
|
||||
if [ ! -d "$WEB_DIST_DIR" ]; then
|
||||
echo "Creating directory $WEB_DIST_DIR for Docker volume mount..."
|
||||
mkdir -p "$WEB_DIST_DIR"
|
||||
fi
|
||||
|
||||
# Stop any running plugin-builder container first
|
||||
|
||||
@@ -25,3 +25,9 @@ backup_file_if_exists usr/local/unraid-api/.env
|
||||
cp usr/local/unraid-api/.env.production usr/local/unraid-api/.env
|
||||
|
||||
# auto-generated actions from makepkg:
|
||||
( cd usr/local/bin ; rm -rf corepack )
|
||||
( cd usr/local/bin ; ln -sf ../lib/node_modules/corepack/dist/corepack.js corepack )
|
||||
( cd usr/local/bin ; rm -rf npm )
|
||||
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm )
|
||||
( cd usr/local/bin ; rm -rf npx )
|
||||
( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx )
|
||||
|
||||
Generated
+320
-4927
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,10 @@ export interface ButtonProps {
|
||||
size?: ButtonVariants['size'];
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
as?: string;
|
||||
href?: string | null;
|
||||
target?: string | null;
|
||||
rel?: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
@@ -50,7 +54,21 @@ const handleKeydown = (event: KeyboardEvent) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
v-if="as === 'a'"
|
||||
:class="buttonClass"
|
||||
:href="href ?? undefined"
|
||||
:target="target ?? undefined"
|
||||
:rel="rel ?? undefined"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
:aria-disabled="disabled"
|
||||
@click="handleClick"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
<span
|
||||
v-else
|
||||
:class="buttonClass"
|
||||
role="button"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
.env.*
|
||||
!.env.staging
|
||||
!.env.example
|
||||
!.env.production
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { encodePermissionsToScopes, decodeScopesToPermissions } from '../utils/authorizationScopes';
|
||||
import { AuthAction, Resource } from '../composables/gql/graphql';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AuthAction, Resource } from '~/composables/gql/graphql';
|
||||
import { decodeScopesToPermissions, encodePermissionsToScopes } from '~/utils/authorizationScopes';
|
||||
|
||||
describe('authorizationScopes', () => {
|
||||
describe('encodePermissionsToScopes', () => {
|
||||
@@ -9,12 +10,12 @@ describe('authorizationScopes', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
|
||||
}
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
|
||||
// Should produce "docker:read_any+read_own+update_any" with distinct actions preserved
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker:read_any+read_own+update_any');
|
||||
@@ -24,16 +25,16 @@ describe('authorizationScopes', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY]
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_OWN]
|
||||
}
|
||||
actions: [AuthAction.READ_OWN],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
|
||||
// Should produce "docker:read_any+read_own" merging both permissions
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker:read_any+read_own');
|
||||
@@ -43,20 +44,20 @@ describe('authorizationScopes', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY]
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN]
|
||||
}
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
|
||||
// Docker: READ_ANY, READ_OWN, UPDATE_ANY, UPDATE_OWN (merged from both)
|
||||
// VMS: READ_ANY, UPDATE_OWN, UPDATE_ANY
|
||||
// Different action sets, so separate scopes
|
||||
@@ -70,16 +71,16 @@ describe('authorizationScopes', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY]
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY],
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY]
|
||||
}
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
|
||||
// Should sort resources (docker before vms) and actions alphabetically
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker+vms:read_any+update_any');
|
||||
@@ -90,31 +91,37 @@ describe('authorizationScopes', () => {
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY, AuthAction.CREATE_OWN,
|
||||
AuthAction.READ_ANY, AuthAction.READ_OWN,
|
||||
AuthAction.UPDATE_ANY, AuthAction.UPDATE_OWN,
|
||||
AuthAction.DELETE_ANY, AuthAction.DELETE_OWN
|
||||
]
|
||||
}
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.CREATE_OWN,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.READ_OWN,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.UPDATE_OWN,
|
||||
AuthAction.DELETE_ANY,
|
||||
AuthAction.DELETE_OWN,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
|
||||
// Should just list all actions, no wildcard conversion
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker:create_any+create_own+delete_any+delete_own+read_any+read_own+update_any+update_own');
|
||||
expect(scopes[0]).toBe(
|
||||
'docker:create_any+create_own+delete_any+delete_own+read_any+read_own+update_any+update_own'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle edge case with empty actions after deduplication', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: []
|
||||
}
|
||||
actions: [],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
|
||||
// Should not produce any scope for empty actions
|
||||
expect(scopes).toHaveLength(0);
|
||||
});
|
||||
@@ -123,24 +130,24 @@ describe('authorizationScopes', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY]
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_OWN]
|
||||
actions: [AuthAction.READ_OWN],
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_OWN]
|
||||
actions: [AuthAction.READ_OWN],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY]
|
||||
}
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
|
||||
// Both DOCKER and VMS have READ_ANY and READ_OWN, should group without duplicates
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker+vms:read_any+read_own');
|
||||
@@ -149,17 +156,17 @@ describe('authorizationScopes', () => {
|
||||
it('should produce deterministic output for same input regardless of order', () => {
|
||||
const permissions1 = [
|
||||
{ resource: Resource.VMS, actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY] },
|
||||
{ resource: Resource.DOCKER, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] }
|
||||
{ resource: Resource.DOCKER, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] },
|
||||
];
|
||||
|
||||
|
||||
const permissions2 = [
|
||||
{ resource: Resource.DOCKER, actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY] },
|
||||
{ resource: Resource.VMS, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] }
|
||||
{ resource: Resource.VMS, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] },
|
||||
];
|
||||
|
||||
|
||||
const scopes1 = encodePermissionsToScopes([], permissions1);
|
||||
const scopes2 = encodePermissionsToScopes([], permissions2);
|
||||
|
||||
|
||||
expect(scopes1).toEqual(scopes2);
|
||||
expect(scopes1[0]).toBe('docker+vms:read_any+update_any');
|
||||
});
|
||||
@@ -170,33 +177,33 @@ describe('authorizationScopes', () => {
|
||||
const originalPermissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY]
|
||||
}
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const scopes = encodePermissionsToScopes([], originalPermissions);
|
||||
const { permissions: decoded } = decodeScopesToPermissions(scopes);
|
||||
|
||||
|
||||
// Now possession is preserved in the encoding
|
||||
expect(decoded).toHaveLength(2);
|
||||
|
||||
const dockerPerm = decoded.find(p => p.resource === Resource.DOCKER);
|
||||
const vmsPerm = decoded.find(p => p.resource === Resource.VMS);
|
||||
|
||||
|
||||
const dockerPerm = decoded.find((p) => p.resource === Resource.DOCKER);
|
||||
const vmsPerm = decoded.find((p) => p.resource === Resource.VMS);
|
||||
|
||||
// Docker should have its specific actions preserved
|
||||
expect(dockerPerm?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(dockerPerm?.actions).toContain(AuthAction.READ_OWN);
|
||||
expect(dockerPerm?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
|
||||
|
||||
// VMS should have its specific actions preserved
|
||||
expect(vmsPerm?.actions).toContain(AuthAction.READ_OWN);
|
||||
expect(vmsPerm?.actions).toContain(AuthAction.UPDATE_OWN);
|
||||
expect(vmsPerm?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
|
||||
|
||||
// The scopes should be separate since they have different action sets
|
||||
expect(scopes).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ComposerTranslation } from 'vue-i18n';
|
||||
import ActivationModal from '~/components/Activation/ActivationModal.vue';
|
||||
|
||||
vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@unraid/ui')>();
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
Dialog: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ComposerTranslation } from 'vue-i18n';
|
||||
import WelcomeModal from '~/components/Activation/WelcomeModal.ce.vue';
|
||||
|
||||
vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@unraid/ui')>();
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
Dialog: {
|
||||
@@ -226,17 +226,16 @@ describe('Activation/WelcomeModal.ce.vue', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
it('shows modal on login page even when isInitialSetup is false', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/login' },
|
||||
writable: true,
|
||||
});
|
||||
mockWelcomeModalDataStore.isInitialSetup.value = false;
|
||||
|
||||
|
||||
const wrapper = await mountComponent();
|
||||
const dialog = wrapper.findComponent({ name: 'Dialog' });
|
||||
|
||||
|
||||
expect(dialog.exists()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -246,10 +245,10 @@ describe('Activation/WelcomeModal.ce.vue', () => {
|
||||
writable: true,
|
||||
});
|
||||
mockWelcomeModalDataStore.isInitialSetup.value = true;
|
||||
|
||||
|
||||
const wrapper = await mountComponent();
|
||||
const dialog = wrapper.findComponent({ name: 'Dialog' });
|
||||
|
||||
|
||||
expect(dialog.exists()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -259,10 +258,10 @@ describe('Activation/WelcomeModal.ce.vue', () => {
|
||||
writable: true,
|
||||
});
|
||||
mockWelcomeModalDataStore.isInitialSetup.value = false;
|
||||
|
||||
|
||||
const wrapper = await mountComponent();
|
||||
const dialog = wrapper.findComponent({ name: 'Dialog' });
|
||||
|
||||
|
||||
expect(dialog.exists()).toBe(true);
|
||||
expect(dialog.props('modelValue')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useThemeStore } from '~/store/theme';
|
||||
|
||||
// Explicitly mock @unraid/ui to ensure we use the actual components
|
||||
vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@unraid/ui')>();
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
};
|
||||
@@ -93,7 +93,7 @@ describe('ColorSwitcher', () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
setActivePinia(pinia);
|
||||
themeStore = useThemeStore();
|
||||
|
||||
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
@@ -136,7 +136,7 @@ describe('ColorSwitcher', () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
setActivePinia(pinia);
|
||||
themeStore = useThemeStore();
|
||||
|
||||
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
|
||||
@@ -12,7 +12,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import DevSettings from '~/components/DevSettings.vue';
|
||||
|
||||
vi.mock('@unraid/ui', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@unraid/ui')>();
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,6 @@ vi.mock('@unraid/shared-callbacks', () => ({
|
||||
useCallback: vi.fn(() => ({ send: vi.fn(), watcher: vi.fn() })),
|
||||
}));
|
||||
|
||||
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useQuery: () => ({
|
||||
result: { value: {} },
|
||||
@@ -123,11 +122,19 @@ describe('HeaderOsVersion', () => {
|
||||
});
|
||||
|
||||
it('renders OS version button with correct version and no update status initially', () => {
|
||||
const versionButton = wrapper.find('button[title*="Version Information"]');
|
||||
// The version button is within the DropdownMenuTrigger
|
||||
const versionButton = wrapper.find('[title="Version Information"]');
|
||||
|
||||
expect(versionButton.exists()).toBe(true);
|
||||
expect(versionButton.text()).toContain('6.12.0');
|
||||
expect(findUpdateStatusComponent()).toBeNull();
|
||||
|
||||
// No update status button should be rendered initially
|
||||
const updateButtons = wrapper.findAll('button');
|
||||
const hasUpdateButton = updateButtons.some((button) => {
|
||||
const title = button.attributes('title');
|
||||
return title && (title.includes('Update') || title.includes('Reboot'));
|
||||
});
|
||||
expect(hasUpdateButton).toBe(false);
|
||||
});
|
||||
|
||||
it('does not render update status when stateDataError is present', async () => {
|
||||
|
||||
@@ -11,9 +11,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ServerStateDataAction, ServerStateDataActionType } from '~/types/server';
|
||||
|
||||
import KeyActions from '../../components/KeyActions.vue';
|
||||
import KeyActions from '~/components/KeyActions.vue';
|
||||
|
||||
import '~/__test__/mocks/ui-components';
|
||||
import '../mocks/ui-components';
|
||||
|
||||
vi.mock('crypto-js/aes', () => ({
|
||||
default: {},
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { AnsiUp } from 'ansi_up';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { AnsiUp } from 'ansi_up';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SingleLogViewer from '~/components/Logs/SingleLogViewer.vue';
|
||||
import {
|
||||
createMockUseQuery,
|
||||
createMockLogFileQuery
|
||||
} from '../../helpers/apollo-mocks';
|
||||
import { createMockLogFileQuery, createMockUseQuery } from '../../helpers/apollo-mocks';
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
@@ -16,24 +15,24 @@ vi.mock('@unraid/ui', () => ({
|
||||
Tooltip: { template: '<div><slot /></div>' },
|
||||
TooltipContent: { template: '<div><slot /></div>' },
|
||||
TooltipProvider: { template: '<div><slot /></div>' },
|
||||
TooltipTrigger: { template: '<div><slot /></div>' }
|
||||
TooltipTrigger: { template: '<div><slot /></div>' },
|
||||
}));
|
||||
|
||||
// Mock the GraphQL query
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
useApolloClient: vi.fn(() => ({
|
||||
client: {
|
||||
query: vi.fn()
|
||||
}
|
||||
query: vi.fn(),
|
||||
},
|
||||
})),
|
||||
useQuery: vi.fn()
|
||||
useQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the theme store
|
||||
vi.mock('~/store/theme', () => ({
|
||||
useThemeStore: vi.fn(() => ({
|
||||
darkMode: false
|
||||
}))
|
||||
darkMode: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
@@ -52,48 +51,48 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
{
|
||||
input: '\x1b[31mRed text\x1b[0m',
|
||||
expected: '<span class="ansi-red-fg">Red text</span>',
|
||||
description: 'red foreground'
|
||||
description: 'red foreground',
|
||||
},
|
||||
{
|
||||
input: '\x1b[32mGreen text\x1b[0m',
|
||||
expected: '<span class="ansi-green-fg">Green text</span>',
|
||||
description: 'green foreground'
|
||||
description: 'green foreground',
|
||||
},
|
||||
{
|
||||
input: '\x1b[33mYellow text\x1b[0m',
|
||||
expected: '<span class="ansi-yellow-fg">Yellow text</span>',
|
||||
description: 'yellow foreground'
|
||||
description: 'yellow foreground',
|
||||
},
|
||||
{
|
||||
input: '\x1b[34mBlue text\x1b[0m',
|
||||
expected: '<span class="ansi-blue-fg">Blue text</span>',
|
||||
description: 'blue foreground'
|
||||
description: 'blue foreground',
|
||||
},
|
||||
{
|
||||
input: '\x1b[91mBright red\x1b[0m',
|
||||
expected: '<span class="ansi-bright-red-fg">Bright red</span>',
|
||||
description: 'bright red foreground'
|
||||
description: 'bright red foreground',
|
||||
},
|
||||
{
|
||||
input: '\x1b[41mRed background\x1b[0m',
|
||||
expected: '<span class="ansi-red-bg">Red background</span>',
|
||||
description: 'red background'
|
||||
description: 'red background',
|
||||
},
|
||||
{
|
||||
input: '\x1b[1mBold text\x1b[0m',
|
||||
expected: '<span style="font-weight:bold">Bold text</span>',
|
||||
description: 'bold text (ansi_up uses inline style for bold)'
|
||||
description: 'bold text (ansi_up uses inline style for bold)',
|
||||
},
|
||||
{
|
||||
input: '\x1b[3mItalic text\x1b[0m',
|
||||
expected: '<span style="font-style:italic">Italic text</span>',
|
||||
description: 'italic text (ansi_up uses inline style for italic)'
|
||||
description: 'italic text (ansi_up uses inline style for italic)',
|
||||
},
|
||||
{
|
||||
input: '\x1b[4mUnderlined text\x1b[0m',
|
||||
expected: '<span style="text-decoration:underline">Underlined text</span>',
|
||||
description: 'underlined text (ansi_up uses inline style for underline)'
|
||||
}
|
||||
description: 'underlined text (ansi_up uses inline style for underline)',
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected, description }) => {
|
||||
@@ -104,7 +103,8 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
|
||||
it('should handle multiple ANSI codes in one string', () => {
|
||||
const input = '\x1b[31mRed\x1b[0m \x1b[32mGreen\x1b[0m \x1b[34mBlue\x1b[0m';
|
||||
const expected = '<span class="ansi-red-fg">Red</span> <span class="ansi-green-fg">Green</span> <span class="ansi-blue-fg">Blue</span>';
|
||||
const expected =
|
||||
'<span class="ansi-red-fg">Red</span> <span class="ansi-green-fg">Green</span> <span class="ansi-blue-fg">Blue</span>';
|
||||
const result = ansiConverter.ansi_to_html(input);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
@@ -118,10 +118,10 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
});
|
||||
|
||||
it('should escape HTML entities for security', () => {
|
||||
const input = '\x1b[31m<script>alert("XSS")</script>\x1b[0m';
|
||||
const input = '\x1b[31m<img src=x onerror=alert(1)>\x1b[0m';
|
||||
const result = ansiConverter.ansi_to_html(input);
|
||||
expect(result).not.toContain('<script>');
|
||||
expect(result).toContain('<script>');
|
||||
expect(result).not.toContain('<img');
|
||||
expect(result).toContain('<img');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
const htmlWithClasses = '<span class="ansi-red-fg">Red text</span>';
|
||||
const sanitized = DOMPurify.sanitize(htmlWithClasses, {
|
||||
ALLOWED_TAGS: ['span', 'br'],
|
||||
ALLOWED_ATTR: ['class']
|
||||
ALLOWED_ATTR: ['class'],
|
||||
});
|
||||
expect(sanitized).toBe(htmlWithClasses);
|
||||
});
|
||||
@@ -139,16 +139,16 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
const htmlWithStyles = '<span style="color: red;">Red text</span>';
|
||||
const sanitized = DOMPurify.sanitize(htmlWithStyles, {
|
||||
ALLOWED_TAGS: ['span', 'br'],
|
||||
ALLOWED_ATTR: ['class'] // Note: 'style' is not allowed
|
||||
ALLOWED_ATTR: ['class'], // Note: 'style' is not allowed
|
||||
});
|
||||
expect(sanitized).toBe('<span>Red text</span>');
|
||||
});
|
||||
|
||||
it('should remove dangerous tags while preserving safe content', () => {
|
||||
const dangerous = '<span class="ansi-red-fg">Safe</span><script>alert("XSS")</script>';
|
||||
const dangerous = '<span class="ansi-red-fg">Safe</span><img src="x" onerror="alert(1)">';
|
||||
const sanitized = DOMPurify.sanitize(dangerous, {
|
||||
ALLOWED_TAGS: ['span', 'br'],
|
||||
ALLOWED_ATTR: ['class']
|
||||
ALLOWED_ATTR: ['class'],
|
||||
});
|
||||
expect(sanitized).toBe('<span class="ansi-red-fg">Safe</span>');
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
const complex = '<span class="ansi-bold"><span class="ansi-red-fg">Bold Red</span></span>';
|
||||
const sanitized = DOMPurify.sanitize(complex, {
|
||||
ALLOWED_TAGS: ['span', 'br'],
|
||||
ALLOWED_ATTR: ['class']
|
||||
ALLOWED_ATTR: ['class'],
|
||||
});
|
||||
expect(sanitized).toBe(complex);
|
||||
});
|
||||
@@ -168,12 +168,12 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
// Mock useQuery to return empty data for this test
|
||||
// @ts-expect-error Mock implementation for testing
|
||||
vi.mocked(useQuery).mockReturnValue(createMockUseQuery());
|
||||
|
||||
|
||||
const wrapper = mount(SingleLogViewer, {
|
||||
props: {
|
||||
logFilePath: '/test/log.txt',
|
||||
lineCount: 100,
|
||||
autoScroll: false
|
||||
autoScroll: false,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -181,17 +181,17 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
Tooltip: true,
|
||||
TooltipContent: true,
|
||||
TooltipProvider: true,
|
||||
TooltipTrigger: true
|
||||
}
|
||||
}
|
||||
TooltipTrigger: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for component to mount
|
||||
await nextTick();
|
||||
|
||||
|
||||
// Check that the component mounts without errors
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -206,7 +206,7 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
// Create mock data
|
||||
const content = '\x1b[31m[ERROR]\x1b[0m Failed to connect\n\x1b[32m[SUCCESS]\x1b[0m Connected';
|
||||
const mockQuery = createMockLogFileQuery(content, 2, 1);
|
||||
|
||||
|
||||
// Mock useQuery to return our data
|
||||
// @ts-expect-error Mock implementation for testing
|
||||
vi.mocked(useQuery).mockReturnValue(mockQuery);
|
||||
@@ -215,13 +215,13 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
props: {
|
||||
logFilePath: '/test/log.txt',
|
||||
lineCount: 100,
|
||||
autoScroll: false
|
||||
}
|
||||
autoScroll: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for the component to mount and process initial data
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
|
||||
// Trigger the watcher by modifying the result
|
||||
// @ts-expect-error Accessing mock properties
|
||||
if (mockQuery.result.value) {
|
||||
@@ -229,21 +229,21 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
mockQuery.result.value = {
|
||||
logFile: {
|
||||
content,
|
||||
totalLines: 2,
|
||||
startLine: 1
|
||||
}
|
||||
totalLines: 2,
|
||||
startLine: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Wait for watchers to process
|
||||
await wrapper.vm.$nextTick();
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
|
||||
// Get the pre element that contains the log content
|
||||
const preElement = wrapper.find('pre.hljs');
|
||||
expect(preElement.exists()).toBe(true);
|
||||
|
||||
|
||||
// Check that the rendered HTML contains the CSS classes
|
||||
const html = preElement.html();
|
||||
if (!html.includes('ansi-red-fg')) {
|
||||
@@ -254,7 +254,7 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
expect(html).toContain('[ERROR]');
|
||||
expect(html).toContain('ansi-green-fg');
|
||||
expect(html).toContain('[SUCCESS]');
|
||||
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
@@ -268,13 +268,13 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
props: {
|
||||
logFilePath: '/test/log.txt',
|
||||
lineCount: 100,
|
||||
autoScroll: false
|
||||
}
|
||||
autoScroll: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for mount and trigger the watcher
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
|
||||
// @ts-expect-error Accessing mock properties
|
||||
if (mockQuery.result.value) {
|
||||
// @ts-expect-error Modifying mock properties
|
||||
@@ -282,30 +282,31 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
logFile: {
|
||||
content,
|
||||
totalLines: 1,
|
||||
startLine: 1
|
||||
}
|
||||
startLine: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Wait for processing
|
||||
await wrapper.vm.$nextTick();
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
|
||||
const preElement = wrapper.find('pre.hljs');
|
||||
expect(preElement.exists()).toBe(true);
|
||||
|
||||
|
||||
const html = preElement.html();
|
||||
expect(html).toContain('Plain text');
|
||||
expect(html).toContain('ansi-yellow-fg');
|
||||
expect(html).toContain('Warning');
|
||||
expect(html).toContain('more plain text');
|
||||
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('should apply client-side filtering while preserving ANSI colors', async () => {
|
||||
const content = '\x1b[31m[ERROR]\x1b[0m Connection failed\n\x1b[32m[INFO]\x1b[0m Connected\n\x1b[31m[ERROR]\x1b[0m Timeout';
|
||||
const content =
|
||||
'\x1b[31m[ERROR]\x1b[0m Connection failed\n\x1b[32m[INFO]\x1b[0m Connected\n\x1b[31m[ERROR]\x1b[0m Timeout';
|
||||
const mockQuery = createMockLogFileQuery(content, 3, 1);
|
||||
// @ts-expect-error Mock implementation for testing
|
||||
vi.mocked(useQuery).mockReturnValue(mockQuery);
|
||||
@@ -315,13 +316,13 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
logFilePath: '/test/log.txt',
|
||||
lineCount: 100,
|
||||
autoScroll: false,
|
||||
clientFilter: 'ERROR'
|
||||
}
|
||||
clientFilter: 'ERROR',
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for mount and trigger the watcher
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
|
||||
// @ts-expect-error Accessing mock properties
|
||||
if (mockQuery.result.value) {
|
||||
// @ts-expect-error Modifying mock properties
|
||||
@@ -329,19 +330,19 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
logFile: {
|
||||
content,
|
||||
totalLines: 3,
|
||||
startLine: 1
|
||||
}
|
||||
startLine: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Wait for processing
|
||||
await wrapper.vm.$nextTick();
|
||||
await flushPromises();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
|
||||
const preElement = wrapper.find('pre.hljs');
|
||||
expect(preElement.exists()).toBe(true);
|
||||
|
||||
|
||||
const html = preElement.html();
|
||||
// Should contain ERROR lines with red color
|
||||
expect(html).toContain('ansi-red-fg');
|
||||
@@ -349,7 +350,7 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
// Should not contain INFO line (due to filter)
|
||||
expect(html).not.toContain('INFO');
|
||||
expect(html).not.toContain('ansi-green-fg');
|
||||
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -367,7 +368,7 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
.join('\n');
|
||||
|
||||
const result = ansiConverter.ansi_to_html(largeInput);
|
||||
|
||||
|
||||
// Should contain the expected number of color spans
|
||||
const colorMatches = result.match(/class="ansi-/g);
|
||||
expect(colorMatches).toHaveLength(lines);
|
||||
@@ -382,9 +383,9 @@ describe('SingleLogViewer - ANSI Color Support', () => {
|
||||
|
||||
const sanitized = DOMPurify.sanitize(largeHtml, {
|
||||
ALLOWED_TAGS: ['span', 'br'],
|
||||
ALLOWED_ATTR: ['class']
|
||||
ALLOWED_ATTR: ['class'],
|
||||
});
|
||||
|
||||
|
||||
// Should preserve all spans
|
||||
const spanMatches = sanitized.match(/<span/g);
|
||||
expect(spanMatches).toHaveLength(lines);
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { Props as ModalProps } from '~/components/Modal.vue';
|
||||
|
||||
import Modal from '~/components/Modal.vue';
|
||||
|
||||
|
||||
const mockSetProperty = vi.fn();
|
||||
const mockRemoveProperty = vi.fn();
|
||||
|
||||
@@ -149,7 +148,7 @@ describe('Modal', () => {
|
||||
|
||||
await nextTick();
|
||||
|
||||
let modalDiv = wrapper.find('[class*="text-left relative z-10"]');
|
||||
let modalDiv = wrapper.find('.text-left.relative.z-10');
|
||||
|
||||
expect(modalDiv.classes()).toContain('shadow-unraid-red/30');
|
||||
expect(modalDiv.classes()).toContain('border-unraid-red/10');
|
||||
@@ -158,7 +157,7 @@ describe('Modal', () => {
|
||||
|
||||
await nextTick();
|
||||
|
||||
modalDiv = wrapper.find('[class*="text-left relative z-10"]');
|
||||
modalDiv = wrapper.find('.text-left.relative.z-10');
|
||||
|
||||
expect(modalDiv.classes()).toContain('shadow-green-600/30');
|
||||
expect(modalDiv.classes()).toContain('border-green-600/10');
|
||||
@@ -175,7 +174,7 @@ describe('Modal', () => {
|
||||
|
||||
await nextTick();
|
||||
|
||||
const modalDiv = wrapper.find('[class*="text-left relative z-10"]');
|
||||
const modalDiv = wrapper.find('.text-left.relative.z-10');
|
||||
|
||||
expect(modalDiv.classes()).toContain('shadow-none');
|
||||
expect(modalDiv.classes()).toContain('border-none');
|
||||
@@ -205,7 +204,7 @@ describe('Modal', () => {
|
||||
|
||||
it('applies overlay color and opacity classes', async () => {
|
||||
const overlayColor = 'bg-blue-500';
|
||||
const overlayOpacity = 'bg-blue-500/50';
|
||||
const overlayOpacity = 'bg-opacity-50';
|
||||
|
||||
wrapper = mount(Modal, {
|
||||
props: {
|
||||
@@ -218,7 +217,7 @@ describe('Modal', () => {
|
||||
|
||||
await nextTick();
|
||||
|
||||
const overlay = wrapper.find('[class*="fixed inset-0 z-0"]');
|
||||
const overlay = wrapper.find('.fixed.inset-0.z-0');
|
||||
|
||||
expect(overlay.classes()).toContain(overlayColor);
|
||||
expect(overlay.classes()).toContain(overlayOpacity);
|
||||
|
||||
@@ -82,7 +82,6 @@ vi.mock('~/components/UserProfile/UptimeExpire.vue', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
// Define initial state for the server store for testing
|
||||
const initialServerState = {
|
||||
dateTimeFormat: { date: 'MMM D, YYYY', time: 'h:mm A' },
|
||||
@@ -127,11 +126,11 @@ describe('Registration.ce.vue', () => {
|
||||
const findItemByLabel = (labelKey: string) => {
|
||||
const allLabels = wrapper.findAll('.font-semibold');
|
||||
const label = allLabels.find((el) => el.html().includes(t(labelKey)));
|
||||
|
||||
|
||||
if (!label) return undefined;
|
||||
|
||||
|
||||
const nextSibling = label.element.nextElementSibling;
|
||||
|
||||
|
||||
return {
|
||||
exists: () => true,
|
||||
props: (prop: string) => {
|
||||
@@ -199,12 +198,10 @@ describe('Registration.ce.vue', () => {
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const keyActionsElement = wrapper.find('[data-testid="key-actions"]');
|
||||
|
||||
|
||||
expect(keyActionsElement.exists(), 'KeyActions element not found').toBe(true);
|
||||
|
||||
const expectedActions = serverStore.keyActions?.filter(
|
||||
(action) => !['renew'].includes(action.name)
|
||||
);
|
||||
const expectedActions = serverStore.keyActions?.filter((action) => !['renew'].includes(action.name));
|
||||
|
||||
expect(expectedActions, 'No expected actions found in store for TRIAL state').toBeDefined();
|
||||
expect(expectedActions!.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -4,20 +4,23 @@
|
||||
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { Mock, MockInstance } from 'vitest';
|
||||
|
||||
import SsoButtons from '~/components/sso/SsoButtons.vue';
|
||||
|
||||
// Mock the child components
|
||||
const SsoProviderButtonStub = {
|
||||
template: '<button @click="handleClick" :disabled="disabled">{{ provider.buttonText || `Sign in with ${provider.name}` }}</button>',
|
||||
template:
|
||||
'<button @click="handleClick" :disabled="disabled">{{ provider.buttonText || `Sign in with ${provider.name}` }}</button>',
|
||||
props: ['provider', 'disabled', 'onClick'],
|
||||
methods: {
|
||||
handleClick(this: { onClick: (id: string) => void; provider: { id: string } }) {
|
||||
this.onClick(this.provider.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock the GraphQL composable
|
||||
@@ -131,16 +134,16 @@ describe('SsoButtons', () => {
|
||||
|
||||
it('renders provider buttons when OIDC providers are available', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net',
|
||||
buttonIcon: null,
|
||||
buttonVariant: 'secondary',
|
||||
buttonStyle: null
|
||||
}
|
||||
buttonStyle: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
@@ -148,18 +151,18 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Wait for the API check to complete
|
||||
await flushPromises();
|
||||
vi.runAllTimers();
|
||||
await flushPromises();
|
||||
|
||||
|
||||
expect(wrapper.text()).toContain('or');
|
||||
expect(wrapper.text()).toContain('Log In With Unraid.net');
|
||||
});
|
||||
@@ -172,26 +175,27 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
await flushPromises();
|
||||
vi.runAllTimers();
|
||||
await flushPromises();
|
||||
|
||||
|
||||
expect(wrapper.text()).not.toContain('or');
|
||||
expect(wrapper.findAll('button')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows checking message while API is being polled', async () => {
|
||||
const refetchMock = vi.fn()
|
||||
const refetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('API not available'))
|
||||
.mockResolvedValueOnce({ data: { publicOidcProviders: [] } });
|
||||
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: null },
|
||||
refetch: refetchMock,
|
||||
@@ -199,36 +203,36 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
expect(wrapper.text()).toContain('Checking authentication options...');
|
||||
|
||||
|
||||
// Advance timers to trigger the polling
|
||||
await flushPromises();
|
||||
vi.advanceTimersByTime(2000);
|
||||
await flushPromises();
|
||||
|
||||
|
||||
// After successful API response, checking message should disappear
|
||||
expect(wrapper.text()).not.toContain('Checking authentication options...');
|
||||
});
|
||||
|
||||
it('navigates to the OIDC provider URL on button click', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net',
|
||||
buttonIcon: null,
|
||||
buttonVariant: 'secondary',
|
||||
buttonStyle: null
|
||||
}
|
||||
buttonStyle: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
@@ -236,13 +240,13 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
await flushPromises();
|
||||
vi.runAllTimers();
|
||||
await flushPromises();
|
||||
@@ -263,20 +267,20 @@ describe('SsoButtons', () => {
|
||||
|
||||
it('handles OIDC callback with token successfully', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net'
|
||||
}
|
||||
buttonText: 'Log In With Unraid.net',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
});
|
||||
|
||||
const mockToken = 'mock_access_token_123';
|
||||
mockLocation.search = ''; // No query params - using hash instead
|
||||
mockLocation.search = ''; // No query params - using hash instead
|
||||
mockLocation.pathname = '/login';
|
||||
mockLocationHref = `http://mock-origin.com/login#token=${mockToken}`;
|
||||
mockLocation.hash = `#token=${mockToken}`;
|
||||
@@ -284,9 +288,9 @@ describe('SsoButtons', () => {
|
||||
// Mount the component so that onMounted hook is called
|
||||
mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -303,29 +307,29 @@ describe('SsoButtons', () => {
|
||||
|
||||
it('handles OIDC callback error from backend', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net'
|
||||
}
|
||||
buttonText: 'Log In With Unraid.net',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
});
|
||||
|
||||
const errorMessage = 'Authentication failed';
|
||||
mockLocation.search = ''; // No query params - using hash instead
|
||||
mockLocation.search = ''; // No query params - using hash instead
|
||||
mockLocation.pathname = '/login';
|
||||
mockLocationHref = `http://mock-origin.com/login#error=${encodeURIComponent(errorMessage)}`;
|
||||
mockLocation.hash = `#error=${encodeURIComponent(errorMessage)}`;
|
||||
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -338,7 +342,7 @@ describe('SsoButtons', () => {
|
||||
|
||||
expect(mockForm.style.display).toBe('block');
|
||||
expect(mockForm.requestSubmit).not.toHaveBeenCalled();
|
||||
|
||||
|
||||
// The URL cleanup happens with both hash and query params being removed
|
||||
const expectedUrl = mockLocation.pathname;
|
||||
expect(mockHistory.replaceState).toHaveBeenCalledWith({}, 'Mock Title', expectedUrl);
|
||||
@@ -346,13 +350,13 @@ describe('SsoButtons', () => {
|
||||
|
||||
it('redirects to OIDC callback endpoint when code and state are present', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net'
|
||||
}
|
||||
buttonText: 'Log In With Unraid.net',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
@@ -366,9 +370,9 @@ describe('SsoButtons', () => {
|
||||
|
||||
mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -382,21 +386,21 @@ describe('SsoButtons', () => {
|
||||
|
||||
it('handles HTTPS with non-standard port correctly', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'tsidp',
|
||||
{
|
||||
id: 'tsidp',
|
||||
name: 'Tailscale IDP',
|
||||
buttonText: 'Sign in with Tailscale',
|
||||
buttonIcon: null,
|
||||
buttonVariant: 'secondary',
|
||||
buttonStyle: null
|
||||
}
|
||||
buttonStyle: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
// Set up location with HTTPS and non-standard port
|
||||
mockLocation.protocol = 'https:';
|
||||
mockLocation.host = 'unraid.mytailnet.ts.net:1443';
|
||||
mockLocation.origin = 'https://unraid.mytailnet.ts.net:1443';
|
||||
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
@@ -404,13 +408,13 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
await flushPromises();
|
||||
vi.runAllTimers();
|
||||
await flushPromises();
|
||||
@@ -424,7 +428,7 @@ describe('SsoButtons', () => {
|
||||
const expectedUrl = `/graphql/api/auth/oidc/authorize/tsidp?state=${encodeURIComponent(generatedState)}&redirect_uri=${encodeURIComponent(redirectUri)}`;
|
||||
|
||||
expect(mockLocation.href).toBe(expectedUrl);
|
||||
|
||||
|
||||
// Reset location mock for other tests
|
||||
mockLocation.protocol = 'http:';
|
||||
mockLocation.host = 'mock-origin.com';
|
||||
@@ -433,24 +437,24 @@ describe('SsoButtons', () => {
|
||||
|
||||
it('handles multiple OIDC providers', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'unraid-net',
|
||||
{
|
||||
id: 'unraid-net',
|
||||
name: 'Unraid.net',
|
||||
buttonText: 'Log In With Unraid.net',
|
||||
buttonIcon: null,
|
||||
buttonVariant: 'secondary',
|
||||
buttonStyle: null
|
||||
buttonStyle: null,
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
buttonText: 'Sign in with Google',
|
||||
buttonIcon: 'https://google.com/icon.png',
|
||||
buttonVariant: 'outline',
|
||||
buttonStyle: 'background: white;'
|
||||
}
|
||||
buttonStyle: 'background: white;',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
mockUseQuery.mockReturnValue({
|
||||
result: { value: { publicOidcProviders: mockProviders } },
|
||||
refetch: vi.fn().mockResolvedValue({ data: { publicOidcProviders: mockProviders } }),
|
||||
@@ -458,17 +462,17 @@ describe('SsoButtons', () => {
|
||||
|
||||
const wrapper = mount(SsoButtons, {
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: {
|
||||
SsoProviderButton: SsoProviderButtonStub,
|
||||
Button: { template: '<button><slot /></button>' }
|
||||
Button: { template: '<button><slot /></button>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
await flushPromises();
|
||||
vi.runAllTimers();
|
||||
await flushPromises();
|
||||
|
||||
|
||||
const buttons = wrapper.findAll('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(wrapper.text()).toContain('Log In With Unraid.net');
|
||||
|
||||
@@ -39,10 +39,14 @@ vi.mock('vue-i18n', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockLocation = {
|
||||
pathname: '/some/other/path',
|
||||
};
|
||||
vi.stubGlobal('location', mockLocation);
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
pathname: '/some/other/path',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
vi.mock('~/helpers/urls', () => ({
|
||||
WEBGUI_TOOLS_UPDATE: { pathname: '/Tools/Update' },
|
||||
@@ -64,7 +68,7 @@ describe('UpdateOs.ce.vue', () => {
|
||||
mockSetRebootVersion.mockClear();
|
||||
mockAccountStore.updateOs.mockClear();
|
||||
mockT.mockClear().mockImplementation((key: string) => key);
|
||||
mockLocation.pathname = '/some/other/path';
|
||||
window.location.pathname = '/some/other/path';
|
||||
});
|
||||
|
||||
it('calls setRebootVersion with prop value on mount', () => {
|
||||
@@ -101,7 +105,7 @@ describe('UpdateOs.ce.vue', () => {
|
||||
|
||||
describe('Initial Rendering and onBeforeMount Logic', () => {
|
||||
it('shows loader and calls updateOs when path matches and rebootType is empty', async () => {
|
||||
mockLocation.pathname = '/Tools/Update';
|
||||
window.location.pathname = '/Tools/Update';
|
||||
mockRebootType.value = '';
|
||||
|
||||
const wrapper = mount(UpdateOs, {
|
||||
@@ -117,17 +121,21 @@ describe('UpdateOs.ce.vue', () => {
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mockAccountStore.updateOs).toHaveBeenCalledTimes(1);
|
||||
// When path matches and rebootType is empty, updateOs should be called
|
||||
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
|
||||
// Since v-show is used, both elements exist in DOM but visibility is toggled
|
||||
// Since v-show is used, both elements exist in DOM
|
||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').isVisible()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').isVisible()).toBe(false);
|
||||
// The loader should be visible when showLoader is true
|
||||
const loaderWrapper = wrapper.find('[data-testid="brand-loading-mock"]').element.parentElement;
|
||||
expect(loaderWrapper?.style.display).not.toBe('none');
|
||||
// The status should be hidden when showLoader is true
|
||||
const statusWrapper = wrapper.find('[data-testid="update-os-status"]').element.parentElement;
|
||||
expect(statusWrapper?.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('shows status and does not call updateOs when path does not match', async () => {
|
||||
mockLocation.pathname = '/some/other/path';
|
||||
window.location.pathname = '/some/other/path';
|
||||
mockRebootType.value = '';
|
||||
|
||||
const wrapper = mount(UpdateOs, {
|
||||
@@ -144,15 +152,13 @@ describe('UpdateOs.ce.vue', () => {
|
||||
await nextTick();
|
||||
|
||||
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
|
||||
// Since v-show is used, both elements exist in DOM but visibility is toggled
|
||||
// Since v-show is used, both elements exist in DOM
|
||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').isVisible()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').isVisible()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows status and does not call updateOs when path matches but rebootType is not empty', async () => {
|
||||
mockLocation.pathname = '/Tools/Update';
|
||||
it('shows status and does not call updateOs when rebootType is not empty', async () => {
|
||||
window.location.pathname = '/Tools/Update';
|
||||
mockRebootType.value = 'downgrade';
|
||||
|
||||
const wrapper = mount(UpdateOs, {
|
||||
@@ -169,11 +175,9 @@ describe('UpdateOs.ce.vue', () => {
|
||||
await nextTick();
|
||||
|
||||
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
|
||||
// Since v-show is used, both elements exist in DOM but visibility is toggled
|
||||
// Since v-show is used, both elements exist in DOM
|
||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="brand-loading-mock"]').isVisible()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="update-os-status"]').isVisible()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { provideApolloClient } from '@vue/apollo-composable';
|
||||
import { ApolloClient, InMemoryCache } from '@apollo/client/core';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { ApolloClient, InMemoryCache } from '@apollo/client/core';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -89,9 +89,9 @@ const initialServerData: Server = {
|
||||
const stubs = {
|
||||
UpcUptimeExpire: { template: '<div data-testid="uptime-expire"></div>' },
|
||||
UpcServerState: { template: '<div data-testid="server-state"></div>' },
|
||||
UpcServerStatus: {
|
||||
template: '<div><div data-testid="uptime-expire"></div><div data-testid="server-state"></div></div>',
|
||||
props: ['class']
|
||||
UpcServerStatus: {
|
||||
template: '<div><div data-testid="uptime-expire"></div><div data-testid="server-state"></div></div>',
|
||||
props: ['class'],
|
||||
},
|
||||
NotificationsSidebar: { template: '<div data-testid="notifications-sidebar"></div>' },
|
||||
DropdownMenu: {
|
||||
@@ -109,6 +109,19 @@ describe('UserProfile.ce.vue', () => {
|
||||
let consoleSpies: Array<ReturnType<typeof vi.spyOn>> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock window.location for server store
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
hostname: 'localhost',
|
||||
port: '3000',
|
||||
pathname: '/',
|
||||
protocol: 'http:',
|
||||
href: 'http://localhost:3000/',
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Create a mock Apollo Client
|
||||
const mockApolloClient = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
|
||||
+26
-168
@@ -1,5 +1,7 @@
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MockInstance } from 'vitest';
|
||||
import type { App as VueApp } from 'vue';
|
||||
|
||||
@@ -58,15 +60,13 @@ vi.mock('~/helpers/i18n-utils', () => ({
|
||||
createHtmlEntityDecoder: vi.fn(() => (str: string) => str),
|
||||
}));
|
||||
|
||||
vi.mock('~/assets/main.css?inline', () => ({
|
||||
default: '.test { color: red; }',
|
||||
}));
|
||||
// CSS is now bundled separately by Vite, no inline imports
|
||||
|
||||
describe('vue-mount-app', () => {
|
||||
let mountVueApp: typeof import('~/components/Wrapper/vue-mount-app').mountVueApp;
|
||||
let unmountVueApp: typeof import('~/components/Wrapper/vue-mount-app').unmountVueApp;
|
||||
let getMountedApp: typeof import('~/components/Wrapper/vue-mount-app').getMountedApp;
|
||||
let autoMountComponent: typeof import('~/components/Wrapper/vue-mount-app').autoMountComponent;
|
||||
describe('mount-engine', () => {
|
||||
let mountVueApp: typeof import('~/components/Wrapper/mount-engine').mountVueApp;
|
||||
let unmountVueApp: typeof import('~/components/Wrapper/mount-engine').unmountVueApp;
|
||||
let getMountedApp: typeof import('~/components/Wrapper/mount-engine').getMountedApp;
|
||||
let autoMountComponent: typeof import('~/components/Wrapper/mount-engine').autoMountComponent;
|
||||
let TestComponent: ReturnType<typeof defineComponent>;
|
||||
let consoleWarnSpy: MockInstance;
|
||||
let consoleErrorSpy: MockInstance;
|
||||
@@ -75,7 +75,7 @@ describe('vue-mount-app', () => {
|
||||
let testContainer: HTMLDivElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await import('~/components/Wrapper/vue-mount-app');
|
||||
const module = await import('~/components/Wrapper/mount-engine');
|
||||
mountVueApp = module.mountVueApp;
|
||||
unmountVueApp = module.unmountVueApp;
|
||||
getMountedApp = module.getMountedApp;
|
||||
@@ -104,7 +104,7 @@ describe('vue-mount-app', () => {
|
||||
document.body.appendChild(testContainer);
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
|
||||
// Clear mounted apps from previous tests
|
||||
if (window.mountedApps) {
|
||||
window.mountedApps.clear();
|
||||
@@ -232,87 +232,6 @@ describe('vue-mount-app', () => {
|
||||
expect(element.shadowRoot?.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should inject styles into shadow root', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'shadow-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#shadow-app',
|
||||
useShadowRoot: true,
|
||||
});
|
||||
|
||||
const styleElement = element.shadowRoot?.querySelector('style[data-tailwind]');
|
||||
expect(styleElement).toBeTruthy();
|
||||
expect(styleElement?.textContent).toBe('.test { color: red; }');
|
||||
});
|
||||
|
||||
it('should inject global styles to document', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
|
||||
const globalStyle = document.querySelector('style[data-tailwind-global]');
|
||||
expect(globalStyle).toBeTruthy();
|
||||
expect(globalStyle?.textContent).toBe('.test { color: red; }');
|
||||
});
|
||||
|
||||
it('should warn when app is already mounted', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const app1 = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
const app2 = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#app',
|
||||
appId: 'test-app',
|
||||
});
|
||||
|
||||
expect(app1).toBeTruthy();
|
||||
expect(app2).toBe(app1);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith('[VueMountApp] App test-app is already mounted');
|
||||
});
|
||||
|
||||
it('should handle modal singleton behavior', () => {
|
||||
const element1 = document.createElement('div');
|
||||
element1.id = 'modals';
|
||||
document.body.appendChild(element1);
|
||||
|
||||
const element2 = document.createElement('div');
|
||||
element2.id = 'unraid-modals';
|
||||
document.body.appendChild(element2);
|
||||
|
||||
const app1 = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#modals',
|
||||
appId: 'modals',
|
||||
});
|
||||
|
||||
const app2 = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#unraid-modals',
|
||||
appId: 'unraid-modals',
|
||||
});
|
||||
|
||||
expect(app1).toBeTruthy();
|
||||
expect(app2).toBe(app1);
|
||||
expect(consoleDebugSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Modals component already mounted as modals, skipping unraid-modals'
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean up existing Vue attributes', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
@@ -358,31 +277,10 @@ describe('vue-mount-app', () => {
|
||||
|
||||
expect(app).toBeNull();
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] No elements found for selector: #non-existent'
|
||||
'[VueMountApp] No elements found for any selector: #non-existent'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mount errors gracefully', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const ErrorComponent = defineComponent({
|
||||
setup() {
|
||||
throw new Error('Component error');
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
mountVueApp({
|
||||
component: ErrorComponent,
|
||||
selector: '#app',
|
||||
});
|
||||
}).toThrow('Component error');
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add unapi class to mounted elements', () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'app';
|
||||
@@ -411,10 +309,10 @@ describe('vue-mount-app', () => {
|
||||
element1.appendChild(orphanedChild);
|
||||
// Now remove element1 from DOM temporarily to trigger the warning
|
||||
element1.remove();
|
||||
|
||||
|
||||
// Add element1 back
|
||||
document.body.appendChild(element1);
|
||||
|
||||
|
||||
// Create elements matching the selector
|
||||
document.body.innerHTML = '';
|
||||
const validElement = document.createElement('div');
|
||||
@@ -426,7 +324,7 @@ describe('vue-mount-app', () => {
|
||||
const container = document.createElement('div');
|
||||
container.appendChild(disconnectedElement);
|
||||
// Now disconnectedElement has a parent but that parent is not in the document
|
||||
|
||||
|
||||
const app = mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '.multi',
|
||||
@@ -518,9 +416,7 @@ describe('vue-mount-app', () => {
|
||||
it('should warn when unmounting non-existent app', () => {
|
||||
const result = unmountVueApp('non-existent');
|
||||
expect(result).toBe(false);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] No app found with id: non-existent'
|
||||
);
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith('[VueMountApp] No app found with id: non-existent');
|
||||
});
|
||||
|
||||
it('should handle unmount errors gracefully', () => {
|
||||
@@ -631,21 +527,19 @@ describe('vue-mount-app', () => {
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(consoleDebugSpy).toHaveBeenCalledWith(
|
||||
'[VueMountApp] Modals component already mounted, skipping #modals'
|
||||
'[VueMountApp] Component already mounted as modals for selector #modals, returning existing instance'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add delay for problematic selectors', async () => {
|
||||
it('should mount immediately for all selectors', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'unraid-connect-settings';
|
||||
document.body.appendChild(element);
|
||||
|
||||
autoMountComponent(TestComponent, '#unraid-connect-settings');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(element.querySelector('.test-component')).toBeFalsy();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
// Component should mount immediately without delay
|
||||
await vi.runAllTimersAsync();
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -675,7 +569,7 @@ describe('vue-mount-app', () => {
|
||||
// Simulate the element having Vue instance references which cause nextSibling errors
|
||||
const mockVueInstance = { appContext: { app: {} as VueApp } };
|
||||
(element as HTMLElementWithVue).__vueParentComponent = mockVueInstance;
|
||||
|
||||
|
||||
// Add an invalid child that will trigger cleanup
|
||||
const textNode = document.createTextNode(' ');
|
||||
element.appendChild(textNode);
|
||||
@@ -685,21 +579,15 @@ describe('vue-mount-app', () => {
|
||||
|
||||
// Should detect and clean up existing Vue state
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[VueMountApp] Element #error-app has Vue attributes but no content, cleaning up')
|
||||
expect.stringContaining(
|
||||
'[VueMountApp] Element #error-app has Vue attributes but no content, cleaning up'
|
||||
)
|
||||
);
|
||||
|
||||
// Should successfully mount after cleanup
|
||||
expect(element.querySelector('.test-component')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should skip mounting if no elements found', async () => {
|
||||
autoMountComponent(TestComponent, '#non-existent');
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass options to mountVueApp', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'options-app';
|
||||
@@ -735,7 +623,9 @@ describe('vue-mount-app', () => {
|
||||
const localeData = {
|
||||
fr_FR: { test: 'Message de test' },
|
||||
};
|
||||
(window as unknown as Record<string, unknown>).LOCALE_DATA = encodeURIComponent(JSON.stringify(localeData));
|
||||
(window as unknown as Record<string, unknown>).LOCALE_DATA = encodeURIComponent(
|
||||
JSON.stringify(localeData)
|
||||
);
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.id = 'i18n-app';
|
||||
@@ -818,38 +708,6 @@ describe('vue-mount-app', () => {
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not attempt recovery with skipRecovery flag', async () => {
|
||||
const element = document.createElement('div');
|
||||
element.id = 'no-recovery-app';
|
||||
document.body.appendChild(element);
|
||||
|
||||
const mockApp = {
|
||||
use: vi.fn().mockReturnThis(),
|
||||
provide: vi.fn().mockReturnThis(),
|
||||
mount: vi.fn().mockImplementation(() => {
|
||||
throw new TypeError('Cannot read property nextSibling of null');
|
||||
}),
|
||||
unmount: vi.fn(),
|
||||
version: '3.0.0',
|
||||
config: { globalProperties: {} },
|
||||
};
|
||||
|
||||
const vueModule = await import('vue');
|
||||
vi.spyOn(vueModule, 'createApp').mockReturnValue(mockApp as never);
|
||||
|
||||
expect(() => {
|
||||
mountVueApp({
|
||||
component: TestComponent,
|
||||
selector: '#no-recovery-app',
|
||||
skipRecovery: true,
|
||||
});
|
||||
}).toThrow('Cannot read property nextSibling of null');
|
||||
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Attempting recovery')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global exposure', () => {
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock all the component imports
|
||||
vi.mock('~/components/Auth.ce.vue', () => ({
|
||||
default: { name: 'MockAuth', template: '<div>Auth</div>' },
|
||||
}));
|
||||
vi.mock('~/components/ConnectSettings/ConnectSettings.ce.vue', () => ({
|
||||
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' },
|
||||
}));
|
||||
vi.mock('~/components/DownloadApiLogs.ce.vue', () => ({
|
||||
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' },
|
||||
}));
|
||||
vi.mock('~/components/HeaderOsVersion.ce.vue', () => ({
|
||||
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' },
|
||||
}));
|
||||
vi.mock('~/components/Modals.ce.vue', () => ({
|
||||
default: { name: 'MockModals', template: '<div>Modals</div>' },
|
||||
}));
|
||||
vi.mock('~/components/UserProfile.ce.vue', () => ({
|
||||
default: { name: 'MockUserProfile', template: '<div>UserProfile</div>' },
|
||||
}));
|
||||
vi.mock('~/components/UpdateOs.ce.vue', () => ({
|
||||
default: { name: 'MockUpdateOs', template: '<div>UpdateOs</div>' },
|
||||
}));
|
||||
vi.mock('~/components/DowngradeOs.ce.vue', () => ({
|
||||
default: { name: 'MockDowngradeOs', template: '<div>DowngradeOs</div>' },
|
||||
}));
|
||||
vi.mock('~/components/Registration.ce.vue', () => ({
|
||||
default: { name: 'MockRegistration', template: '<div>Registration</div>' },
|
||||
}));
|
||||
vi.mock('~/components/WanIpCheck.ce.vue', () => ({
|
||||
default: { name: 'MockWanIpCheck', template: '<div>WanIpCheck</div>' },
|
||||
}));
|
||||
vi.mock('~/components/Activation/WelcomeModal.ce.vue', () => ({
|
||||
default: { name: 'MockWelcomeModal', template: '<div>WelcomeModal</div>' },
|
||||
}));
|
||||
vi.mock('~/components/SsoButton.ce.vue', () => ({
|
||||
default: { name: 'MockSsoButton', template: '<div>SsoButton</div>' },
|
||||
}));
|
||||
vi.mock('~/components/Logs/LogViewer.ce.vue', () => ({
|
||||
default: { name: 'MockLogViewer', template: '<div>LogViewer</div>' },
|
||||
}));
|
||||
vi.mock('~/components/ThemeSwitcher.ce.vue', () => ({
|
||||
default: { name: 'MockThemeSwitcher', template: '<div>ThemeSwitcher</div>' },
|
||||
}));
|
||||
vi.mock('~/components/ApiKeyPage.ce.vue', () => ({
|
||||
default: { name: 'MockApiKeyPage', template: '<div>ApiKeyPage</div>' },
|
||||
}));
|
||||
vi.mock('~/components/DevModalTest.ce.vue', () => ({
|
||||
default: { name: 'MockDevModalTest', template: '<div>DevModalTest</div>' },
|
||||
}));
|
||||
vi.mock('~/components/ApiKeyAuthorize.ce.vue', () => ({
|
||||
default: { name: 'MockApiKeyAuthorize', template: '<div>ApiKeyAuthorize</div>' },
|
||||
}));
|
||||
vi.mock('~/components/UnraidToaster.vue', () => ({
|
||||
default: { name: 'MockUnraidToaster', template: '<div>UnraidToaster</div>' },
|
||||
}));
|
||||
|
||||
// Mock mount-engine module
|
||||
const mockAutoMountComponent = vi.fn();
|
||||
const mockAutoMountAllComponents = vi.fn();
|
||||
const mockMountVueApp = vi.fn();
|
||||
const mockGetMountedApp = vi.fn();
|
||||
|
||||
vi.mock('~/components/Wrapper/mount-engine', () => ({
|
||||
autoMountComponent: mockAutoMountComponent,
|
||||
autoMountAllComponents: mockAutoMountAllComponents,
|
||||
mountVueApp: mockMountVueApp,
|
||||
getMountedApp: mockGetMountedApp,
|
||||
}));
|
||||
|
||||
// Mock theme initializer
|
||||
const mockInitializeTheme = vi.fn(() => Promise.resolve());
|
||||
vi.mock('~/store/themeInitializer', () => ({
|
||||
initializeTheme: mockInitializeTheme,
|
||||
isThemeReady: vi.fn(() => true),
|
||||
resetThemeInitialization: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock globalPinia
|
||||
vi.mock('~/store/globalPinia', () => ({
|
||||
globalPinia: { state: {} },
|
||||
}));
|
||||
|
||||
// Mock apollo client
|
||||
const mockApolloClient = {
|
||||
query: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
vi.mock('~/helpers/create-apollo-client', () => ({
|
||||
client: mockApolloClient,
|
||||
}));
|
||||
|
||||
// Mock @vue/apollo-composable
|
||||
const mockProvideApolloClient = vi.fn();
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
provideApolloClient: mockProvideApolloClient,
|
||||
}));
|
||||
|
||||
// Mock graphql
|
||||
const mockParse = vi.fn();
|
||||
vi.mock('graphql', () => ({
|
||||
parse: mockParse,
|
||||
}));
|
||||
|
||||
// Mock @unraid/ui
|
||||
const mockEnsureTeleportContainer = vi.fn();
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
ensureTeleportContainer: mockEnsureTeleportContainer,
|
||||
}));
|
||||
|
||||
describe('component-registry', () => {
|
||||
beforeEach(() => {
|
||||
// Reset module cache to ensure fresh imports
|
||||
vi.resetModules();
|
||||
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Use Vitest's unstubAllGlobals to clean up any global stubs from previous tests
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
// Mock document methods
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation(() => document.createElement('style'));
|
||||
vi.spyOn(document, 'addEventListener').mockImplementation(() => {});
|
||||
|
||||
// Clear DOM
|
||||
document.head.innerHTML = '';
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should set up Apollo client globally', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
expect(window.apolloClient).toBe(mockApolloClient);
|
||||
expect(window.graphqlParse).toBe(mockParse);
|
||||
expect(window.gql).toBe(mockParse);
|
||||
expect(mockProvideApolloClient).toHaveBeenCalledWith(mockApolloClient);
|
||||
});
|
||||
|
||||
it('should initialize theme once', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
expect(mockInitializeTheme).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ensure teleport container exists', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('component auto-mounting', () => {
|
||||
it('should auto-mount components when DOM elements exist', async () => {
|
||||
// Create DOM elements for components to mount to
|
||||
const authElement = document.createElement('div');
|
||||
authElement.setAttribute('id', 'unraid-auth');
|
||||
document.body.appendChild(authElement);
|
||||
|
||||
const modalElement = document.createElement('div');
|
||||
modalElement.setAttribute('id', 'modals');
|
||||
document.body.appendChild(modalElement);
|
||||
|
||||
// Clear previous calls
|
||||
mockAutoMountAllComponents.mockClear();
|
||||
|
||||
// Import auto-mount which will trigger auto-mounting
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
// Auto-mount should be called when DOM is ready
|
||||
expect(mockAutoMountAllComponents).toHaveBeenCalled();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(authElement);
|
||||
document.body.removeChild(modalElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global exports', () => {
|
||||
it('should expose utility functions globally', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
expect(window.mountVueApp).toBe(mockMountVueApp);
|
||||
expect(window.getMountedApp).toBe(mockGetMountedApp);
|
||||
expect(window.autoMountComponent).toBe(mockAutoMountComponent);
|
||||
});
|
||||
|
||||
it('should expose mountVueApp function globally', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
// Check that mountVueApp is exposed
|
||||
expect(typeof window.mountVueApp).toBe('function');
|
||||
|
||||
// Note: Dynamic mount functions are no longer created automatically
|
||||
// They would be created via mountVueApp calls
|
||||
});
|
||||
|
||||
it('should expose autoMountComponent function globally', async () => {
|
||||
await import('~/components/Wrapper/auto-mount');
|
||||
|
||||
// Check that autoMountComponent is exposed
|
||||
expect(typeof window.autoMountComponent).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,278 +0,0 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock all the component imports
|
||||
vi.mock('~/components/Auth.ce.vue', () => ({
|
||||
default: { name: 'MockAuth', template: '<div>Auth</div>' }
|
||||
}));
|
||||
vi.mock('~/components/ConnectSettings/ConnectSettings.ce.vue', () => ({
|
||||
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' }
|
||||
}));
|
||||
vi.mock('~/components/DownloadApiLogs.ce.vue', () => ({
|
||||
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' }
|
||||
}));
|
||||
vi.mock('~/components/HeaderOsVersion.ce.vue', () => ({
|
||||
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' }
|
||||
}));
|
||||
vi.mock('~/components/Modals.ce.vue', () => ({
|
||||
default: { name: 'MockModals', template: '<div>Modals</div>' }
|
||||
}));
|
||||
vi.mock('~/components/UserProfile.ce.vue', () => ({
|
||||
default: { name: 'MockUserProfile', template: '<div>UserProfile</div>' }
|
||||
}));
|
||||
vi.mock('~/components/UpdateOs.ce.vue', () => ({
|
||||
default: { name: 'MockUpdateOs', template: '<div>UpdateOs</div>' }
|
||||
}));
|
||||
vi.mock('~/components/DowngradeOs.ce.vue', () => ({
|
||||
default: { name: 'MockDowngradeOs', template: '<div>DowngradeOs</div>' }
|
||||
}));
|
||||
vi.mock('~/components/Registration.ce.vue', () => ({
|
||||
default: { name: 'MockRegistration', template: '<div>Registration</div>' }
|
||||
}));
|
||||
vi.mock('~/components/WanIpCheck.ce.vue', () => ({
|
||||
default: { name: 'MockWanIpCheck', template: '<div>WanIpCheck</div>' }
|
||||
}));
|
||||
vi.mock('~/components/Activation/WelcomeModal.ce.vue', () => ({
|
||||
default: { name: 'MockWelcomeModal', template: '<div>WelcomeModal</div>' }
|
||||
}));
|
||||
vi.mock('~/components/SsoButton.ce.vue', () => ({
|
||||
default: { name: 'MockSsoButton', template: '<div>SsoButton</div>' }
|
||||
}));
|
||||
vi.mock('~/components/Logs/LogViewer.ce.vue', () => ({
|
||||
default: { name: 'MockLogViewer', template: '<div>LogViewer</div>' }
|
||||
}));
|
||||
vi.mock('~/components/ThemeSwitcher.ce.vue', () => ({
|
||||
default: { name: 'MockThemeSwitcher', template: '<div>ThemeSwitcher</div>' }
|
||||
}));
|
||||
vi.mock('~/components/ApiKeyPage.ce.vue', () => ({
|
||||
default: { name: 'MockApiKeyPage', template: '<div>ApiKeyPage</div>' }
|
||||
}));
|
||||
vi.mock('~/components/DevModalTest.ce.vue', () => ({
|
||||
default: { name: 'MockDevModalTest', template: '<div>DevModalTest</div>' }
|
||||
}));
|
||||
vi.mock('~/components/ApiKeyAuthorize.ce.vue', () => ({
|
||||
default: { name: 'MockApiKeyAuthorize', template: '<div>ApiKeyAuthorize</div>' }
|
||||
}));
|
||||
vi.mock('~/components/UnraidToaster.vue', () => ({
|
||||
default: { name: 'MockUnraidToaster', template: '<div>UnraidToaster</div>' }
|
||||
}));
|
||||
|
||||
// Mock vue-mount-app module
|
||||
const mockAutoMountComponent = vi.fn();
|
||||
const mockMountVueApp = vi.fn();
|
||||
const mockGetMountedApp = vi.fn();
|
||||
|
||||
vi.mock('~/components/Wrapper/vue-mount-app', () => ({
|
||||
autoMountComponent: mockAutoMountComponent,
|
||||
mountVueApp: mockMountVueApp,
|
||||
getMountedApp: mockGetMountedApp,
|
||||
}));
|
||||
|
||||
// Mock theme store
|
||||
const mockSetTheme = vi.fn();
|
||||
const mockSetCssVars = vi.fn();
|
||||
const mockUseThemeStore = vi.fn(() => ({
|
||||
setTheme: mockSetTheme,
|
||||
setCssVars: mockSetCssVars,
|
||||
}));
|
||||
|
||||
vi.mock('~/store/theme', () => ({
|
||||
useThemeStore: mockUseThemeStore,
|
||||
}));
|
||||
|
||||
// Mock globalPinia
|
||||
vi.mock('~/store/globalPinia', () => ({
|
||||
globalPinia: { state: {} },
|
||||
}));
|
||||
|
||||
// Mock apollo client
|
||||
const mockApolloClient = {
|
||||
query: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
};
|
||||
vi.mock('~/helpers/create-apollo-client', () => ({
|
||||
client: mockApolloClient,
|
||||
}));
|
||||
|
||||
// Mock @vue/apollo-composable
|
||||
const mockProvideApolloClient = vi.fn();
|
||||
vi.mock('@vue/apollo-composable', () => ({
|
||||
provideApolloClient: mockProvideApolloClient,
|
||||
}));
|
||||
|
||||
// Mock graphql
|
||||
const mockParse = vi.fn();
|
||||
vi.mock('graphql', () => ({
|
||||
parse: mockParse,
|
||||
}));
|
||||
|
||||
// Mock @unraid/ui
|
||||
const mockEnsureTeleportContainer = vi.fn();
|
||||
vi.mock('@unraid/ui', () => ({
|
||||
ensureTeleportContainer: mockEnsureTeleportContainer,
|
||||
}));
|
||||
|
||||
describe('standalone-mount', () => {
|
||||
beforeEach(() => {
|
||||
// Reset module cache to ensure fresh imports
|
||||
vi.resetModules();
|
||||
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Use Vitest's unstubAllGlobals to clean up any global stubs from previous tests
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
// Mock document methods
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation(() => document.createElement('style'));
|
||||
vi.spyOn(document, 'addEventListener').mockImplementation(() => {});
|
||||
|
||||
// Clear DOM
|
||||
document.head.innerHTML = '';
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
|
||||
it('should set up Apollo client globally', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(window.apolloClient).toBe(mockApolloClient);
|
||||
expect(window.graphqlParse).toBe(mockParse);
|
||||
expect(window.gql).toBe(mockParse);
|
||||
expect(mockProvideApolloClient).toHaveBeenCalledWith(mockApolloClient);
|
||||
});
|
||||
|
||||
it('should initialize theme store', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(mockUseThemeStore).toHaveBeenCalled();
|
||||
expect(mockSetTheme).toHaveBeenCalled();
|
||||
expect(mockSetCssVars).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ensure teleport container exists', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(mockEnsureTeleportContainer).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('component auto-mounting', () => {
|
||||
it('should auto-mount all defined components', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
// Verify that autoMountComponent was called multiple times
|
||||
expect(mockAutoMountComponent.mock.calls.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify all calls have the correct structure
|
||||
mockAutoMountComponent.mock.calls.forEach(call => {
|
||||
expect(call[0]).toBeDefined(); // Component
|
||||
expect(call[1]).toBeDefined(); // Selector
|
||||
expect(call[2]).toMatchObject({
|
||||
appId: expect.any(String),
|
||||
useShadowRoot: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Extract all selectors that were mounted
|
||||
const mountedSelectors = mockAutoMountComponent.mock.calls.map(call => call[1]);
|
||||
|
||||
// Verify critical components are mounted
|
||||
expect(mountedSelectors).toContain('unraid-auth');
|
||||
expect(mountedSelectors).toContain('unraid-modals');
|
||||
expect(mountedSelectors).toContain('unraid-user-profile');
|
||||
expect(mountedSelectors).toContain('uui-toaster');
|
||||
expect(mountedSelectors).toContain('#modals'); // Legacy modal selector
|
||||
|
||||
// Verify no shadow DOM is used
|
||||
const allUseShadowRoot = mockAutoMountComponent.mock.calls.every(
|
||||
call => call[2].useShadowRoot === false
|
||||
);
|
||||
expect(allUseShadowRoot).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global exports', () => {
|
||||
it('should expose UnraidComponents globally', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(window.UnraidComponents).toBeDefined();
|
||||
expect(window.UnraidComponents).toHaveProperty('Auth');
|
||||
expect(window.UnraidComponents).toHaveProperty('ConnectSettings');
|
||||
expect(window.UnraidComponents).toHaveProperty('DownloadApiLogs');
|
||||
expect(window.UnraidComponents).toHaveProperty('HeaderOsVersion');
|
||||
expect(window.UnraidComponents).toHaveProperty('Modals');
|
||||
expect(window.UnraidComponents).toHaveProperty('UserProfile');
|
||||
expect(window.UnraidComponents).toHaveProperty('UpdateOs');
|
||||
expect(window.UnraidComponents).toHaveProperty('DowngradeOs');
|
||||
expect(window.UnraidComponents).toHaveProperty('Registration');
|
||||
expect(window.UnraidComponents).toHaveProperty('WanIpCheck');
|
||||
expect(window.UnraidComponents).toHaveProperty('WelcomeModal');
|
||||
expect(window.UnraidComponents).toHaveProperty('SsoButton');
|
||||
expect(window.UnraidComponents).toHaveProperty('LogViewer');
|
||||
expect(window.UnraidComponents).toHaveProperty('ThemeSwitcher');
|
||||
expect(window.UnraidComponents).toHaveProperty('ApiKeyPage');
|
||||
expect(window.UnraidComponents).toHaveProperty('DevModalTest');
|
||||
expect(window.UnraidComponents).toHaveProperty('ApiKeyAuthorize');
|
||||
expect(window.UnraidComponents).toHaveProperty('UnraidToaster');
|
||||
});
|
||||
|
||||
it('should expose utility functions globally', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
expect(window.mountVueApp).toBe(mockMountVueApp);
|
||||
expect(window.getMountedApp).toBe(mockGetMountedApp);
|
||||
});
|
||||
|
||||
it('should create dynamic mount functions for each component', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
// Check for some dynamic mount functions
|
||||
expect(typeof window.mountAuth).toBe('function');
|
||||
expect(typeof window.mountConnectSettings).toBe('function');
|
||||
expect(typeof window.mountUserProfile).toBe('function');
|
||||
expect(typeof window.mountModals).toBe('function');
|
||||
expect(typeof window.mountThemeSwitcher).toBe('function');
|
||||
|
||||
// Test calling a dynamic mount function
|
||||
const customSelector = '#custom-auth';
|
||||
window.mountAuth?.(customSelector);
|
||||
|
||||
expect(mockMountVueApp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: customSelector,
|
||||
useShadowRoot: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default selector when no custom selector provided', async () => {
|
||||
await import('~/components/standalone-mount');
|
||||
|
||||
// Call mount function without custom selector
|
||||
window.mountAuth?.();
|
||||
|
||||
expect(mockMountVueApp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'unraid-auth',
|
||||
useShadowRoot: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Skip SSR safety test as it's complex to test with module isolation
|
||||
describe.skip('SSR safety', () => {
|
||||
it('should not initialize when window is undefined', async () => {
|
||||
// This test is skipped because the module initialization happens at import time
|
||||
// and it's difficult to properly isolate the window object manipulation
|
||||
// The functionality is simple enough - just checking if window exists before running code
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AuthAction, Resource, Role } from '~/composables/gql/graphql.js';
|
||||
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
|
||||
import { Role, Resource, AuthAction } from '~/composables/gql/graphql.js';
|
||||
|
||||
// Mock window.location for the tests
|
||||
Object.defineProperty(window, 'location', {
|
||||
@@ -19,7 +20,8 @@ describe('useAuthorizationLink', () => {
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
});
|
||||
|
||||
const { formData, displayAppName, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
const { formData, displayAppName, hasPermissions, permissionsSummary } =
|
||||
useAuthorizationLink(params);
|
||||
|
||||
expect(formData.value).toEqual({
|
||||
name: 'MyApp',
|
||||
@@ -43,19 +45,20 @@ describe('useAuthorizationLink', () => {
|
||||
|
||||
// docker has read_any+update_any, vms only has read_any - these should be separate groups
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
|
||||
// Find the group with just READ_ANY
|
||||
const readOnlyGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
|
||||
(p) => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
|
||||
);
|
||||
expect(readOnlyGroup).toBeDefined();
|
||||
expect(readOnlyGroup?.resources).toEqual([Resource.VMS]);
|
||||
|
||||
|
||||
// Find the group with READ_ANY and UPDATE_ANY
|
||||
const readUpdateGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 2 &&
|
||||
p.actions.includes(AuthAction.READ_ANY) &&
|
||||
p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
(p) =>
|
||||
p.actions.length === 2 &&
|
||||
p.actions.includes(AuthAction.READ_ANY) &&
|
||||
p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
);
|
||||
expect(readUpdateGroup).toBeDefined();
|
||||
expect(readUpdateGroup?.resources).toEqual([Resource.DOCKER]);
|
||||
@@ -74,18 +77,16 @@ describe('useAuthorizationLink', () => {
|
||||
|
||||
expect(formData.value.roles).toEqual([Role.ADMIN]);
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
|
||||
// Docker should have just read permission
|
||||
const dockerGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.DOCKER)
|
||||
const dockerGroup = formData.value.customPermissions!.find((p) =>
|
||||
p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(dockerGroup).toBeDefined();
|
||||
expect(dockerGroup?.actions).toEqual([AuthAction.READ_ANY]);
|
||||
|
||||
|
||||
// VMs should have all CRUD permissions from wildcard
|
||||
const vmsGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
const vmsGroup = formData.value.customPermissions!.find((p) => p.resources.includes(Resource.VMS));
|
||||
expect(vmsGroup).toBeDefined();
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
@@ -188,17 +189,15 @@ describe('useAuthorizationLink', () => {
|
||||
|
||||
// Docker has read+update, VMs has create+delete - these should be separate
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const dockerGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.DOCKER)
|
||||
|
||||
const dockerGroup = formData.value.customPermissions!.find((p) =>
|
||||
p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(dockerGroup).toBeDefined();
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
|
||||
const vmsGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
|
||||
const vmsGroup = formData.value.customPermissions!.find((p) => p.resources.includes(Resource.VMS));
|
||||
expect(vmsGroup).toBeDefined();
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
@@ -214,14 +213,14 @@ describe('useAuthorizationLink', () => {
|
||||
|
||||
// Docker has read, VMs has update - different actions so separate groups
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const readGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.READ_ANY)
|
||||
|
||||
const readGroup = formData.value.customPermissions!.find((p) =>
|
||||
p.actions.includes(AuthAction.READ_ANY)
|
||||
);
|
||||
expect(readGroup?.resources).toEqual([Resource.DOCKER]);
|
||||
|
||||
const updateGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
|
||||
const updateGroup = formData.value.customPermissions!.find((p) =>
|
||||
p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
);
|
||||
expect(updateGroup?.resources).toEqual([Resource.VMS]);
|
||||
});
|
||||
@@ -236,9 +235,9 @@ describe('useAuthorizationLink', () => {
|
||||
|
||||
// Docker has all CRUD, VMs has just read - different action sets so separate groups
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const dockerGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.DOCKER)
|
||||
|
||||
const dockerGroup = formData.value.customPermissions!.find((p) =>
|
||||
p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(dockerGroup).toBeDefined();
|
||||
// Should have all CRUD actions from wildcard
|
||||
@@ -246,10 +245,8 @@ describe('useAuthorizationLink', () => {
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
|
||||
const vmsGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
|
||||
const vmsGroup = formData.value.customPermissions!.find((p) => p.resources.includes(Resource.VMS));
|
||||
expect(vmsGroup).toBeDefined();
|
||||
expect(vmsGroup?.actions).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
@@ -257,7 +254,8 @@ describe('useAuthorizationLink', () => {
|
||||
it('should handle complex permission combinations', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Complex Permissions App',
|
||||
scopes: 'connect:read_any,disk:read_any,docker:*,vms:update_any,vms:delete_any,dashboard:read_any',
|
||||
scopes:
|
||||
'connect:read_any,disk:read_any,docker:*,vms:update_any,vms:delete_any,dashboard:read_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
@@ -267,10 +265,10 @@ describe('useAuthorizationLink', () => {
|
||||
// - docker has all CRUD from wildcard (group 2)
|
||||
// - vms has update+delete (group 3)
|
||||
expect(formData.value.customPermissions!).toHaveLength(3);
|
||||
|
||||
|
||||
// Find read-only group (connect, disk, dashboard)
|
||||
const readOnlyGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
|
||||
(p) => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
|
||||
);
|
||||
expect(readOnlyGroup).toBeDefined();
|
||||
expect(readOnlyGroup?.resources).toContain(Resource.CONNECT);
|
||||
@@ -279,7 +277,7 @@ describe('useAuthorizationLink', () => {
|
||||
|
||||
// Find full CRUD group (docker with wildcard)
|
||||
const fullCrudGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 4 && p.resources.includes(Resource.DOCKER)
|
||||
(p) => p.actions.length === 4 && p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(fullCrudGroup).toBeDefined();
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
@@ -288,8 +286,8 @@ describe('useAuthorizationLink', () => {
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
|
||||
// Find update+delete group (vms)
|
||||
const updateDeleteGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
const updateDeleteGroup = formData.value.customPermissions!.find((p) =>
|
||||
p.resources.includes(Resource.VMS)
|
||||
);
|
||||
expect(updateDeleteGroup).toBeDefined();
|
||||
expect(updateDeleteGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
@@ -324,16 +322,16 @@ describe('useAuthorizationLink', () => {
|
||||
|
||||
// Should have two groups: docker+vms with read, dashboard with update
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const readGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.READ_ANY)
|
||||
|
||||
const readGroup = formData.value.customPermissions!.find((p) =>
|
||||
p.actions.includes(AuthAction.READ_ANY)
|
||||
);
|
||||
expect(readGroup).toBeDefined();
|
||||
expect(readGroup?.resources).toContain(Resource.DOCKER);
|
||||
expect(readGroup?.resources).toContain(Resource.VMS);
|
||||
|
||||
const updateGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
|
||||
const updateGroup = formData.value.customPermissions!.find((p) =>
|
||||
p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
);
|
||||
expect(updateGroup).toBeDefined();
|
||||
expect(updateGroup?.resources).toEqual([Resource.DASHBOARD]);
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`sanitization > strips javascript 1`] = `
|
||||
"<p><img src="x"></p>
|
||||
"
|
||||
`;
|
||||
exports[`sanitization > strips javascript 1`] = `"<p></p>"`;
|
||||
|
||||
exports[`sanitization > strips javascript 2`] = `
|
||||
"<p><img src="x"></p>
|
||||
"
|
||||
`;
|
||||
exports[`sanitization > strips javascript 2`] = `"<p></p>"`;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Types for mock data
|
||||
@@ -27,13 +28,15 @@ export function createMockUseQuery<TData = unknown>(
|
||||
result: ref(resultData),
|
||||
loading: ref(options.loading ?? false),
|
||||
error: ref(options.error ?? null),
|
||||
refetch: vi.fn(() => Promise.resolve({
|
||||
data: resultData || {},
|
||||
loading: false,
|
||||
networkStatus: 7,
|
||||
stale: false,
|
||||
error: undefined
|
||||
})),
|
||||
refetch: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
data: resultData || {},
|
||||
loading: false,
|
||||
networkStatus: 7,
|
||||
stale: false,
|
||||
error: undefined,
|
||||
})
|
||||
),
|
||||
subscribeToMore: vi.fn(),
|
||||
networkStatus: ref(7),
|
||||
start: vi.fn(),
|
||||
@@ -47,7 +50,7 @@ export function createMockUseQuery<TData = unknown>(
|
||||
fetchMore: vi.fn(),
|
||||
updateQuery: vi.fn(),
|
||||
onResult: vi.fn(),
|
||||
onError: vi.fn()
|
||||
onError: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,10 +66,10 @@ export function createMockLogFileQuery(
|
||||
logFile: {
|
||||
content,
|
||||
totalLines,
|
||||
startLine
|
||||
}
|
||||
startLine,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
return createMockUseQuery(result);
|
||||
}
|
||||
|
||||
@@ -78,9 +81,9 @@ export function apolloComposableMockFactory() {
|
||||
return {
|
||||
useApolloClient: vi.fn(() => ({
|
||||
client: {
|
||||
query: vi.fn()
|
||||
}
|
||||
query: vi.fn(),
|
||||
},
|
||||
})),
|
||||
useQuery: vi.fn(() => createMockUseQuery())
|
||||
useQuery: vi.fn(() => createMockUseQuery()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { config } from '@vue/test-utils';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Import mocks
|
||||
import './mocks/ui-components.js';
|
||||
import '@/../__test__/mocks/ui-components.js';
|
||||
|
||||
// Configure Vue Test Utils
|
||||
config.global.plugins = [
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Modal store test coverage
|
||||
*/
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -11,10 +11,12 @@ import { useModalStore } from '~/store/modal';
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useToggle: (initial: boolean) => {
|
||||
const state = ref(initial)
|
||||
const toggle = () => { state.value = !state.value }
|
||||
return [state, toggle]
|
||||
}
|
||||
const state = ref(initial);
|
||||
const toggle = () => {
|
||||
state.value = !state.value;
|
||||
};
|
||||
return [state, toggle];
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Modal Store', () => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Purchase store test coverage
|
||||
*/
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import { PURCHASE_CALLBACK } from '~/helpers/urls';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -817,19 +817,31 @@ describe('useServerStore', () => {
|
||||
expect(store.trialWithin5DaysOfExpiration).toBe(false);
|
||||
|
||||
// Trial expiring in 3 days
|
||||
store.setServer({ state: 'TRIAL' as ServerState, expireTime: dayjs().add(3, 'day').unix() * 1000 });
|
||||
store.setServer({
|
||||
state: 'TRIAL' as ServerState,
|
||||
expireTime: dayjs().add(3, 'day').unix() * 1000,
|
||||
});
|
||||
expect(store.trialWithin5DaysOfExpiration).toBe(true);
|
||||
|
||||
// Trial expiring in exactly 5 days
|
||||
store.setServer({ state: 'TRIAL' as ServerState, expireTime: dayjs().add(5, 'day').unix() * 1000 });
|
||||
store.setServer({
|
||||
state: 'TRIAL' as ServerState,
|
||||
expireTime: dayjs().add(5, 'day').unix() * 1000,
|
||||
});
|
||||
expect(store.trialWithin5DaysOfExpiration).toBe(true);
|
||||
|
||||
// Trial expiring in 7 days (to ensure it's clearly outside the 5-day window)
|
||||
store.setServer({ state: 'TRIAL' as ServerState, expireTime: dayjs().add(7, 'day').unix() * 1000 });
|
||||
store.setServer({
|
||||
state: 'TRIAL' as ServerState,
|
||||
expireTime: dayjs().add(7, 'day').unix() * 1000,
|
||||
});
|
||||
expect(store.trialWithin5DaysOfExpiration).toBe(false);
|
||||
|
||||
// Trial already expired
|
||||
store.setServer({ state: 'TRIAL' as ServerState, expireTime: dayjs().subtract(1, 'day').unix() * 1000 });
|
||||
store.setServer({
|
||||
state: 'TRIAL' as ServerState,
|
||||
expireTime: dayjs().subtract(1, 'day').unix() * 1000,
|
||||
});
|
||||
expect(store.trialWithin5DaysOfExpiration).toBe(false);
|
||||
});
|
||||
|
||||
@@ -946,13 +958,17 @@ describe('useServerStore', () => {
|
||||
|
||||
let trialMessage = '';
|
||||
if (store.trialExtensionEligibleInsideRenewalWindow) {
|
||||
trialMessage = '<p>Your <em>Trial</em> key includes all the functionality and device support of an <em>Unleashed</em> key.</p><p>Your trial is expiring soon. When it expires, <strong>the array will stop</strong>. You may extend your trial now, purchase a license key, or wait until expiration to take action.</p>';
|
||||
trialMessage =
|
||||
'<p>Your <em>Trial</em> key includes all the functionality and device support of an <em>Unleashed</em> key.</p><p>Your trial is expiring soon. When it expires, <strong>the array will stop</strong>. You may extend your trial now, purchase a license key, or wait until expiration to take action.</p>';
|
||||
} else if (store.trialExtensionIneligibleInsideRenewalWindow) {
|
||||
trialMessage = '<p>Your <em>Trial</em> key includes all the functionality and device support of an <em>Unleashed</em> key.</p><p>Your trial is expiring soon and you have used all available extensions. When it expires, <strong>the array will stop</strong>. To continue using Unraid OS, you must purchase a license key.</p>';
|
||||
trialMessage =
|
||||
'<p>Your <em>Trial</em> key includes all the functionality and device support of an <em>Unleashed</em> key.</p><p>Your trial is expiring soon and you have used all available extensions. When it expires, <strong>the array will stop</strong>. To continue using Unraid OS, you must purchase a license key.</p>';
|
||||
} else if (store.trialExtensionEligibleOutsideRenewalWindow) {
|
||||
trialMessage = '<p>Your <em>Trial</em> key includes all the functionality and device support of an <em>Unleashed</em> key.</p><p>When your <em>Trial</em> expires, <strong>the array will stop</strong>. At that point you may either purchase a license key or request a <em>Trial</em> extension.</p>';
|
||||
trialMessage =
|
||||
'<p>Your <em>Trial</em> key includes all the functionality and device support of an <em>Unleashed</em> key.</p><p>When your <em>Trial</em> expires, <strong>the array will stop</strong>. At that point you may either purchase a license key or request a <em>Trial</em> extension.</p>';
|
||||
} else {
|
||||
trialMessage = '<p>Your <em>Trial</em> key includes all the functionality and device support of an <em>Unleashed</em> key.</p><p>You have used all available trial extensions. When your <em>Trial</em> expires, <strong>the array will stop</strong>. To continue using Unraid OS after expiration, you must purchase a license key.</p>';
|
||||
trialMessage =
|
||||
'<p>Your <em>Trial</em> key includes all the functionality and device support of an <em>Unleashed</em> key.</p><p>You have used all available trial extensions. When your <em>Trial</em> expires, <strong>the array will stop</strong>. To continue using Unraid OS after expiration, you must purchase a license key.</p>';
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -979,8 +995,12 @@ describe('useServerStore', () => {
|
||||
regGen: 2,
|
||||
expireTime: dayjs().add(3, 'day').unix() * 1000,
|
||||
});
|
||||
expect(store.stateData.message).toContain('Your trial is expiring soon and you have used all available extensions');
|
||||
expect(store.stateData.message).toContain('To continue using Unraid OS, you must purchase a license key');
|
||||
expect(store.stateData.message).toContain(
|
||||
'Your trial is expiring soon and you have used all available extensions'
|
||||
);
|
||||
expect(store.stateData.message).toContain(
|
||||
'To continue using Unraid OS, you must purchase a license key'
|
||||
);
|
||||
|
||||
// Test case 3: Eligible outside renewal window
|
||||
store.setServer({
|
||||
@@ -988,7 +1008,9 @@ describe('useServerStore', () => {
|
||||
regGen: 0,
|
||||
expireTime: dayjs().add(10, 'day').unix() * 1000,
|
||||
});
|
||||
expect(store.stateData.message).toContain('At that point you may either purchase a license key or request a <em>Trial</em> extension');
|
||||
expect(store.stateData.message).toContain(
|
||||
'At that point you may either purchase a license key or request a <em>Trial</em> extension'
|
||||
);
|
||||
|
||||
// Test case 4: Ineligible outside renewal window
|
||||
store.setServer({
|
||||
@@ -997,7 +1019,9 @@ describe('useServerStore', () => {
|
||||
expireTime: dayjs().add(10, 'day').unix() * 1000,
|
||||
});
|
||||
expect(store.stateData.message).toContain('You have used all available trial extensions');
|
||||
expect(store.stateData.message).toContain('To continue using Unraid OS after expiration, you must purchase a license key');
|
||||
expect(store.stateData.message).toContain(
|
||||
'To continue using Unraid OS after expiration, you must purchase a license key'
|
||||
);
|
||||
});
|
||||
|
||||
it('should include trial extend action only when eligible inside renewal window', () => {
|
||||
@@ -1060,7 +1084,9 @@ describe('useServerStore', () => {
|
||||
registered: true,
|
||||
connectPluginInstalled: 'true' as ServerconnectPluginInstalled,
|
||||
});
|
||||
expect(store.stateData.actions?.some((action: { name: string }) => action.name === 'trialExtend')).toBe(true);
|
||||
expect(
|
||||
store.stateData.actions?.some((action: { name: string }) => action.name === 'trialExtend')
|
||||
).toBe(true);
|
||||
|
||||
// Test case 2: Not eligible inside renewal window - should NOT include trialExtend action
|
||||
store.setServer({
|
||||
@@ -1070,7 +1096,9 @@ describe('useServerStore', () => {
|
||||
registered: true,
|
||||
connectPluginInstalled: 'true' as ServerconnectPluginInstalled,
|
||||
});
|
||||
expect(store.stateData.actions?.some((action: { name: string }) => action.name === 'trialExtend')).toBe(false);
|
||||
expect(
|
||||
store.stateData.actions?.some((action: { name: string }) => action.name === 'trialExtend')
|
||||
).toBe(false);
|
||||
|
||||
// Test case 3: Eligible outside renewal window - should NOT include trialExtend action
|
||||
store.setServer({
|
||||
@@ -1080,7 +1108,9 @@ describe('useServerStore', () => {
|
||||
registered: true,
|
||||
connectPluginInstalled: 'true' as ServerconnectPluginInstalled,
|
||||
});
|
||||
expect(store.stateData.actions?.some((action: { name: string }) => action.name === 'trialExtend')).toBe(false);
|
||||
expect(
|
||||
store.stateData.actions?.some((action: { name: string }) => action.name === 'trialExtend')
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('Theme Store', () => {
|
||||
const originalAddClassFn = document.body.classList.add;
|
||||
const originalRemoveClassFn = document.body.classList.remove;
|
||||
const originalStyleCssText = document.body.style.cssText;
|
||||
const originalDocumentElementSetProperty = document.documentElement.style.setProperty;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
@@ -28,6 +29,9 @@ describe('Theme Store', () => {
|
||||
document.body.classList.add = vi.fn();
|
||||
document.body.classList.remove = vi.fn();
|
||||
document.body.style.cssText = '';
|
||||
document.documentElement.style.setProperty = vi.fn();
|
||||
document.documentElement.classList.add = vi.fn();
|
||||
document.documentElement.classList.remove = vi.fn();
|
||||
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
@@ -42,6 +46,7 @@ describe('Theme Store', () => {
|
||||
document.body.classList.add = originalAddClassFn;
|
||||
document.body.classList.remove = originalRemoveClassFn;
|
||||
document.body.style.cssText = originalStyleCssText;
|
||||
document.documentElement.style.setProperty = originalDocumentElementSetProperty;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -80,7 +85,9 @@ describe('Theme Store', () => {
|
||||
banner: true,
|
||||
bannerGradient: true,
|
||||
});
|
||||
expect(store.bannerGradient).toMatchInlineSnapshot(`"background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, var(--header-background-color) 90%);"`);
|
||||
expect(store.bannerGradient).toMatchInlineSnapshot(
|
||||
`"background-image: linear-gradient(90deg, rgba(0, 0, 0, 0) 0, var(--header-background-color) 90%);"`
|
||||
);
|
||||
|
||||
store.setTheme({
|
||||
...store.theme,
|
||||
@@ -88,7 +95,9 @@ describe('Theme Store', () => {
|
||||
bannerGradient: true,
|
||||
bgColor: '#123456',
|
||||
});
|
||||
expect(store.bannerGradient).toMatchInlineSnapshot(`"background-image: linear-gradient(90deg, var(--header-gradient-start) 0, var(--header-gradient-end) 90%);"`);
|
||||
expect(store.bannerGradient).toMatchInlineSnapshot(
|
||||
`"background-image: linear-gradient(90deg, var(--header-gradient-start) 0, var(--header-gradient-end) 90%);"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,9 +142,22 @@ describe('Theme Store', () => {
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(store.activeColorVariables['--header-text-primary']).toBe('#333333');
|
||||
expect(store.activeColorVariables['--header-text-secondary']).toBe('#666666');
|
||||
expect(store.activeColorVariables['--header-background-color']).toBe('#ffffff');
|
||||
// activeColorVariables now contains the theme defaults from defaultColors
|
||||
// Custom values are applied as CSS variables on the documentElement
|
||||
// The white theme's --color-beta is a reference to var(--header-text-primary)
|
||||
expect(store.activeColorVariables['--color-beta']).toBe('var(--header-text-primary)');
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-text-primary',
|
||||
'#333333'
|
||||
);
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-text-secondary',
|
||||
'#666666'
|
||||
);
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-background-color',
|
||||
'#ffffff'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle banner gradient correctly', async () => {
|
||||
@@ -155,8 +177,19 @@ describe('Theme Store', () => {
|
||||
expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0);
|
||||
expect(mockHexToRgba).toHaveBeenCalledWith('#112233', 0.7);
|
||||
|
||||
expect(store.activeColorVariables['--header-gradient-start']).toBe('rgba(mock-#112233-0)');
|
||||
expect(store.activeColorVariables['--header-gradient-end']).toBe('rgba(mock-#112233-0.7)');
|
||||
// Banner gradient values are now set as custom CSS variables on documentElement
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-gradient-start',
|
||||
'rgba(mock-#112233-0)'
|
||||
);
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--custom-header-gradient-end',
|
||||
'rgba(mock-#112233-0.7)'
|
||||
);
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--banner-gradient',
|
||||
'linear-gradient(90deg, rgba(mock-#112233-0) 0, rgba(mock-#112233-0.7) 90%)'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+7
-1
@@ -1,7 +1,13 @@
|
||||
export default {
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'primary',
|
||||
primary: 'blue',
|
||||
neutral: 'gray',
|
||||
},
|
||||
},
|
||||
toaster: {
|
||||
position: 'bottom-right' as const,
|
||||
expand: true,
|
||||
duration: 5000,
|
||||
},
|
||||
};
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import { NuxtLayout, NuxtPage, UApp } from '#components';
|
||||
import { devConfig } from '~/helpers/env';
|
||||
|
||||
onMounted(() => {
|
||||
document.documentElement.setAttribute('data-env', devConfig.NODE_ENV || 'production');
|
||||
|
||||
// Override text sizes back to 16px base in dev mode (from 10px base in index.css)
|
||||
if (devConfig.NODE_ENV === 'development') {
|
||||
document.documentElement.style.setProperty('--text-xs', '0.75rem'); /* 12px */
|
||||
document.documentElement.style.setProperty('--text-sm', '0.875rem'); /* 14px */
|
||||
document.documentElement.style.setProperty('--text-base', '1rem'); /* 16px */
|
||||
document.documentElement.style.setProperty('--text-lg', '1.125rem'); /* 18px */
|
||||
document.documentElement.style.setProperty('--text-xl', '1.25rem'); /* 20px */
|
||||
document.documentElement.style.setProperty('--text-2xl', '1.5rem'); /* 24px */
|
||||
document.documentElement.style.setProperty('--text-3xl', '1.875rem'); /* 30px */
|
||||
document.documentElement.style.setProperty('--text-4xl', '2.25rem'); /* 36px */
|
||||
document.documentElement.style.setProperty('--text-5xl', '3rem'); /* 48px */
|
||||
document.documentElement.style.setProperty('--text-6xl', '3.75rem'); /* 60px */
|
||||
document.documentElement.style.setProperty('--text-7xl', '4.5rem'); /* 72px */
|
||||
document.documentElement.style.setProperty('--text-8xl', '6rem'); /* 96px */
|
||||
document.documentElement.style.setProperty('--text-9xl', '8rem'); /* 128px */
|
||||
document.documentElement.style.setProperty('--spacing', '0.25rem'); /* 4px */
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</UApp>
|
||||
</template>
|
||||
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
* Tailwind v4 configuration without global preflight
|
||||
* This prevents Tailwind from applying global resets that affect webgui
|
||||
*/
|
||||
|
||||
/* Import only the parts of Tailwind we need - NO PREFLIGHT */
|
||||
@import 'tailwindcss/theme.css';
|
||||
@import 'tailwindcss/utilities.css';
|
||||
@import 'tw-animate-css';
|
||||
@import '../../@tailwind-shared/index.css';
|
||||
@import '@nuxt/ui';
|
||||
|
||||
/* Scan unraid-ui package from linked directory for class usage */
|
||||
@source "../../unraid-ui/dist/**/*.{js,mjs}";
|
||||
@source "../../unraid-ui/src/**/*.{vue,ts}";
|
||||
@source "../**/*.{vue,ts,js}";
|
||||
|
||||
/*
|
||||
* Minimal styles for our components
|
||||
* Using @layer to ensure these have lower priority than Tailwind utilities
|
||||
*/
|
||||
|
||||
@layer base {
|
||||
/* Box-sizing for proper layout */
|
||||
.unapi *,
|
||||
.unapi *::before,
|
||||
.unapi *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Reset figure element for logo */
|
||||
.unapi figure {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset heading elements - only margin/padding */
|
||||
.unapi h1,
|
||||
.unapi h2,
|
||||
.unapi h3,
|
||||
.unapi h4,
|
||||
.unapi h5 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset paragraph element */
|
||||
.unapi p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Reset UL styles to prevent default browser styling */
|
||||
.unapi ul {
|
||||
padding-inline-start: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
/* Reset toggle/switch button backgrounds */
|
||||
.unapi button[role="switch"],
|
||||
.unapi button[role="switch"][data-state="checked"],
|
||||
.unapi button[role="switch"][data-state="unchecked"] {
|
||||
background-color: transparent;
|
||||
background: transparent;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* Style for checked state */
|
||||
.unapi button[role="switch"][data-state="checked"] {
|
||||
background-color: #ff8c2f; /* Unraid orange */
|
||||
}
|
||||
|
||||
/* Style for unchecked state */
|
||||
.unapi button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
/* Dark mode toggle styles */
|
||||
.unapi.dark button[role="switch"][data-state="unchecked"],
|
||||
.dark .unapi button[role="switch"][data-state="unchecked"] {
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
/* Toggle thumb/handle */
|
||||
.unapi button[role="switch"] span {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const avatarGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['avatarGroupInjectionKey']
|
||||
const defineLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['defineLocale']
|
||||
const defineShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['defineShortcuts']
|
||||
const extendLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineLocale.js')['extendLocale']
|
||||
const extractShortcuts: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.js')['extractShortcuts']
|
||||
const fieldGroupInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['fieldGroupInjectionKey']
|
||||
const formBusInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formBusInjectionKey']
|
||||
const formFieldInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formFieldInjectionKey']
|
||||
const formInputsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formInputsInjectionKey']
|
||||
const formLoadingInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formLoadingInjectionKey']
|
||||
const formOptionsInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['formOptionsInjectionKey']
|
||||
const inputIdInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['inputIdInjectionKey']
|
||||
const kbdKeysMap: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['kbdKeysMap']
|
||||
const localeContextInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['localeContextInjectionKey']
|
||||
const portalTargetInjectionKey: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['portalTargetInjectionKey']
|
||||
const useAppConfig: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/vue/composables/useAppConfig.js')['useAppConfig']
|
||||
const useAvatarGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useAvatarGroup.js')['useAvatarGroup']
|
||||
const useComponentIcons: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.js')['useComponentIcons']
|
||||
const useContentSearch: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useContentSearch.js')['useContentSearch']
|
||||
const useFieldGroup: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFieldGroup.js')['useFieldGroup']
|
||||
const useFileUpload: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.js')['useFileUpload']
|
||||
const useFormField: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFormField.js')['useFormField']
|
||||
const useKbd: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.js')['useKbd']
|
||||
const useLocale: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useLocale.js')['useLocale']
|
||||
const useOverlay: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.js')['useOverlay']
|
||||
const usePortal: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/usePortal.js')['usePortal']
|
||||
const useResizable: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.js')['useResizable']
|
||||
const useScrollspy: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useScrollspy.js')['useScrollspy']
|
||||
const useToast: typeof import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.js')['useToast']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { ShortcutConfig, ShortcutsConfig, ShortcutsOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/defineShortcuts.d')
|
||||
// @ts-ignore
|
||||
export type { UseComponentIconsProps } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useComponentIcons.d')
|
||||
// @ts-ignore
|
||||
export type { UseFileUploadOptions } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useFileUpload.d')
|
||||
// @ts-ignore
|
||||
export type { KbdKey, KbdKeySpecific } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useKbd.d')
|
||||
// @ts-ignore
|
||||
export type { OverlayOptions, Overlay } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useOverlay.d')
|
||||
// @ts-ignore
|
||||
export type { UseResizableProps, UseResizableReturn } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useResizable.d')
|
||||
// @ts-ignore
|
||||
export type { Toast } from '../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d'
|
||||
import('../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/composables/useToast.d')
|
||||
}
|
||||
+1
-5
@@ -1,9 +1,5 @@
|
||||
import type { CodegenConfig } from '@graphql-codegen/cli';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const config: CodegenConfig = {
|
||||
overwrite: true,
|
||||
documents: ['./**/**/*.ts'],
|
||||
@@ -24,7 +20,7 @@ const config: CodegenConfig = {
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
'composables/gql/': {
|
||||
'src/composables/gql/': {
|
||||
preset: 'client',
|
||||
config: {
|
||||
useTypeImports: true,
|
||||
|
||||
Vendored
+127
@@ -0,0 +1,127 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ActivationModal: typeof import('./src/components/Activation/ActivationModal.vue')['default']
|
||||
ActivationPartnerLogo: typeof import('./src/components/Activation/ActivationPartnerLogo.vue')['default']
|
||||
ActivationPartnerLogoImg: typeof import('./src/components/Activation/ActivationPartnerLogoImg.vue')['default']
|
||||
ActivationSteps: typeof import('./src/components/Activation/ActivationSteps.vue')['default']
|
||||
'ApiKeyAuthorize.ce': typeof import('./src/components/ApiKeyAuthorize.ce.vue')['default']
|
||||
ApiKeyCreate: typeof import('./src/components/ApiKey/ApiKeyCreate.vue')['default']
|
||||
ApiKeyManager: typeof import('./src/components/ApiKey/ApiKeyManager.vue')['default']
|
||||
'ApiKeyPage.ce': typeof import('./src/components/ApiKeyPage.ce.vue')['default']
|
||||
'Auth.ce': typeof import('./src/components/Auth.ce.vue')['default']
|
||||
Avatar: typeof import('./src/components/Brand/Avatar.vue')['default']
|
||||
Beta: typeof import('./src/components/UserProfile/Beta.vue')['default']
|
||||
CallbackButton: typeof import('./src/components/UpdateOs/CallbackButton.vue')['default']
|
||||
CallbackFeedback: typeof import('./src/components/UserProfile/CallbackFeedback.vue')['default']
|
||||
CallbackFeedbackStatus: typeof import('./src/components/UserProfile/CallbackFeedbackStatus.vue')['default']
|
||||
'CallbackHandler.ce': typeof import('./src/components/CallbackHandler.ce.vue')['default']
|
||||
Card: typeof import('./src/components/LayoutViews/Card/Card.vue')['default']
|
||||
CardGrid: typeof import('./src/components/LayoutViews/Card/CardGrid.vue')['default']
|
||||
CardGroupHeader: typeof import('./src/components/LayoutViews/Card/CardGroupHeader.vue')['default']
|
||||
CardHeader: typeof import('./src/components/LayoutViews/Card/CardHeader.vue')['default']
|
||||
CardItem: typeof import('./src/components/LayoutViews/Card/CardItem.vue')['default']
|
||||
ChangelogModal: typeof import('./src/components/UpdateOs/ChangelogModal.vue')['default']
|
||||
CheckUpdateResponseModal: typeof import('./src/components/UpdateOs/CheckUpdateResponseModal.vue')['default']
|
||||
'ColorSwitcher.ce': typeof import('./src/components/ColorSwitcher.ce.vue')['default']
|
||||
'ConnectSettings.ce': typeof import('./src/components/ConnectSettings/ConnectSettings.ce.vue')['default']
|
||||
Console: typeof import('./src/components/Docker/Console.vue')['default']
|
||||
Detail: typeof import('./src/components/LayoutViews/Detail/Detail.vue')['default']
|
||||
DetailContentHeader: typeof import('./src/components/LayoutViews/Detail/DetailContentHeader.vue')['default']
|
||||
DetailLeftNavigation: typeof import('./src/components/LayoutViews/Detail/DetailLeftNavigation.vue')['default']
|
||||
DetailRightContent: typeof import('./src/components/LayoutViews/Detail/DetailRightContent.vue')['default']
|
||||
'DetailTest.ce': typeof import('./src/components/LayoutViews/Detail/DetailTest.ce.vue')['default']
|
||||
DeveloperAuthorizationLink: typeof import('./src/components/ApiKey/DeveloperAuthorizationLink.vue')['default']
|
||||
'DevModalTest.ce': typeof import('./src/components/DevModalTest.ce.vue')['default']
|
||||
DevSettings: typeof import('./src/components/DevSettings.vue')['default']
|
||||
Downgrade: typeof import('./src/components/UpdateOs/Downgrade.vue')['default']
|
||||
'DowngradeOs.ce': typeof import('./src/components/DowngradeOs.ce.vue')['default']
|
||||
'DownloadApiLogs.ce': typeof import('./src/components/DownloadApiLogs.ce.vue')['default']
|
||||
DropdownConnectStatus: typeof import('./src/components/UserProfile/DropdownConnectStatus.vue')['default']
|
||||
DropdownContent: typeof import('./src/components/UserProfile/DropdownContent.vue')['default']
|
||||
DropdownError: typeof import('./src/components/UserProfile/DropdownError.vue')['default']
|
||||
DropdownItem: typeof import('./src/components/UserProfile/DropdownItem.vue')['default']
|
||||
DropdownLaunchpad: typeof import('./src/components/UserProfile/DropdownLaunchpad.vue')['default']
|
||||
DropdownTrigger: typeof import('./src/components/UserProfile/DropdownTrigger.vue')['default']
|
||||
DropdownWrapper: typeof import('./src/components/UserProfile/DropdownWrapper.vue')['default']
|
||||
DummyServerSwitcher: typeof import('./src/components/DummyServerSwitcher.vue')['default']
|
||||
Edit: typeof import('./src/components/Docker/Edit.vue')['default']
|
||||
EffectivePermissions: typeof import('./src/components/ApiKey/EffectivePermissions.vue')['default']
|
||||
FileViewer: typeof import('./src/components/FileViewer.vue')['default']
|
||||
FilteredLogModal: typeof import('./src/components/Logs/FilteredLogModal.vue')['default']
|
||||
HeaderContent: typeof import('./src/components/Docker/HeaderContent.vue')['default']
|
||||
'HeaderOsVersion.ce': typeof import('./src/components/HeaderOsVersion.ce.vue')['default']
|
||||
IgnoredRelease: typeof import('./src/components/UpdateOs/IgnoredRelease.vue')['default']
|
||||
Indicator: typeof import('./src/components/Notifications/Indicator.vue')['default']
|
||||
Item: typeof import('./src/components/Notifications/Item.vue')['default']
|
||||
KeyActions: typeof import('./src/components/KeyActions.vue')['default']
|
||||
Keyline: typeof import('./src/components/UserProfile/Keyline.vue')['default']
|
||||
KeyLinkedStatus: typeof import('./src/components/Registration/KeyLinkedStatus.vue')['default']
|
||||
List: typeof import('./src/components/Notifications/List.vue')['default']
|
||||
LogFilterInput: typeof import('./src/components/Logs/LogFilterInput.vue')['default']
|
||||
Logo: typeof import('./src/components/Brand/Logo.vue')['default']
|
||||
Logs: typeof import('./src/components/Docker/Logs.vue')['default']
|
||||
'LogViewer.ce': typeof import('./src/components/Logs/LogViewer.ce.vue')['default']
|
||||
LogViewerToolbar: typeof import('./src/components/Logs/LogViewerToolbar.vue')['default']
|
||||
Mark: typeof import('./src/components/Brand/Mark.vue')['default']
|
||||
Modal: typeof import('./src/components/Modal.vue')['default']
|
||||
'Modals.ce': typeof import('./src/components/Modals.ce.vue')['default']
|
||||
OidcDebugButton: typeof import('./src/components/Logs/OidcDebugButton.vue')['default']
|
||||
OidcDebugLogs: typeof import('./src/components/ConnectSettings/OidcDebugLogs.vue')['default']
|
||||
Overview: typeof import('./src/components/Docker/Overview.vue')['default']
|
||||
PermissionCounter: typeof import('./src/components/ApiKey/PermissionCounter.vue')['default']
|
||||
Preview: typeof import('./src/components/Docker/Preview.vue')['default']
|
||||
RawChangelogRenderer: typeof import('./src/components/UpdateOs/RawChangelogRenderer.vue')['default']
|
||||
RCloneConfig: typeof import('./src/components/RClone/RCloneConfig.vue')['default']
|
||||
RCloneOverview: typeof import('./src/components/RClone/RCloneOverview.vue')['default']
|
||||
'Registration.ce': typeof import('./src/components/Registration.ce.vue')['default']
|
||||
ReleaseNotesModal: typeof import('./src/components/ReleaseNotesModal.vue')['default']
|
||||
RemoteItem: typeof import('./src/components/RClone/RemoteItem.vue')['default']
|
||||
ReplaceCheck: typeof import('./src/components/Registration/ReplaceCheck.vue')['default']
|
||||
ResponsiveModal: typeof import('./src/components/ResponsiveModal.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ServerState: typeof import('./src/components/UserProfile/ServerState.vue')['default']
|
||||
ServerStateBuy: typeof import('./src/components/UserProfile/ServerStateBuy.vue')['default']
|
||||
ServerStatus: typeof import('./src/components/UserProfile/ServerStatus.vue')['default']
|
||||
Sidebar: typeof import('./src/components/Notifications/Sidebar.vue')['default']
|
||||
SingleLogViewer: typeof import('./src/components/Logs/SingleLogViewer.vue')['default']
|
||||
'SsoButton.ce': typeof import('./src/components/SsoButton.ce.vue')['default']
|
||||
SsoButtons: typeof import('./src/components/sso/SsoButtons.vue')['default']
|
||||
SsoProviderButton: typeof import('./src/components/sso/SsoProviderButton.vue')['default']
|
||||
Status: typeof import('./src/components/UpdateOs/Status.vue')['default']
|
||||
'ThemeSwitcher.ce': typeof import('./src/components/ThemeSwitcher.ce.vue')['default']
|
||||
ThirdPartyDrivers: typeof import('./src/components/UpdateOs/ThirdPartyDrivers.vue')['default']
|
||||
Trial: typeof import('./src/components/UserProfile/Trial.vue')['default']
|
||||
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
|
||||
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
|
||||
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
|
||||
UCheckbox: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Checkbox.vue')['default']
|
||||
UDrawer: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Drawer.vue')['default']
|
||||
UDropdownMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/DropdownMenu.vue')['default']
|
||||
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
|
||||
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
|
||||
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
|
||||
UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
|
||||
UnraidToaster: typeof import('./src/components/UnraidToaster.vue')['default']
|
||||
Update: typeof import('./src/components/UpdateOs/Update.vue')['default']
|
||||
UpdateExpiration: typeof import('./src/components/Registration/UpdateExpiration.vue')['default']
|
||||
UpdateExpirationAction: typeof import('./src/components/Registration/UpdateExpirationAction.vue')['default']
|
||||
UpdateIneligible: typeof import('./src/components/UpdateOs/UpdateIneligible.vue')['default']
|
||||
'UpdateOs.ce': typeof import('./src/components/UpdateOs.ce.vue')['default']
|
||||
UptimeExpire: typeof import('./src/components/UserProfile/UptimeExpire.vue')['default']
|
||||
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
|
||||
'UserProfile.ce': typeof import('./src/components/UserProfile.ce.vue')['default']
|
||||
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
|
||||
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.3_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_717626353d7e2aa0e50ed397345224b8/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
|
||||
'WanIpCheck.ce': typeof import('./src/components/WanIpCheck.ce.vue')['default']
|
||||
'WelcomeModal.ce': typeof import('./src/components/Activation/WelcomeModal.ce.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useServerStore } from '~/store/server';
|
||||
|
||||
import BrandMark from '~/components/Brand/Mark.vue';
|
||||
|
||||
export interface Props {
|
||||
gradientStart?: string;
|
||||
gradientStop?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
gradientStart: '#e32929',
|
||||
gradientStop: '#ff8d30',
|
||||
});
|
||||
|
||||
const serverStore = useServerStore();
|
||||
const { avatar, connectPluginInstalled, registered, username } = storeToRefs(serverStore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<figure class="group relative z-0 flex items-center justify-center min-w-9 w-9 h-9 rounded-full bg-linear-to-r from-unraid-red to-orange flex-shrink-0">
|
||||
<img
|
||||
v-if="avatar && connectPluginInstalled && registered"
|
||||
:src="avatar"
|
||||
:alt="username"
|
||||
class="absolute z-10 inset-0 w-9 h-9 rounded-full overflow-hidden object-cover"
|
||||
>
|
||||
<template v-else>
|
||||
<BrandMark gradient-start="#fff" gradient-stop="#fff" class="opacity-100 absolute z-10 w-9 h-9 p-[6px]" />
|
||||
</template>
|
||||
</figure>
|
||||
</template>
|
||||
@@ -1,82 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useContentHighlighting } from '~/composables/useContentHighlighting';
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
language?: string;
|
||||
showLineNumbers?: boolean;
|
||||
maxHeight?: string;
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
const { highlightContent } = useContentHighlighting();
|
||||
|
||||
const highlightedContent = computed(() => {
|
||||
return highlightContent(props.content, props.language);
|
||||
});
|
||||
|
||||
const lines = computed(() => {
|
||||
return props.content.split('\n');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'file-viewer-container',
|
||||
'relative rounded border bg-background text-foreground overflow-hidden',
|
||||
props.class
|
||||
]"
|
||||
:style="{ height: maxHeight || '300px' }"
|
||||
>
|
||||
<div class="absolute inset-0 overflow-auto">
|
||||
<div class="flex min-w-full">
|
||||
<!-- Line numbers -->
|
||||
<div
|
||||
v-if="showLineNumbers"
|
||||
class="flex-shrink-0 select-none border-r bg-muted/50 px-2 py-2 text-xs font-mono text-muted-foreground"
|
||||
>
|
||||
<div v-for="(_, index) in lines" :key="index" class="leading-5 text-right pr-2">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<pre
|
||||
class="p-3 text-xs font-mono leading-5 whitespace-pre m-0"
|
||||
v-html="highlightedContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Add some basic styling for the highlighted content */
|
||||
:deep(.hljs) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ANSI color classes */
|
||||
:deep(.ansi-bright-black) { color: #666; }
|
||||
:deep(.ansi-bright-red) { color: #ff6b6b; }
|
||||
:deep(.ansi-bright-green) { color: #51cf66; }
|
||||
:deep(.ansi-bright-yellow) { color: #ffd43b; }
|
||||
:deep(.ansi-bright-blue) { color: #339af0; }
|
||||
:deep(.ansi-bright-magenta) { color: #f06292; }
|
||||
:deep(.ansi-bright-cyan) { color: #22d3ee; }
|
||||
:deep(.ansi-bright-white) { color: #f8f9fa; }
|
||||
|
||||
/* Standard ANSI colors for dark theme */
|
||||
:deep(.ansi-black) { color: #000; }
|
||||
:deep(.ansi-red) { color: #e03131; }
|
||||
:deep(.ansi-green) { color: #2f9e44; }
|
||||
:deep(.ansi-yellow) { color: #f59f00; }
|
||||
:deep(.ansi-blue) { color: #1971c2; }
|
||||
:deep(.ansi-magenta) { color: #c2255c; }
|
||||
:deep(.ansi-cyan) { color: #0891b2; }
|
||||
:deep(.ansi-white) { color: #495057; }
|
||||
</style>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import SsoButtons from './sso/SsoButtons.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SsoButtons />
|
||||
</template>
|
||||
|
||||
<!-- Font size overrides are handled in standalone-mount.ts for custom elements -->
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { Toaster } from '@unraid/ui';
|
||||
|
||||
defineProps<{
|
||||
position: 'top-center' | 'top-right' | 'top-left' | 'bottom-center' | 'bottom-right' | 'bottom-left';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toaster rich-colors close-button :position="position" />
|
||||
</template>
|
||||
@@ -1,8 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-y-2 p-2 bg-popover rounded-lg shadow-xl shadow-orange/10">
|
||||
<slot />
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<hr class="w-full h-[2px] bg-linear-to-r from-unraid-red to-orange shadow-none border-none rounded">
|
||||
</template>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@unraid/ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-xs font-semibold transition-colors duration-150 ease-in-out border-t-0 border-l-0 border-r-0 border-b-2 border-transparent hover:border-orange-dark focus:border-orange-dark focus:outline-hidden h-auto p-0"
|
||||
>
|
||||
<slot />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn, type ClassValue } from '@unraid/ui';
|
||||
import UpcUptimeExpire from './UptimeExpire.vue';
|
||||
import UpcServerState from './ServerState.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'text-header-text-secondary font-semibold leading-tight',
|
||||
'flex flex-col items-end gap-y-0.5 justify-end',
|
||||
'xs:flex-row xs:items-baseline xs:gap-x-2 xs:gap-y-0',
|
||||
'text-xs',
|
||||
$attrs.class as ClassValue
|
||||
)
|
||||
"
|
||||
>
|
||||
<UpcUptimeExpire :as="'span'" :short-text="true" class="text-xs" />
|
||||
<span class="hidden xs:inline">•</span>
|
||||
<UpcServerState class="text-xs" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import type { App } from 'vue';
|
||||
import { DefaultApolloClient } from '@vue/apollo-composable';
|
||||
|
||||
// Import Tailwind CSS for web components shadow DOM injection
|
||||
import tailwindStyles from '~/assets/main.css?inline';
|
||||
|
||||
import en_US from '~/locales/en_US.json';
|
||||
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
|
||||
import { globalPinia } from '~/store/globalPinia';
|
||||
import { client } from '~/helpers/create-apollo-client';
|
||||
|
||||
export default function (Vue: App) {
|
||||
// Create and configure i18n
|
||||
const defaultLocale = 'en_US';
|
||||
let parsedLocale = '';
|
||||
let parsedMessages = {};
|
||||
let nonDefaultLocale = false;
|
||||
|
||||
// Check for window locale data
|
||||
if (typeof window !== 'undefined') {
|
||||
const windowLocaleData = (window as unknown as { LOCALE_DATA?: string }).LOCALE_DATA || null;
|
||||
if (windowLocaleData) {
|
||||
try {
|
||||
parsedMessages = JSON.parse(decodeURIComponent(windowLocaleData));
|
||||
parsedLocale = Object.keys(parsedMessages)[0];
|
||||
nonDefaultLocale = parsedLocale !== defaultLocale;
|
||||
} catch (error) {
|
||||
console.error('[WebComponentPlugins] error parsing messages', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: nonDefaultLocale ? parsedLocale : defaultLocale,
|
||||
fallbackLocale: defaultLocale,
|
||||
messages: {
|
||||
en_US,
|
||||
...(nonDefaultLocale ? parsedMessages : {}),
|
||||
},
|
||||
postTranslation: createHtmlEntityDecoder(),
|
||||
});
|
||||
|
||||
Vue.use(i18n);
|
||||
|
||||
// Use the shared Pinia instance
|
||||
Vue.use(globalPinia);
|
||||
|
||||
// Provide Apollo client for all web components
|
||||
Vue.provide(DefaultApolloClient, client);
|
||||
|
||||
// Inject Tailwind CSS into the shadow DOM
|
||||
Vue.mixin({
|
||||
mounted() {
|
||||
if (typeof window !== 'undefined' && this.$el) {
|
||||
const shadowRoot = this.$el.getRootNode();
|
||||
if (shadowRoot && shadowRoot !== document && !shadowRoot.querySelector('style[data-tailwind]')) {
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.setAttribute('data-tailwind', 'true');
|
||||
styleElement.textContent = tailwindStyles;
|
||||
// Append instead of prepend to ensure styles come after any component styles
|
||||
shadowRoot.appendChild(styleElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// Import all components
|
||||
import Auth from './Auth.ce.vue';
|
||||
import ConnectSettings from './ConnectSettings/ConnectSettings.ce.vue';
|
||||
import DownloadApiLogs from './DownloadApiLogs.ce.vue';
|
||||
import HeaderOsVersion from './HeaderOsVersion.ce.vue';
|
||||
import Modals from './Modals.ce.vue';
|
||||
import UserProfile from './UserProfile.ce.vue';
|
||||
import UpdateOs from './UpdateOs.ce.vue';
|
||||
import DowngradeOs from './DowngradeOs.ce.vue';
|
||||
import Registration from './Registration.ce.vue';
|
||||
import WanIpCheck from './WanIpCheck.ce.vue';
|
||||
import WelcomeModal from './Activation/WelcomeModal.ce.vue';
|
||||
import SsoButton from './SsoButton.ce.vue';
|
||||
import LogViewer from './Logs/LogViewer.ce.vue';
|
||||
import ThemeSwitcher from './ThemeSwitcher.ce.vue';
|
||||
import ApiKeyPage from './ApiKeyPage.ce.vue';
|
||||
import DevModalTest from './DevModalTest.ce.vue';
|
||||
import ApiKeyAuthorize from './ApiKeyAuthorize.ce.vue';
|
||||
import UnraidToaster from './UnraidToaster.vue';
|
||||
// Import utilities
|
||||
import { autoMountComponent, mountVueApp, getMountedApp } from './Wrapper/vue-mount-app';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
import { globalPinia } from '~/store/globalPinia';
|
||||
import { client as apolloClient } from '~/helpers/create-apollo-client';
|
||||
import { provideApolloClient } from '@vue/apollo-composable';
|
||||
import { parse } from 'graphql';
|
||||
import { ensureTeleportContainer } from '@unraid/ui';
|
||||
|
||||
// Window type definitions are automatically included via tsconfig.json
|
||||
|
||||
|
||||
// Initialize global Apollo client context
|
||||
if (typeof window !== 'undefined') {
|
||||
// Make Apollo client globally available
|
||||
window.apolloClient = apolloClient;
|
||||
|
||||
// Make graphql parse function available for browser console usage
|
||||
window.graphqlParse = parse;
|
||||
window.gql = parse;
|
||||
|
||||
// Provide Apollo client globally for all components
|
||||
provideApolloClient(apolloClient);
|
||||
|
||||
// Initialize theme store and set CSS variables - this is needed by all components
|
||||
const themeStore = useThemeStore(globalPinia);
|
||||
themeStore.setTheme();
|
||||
themeStore.setCssVars();
|
||||
|
||||
// Pre-create the teleport container to avoid mounting issues
|
||||
// This ensures the container exists before any components try to teleport to it
|
||||
ensureTeleportContainer();
|
||||
}
|
||||
|
||||
// Define component mappings
|
||||
const componentMappings = [
|
||||
{ component: Auth, selector: 'unraid-auth', appId: 'auth' },
|
||||
{ component: ConnectSettings, selector: 'unraid-connect-settings', appId: 'connect-settings' },
|
||||
{ component: DownloadApiLogs, selector: 'unraid-download-api-logs', appId: 'download-api-logs' },
|
||||
{ component: HeaderOsVersion, selector: 'unraid-header-os-version', appId: 'header-os-version' },
|
||||
{ component: Modals, selector: 'unraid-modals', appId: 'modals' },
|
||||
{ component: Modals, selector: '#modals', appId: 'modals-legacy' }, // Legacy ID selector
|
||||
{ component: UserProfile, selector: 'unraid-user-profile', appId: 'user-profile' },
|
||||
{ component: UpdateOs, selector: 'unraid-update-os', appId: 'update-os' },
|
||||
{ component: DowngradeOs, selector: 'unraid-downgrade-os', appId: 'downgrade-os' },
|
||||
{ component: Registration, selector: 'unraid-registration', appId: 'registration' },
|
||||
{ component: WanIpCheck, selector: 'unraid-wan-ip-check', appId: 'wan-ip-check' },
|
||||
{ component: WelcomeModal, selector: 'unraid-welcome-modal', appId: 'welcome-modal' },
|
||||
{ component: SsoButton, selector: 'unraid-sso-button', appId: 'sso-button' },
|
||||
{ component: LogViewer, selector: 'unraid-log-viewer', appId: 'log-viewer' },
|
||||
{ component: ThemeSwitcher, selector: 'unraid-theme-switcher', appId: 'theme-switcher' },
|
||||
{ component: ApiKeyPage, selector: 'unraid-api-key-manager', appId: 'api-key-manager' },
|
||||
{ component: DevModalTest, selector: 'unraid-dev-modal-test', appId: 'dev-modal-test' },
|
||||
{ component: ApiKeyAuthorize, selector: 'unraid-api-key-authorize', appId: 'api-key-authorize' },
|
||||
{ component: UnraidToaster, selector: 'uui-toaster', appId: 'toaster' },
|
||||
{ component: UnraidToaster, selector: 'unraid-toaster', appId: 'toaster-legacy' }, // Legacy alias
|
||||
];
|
||||
|
||||
// Auto-mount all components
|
||||
componentMappings.forEach(({ component, selector, appId }) => {
|
||||
autoMountComponent(component, selector, {
|
||||
appId,
|
||||
useShadowRoot: false, // Mount directly to avoid shadow DOM issues
|
||||
});
|
||||
});
|
||||
|
||||
// Window interface extensions are defined in ~/types/window.d.ts
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// Expose all components
|
||||
window.UnraidComponents = {
|
||||
Auth,
|
||||
ConnectSettings,
|
||||
DownloadApiLogs,
|
||||
HeaderOsVersion,
|
||||
Modals,
|
||||
UserProfile,
|
||||
UpdateOs,
|
||||
DowngradeOs,
|
||||
Registration,
|
||||
WanIpCheck,
|
||||
WelcomeModal,
|
||||
SsoButton,
|
||||
LogViewer,
|
||||
ThemeSwitcher,
|
||||
ApiKeyPage,
|
||||
DevModalTest,
|
||||
ApiKeyAuthorize,
|
||||
UnraidToaster,
|
||||
};
|
||||
|
||||
// Expose utility functions
|
||||
window.mountVueApp = mountVueApp;
|
||||
window.getMountedApp = getMountedApp;
|
||||
|
||||
// Create dynamic mount functions for each component
|
||||
componentMappings.forEach(({ component, selector, appId }) => {
|
||||
const componentName = appId.split('-').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join('');
|
||||
|
||||
(window as unknown as Record<string, unknown>)[`mount${componentName}`] = (customSelector?: string) => {
|
||||
return mountVueApp({
|
||||
component,
|
||||
selector: customSelector || selector,
|
||||
appId: `${appId}-${Date.now()}`,
|
||||
useShadowRoot: false,
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type Documents = {
|
||||
"\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n": typeof types.GetApiKeyCreationFormSchemaDocument,
|
||||
"\n fragment ApiKey on ApiKey {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyFragmentDoc,
|
||||
"\n query ApiKeys {\n apiKeys {\n ...ApiKey\n }\n }\n": typeof types.ApiKeysDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n } \n }\n": typeof types.CreateApiKeyDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n }\n }\n": typeof types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKey\n }\n }\n }\n": typeof types.UpdateApiKeyDocument,
|
||||
"\n mutation DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\n }\n }\n": typeof types.DeleteApiKeyDocument,
|
||||
"\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n": typeof types.ApiKeyMetaDocument,
|
||||
@@ -49,8 +49,6 @@ type Documents = {
|
||||
"\n query InfoVersions {\n info {\n id\n os {\n id\n hostname\n }\n versions {\n id\n core {\n unraid\n api\n }\n }\n }\n }\n": typeof types.InfoVersionsDocument,
|
||||
"\n query OidcProviders {\n settings {\n sso {\n oidcProviders {\n id\n name\n clientId\n issuer\n authorizationEndpoint\n tokenEndpoint\n jwksUri\n scopes\n authorizationRules {\n claim\n operator\n value\n }\n authorizationRuleMode\n buttonText\n buttonIcon\n }\n }\n }\n }\n": typeof types.OidcProvidersDocument,
|
||||
"\n query PublicOidcProviders {\n publicOidcProviders {\n id\n name\n buttonText\n buttonIcon\n buttonVariant\n buttonStyle\n }\n }\n": typeof types.PublicOidcProvidersDocument,
|
||||
"\n query AllConfigFiles {\n allConfigFiles {\n files {\n name\n content\n path\n sizeReadable\n }\n }\n }\n": typeof types.AllConfigFilesDocument,
|
||||
"\n query ConfigFile($name: String!) {\n configFile(name: $name) {\n name\n content\n path\n sizeReadable\n }\n }\n": typeof types.ConfigFileDocument,
|
||||
"\n query serverInfo {\n info {\n os {\n hostname\n }\n }\n vars {\n comment\n }\n }\n": typeof types.ServerInfoDocument,
|
||||
"\n mutation ConnectSignIn($input: ConnectSignInInput!) {\n connectSignIn(input: $input)\n }\n": typeof types.ConnectSignInDocument,
|
||||
"\n mutation SignOut {\n connectSignOut\n }\n": typeof types.SignOutDocument,
|
||||
@@ -67,7 +65,7 @@ const documents: Documents = {
|
||||
"\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n": types.GetApiKeyCreationFormSchemaDocument,
|
||||
"\n fragment ApiKey on ApiKey {\n id\n key\n name\n description\n createdAt\n roles\n permissions {\n resource\n actions\n }\n }\n": types.ApiKeyFragmentDoc,
|
||||
"\n query ApiKeys {\n apiKeys {\n ...ApiKey\n }\n }\n": types.ApiKeysDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n } \n }\n": types.CreateApiKeyDocument,
|
||||
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n }\n }\n": types.CreateApiKeyDocument,
|
||||
"\n mutation UpdateApiKey($input: UpdateApiKeyInput!) {\n apiKey {\n update(input: $input) {\n ...ApiKey\n }\n }\n }\n": types.UpdateApiKeyDocument,
|
||||
"\n mutation DeleteApiKey($input: DeleteApiKeyInput!) {\n apiKey {\n delete(input: $input)\n }\n }\n": types.DeleteApiKeyDocument,
|
||||
"\n query ApiKeyMeta {\n apiKeyPossibleRoles\n apiKeyPossiblePermissions {\n resource\n actions\n }\n }\n": types.ApiKeyMetaDocument,
|
||||
@@ -96,8 +94,6 @@ const documents: Documents = {
|
||||
"\n query InfoVersions {\n info {\n id\n os {\n id\n hostname\n }\n versions {\n id\n core {\n unraid\n api\n }\n }\n }\n }\n": types.InfoVersionsDocument,
|
||||
"\n query OidcProviders {\n settings {\n sso {\n oidcProviders {\n id\n name\n clientId\n issuer\n authorizationEndpoint\n tokenEndpoint\n jwksUri\n scopes\n authorizationRules {\n claim\n operator\n value\n }\n authorizationRuleMode\n buttonText\n buttonIcon\n }\n }\n }\n }\n": types.OidcProvidersDocument,
|
||||
"\n query PublicOidcProviders {\n publicOidcProviders {\n id\n name\n buttonText\n buttonIcon\n buttonVariant\n buttonStyle\n }\n }\n": types.PublicOidcProvidersDocument,
|
||||
"\n query AllConfigFiles {\n allConfigFiles {\n files {\n name\n content\n path\n sizeReadable\n }\n }\n }\n": types.AllConfigFilesDocument,
|
||||
"\n query ConfigFile($name: String!) {\n configFile(name: $name) {\n name\n content\n path\n sizeReadable\n }\n }\n": types.ConfigFileDocument,
|
||||
"\n query serverInfo {\n info {\n os {\n hostname\n }\n }\n vars {\n comment\n }\n }\n": types.ServerInfoDocument,
|
||||
"\n mutation ConnectSignIn($input: ConnectSignInInput!) {\n connectSignIn(input: $input)\n }\n": types.ConnectSignInDocument,
|
||||
"\n mutation SignOut {\n connectSignOut\n }\n": types.SignOutDocument,
|
||||
@@ -149,7 +145,7 @@ export function graphql(source: "\n query ApiKeys {\n apiKeys {\n ...Ap
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n } \n }\n"): (typeof documents)["\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n } \n }\n"];
|
||||
export function graphql(source: "\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n }\n }\n"): (typeof documents)["\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -262,14 +258,6 @@ export function graphql(source: "\n query OidcProviders {\n settings {\n
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query PublicOidcProviders {\n publicOidcProviders {\n id\n name\n buttonText\n buttonIcon\n buttonVariant\n buttonStyle\n }\n }\n"): (typeof documents)["\n query PublicOidcProviders {\n publicOidcProviders {\n id\n name\n buttonText\n buttonIcon\n buttonVariant\n buttonStyle\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query AllConfigFiles {\n allConfigFiles {\n files {\n name\n content\n path\n sizeReadable\n }\n }\n }\n"): (typeof documents)["\n query AllConfigFiles {\n allConfigFiles {\n files {\n name\n content\n path\n sizeReadable\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ConfigFile($name: String!) {\n configFile(name: $name) {\n name\n content\n path\n sizeReadable\n }\n }\n"): (typeof documents)["\n query ConfigFile($name: String!) {\n configFile(name: $name) {\n name\n content\n path\n sizeReadable\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -448,20 +448,6 @@ export enum ConfigErrorState {
|
||||
WITHDRAWN = 'WITHDRAWN'
|
||||
}
|
||||
|
||||
export type ConfigFile = {
|
||||
__typename?: 'ConfigFile';
|
||||
content: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
path: Scalars['String']['output'];
|
||||
/** Human-readable file size (e.g., "1.5 KB", "2.3 MB") */
|
||||
sizeReadable: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type ConfigFilesResponse = {
|
||||
__typename?: 'ConfigFilesResponse';
|
||||
files: Array<ConfigFile>;
|
||||
};
|
||||
|
||||
export type Connect = Node & {
|
||||
__typename?: 'Connect';
|
||||
/** The status of dynamic remote access */
|
||||
@@ -553,12 +539,16 @@ export type CoreVersions = {
|
||||
/** CPU load for a single core */
|
||||
export type CpuLoad = {
|
||||
__typename?: 'CpuLoad';
|
||||
/** The percentage of time the CPU spent running virtual machines (guest). */
|
||||
percentGuest: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU was idle. */
|
||||
percentIdle: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent servicing hardware interrupts. */
|
||||
percentIrq: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent on low-priority (niced) user space processes. */
|
||||
percentNice: Scalars['Float']['output'];
|
||||
/** The percentage of CPU time stolen by the hypervisor. */
|
||||
percentSteal: Scalars['Float']['output'];
|
||||
/** The percentage of time the CPU spent in kernel space. */
|
||||
percentSystem: Scalars['Float']['output'];
|
||||
/** The total CPU load on a single core, in percent. */
|
||||
@@ -1645,7 +1635,6 @@ export type PublicPartnerInfo = {
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
allConfigFiles: ConfigFilesResponse;
|
||||
apiKey?: Maybe<ApiKey>;
|
||||
/** All possible permissions for API keys */
|
||||
apiKeyPossiblePermissions: Array<Permission>;
|
||||
@@ -1655,7 +1644,6 @@ export type Query = {
|
||||
array: UnraidArray;
|
||||
cloud: Cloud;
|
||||
config: Config;
|
||||
configFile?: Maybe<ConfigFile>;
|
||||
connect: Connect;
|
||||
customization?: Maybe<Customization>;
|
||||
disk: Disk;
|
||||
@@ -1719,11 +1707,6 @@ export type QueryApiKeyArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryConfigFileArgs = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryDiskArgs = {
|
||||
id: Scalars['PrefixedID']['input'];
|
||||
};
|
||||
@@ -2796,18 +2779,6 @@ export type PublicOidcProvidersQueryVariables = Exact<{ [key: string]: never; }>
|
||||
|
||||
export type PublicOidcProvidersQuery = { __typename?: 'Query', publicOidcProviders: Array<{ __typename?: 'PublicOidcProvider', id: string, name: string, buttonText?: string | null, buttonIcon?: string | null, buttonVariant?: string | null, buttonStyle?: string | null }> };
|
||||
|
||||
export type AllConfigFilesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type AllConfigFilesQuery = { __typename?: 'Query', allConfigFiles: { __typename?: 'ConfigFilesResponse', files: Array<{ __typename?: 'ConfigFile', name: string, content: string, path: string, sizeReadable: string }> } };
|
||||
|
||||
export type ConfigFileQueryVariables = Exact<{
|
||||
name: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type ConfigFileQuery = { __typename?: 'Query', configFile?: { __typename?: 'ConfigFile', name: string, content: string, path: string, sizeReadable: string } | null };
|
||||
|
||||
export type ServerInfoQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -2886,8 +2857,6 @@ export const ListRCloneRemotesDocument = {"kind":"Document","definitions":[{"kin
|
||||
export const InfoVersionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"InfoVersions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"core"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraid"}},{"kind":"Field","name":{"kind":"Name","value":"api"}}]}}]}}]}}]}}]} as unknown as DocumentNode<InfoVersionsQuery, InfoVersionsQueryVariables>;
|
||||
export const OidcProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OidcProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sso"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"oidcProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"clientId"}},{"kind":"Field","name":{"kind":"Name","value":"issuer"}},{"kind":"Field","name":{"kind":"Name","value":"authorizationEndpoint"}},{"kind":"Field","name":{"kind":"Name","value":"tokenEndpoint"}},{"kind":"Field","name":{"kind":"Name","value":"jwksUri"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"authorizationRules"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"claim"}},{"kind":"Field","name":{"kind":"Name","value":"operator"}},{"kind":"Field","name":{"kind":"Name","value":"value"}}]}},{"kind":"Field","name":{"kind":"Name","value":"authorizationRuleMode"}},{"kind":"Field","name":{"kind":"Name","value":"buttonText"}},{"kind":"Field","name":{"kind":"Name","value":"buttonIcon"}}]}}]}}]}}]}}]} as unknown as DocumentNode<OidcProvidersQuery, OidcProvidersQueryVariables>;
|
||||
export const PublicOidcProvidersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PublicOidcProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicOidcProviders"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"buttonText"}},{"kind":"Field","name":{"kind":"Name","value":"buttonIcon"}},{"kind":"Field","name":{"kind":"Name","value":"buttonVariant"}},{"kind":"Field","name":{"kind":"Name","value":"buttonStyle"}}]}}]}}]} as unknown as DocumentNode<PublicOidcProvidersQuery, PublicOidcProvidersQueryVariables>;
|
||||
export const AllConfigFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AllConfigFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allConfigFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"files"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"sizeReadable"}}]}}]}}]}}]} as unknown as DocumentNode<AllConfigFilesQuery, AllConfigFilesQueryVariables>;
|
||||
export const ConfigFileDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConfigFile"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"configFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"sizeReadable"}}]}}]}}]} as unknown as DocumentNode<ConfigFileQuery, ConfigFileQueryVariables>;
|
||||
export const ServerInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comment"}}]}}]}}]} as unknown as DocumentNode<ServerInfoQuery, ServerInfoQueryVariables>;
|
||||
export const ConnectSignInDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ConnectSignIn"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ConnectSignInInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignIn"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<ConnectSignInMutation, ConnectSignInMutationVariables>;
|
||||
export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SignOut"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectSignOut"}}]}}]} as unknown as DocumentNode<SignOutMutation, SignOutMutationVariables>;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./fragment-masking";
|
||||
export * from "./gql";
|
||||
export * from './fragment-masking';
|
||||
export * from './gql';
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import wretch from 'wretch';
|
||||
import formData from 'wretch/addons/formData';
|
||||
import formUrl from 'wretch/addons/formUrl';
|
||||
import queryString from 'wretch/addons/queryString';
|
||||
|
||||
import { useErrorsStore } from '~/store/errors';
|
||||
|
||||
const errorsStore = useErrorsStore();
|
||||
|
||||
export const request = wretch()
|
||||
.addon(formData)
|
||||
.addon(formUrl)
|
||||
.addon(queryString)
|
||||
.errorType('json')
|
||||
.resolve((response) => {
|
||||
return (
|
||||
response
|
||||
.error('Error', (error) => {
|
||||
errorsStore.setError({
|
||||
heading: `WretchError ${error.status}`,
|
||||
message: `${error.text} • ${error.url}`,
|
||||
level: 'error',
|
||||
ref: 'wretchError',
|
||||
type: 'request',
|
||||
});
|
||||
})
|
||||
.error('TypeError', (error) => {
|
||||
errorsStore.setError({
|
||||
heading: `WretchTypeError ${error.status}`,
|
||||
message: `${error.text} • ${error.url}`,
|
||||
level: 'error',
|
||||
ref: 'wretchTypeError',
|
||||
type: 'request',
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
+195
-51
@@ -1,61 +1,205 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import noRelativeImportPaths from 'eslint-plugin-no-relative-import-paths';
|
||||
import prettier from 'eslint-plugin-prettier';
|
||||
import vuePlugin from 'eslint-plugin-vue';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
// Import vue-eslint-parser as an ESM import
|
||||
import vueEslintParser from 'vue-eslint-parser';
|
||||
|
||||
import withNuxt from './.nuxt/eslint.config.mjs';
|
||||
// Common rules shared across file types
|
||||
const commonRules = {
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{
|
||||
prefer: 'type-imports',
|
||||
disallowTypeAnnotations: false, // Allow type annotations in import expressions
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': ['off'],
|
||||
'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }],
|
||||
'no-relative-import-paths/no-relative-import-paths': [
|
||||
'error',
|
||||
{ allowSameFolder: false, rootDir: 'src', prefix: '@' },
|
||||
],
|
||||
'prettier/prettier': 'warn',
|
||||
'no-restricted-globals': [
|
||||
'error',
|
||||
{
|
||||
name: '__dirname',
|
||||
message: 'Use import.meta.url instead of __dirname in ESM',
|
||||
},
|
||||
{
|
||||
name: '__filename',
|
||||
message: 'Use import.meta.url instead of __filename in ESM',
|
||||
},
|
||||
],
|
||||
'eol-last': ['error', 'always'],
|
||||
'@typescript-eslint/no-explicit-any': [
|
||||
'error',
|
||||
{
|
||||
ignoreRestArgs: true,
|
||||
fixToUnknown: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default withNuxt(
|
||||
// Vue-specific rules
|
||||
const vueRules = {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
// Nuxt UI components are auto-imported by the @nuxt/ui vite plugin
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: [
|
||||
'^U[A-Z].*', // All Nuxt UI components (UButton, UCard, etc.)
|
||||
'client-only', // Vue/Nuxt built-in
|
||||
],
|
||||
},
|
||||
],
|
||||
'vue/html-self-closing': [
|
||||
'error',
|
||||
{
|
||||
html: {
|
||||
void: 'always',
|
||||
normal: 'always',
|
||||
component: 'always',
|
||||
},
|
||||
},
|
||||
],
|
||||
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
|
||||
'vue/component-definition-name-casing': ['error', 'PascalCase'],
|
||||
'vue/no-unsupported-features': [
|
||||
'error',
|
||||
{
|
||||
version: '^3.3.0',
|
||||
},
|
||||
],
|
||||
'vue/no-unused-properties': [
|
||||
'error',
|
||||
{
|
||||
groups: ['props'],
|
||||
deepData: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Common language options
|
||||
const commonLanguageOptions = {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
};
|
||||
|
||||
// No need to manually define globals - using globals package
|
||||
|
||||
export default [
|
||||
// Base config from recommended configs
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended, // TypeScript Files (.ts)
|
||||
{
|
||||
ignores: ['./coverage/**'],
|
||||
files: ['**/*.ts'],
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
...commonLanguageOptions,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'no-relative-import-paths': noRelativeImportPaths,
|
||||
prettier: prettier,
|
||||
import: importPlugin,
|
||||
},
|
||||
rules: {
|
||||
...commonRules,
|
||||
},
|
||||
}, // Vue Files (.vue)
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
languageOptions: {
|
||||
parser: vueEslintParser,
|
||||
parserOptions: {
|
||||
...commonLanguageOptions,
|
||||
parser: tseslint.parser,
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'no-relative-import-paths': noRelativeImportPaths,
|
||||
prettier: prettier,
|
||||
import: importPlugin,
|
||||
vue: vuePlugin,
|
||||
},
|
||||
rules: {
|
||||
...commonRules,
|
||||
...vueRules,
|
||||
},
|
||||
}, // Ignores
|
||||
{
|
||||
ignores: [
|
||||
'src/graphql/generated/client/**/*',
|
||||
'src/global.d.ts',
|
||||
'eslint.config.ts',
|
||||
'.output/**/*',
|
||||
'dist/**/*',
|
||||
'.nuxt/**/*',
|
||||
'node_modules/**/*',
|
||||
'coverage/**/*',
|
||||
],
|
||||
},
|
||||
// JavaScript files
|
||||
{
|
||||
files: ['**/*.js', '**/*.mjs'],
|
||||
languageOptions: {
|
||||
...commonLanguageOptions,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'no-relative-import-paths': noRelativeImportPaths,
|
||||
prettier: prettier,
|
||||
},
|
||||
rules: {
|
||||
...commonRules,
|
||||
'@typescript-eslint/no-unused-vars': 'off', // Use regular no-unused-vars for JS
|
||||
'no-unused-vars': ['error'],
|
||||
'@typescript-eslint/consistent-type-imports': 'off', // Not applicable to JS
|
||||
'@typescript-eslint/no-explicit-any': 'off', // Not applicable to JS
|
||||
},
|
||||
},
|
||||
// Node.js files (config files, scripts)
|
||||
{
|
||||
files: ['**/*.config.ts', '**/*.config.js', '**/*.config.mjs', 'scripts/**/*', 'vite-plugin-*.ts'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
// Node.js globals
|
||||
NodeJS: 'readonly',
|
||||
// Browser APIs
|
||||
EventListenerOrEventListenerObject: 'readonly',
|
||||
HTMLCollectionOf: 'readonly',
|
||||
// webGUI-specific globals
|
||||
openPlugin: 'readonly',
|
||||
openBox: 'readonly',
|
||||
openChanges: 'readonly',
|
||||
FeedbackButton: 'readonly',
|
||||
flashBackup: 'readonly',
|
||||
confirmDowngrade: 'readonly',
|
||||
downloadDiagnostics: 'readonly',
|
||||
// Nuxt globals
|
||||
defineNuxtConfig: 'readonly',
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: [
|
||||
// Custom Elements (components ending with Ce)
|
||||
'.*Ce$',
|
||||
// Web Components (components starting with unraid-)
|
||||
'^unraid-.*',
|
||||
// Client-only component
|
||||
'^client-only$',
|
||||
// Other common components
|
||||
'^ClientOnly$',
|
||||
'^BrandLogo$',
|
||||
'^ColorSwitcherCe$',
|
||||
'^DummyServerSwitcher$',
|
||||
'^HeaderOsVersionCe$',
|
||||
'^ConnectSettingsCe$',
|
||||
],
|
||||
},
|
||||
],
|
||||
'eol-last': ['error', 'always'],
|
||||
|
||||
// TypeScript rules for unused variables and undefined variables
|
||||
'@typescript-eslint/no-unused-vars': ['error', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_'
|
||||
}],
|
||||
'no-undef': 'error',
|
||||
'no-restricted-globals': 'off', // Allow __dirname in config files
|
||||
// Keep no-require-imports enabled to enforce pure ESM
|
||||
},
|
||||
},
|
||||
);
|
||||
// Disable no-relative-import-paths specifically for vite.config.ts
|
||||
{
|
||||
files: ['vite.config.ts'],
|
||||
rules: {
|
||||
'no-relative-import-paths/no-relative-import-paths': 'off', // Allow relative imports in vite.config.ts for local plugins
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Unraid Web Components</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="isolate"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { ClientOnly, NuxtLink } from '#components';
|
||||
import { Badge, Toaster } from '@unraid/ui';
|
||||
|
||||
import ColorSwitcherCe from '~/components/ColorSwitcher.ce.vue';
|
||||
import DummyServerSwitcher from '~/components/DummyServerSwitcher.vue';
|
||||
import ModalsCe from '~/components/Modals.ce.vue';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const routes = computed(() => {
|
||||
return router
|
||||
.getRoutes()
|
||||
.filter((route) => !route.path.includes(':') && route.path !== '/404' && route.name)
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
});
|
||||
|
||||
function formatRouteName(name: string | symbol | undefined) {
|
||||
if (!name) return 'Home';
|
||||
// Convert symbols to strings if needed
|
||||
const nameStr = typeof name === 'symbol' ? name.toString() : name;
|
||||
// Convert route names like "web-components" to "Web Components"
|
||||
return nameStr
|
||||
.replace(/-/g, ' ')
|
||||
.split(' ')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-black bg-white dark:text-white dark:bg-black">
|
||||
<ClientOnly>
|
||||
<div class="bg-white dark:bg-zinc-800 border-b border-muted">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 p-3 md:p-4">
|
||||
<nav class="flex flex-wrap items-center gap-2">
|
||||
<template v-for="route in routes" :key="route.path">
|
||||
<NuxtLink :to="route.path">
|
||||
<Badge
|
||||
:variant="router.currentRoute.value.path === route.path ? 'orange' : 'gray'"
|
||||
size="xs"
|
||||
class="cursor-pointer header-nav-badge hover:brightness-90 hover:bg-transparent [&.bg-gray-200]:hover:bg-gray-200 [&.bg-orange]:hover:bg-orange"
|
||||
>
|
||||
{{ formatRouteName(route.name) }}
|
||||
</Badge>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
<ModalsCe />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-3 p-3 md:p-4 bg-gray-50 dark:bg-zinc-900 border-b border-muted">
|
||||
<DummyServerSwitcher />
|
||||
<ColorSwitcherCe />
|
||||
</div>
|
||||
<slot />
|
||||
<Toaster />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Import theme styles */
|
||||
@import '~/assets/main.css';
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import UApp from '@nuxt/ui/components/App.vue';
|
||||
import UPage from '@nuxt/ui/components/Page.vue';
|
||||
|
||||
import { defaultColors } from '~/themes/default';
|
||||
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
// Initialize theme on mount
|
||||
onMounted(() => {
|
||||
// Set a default theme similar to ColorSwitcherCe
|
||||
const whiteTheme = defaultColors.white;
|
||||
if (whiteTheme) {
|
||||
const defaultTheme = {
|
||||
banner: false,
|
||||
bannerGradient: false,
|
||||
descriptionShow: true,
|
||||
textColor: whiteTheme['--header-text-primary'],
|
||||
metaColor: whiteTheme['--header-text-secondary'],
|
||||
bgColor: whiteTheme['--header-background-color'],
|
||||
name: 'white',
|
||||
};
|
||||
themeStore.setTheme(defaultTheme);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<UPage>
|
||||
<slot />
|
||||
</UPage>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Import theme styles */
|
||||
@import '~/assets/main.css';
|
||||
</style>
|
||||
@@ -1,211 +0,0 @@
|
||||
import path from 'path';
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import removeConsole from 'vite-plugin-remove-console';
|
||||
|
||||
import type { PluginOption } from 'vite';
|
||||
|
||||
/**
|
||||
* Used to avoid redeclaring variables in the webgui codebase.
|
||||
* @see alt solution https://github.com/terser/terser/issues/1001, https://github.com/terser/terser/pull/1038
|
||||
*/
|
||||
function terserReservations(inputStr: string) {
|
||||
const combinations = ['ace'];
|
||||
|
||||
// Add 1-character combinations
|
||||
for (let i = 0; i < inputStr.length; i++) {
|
||||
combinations.push(inputStr[i]);
|
||||
}
|
||||
|
||||
// Add 2-character combinations
|
||||
for (let i = 0; i < inputStr.length; i++) {
|
||||
for (let j = 0; j < inputStr.length; j++) {
|
||||
combinations.push(inputStr[i] + inputStr[j]);
|
||||
}
|
||||
}
|
||||
|
||||
return combinations;
|
||||
}
|
||||
|
||||
const charsToReserve = '_$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
const dropConsole = process.env.VITE_ALLOW_CONSOLE_LOGS !== 'true';
|
||||
console.log(dropConsole ? 'WARN: Console logs are disabled' : 'INFO: Console logs are enabled');
|
||||
|
||||
const assetsDir = path.join(__dirname, '../api/dev/webGui/');
|
||||
|
||||
// REMOVED: No longer needed with standalone mount approach
|
||||
// const createWebComponentTag = (name: string, path: string, appContext: string) => ({
|
||||
// async: false,
|
||||
// name,
|
||||
// path,
|
||||
// appContext
|
||||
// });
|
||||
|
||||
/**
|
||||
* Shared terser options for consistent minification
|
||||
*/
|
||||
const sharedTerserOptions = {
|
||||
mangle: {
|
||||
reserved: terserReservations(charsToReserve),
|
||||
toplevel: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared plugins configuration
|
||||
*/
|
||||
const getSharedPlugins = (includeJQueryIsolation = false) => {
|
||||
const plugins: PluginOption[] = [];
|
||||
|
||||
// Add Tailwind CSS plugin
|
||||
plugins.push(tailwindcss());
|
||||
|
||||
// Remove console logs in production
|
||||
if (dropConsole) {
|
||||
plugins.push(
|
||||
removeConsole({
|
||||
includes: ['log', 'info', 'debug'],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Add jQuery isolation plugin for custom elements
|
||||
if (includeJQueryIsolation) {
|
||||
plugins.push({
|
||||
name: 'jquery-isolation',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
generateBundle(options: any, bundle: any) {
|
||||
// Find the main JS file
|
||||
const jsFile = Object.keys(bundle).find((key) => key.endsWith('.js'));
|
||||
if (jsFile && bundle[jsFile] && 'code' in bundle[jsFile]) {
|
||||
const originalCode = bundle[jsFile].code;
|
||||
// Wrap the entire bundle to preserve and restore jQuery
|
||||
bundle[jsFile].code = `
|
||||
(function() {
|
||||
// Preserve the original jQuery $ if it exists
|
||||
var originalJQuery = (typeof window !== 'undefined' && typeof window.$ !== 'undefined') ? window.$ : undefined;
|
||||
|
||||
// Temporarily clear $ to avoid conflicts
|
||||
if (typeof window !== 'undefined' && typeof window.$ !== 'undefined') {
|
||||
window.$ = undefined;
|
||||
}
|
||||
|
||||
// Execute the web component code
|
||||
${originalCode}
|
||||
|
||||
// Restore jQuery $ if it was originally defined
|
||||
if (originalJQuery !== undefined && typeof window !== 'undefined') {
|
||||
window.$ = originalJQuery;
|
||||
}
|
||||
})();
|
||||
`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return plugins.filter(Boolean);
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared define configuration
|
||||
*/
|
||||
const sharedDefine = {
|
||||
'globalThis.__DEV__': process.env.NODE_ENV === 'development',
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
};
|
||||
|
||||
// REMOVED: No longer needed with standalone mount approach
|
||||
// const applySharedViteConfig = (config: UserConfig, includeJQueryIsolation = false) => {
|
||||
// if (!config.plugins) config.plugins = [];
|
||||
// if (!config.define) config.define = {};
|
||||
// if (!config.build) config.build = {};
|
||||
//
|
||||
// // Add shared plugins
|
||||
// config.plugins.push(...getSharedPlugins(includeJQueryIsolation));
|
||||
//
|
||||
// // Merge define values
|
||||
// Object.assign(config.define, sharedDefine);
|
||||
//
|
||||
// // Apply build configuration
|
||||
// config.build.minify = 'terser';
|
||||
// config.build.terserOptions = sharedTerserOptions;
|
||||
//
|
||||
// return config;
|
||||
// };
|
||||
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
devServer: {
|
||||
port: 3000,
|
||||
},
|
||||
|
||||
css: ['@/assets/main.css'],
|
||||
|
||||
devtools: {
|
||||
enabled: process.env.NODE_ENV === 'development',
|
||||
},
|
||||
|
||||
modules: ['@vueuse/nuxt', '@pinia/nuxt', '@nuxt/eslint', '@nuxt/ui'],
|
||||
|
||||
ui: {
|
||||
theme: {
|
||||
colors: ['primary'],
|
||||
},
|
||||
},
|
||||
|
||||
// Disable auto-imports
|
||||
imports: {
|
||||
autoImport: false,
|
||||
},
|
||||
|
||||
// Properly handle ES modules in testing and build environments
|
||||
build: {
|
||||
transpile: [/node_modules\/.*\.mjs$/],
|
||||
},
|
||||
|
||||
ignore: ['/webGui/images'],
|
||||
|
||||
// Disable component auto-imports
|
||||
components: false,
|
||||
|
||||
vite: {
|
||||
plugins: getSharedPlugins(),
|
||||
define: sharedDefine,
|
||||
build: {
|
||||
minify: 'terser',
|
||||
terserOptions: sharedTerserOptions,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
compatibilityDate: '2024-12-05',
|
||||
|
||||
ssr: false,
|
||||
|
||||
// Configure for static generation
|
||||
nitro: {
|
||||
preset: 'static',
|
||||
publicAssets: [
|
||||
{
|
||||
baseURL: '/webGui/',
|
||||
dir: assetsDir,
|
||||
},
|
||||
],
|
||||
devProxy: {
|
||||
'/graphql': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
secure: false,
|
||||
// Important: preserve the host header
|
||||
headers: {
|
||||
'X-Forwarded-Host': 'localhost:3000',
|
||||
'X-Forwarded-Proto': 'http',
|
||||
'X-Forwarded-For': '127.0.0.1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
+23
-28
@@ -2,25 +2,21 @@
|
||||
"name": "@unraid/web",
|
||||
"version": "4.19.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"scripts": {
|
||||
"// Development": "",
|
||||
"predev": "pnpm --filter=@unraid/ui build",
|
||||
"dev": "nuxt dev --dotenv .env.example",
|
||||
"preview": "nuxt preview",
|
||||
"serve": "NODE_ENV=production PORT=${PORT:-4321} node .output/server/index.mjs",
|
||||
"predev": "node ./scripts/build-ui-if-needed.js",
|
||||
"dev": "vite --mode development",
|
||||
"preview": "vite preview",
|
||||
"serve": "NODE_ENV=production PORT=${PORT:-4321} vite preview --port ${PORT:-4321}",
|
||||
"// Build": "",
|
||||
"prebuild:dev": "pnpm predev",
|
||||
"build:dev": "pnpm run build && pnpm run deploy-to-unraid:dev",
|
||||
"build:webgui": "pnpm run type-check && nuxi build --dotenv .env.production && pnpm run manifest-ts && pnpm run copy-to-webgui-repo",
|
||||
"build": "NODE_ENV=production nuxi build --dotenv .env.production && pnpm run build:standalone && pnpm run manifest-ts && pnpm run validate:css",
|
||||
"build:standalone": "vite build --config vite.standalone.config.ts && pnpm run manifest-standalone",
|
||||
"build": "NODE_ENV=production vite build && pnpm run manifest-ts",
|
||||
"prebuild:watch": "pnpm predev",
|
||||
"build:watch": "nuxi build --dotenv .env.production --watch && pnpm run manifest-ts",
|
||||
"generate": "nuxt generate",
|
||||
"manifest-ts": "node ./scripts/add-timestamp-webcomponent-manifest.js",
|
||||
"manifest-standalone": "node ./scripts/add-timestamp-standalone-manifest.js",
|
||||
"validate:css": "node ./scripts/validate-custom-elements-css.js",
|
||||
"build:watch": "vite build --watch && pnpm run manifest-ts",
|
||||
"manifest-ts": "node ./scripts/add-timestamp-standalone-manifest.js",
|
||||
"// Deployment": "",
|
||||
"unraid:deploy": "pnpm build:dev",
|
||||
"deploy-to-unraid:dev": "./scripts/deploy-dev.sh",
|
||||
@@ -29,8 +25,8 @@
|
||||
"// Code Quality": "",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"type-check": "nuxi typecheck",
|
||||
"clean": "rm -rf .nuxt .output dist",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"clean": "rm -rf dist dist-wc",
|
||||
"// GraphQL Codegen": "",
|
||||
"codegen": "graphql-codegen --config codegen.ts -r dotenv/config",
|
||||
"codegen:watch": "graphql-codegen --config codegen.ts --watch -r dotenv/config",
|
||||
@@ -38,19 +34,15 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ci": "vitest run",
|
||||
"test:standalone": "pnpm run build:standalone && vite --config vite.test.config.ts",
|
||||
"// Nuxt": "",
|
||||
"postinstall": "nuxt prepare"
|
||||
"test:standalone": "pnpm run build && vite --config vite.test.config.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.34.0",
|
||||
"@graphql-codegen/cli": "5.0.7",
|
||||
"@graphql-codegen/client-preset": "4.8.3",
|
||||
"@graphql-codegen/introspection": "4.0.3",
|
||||
"@graphql-typed-document-node/core": "3.2.0",
|
||||
"@ianvs/prettier-plugin-sort-imports": "4.6.3",
|
||||
"@nuxt/devtools": "2.6.3",
|
||||
"@nuxt/eslint": "1.9.0",
|
||||
"@nuxt/test-utils": "3.19.2",
|
||||
"@pinia/testing": "1.0.2",
|
||||
"@rollup/plugin-strip": "3.0.4",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
@@ -67,28 +59,32 @@
|
||||
"@vue/apollo-util": "4.2.2",
|
||||
"@vue/test-utils": "2.4.6",
|
||||
"@vueuse/core": "13.8.0",
|
||||
"@vueuse/nuxt": "13.8.0",
|
||||
"eslint": "9.34.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-no-relative-import-paths": "1.6.1",
|
||||
"eslint-plugin-prettier": "5.5.4",
|
||||
"eslint-plugin-storybook": "9.1.3",
|
||||
"eslint-plugin-vue": "10.4.0",
|
||||
"globals": "^16.3.0",
|
||||
"happy-dom": "18.0.1",
|
||||
"kebab-case": "2.0.2",
|
||||
"lodash-es": "4.17.21",
|
||||
"nuxt": "3.18.1",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"shadcn-nuxt": "2.2.0",
|
||||
"tailwindcss": "4.1.12",
|
||||
"terser": "5.43.1",
|
||||
"tw-animate-css": "1.3.7",
|
||||
"typescript": "5.9.2",
|
||||
"typescript-eslint": "8.41.0",
|
||||
"vite": "7.1.3",
|
||||
"vite-plugin-remove-console": "2.2.0",
|
||||
"vite-plugin-vue-tracer": "1.0.0",
|
||||
"vitest": "3.2.4",
|
||||
"vue": "3.5.20",
|
||||
"vue-tsc": "3.0.6",
|
||||
"vuetify-nuxt-module": "0.18.7"
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "3.14.0",
|
||||
@@ -101,9 +97,7 @@
|
||||
"@jsonforms/vue": "3.6.0",
|
||||
"@jsonforms/vue-vanilla": "3.6.0",
|
||||
"@jsonforms/vue-vuetify": "3.6.0",
|
||||
"@nuxt/ui": "3.3.2",
|
||||
"@nuxtjs/color-mode": "3.5.2",
|
||||
"@pinia/nuxt": "0.11.2",
|
||||
"@nuxt/ui": "4.0.0-alpha.0",
|
||||
"@unraid/shared-callbacks": "1.1.1",
|
||||
"@unraid/ui": "link:../unraid-ui",
|
||||
"@vue/apollo-composable": "4.2.2",
|
||||
@@ -130,6 +124,7 @@
|
||||
"semver": "7.7.2",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"vue-i18n": "11.1.11",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-web-component-wrapper": "1.7.7",
|
||||
"vuetify": "3.9.6",
|
||||
"wretch": "2.11.0"
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeMount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUpdateOsStore } from '~/store/updateOs';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import UpdateOsChangelogModal from '~/components/UpdateOs/ChangelogModal.vue';
|
||||
|
||||
const updateOsStore = useUpdateOsStore();
|
||||
const { changelogModalVisible } = storeToRefs(updateOsStore);
|
||||
const { t } = useI18n();
|
||||
|
||||
onBeforeMount(() => {
|
||||
// Register custom elements if needed for ColorSwitcherCe
|
||||
});
|
||||
|
||||
async function showChangelogModalFromReleasesEndpoint() {
|
||||
const response = await fetch('https://releases.unraid.net/os?branch=stable¤t_version=6.12.3');
|
||||
const data = await response.json();
|
||||
updateOsStore.setReleaseForUpdate(data);
|
||||
}
|
||||
|
||||
function showChangelogModalWithTestData() {
|
||||
updateOsStore.setReleaseForUpdate({
|
||||
version: '6.12.3',
|
||||
date: '2023-07-15',
|
||||
changelog: 'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/6.12.3.md',
|
||||
changelogPretty: 'https://docs.unraid.net/go/release-notes/6.12.3',
|
||||
name: '6.12.3',
|
||||
isEligible: true,
|
||||
isNewer: true,
|
||||
sha256: '1234567890'
|
||||
});
|
||||
}
|
||||
|
||||
function showChangelogWithoutPretty() {
|
||||
updateOsStore.setReleaseForUpdate({
|
||||
version: '6.12.3',
|
||||
date: '2023-07-15',
|
||||
changelog: 'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/6.12.3.md',
|
||||
changelogPretty: '',
|
||||
name: '6.12.3',
|
||||
isEligible: true,
|
||||
isNewer: true,
|
||||
sha256: '1234567890'
|
||||
});
|
||||
}
|
||||
|
||||
function showChangelogBrokenParse() {
|
||||
updateOsStore.setReleaseForUpdate({
|
||||
version: '6.12.3',
|
||||
date: '2023-07-15',
|
||||
changelog: null,
|
||||
changelogPretty: undefined, // intentionally broken
|
||||
name: '6.12.3',
|
||||
isEligible: true,
|
||||
isNewer: true,
|
||||
sha256: '1234567890'
|
||||
});
|
||||
}
|
||||
|
||||
function showChangelogFromLocalhost() {
|
||||
updateOsStore.setReleaseForUpdate({
|
||||
version: '6.12.3',
|
||||
date: '2023-07-15',
|
||||
changelog: 'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/6.12.3.md',
|
||||
changelogPretty: 'http://localhost:3000/unraid-os/release-notes/6.12.3',
|
||||
name: '6.12.3',
|
||||
isEligible: true,
|
||||
isNewer: true,
|
||||
sha256: '1234567890'
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto p-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Changelog</h1>
|
||||
<UpdateOsChangelogModal :t="t" :open="changelogModalVisible" />
|
||||
<div class="mb-6 flex flex-col gap-4">
|
||||
<div class="max-w-md flex flex-col gap-4">
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
@click="showChangelogModalFromReleasesEndpoint"
|
||||
>
|
||||
Test Changelog Modal (from releases endpoint)
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600"
|
||||
@click="showChangelogFromLocalhost"
|
||||
>
|
||||
Test Local Pretty Changelog (:3000)
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
@click="showChangelogModalWithTestData"
|
||||
>
|
||||
Test Changelog Modal (with test data)
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
|
||||
@click="showChangelogWithoutPretty"
|
||||
>
|
||||
Test Without Pretty Changelog
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
|
||||
@click="showChangelogBrokenParse"
|
||||
>
|
||||
Test Broken Parse Changelog
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,213 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid';
|
||||
import { BrandButton, Toaster } from '@unraid/ui';
|
||||
import { UButton } from '#components';
|
||||
import { useDummyServerStore } from '~/_data/serverState';
|
||||
import AES from 'crypto-js/aes';
|
||||
|
||||
import type { SendPayloads } from '@unraid/shared-callbacks';
|
||||
|
||||
import WelcomeModalCe from '~/components/Activation/WelcomeModal.ce.vue';
|
||||
import ConnectSettingsCe from '~/components/ConnectSettings/ConnectSettings.ce.vue';
|
||||
import DowngradeOsCe from '~/components/DowngradeOs.ce.vue';
|
||||
import HeaderOsVersionCe from '~/components/HeaderOsVersion.ce.vue';
|
||||
import LogViewerCe from '~/components/Logs/LogViewer.ce.vue';
|
||||
import ModalsCe from '~/components/Modals.ce.vue';
|
||||
import RegistrationCe from '~/components/Registration.ce.vue';
|
||||
import SsoButtonCe from '~/components/SsoButton.ce.vue';
|
||||
import UpdateOsCe from '~/components/UpdateOs.ce.vue';
|
||||
import UserProfileCe from '~/components/UserProfile.ce.vue';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
|
||||
const serverStore = useDummyServerStore();
|
||||
const { serverState } = storeToRefs(serverStore);
|
||||
|
||||
onMounted(() => {
|
||||
document.cookie = 'unraid_session_cookie=mockusersession';
|
||||
});
|
||||
|
||||
const valueToMakeCallback = ref<SendPayloads | undefined>();
|
||||
const callbackDestination = ref<string | undefined>('');
|
||||
|
||||
const createCallbackUrl = (payload: SendPayloads, sendType: string) => {
|
||||
// params differs from callbackActions.send
|
||||
console.debug('[callback.send]');
|
||||
|
||||
valueToMakeCallback.value = payload; // differs from callbackActions.send
|
||||
|
||||
const stringifiedData = JSON.stringify({
|
||||
actions: [...payload],
|
||||
sender: window.location.href,
|
||||
type: sendType,
|
||||
});
|
||||
const encryptedMessage = AES.encrypt(stringifiedData, import.meta.env.VITE_CALLBACK_KEY).toString();
|
||||
// build and go to url
|
||||
const destinationUrl = new URL(window.location.href); // differs from callbackActions.send
|
||||
destinationUrl.searchParams.set('data', encodeURI(encryptedMessage));
|
||||
|
||||
callbackDestination.value = destinationUrl.toString(); // differs from callbackActions.send
|
||||
};
|
||||
|
||||
const variants = [
|
||||
'fill',
|
||||
'black',
|
||||
'gray',
|
||||
'outline',
|
||||
'outline-black',
|
||||
'outline-white',
|
||||
'underline',
|
||||
'underline-hover-red',
|
||||
'white',
|
||||
'none',
|
||||
] as const;
|
||||
|
||||
onMounted(() => {
|
||||
createCallbackUrl(
|
||||
[
|
||||
{
|
||||
// keyUrl: 'https://keys.lime-technology.com/unraid/d26a033e3097c65ab0b4f742a7c02ce808c6e963/Starter.key', // assigned to guid 1111-1111-5GDB-123412341234, use to test EGUID after key install
|
||||
keyUrl:
|
||||
'https://keys.lime-technology.com/unraid/7f7c2ddff1c38f21ed174f5c5d9f97b7b4577344/Starter.key',
|
||||
type: 'renew',
|
||||
},
|
||||
{
|
||||
sha256: 'a7d1a42fc661f55ee45d36bbc49aac71aef045cc1d287b1e7f16be0ba485c9b6',
|
||||
type: 'updateOs',
|
||||
},
|
||||
],
|
||||
'forUpc'
|
||||
);
|
||||
});
|
||||
const bannerImage = ref<string>('none');
|
||||
|
||||
const { theme } = storeToRefs(useThemeStore());
|
||||
watch(
|
||||
theme,
|
||||
(newTheme) => {
|
||||
if (newTheme.banner) {
|
||||
bannerImage.value = `url(https://picsum.photos/1920/200?${Math.round(Math.random() * 100)})`;
|
||||
} else {
|
||||
bannerImage.value = 'none';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-black bg-white dark:text-white dark:bg-black">
|
||||
<div class="pb-12 mx-auto">
|
||||
<client-only>
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<h2 class="text-xl font-semibold font-mono">Vue Components</h2>
|
||||
<h3 class="text-lg font-semibold font-mono">UserProfileCe</h3>
|
||||
<header
|
||||
class="bg-header-background-color flex justify-between items-center"
|
||||
:style="{
|
||||
backgroundImage: bannerImage,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}"
|
||||
>
|
||||
<div class="inline-flex flex-col gap-4 items-start">
|
||||
<HeaderOsVersionCe />
|
||||
</div>
|
||||
<UserProfileCe :server="serverState" />
|
||||
</header>
|
||||
<!-- <hr class="border-black dark:border-white"> -->
|
||||
|
||||
<h3 class="text-lg font-semibold font-mono">ConnectSettingsCe</h3>
|
||||
<ConnectSettingsCe />
|
||||
<hr class="border-muted" >
|
||||
|
||||
<!-- <h3 class="text-lg font-semibold font-mono">
|
||||
DownloadApiLogsCe
|
||||
</h3>
|
||||
<DownloadApiLogsCe />
|
||||
<hr class="border-black dark:border-white"> -->
|
||||
<!-- <h3 class="text-lg font-semibold font-mono">
|
||||
AuthCe
|
||||
</h3>
|
||||
<AuthCe />
|
||||
<hr class="border-black dark:border-white"> -->
|
||||
<!-- <h3 class="text-lg font-semibold font-mono">
|
||||
WanIpCheckCe
|
||||
</h3>
|
||||
<WanIpCheckCe php-wan-ip="47.184.85.45" />
|
||||
<hr class="border-black dark:border-white"> -->
|
||||
<h3 class="text-lg font-semibold font-mono">UpdateOsCe</h3>
|
||||
<UpdateOsCe />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">DowngraadeOsCe</h3>
|
||||
<DowngradeOsCe :restore-release-date="'2022-10-10'" :restore-version="'6.11.2'" />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">RegistrationCe</h3>
|
||||
<RegistrationCe />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">ModalsCe</h3>
|
||||
<ModalsCe />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">WelcomeModalCe</h3>
|
||||
<WelcomeModalCe />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">Test Callback Builder</h3>
|
||||
<div class="flex flex-col justify-end gap-2">
|
||||
<p>
|
||||
Modify the <code>createCallbackUrl</code> param in <code>onMounted</code> to test a
|
||||
callback.
|
||||
</p>
|
||||
<code>
|
||||
<pre>{{ valueToMakeCallback }}</pre>
|
||||
</code>
|
||||
<BrandButton v-if="callbackDestination" :href="callbackDestination" :external="true">
|
||||
{{ 'Go to Callback URL' }}
|
||||
</BrandButton>
|
||||
<h4>Full URL Destination</h4>
|
||||
<code>
|
||||
<pre>{{ callbackDestination }}</pre>
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<hr class="border-muted" >
|
||||
<h2 class="text-xl font-semibold font-mono">Nuxt UI Button - Primary Color Test</h2>
|
||||
<div class="flex gap-4 items-center">
|
||||
<UButton color="primary" variant="solid">Primary Solid</UButton>
|
||||
<UButton color="primary" variant="outline">Primary Outline</UButton>
|
||||
<UButton color="primary" variant="soft">Primary Soft</UButton>
|
||||
<UButton color="primary" variant="ghost">Primary Ghost</UButton>
|
||||
<UButton color="primary" variant="link">Primary Link</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-background">
|
||||
<hr class="border-muted" >
|
||||
<h2 class="text-xl font-semibold font-mono">Brand Button Component</h2>
|
||||
<template v-for="variant in variants" :key="variant">
|
||||
<BrandButton
|
||||
:variant="variant"
|
||||
type="button"
|
||||
size="14px"
|
||||
:icon="ExclamationTriangleIcon"
|
||||
>{{ variant }}</BrandButton
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
<div class="bg-background">
|
||||
<hr class="border-muted" >
|
||||
<h2 class="text-xl font-semibold font-mono">SSO Button Component</h2>
|
||||
<SsoButtonCe />
|
||||
</div>
|
||||
<div class="bg-background">
|
||||
<hr class="border-muted" >
|
||||
<h2 class="text-xl font-semibold font-mono">Log Viewer Component</h2>
|
||||
<LogViewerCe />
|
||||
</div>
|
||||
</div>
|
||||
</client-only>
|
||||
</div>
|
||||
<Toaster rich-colors close-button />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useDummyServerStore } from '~/_data/serverState';
|
||||
import { Toaster } from '@unraid/ui';
|
||||
import BrandLogo from '~/components/Brand/Logo.vue';
|
||||
import HeaderOsVersionCe from '~/components/HeaderOsVersion.ce.vue';
|
||||
import ConnectSettingsCe from '~/components/ConnectSettings/ConnectSettings.ce.vue';
|
||||
|
||||
const serverStore = useDummyServerStore();
|
||||
const { serverState } = storeToRefs(serverStore);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<client-only>
|
||||
<div
|
||||
class="flex flex-col gap-6 p-6 mx-auto text-black bg-white dark:text-white dark:bg-black"
|
||||
>
|
||||
<h2 class="text-xl font-semibold font-mono">Web Components</h2>
|
||||
<h3 class="text-lg font-semibold font-mono">UserProfileCe</h3>
|
||||
<header class="bg-header-background-color py-4 flex flex-row justify-between items-center">
|
||||
<div class="inline-flex flex-col gap-4 items-start px-4">
|
||||
<a href="https://unraid.net" target="_blank">
|
||||
<BrandLogo class="w-[100px] sm:w-[150px]" />
|
||||
</a>
|
||||
<HeaderOsVersionCe />
|
||||
</div>
|
||||
<unraid-user-profile :server="JSON.stringify(serverState)" />
|
||||
</header>
|
||||
<hr class="border-muted" >
|
||||
|
||||
<h3 class="text-lg font-semibold font-mono">ConnectSettingsCe</h3>
|
||||
<ConnectSettingsCe />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">DownloadApiLogsCe</h3>
|
||||
<unraid-download-api-logs />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">AuthCe</h3>
|
||||
<unraid-auth />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">WanIpCheckCe</h3>
|
||||
<unraid-wan-ip-check php-wan-ip="47.184.85.45" />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">HeaderOsVersion</h3>
|
||||
<unraid-header-os-version />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">UpdateOsCe</h3>
|
||||
<unraid-update-os />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">DowngradeOsCe</h3>
|
||||
<unraid-downgrade-os restore-release-date="2022-10-10" restore-version="6.11.2" />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">RegistrationCe</h3>
|
||||
<unraid-registration />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">ModalsCe</h3>
|
||||
<!-- uncomment to test modals <unraid-modals />-->
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">SSOSignInButtonCe</h3>
|
||||
<unraid-sso-button />
|
||||
<hr class="border-muted" >
|
||||
<h3 class="text-lg font-semibold font-mono">ApiKeyManagerCe</h3>
|
||||
<unraid-api-key-manager />
|
||||
</div>
|
||||
<Toaster rich-colors close-button />
|
||||
</client-only>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineNuxtPlugin } from '#imports';
|
||||
import { DefaultApolloClient } from '@vue/apollo-composable';
|
||||
import { client } from '~/helpers/create-apollo-client';
|
||||
|
||||
export default defineNuxtPlugin(({ vueApp }) => {
|
||||
vueApp.provide(DefaultApolloClient, client);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { defineNuxtPlugin } from '#imports';
|
||||
|
||||
import en_US from '@/locales/en_US.json';
|
||||
import { createHtmlEntityDecoder } from '~/helpers/i18n-utils';
|
||||
|
||||
export default defineNuxtPlugin(({ vueApp }) => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale: 'en_US',
|
||||
fallbackLocale: 'en_US',
|
||||
messages: {
|
||||
en_US,
|
||||
},
|
||||
postTranslation: createHtmlEntityDecoder(),
|
||||
});
|
||||
|
||||
vueApp.use(i18n);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>All Components - Unraid Component Test</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.component-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.component-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.component-card h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #1f2937;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.component-card .selector {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 15px;
|
||||
background: #f3f4f6;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
.component-mount {
|
||||
min-height: 50px;
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.status {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
}
|
||||
.category-header {
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
margin: 30px 0 15px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Authentication & User -->
|
||||
<div class="category-header">👤 Authentication & User</div>
|
||||
<div class="component-grid">
|
||||
<div class="component-card">
|
||||
<h3>Authentication</h3>
|
||||
<span class="selector"><unraid-auth></span>
|
||||
<div class="component-mount">
|
||||
<unraid-auth></unraid-auth>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>User Profile</h3>
|
||||
<span class="selector"><unraid-user-profile></span>
|
||||
<div class="component-mount">
|
||||
<unraid-user-profile></unraid-user-profile>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>SSO Button</h3>
|
||||
<span class="selector"><unraid-sso-button></span>
|
||||
<div class="component-mount">
|
||||
<unraid-sso-button></unraid-sso-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Registration</h3>
|
||||
<span class="selector"><unraid-registration></span>
|
||||
<div class="component-mount">
|
||||
<unraid-registration></unraid-registration>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System & Settings -->
|
||||
<div class="category-header">⚙️ System & Settings</div>
|
||||
<div class="component-grid">
|
||||
<div class="component-card">
|
||||
<h3>Connect Settings</h3>
|
||||
<span class="selector"><unraid-connect-settings></span>
|
||||
<div class="component-mount">
|
||||
<unraid-connect-settings></unraid-connect-settings>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Theme Switcher</h3>
|
||||
<span class="selector"><unraid-theme-switcher></span>
|
||||
<div class="component-mount">
|
||||
<unraid-theme-switcher current="white"></unraid-theme-switcher>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Header OS Version</h3>
|
||||
<span class="selector"><unraid-header-os-version></span>
|
||||
<div class="component-mount">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>WAN IP Check</h3>
|
||||
<span class="selector"><unraid-wan-ip-check></span>
|
||||
<div class="component-mount">
|
||||
<unraid-wan-ip-check php-wan-ip="192.168.1.1"></unraid-wan-ip-check>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OS Management -->
|
||||
<div class="category-header">💿 OS Management</div>
|
||||
<div class="component-grid">
|
||||
<div class="component-card">
|
||||
<h3>Update OS</h3>
|
||||
<span class="selector"><unraid-update-os></span>
|
||||
<div class="component-mount">
|
||||
<unraid-update-os></unraid-update-os>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Downgrade OS</h3>
|
||||
<span class="selector"><unraid-downgrade-os></span>
|
||||
<div class="component-mount">
|
||||
<unraid-downgrade-os></unraid-downgrade-os>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API & Developer -->
|
||||
<div class="category-header">🔧 API & Developer</div>
|
||||
<div class="component-grid">
|
||||
<div class="component-card">
|
||||
<h3>API Key Manager</h3>
|
||||
<span class="selector"><unraid-api-key-manager></span>
|
||||
<div class="component-mount">
|
||||
<unraid-api-key-manager></unraid-api-key-manager>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>API Key Authorize</h3>
|
||||
<span class="selector"><unraid-api-key-authorize></span>
|
||||
<div class="component-mount">
|
||||
<unraid-api-key-authorize></unraid-api-key-authorize>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Download API Logs</h3>
|
||||
<span class="selector"><unraid-download-api-logs></span>
|
||||
<div class="component-mount">
|
||||
<unraid-download-api-logs></unraid-download-api-logs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Log Viewer</h3>
|
||||
<span class="selector"><unraid-log-viewer></span>
|
||||
<div class="component-mount">
|
||||
<unraid-log-viewer></unraid-log-viewer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UI Components -->
|
||||
<div class="category-header">🎨 UI Components</div>
|
||||
<div class="component-grid">
|
||||
<div class="component-card">
|
||||
<h3>Modals</h3>
|
||||
<span class="selector"><unraid-modals></span>
|
||||
<div class="component-mount">
|
||||
<unraid-modals></unraid-modals>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Welcome Modal</h3>
|
||||
<span class="selector"><unraid-welcome-modal></span>
|
||||
<div class="component-mount">
|
||||
<unraid-welcome-modal></unraid-welcome-modal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Dev Modal Test</h3>
|
||||
<span class="selector"><unraid-dev-modal-test></span>
|
||||
<div class="component-mount">
|
||||
<unraid-dev-modal-test></unraid-dev-modal-test>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Toaster</h3>
|
||||
<span class="selector"><unraid-toaster></span>
|
||||
<div class="component-mount">
|
||||
<unraid-toaster></unraid-toaster>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Controls -->
|
||||
<div class="category-header">🎮 Test Controls</div>
|
||||
<div style="background: white; padding: 20px; border-radius: 8px; margin-top: 15px;">
|
||||
<h3>jQuery Interaction Tests</h3>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px;">
|
||||
<button id="test-notification" class="test-btn">Trigger Notification</button>
|
||||
<button id="test-modal" class="test-btn">Open Test Modal</button>
|
||||
<button id="test-theme" class="test-btn">Toggle Theme</button>
|
||||
<button id="test-update-profile" class="test-btn">Update Profile Data</button>
|
||||
<button id="test-settings" class="test-btn">Update Settings</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<h4>Console Output</h4>
|
||||
<div id="test-output" style="background: #1f2937; color: #10b981; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px; min-height: 100px; max-height: 200px; overflow-y: auto;">
|
||||
> Ready for testing...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.test-btn {
|
||||
padding: 8px 16px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.test-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Load the manifest and inject resources -->
|
||||
<script src="/test-pages/load-manifest.js"></script>
|
||||
<script src="/test-pages/test-server-state.js"></script>
|
||||
<script src="/test-pages/shared-header.js"></script>
|
||||
|
||||
<!-- Test interactions -->
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
const output = $('#test-output');
|
||||
|
||||
function log(message) {
|
||||
// Use shared header's testLog if available, otherwise local log
|
||||
if (window.testLog) {
|
||||
window.testLog(message);
|
||||
}
|
||||
if (output.length) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
output.append('\n> [' + timestamp + '] ' + message);
|
||||
output.scrollTop(output[0].scrollHeight);
|
||||
}
|
||||
}
|
||||
|
||||
// Test notification
|
||||
$('#test-notification').on('click', function() {
|
||||
log('Triggering notification...');
|
||||
const event = new CustomEvent('unraid:notification', {
|
||||
detail: {
|
||||
title: 'Test Notification',
|
||||
message: 'This is a test from jQuery!',
|
||||
type: 'success'
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// Test modal
|
||||
$('#test-modal').on('click', function() {
|
||||
log('Opening test modal...');
|
||||
// This would trigger the modal system
|
||||
window.dispatchEvent(new CustomEvent('unraid:open-modal', {
|
||||
detail: { modalId: 'test-modal' }
|
||||
}));
|
||||
});
|
||||
|
||||
// Test theme toggle
|
||||
$('#test-theme').on('click', function() {
|
||||
log('Toggling theme...');
|
||||
const currentTheme = $('body').hasClass('dark') ? 'light' : 'dark';
|
||||
$('body').toggleClass('dark');
|
||||
log('Theme changed to: ' + currentTheme);
|
||||
});
|
||||
|
||||
// Test profile update
|
||||
$('#test-update-profile').on('click', function() {
|
||||
log('Updating profile data...');
|
||||
const profileData = {
|
||||
name: 'Test User ' + Math.floor(Math.random() * 100),
|
||||
email: 'test' + Math.floor(Math.random() * 100) + '@example.com',
|
||||
username: 'testuser'
|
||||
};
|
||||
$('unraid-user-profile').attr('server', JSON.stringify(profileData));
|
||||
log('Profile updated: ' + JSON.stringify(profileData));
|
||||
});
|
||||
|
||||
// Test settings update
|
||||
$('#test-settings').on('click', function() {
|
||||
log('Updating connect settings...');
|
||||
const settings = {
|
||||
enabled: Math.random() > 0.5,
|
||||
url: 'https://connect.unraid.net',
|
||||
lastSync: new Date().toISOString()
|
||||
};
|
||||
$('unraid-connect-settings').attr('initial-settings', JSON.stringify(settings));
|
||||
log('Settings updated: ' + JSON.stringify(settings));
|
||||
});
|
||||
|
||||
// Listen for component events
|
||||
$(document).on('unraid:theme-changed', function(e, data) {
|
||||
log('Theme changed event received: ' + JSON.stringify(data));
|
||||
});
|
||||
|
||||
$(document).on('unraid:settings-saved', function(e, data) {
|
||||
log('Settings saved event received: ' + JSON.stringify(data));
|
||||
});
|
||||
|
||||
log('Test page initialized - all components loaded');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,217 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authentication Flow - Unraid Component Test</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.auth-container {
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.auth-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.auth-card h2 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
.user-info {
|
||||
background: #f3f4f6;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.status-badge.authenticated {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
.status-badge.unauthenticated {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
<!-- Login Card -->
|
||||
<div class="auth-card">
|
||||
<h2>🔐 Authentication</h2>
|
||||
<unraid-auth></unraid-auth>
|
||||
<div class="user-info">
|
||||
<strong>Session Status:</strong>
|
||||
<div id="auth-status">
|
||||
<span class="status-badge unauthenticated">Not Authenticated</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSO Options -->
|
||||
<div class="auth-card">
|
||||
<h2>🔗 Single Sign-On</h2>
|
||||
<p style="color: #6b7280; margin-bottom: 20px;">Alternative authentication methods</p>
|
||||
<unraid-sso-button></unraid-sso-button>
|
||||
</div>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="auth-card">
|
||||
<h2>👤 User Profile</h2>
|
||||
<p style="color: #6b7280; margin-bottom: 20px;">Displays when authenticated</p>
|
||||
<unraid-user-profile id="user-profile"></unraid-user-profile>
|
||||
</div>
|
||||
|
||||
<!-- Registration -->
|
||||
<div class="auth-card">
|
||||
<h2>📝 System Registration</h2>
|
||||
<unraid-registration></unraid-registration>
|
||||
</div>
|
||||
|
||||
<!-- Test Controls -->
|
||||
<div class="auth-card" style="background: #1f2937; color: white;">
|
||||
<h2 style="color: white;">🧪 Test Controls</h2>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button id="simulate-login" class="btn">Simulate Login</button>
|
||||
<button id="simulate-logout" class="btn">Simulate Logout</button>
|
||||
<button id="update-profile" class="btn">Update Profile</button>
|
||||
<button id="check-session" class="btn">Check Session</button>
|
||||
</div>
|
||||
<div id="console-output" style="margin-top: 20px; padding: 10px; background: black; border-radius: 4px; font-family: monospace; font-size: 12px; min-height: 80px;">
|
||||
> Ready...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Global Modals -->
|
||||
<unraid-modals></unraid-modals>
|
||||
|
||||
<!-- Load the manifest -->
|
||||
<script src="/test-pages/load-manifest.js"></script>
|
||||
|
||||
<!-- Authentication test logic -->
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
const output = $('#console-output');
|
||||
let isAuthenticated = false;
|
||||
|
||||
function log(message) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
output.append('\n> [' + timestamp + '] ' + message);
|
||||
output.scrollTop(output[0].scrollHeight);
|
||||
}
|
||||
|
||||
// Simulate login
|
||||
$('#simulate-login').on('click', function() {
|
||||
log('Simulating login...');
|
||||
isAuthenticated = true;
|
||||
|
||||
const userData = {
|
||||
username: 'admin',
|
||||
email: 'admin@unraid.local',
|
||||
name: 'Administrator',
|
||||
avatarUrl: '/webGui/images/default-avatar.png',
|
||||
role: 'admin',
|
||||
sessionId: 'sess_' + Math.random().toString(36).substr(2, 9)
|
||||
};
|
||||
|
||||
// Update components
|
||||
$('#user-profile').attr('server', JSON.stringify(userData));
|
||||
$('#auth-status').html('<span class="status-badge authenticated">Authenticated as ' + userData.username + '</span>');
|
||||
|
||||
// Dispatch login event
|
||||
$(document).trigger('unraid:auth-login', userData);
|
||||
log('Login successful: ' + userData.username);
|
||||
});
|
||||
|
||||
// Simulate logout
|
||||
$('#simulate-logout').on('click', function() {
|
||||
log('Simulating logout...');
|
||||
isAuthenticated = false;
|
||||
|
||||
$('#user-profile').attr('server', '{}');
|
||||
$('#auth-status').html('<span class="status-badge unauthenticated">Not Authenticated</span>');
|
||||
|
||||
// Dispatch logout event
|
||||
$(document).trigger('unraid:auth-logout');
|
||||
log('Logged out successfully');
|
||||
});
|
||||
|
||||
// Update profile
|
||||
$('#update-profile').on('click', function() {
|
||||
if (!isAuthenticated) {
|
||||
log('Error: Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
log('Updating profile...');
|
||||
const updatedData = {
|
||||
username: 'admin',
|
||||
email: 'newemail@unraid.local',
|
||||
name: 'Updated Admin',
|
||||
lastModified: new Date().toISOString()
|
||||
};
|
||||
|
||||
$('#user-profile').attr('server', JSON.stringify(updatedData));
|
||||
log('Profile updated');
|
||||
});
|
||||
|
||||
// Check session
|
||||
$('#check-session').on('click', function() {
|
||||
log('Checking session status...');
|
||||
log('Authenticated: ' + (isAuthenticated ? 'Yes' : 'No'));
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Would normally make an API call here
|
||||
log('Session valid until: ' + new Date(Date.now() + 3600000).toLocaleTimeString());
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for auth events from components
|
||||
$(document).on('unraid:auth-required', function() {
|
||||
log('Authentication required by component');
|
||||
});
|
||||
|
||||
$(document).on('unraid:session-expired', function() {
|
||||
log('Session expired - please login again');
|
||||
$('#simulate-logout').click();
|
||||
});
|
||||
|
||||
log('Authentication test page ready');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,203 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard - Unraid Component Test</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.dashboard-header {
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card h2 {
|
||||
margin-top: 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
.breadcrumb {
|
||||
padding: 10px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.component-mount {
|
||||
padding: 10px;
|
||||
border: 2px dashed #e5e7eb;
|
||||
border-radius: 4px;
|
||||
min-height: 50px;
|
||||
position: relative;
|
||||
}
|
||||
.component-mount::before {
|
||||
content: attr(data-component);
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 10px;
|
||||
background: white;
|
||||
padding: 0 5px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Main dashboard content -->
|
||||
<div class="dashboard-header">
|
||||
<div class="header-left">
|
||||
<div class="logo">UNRAID</div>
|
||||
<!-- OS Version Component -->
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- User Profile Component -->
|
||||
<unraid-user-profile id="header-user-profile"></unraid-user-profile>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<div class="breadcrumb">
|
||||
<a href="/test-pages/">Test Pages</a> / Dashboard
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<div class="card">
|
||||
<h2>System Information</h2>
|
||||
<div class="component-mount" data-component="unraid-header-os-version">
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Theme Settings</h2>
|
||||
<div class="component-mount" data-component="unraid-theme-switcher">
|
||||
<unraid-theme-switcher current="white"></unraid-theme-switcher>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Authentication</h2>
|
||||
<div class="component-mount" data-component="unraid-auth">
|
||||
<unraid-auth></unraid-auth>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>WAN IP Check</h2>
|
||||
<div class="component-mount" data-component="unraid-wan-ip-check">
|
||||
<unraid-wan-ip-check php-wan-ip="192.168.1.1"></unraid-wan-ip-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>API Logs</h2>
|
||||
<button id="toggle-logs" style="padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
||||
Toggle Log Component
|
||||
</button>
|
||||
<div id="logs-container" class="component-mount" data-component="unraid-download-api-logs" style="margin-top: 10px; display: none;">
|
||||
<unraid-download-api-logs></unraid-download-api-logs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Modals -->
|
||||
<unraid-modals></unraid-modals>
|
||||
|
||||
<!-- Load the manifest and inject resources (mimics PHP extractor) -->
|
||||
<script src="/test-pages/load-manifest.js"></script>
|
||||
<script src="/test-pages/test-server-state.js"></script>
|
||||
<script src="/test-pages/shared-header.js"></script>
|
||||
|
||||
<!-- jQuery interactions mimicking Unraid -->
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Example: Pass server data to user profile component via jQuery
|
||||
// This mimics how Unraid PHP would set data
|
||||
var serverData = {
|
||||
name: 'TestServer',
|
||||
version: '6.12.4',
|
||||
username: 'admin',
|
||||
email: 'admin@unraid.net',
|
||||
avatarUrl: '/webGui/images/default-avatar.png'
|
||||
};
|
||||
|
||||
// Set attribute on the component (Vue will pick this up)
|
||||
$('#header-user-profile').attr('server', JSON.stringify(serverData));
|
||||
|
||||
// Toggle logs visibility with jQuery
|
||||
$('#toggle-logs').on('click', function() {
|
||||
$('#logs-container').slideToggle();
|
||||
});
|
||||
|
||||
// Example: Update WAN IP dynamically (like Unraid would do after an AJAX call)
|
||||
setTimeout(function() {
|
||||
$('unraid-wan-ip-check').attr('php-wan-ip', '203.0.113.42');
|
||||
console.log('Updated WAN IP via jQuery');
|
||||
}, 3000);
|
||||
|
||||
// Example: Trigger component methods from jQuery
|
||||
// Components can expose methods via window object
|
||||
window.updateUserProfile = function(newData) {
|
||||
$('#header-user-profile').attr('server', JSON.stringify(newData));
|
||||
};
|
||||
|
||||
// Example: Listen for events from Vue components
|
||||
// Components can emit custom DOM events
|
||||
$(document).on('unraid:theme-changed', function(e, data) {
|
||||
console.log('Theme changed to:', data.theme);
|
||||
// Update body class or do other jQuery operations
|
||||
$('body').toggleClass('dark-mode', data.theme === 'dark');
|
||||
});
|
||||
|
||||
// Example: Show a notification (like Unraid's addNotification)
|
||||
window.addNotification = function(title, message, type) {
|
||||
// This would trigger the Vue toast/notification system
|
||||
var event = new CustomEvent('unraid:notification', {
|
||||
detail: { title: title, message: message, type: type }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
};
|
||||
|
||||
// Test notification after 2 seconds
|
||||
setTimeout(function() {
|
||||
addNotification('Test Notification', 'This is from jQuery!', 'success');
|
||||
}, 2000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,247 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Unraid Component Test Pages</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 32px;
|
||||
}
|
||||
.header p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.category-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.category-header {
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.page-list {
|
||||
background: white;
|
||||
border-radius: 0 0 6px 6px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.page-item {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.page-item:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.page-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.page-item h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #1f2937;
|
||||
}
|
||||
.page-item p {
|
||||
margin: 5px 0 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
.page-item .badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.page-item .badge.new {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.page-item a {
|
||||
padding: 8px 16px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.page-item a:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.info-box {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 30px;
|
||||
color: #92400e;
|
||||
}
|
||||
.info-box strong {
|
||||
color: #78350f;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🧪 Unraid Component Test Environment</h1>
|
||||
<p>HTML-based test pages that mimic Unraid OS integration with jQuery and component mounting</p>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>ℹ️ Testing Mode:</strong> These pages replicate how Unraid OS mounts Vue components into existing HTML/PHP pages using jQuery for interaction. Each page demonstrates real-world integration patterns.
|
||||
</div>
|
||||
|
||||
<!-- Core Test Pages -->
|
||||
<div class="category-section">
|
||||
<div class="category-header">
|
||||
🎯 Core Test Pages
|
||||
</div>
|
||||
<div class="page-list">
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>All Components <span class="badge new">COMPREHENSIVE</span></h3>
|
||||
<p>Complete component library showcase with all available components</p>
|
||||
</div>
|
||||
<a href="/test-pages/all-components.html">Open →</a>
|
||||
</div>
|
||||
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>Dashboard</h3>
|
||||
<p>Main dashboard with header components, system status, and user profile</p>
|
||||
</div>
|
||||
<a href="/test-pages/dashboard.html">Open →</a>
|
||||
</div>
|
||||
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>Settings</h3>
|
||||
<p>System settings with theme switcher, Connect configuration, and API keys</p>
|
||||
</div>
|
||||
<a href="/test-pages/settings.html">Open →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature-Specific Pages -->
|
||||
<div class="category-section">
|
||||
<div class="category-header">
|
||||
⚡ Feature-Specific Pages
|
||||
</div>
|
||||
<div class="page-list">
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>Authentication Flow <span class="badge new">NEW</span></h3>
|
||||
<p>Complete auth workflow with login, SSO, user profile, and registration</p>
|
||||
</div>
|
||||
<a href="/test-pages/authentication.html">Open →</a>
|
||||
</div>
|
||||
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>OS Management <span class="badge new">NEW</span></h3>
|
||||
<p>System updates, downgrades, and version management with progress simulation</p>
|
||||
</div>
|
||||
<a href="/test-pages/os-management.html">Open →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="category-section">
|
||||
<div class="category-header">
|
||||
🔗 Quick Component Tests
|
||||
</div>
|
||||
<div class="page-list">
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>Theme Testing</h3>
|
||||
<p>Light/dark mode switching and CSS variable management</p>
|
||||
</div>
|
||||
<a href="/test-pages/all-components.html#theme-switcher">Open →</a>
|
||||
</div>
|
||||
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>Modal System</h3>
|
||||
<p>Global modal management and event-driven popups</p>
|
||||
</div>
|
||||
<a href="/test-pages/all-components.html#modals">Open →</a>
|
||||
</div>
|
||||
|
||||
<div class="page-item">
|
||||
<div>
|
||||
<h3>API & Logs</h3>
|
||||
<p>API key management, log viewer, and debug tools</p>
|
||||
</div>
|
||||
<a href="/test-pages/all-components.html#api-developer">Open →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Testing Info -->
|
||||
<div style="background: white; padding: 20px; border-radius: 8px; margin-top: 30px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h3 style="margin-top: 0;">🛠️ Testing Guidelines</h3>
|
||||
<ul style="color: #6b7280; line-height: 1.8;">
|
||||
<li>Each page loads components using the same mechanism as Unraid OS (manifest-based loading)</li>
|
||||
<li>jQuery is available for simulating PHP/backend interactions</li>
|
||||
<li>Components communicate via DOM attributes and custom events</li>
|
||||
<li>Hot module replacement (HMR) is enabled in dev mode for instant updates</li>
|
||||
<li>Use browser DevTools console to monitor component events and interactions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load the manifest and inject resources (mimics PHP extractor) -->
|
||||
<script src="/test-pages/load-manifest.js"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Add some interactivity to the index page
|
||||
$('.page-item').on('mouseenter', function() {
|
||||
$(this).find('a').css('transform', 'translateX(2px)');
|
||||
}).on('mouseleave', function() {
|
||||
$(this).find('a').css('transform', 'translateX(0)');
|
||||
});
|
||||
|
||||
// Set up smooth transitions
|
||||
$('a').css('transition', 'all 0.2s ease');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,163 @@
|
||||
// JavaScript version of the PHP WebComponentsExtractor
|
||||
// This loads the manifest and injects the CSS and JS files
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Detect if we're in dev mode (Vite dev server)
|
||||
const isDevMode = window.location.port === '3000' || window.location.hostname === 'localhost';
|
||||
|
||||
// Function to load resources in dev mode with hot reloading
|
||||
async function loadDevResources() {
|
||||
console.log('Loading resources in dev mode with hot reloading');
|
||||
|
||||
// In dev mode, load the source files directly through Vite
|
||||
// CSS is imported within auto-mount.ts, so Vite will handle it
|
||||
if (!document.getElementById('unraid-dev-mount')) {
|
||||
const script = document.createElement('script');
|
||||
script.id = 'unraid-dev-mount';
|
||||
script.type = 'module';
|
||||
// Load the auto-mount entry point - Vite will handle HMR and CSS
|
||||
script.src = '/src/components/Wrapper/auto-mount.ts';
|
||||
script.setAttribute('data-unraid', '1');
|
||||
document.head.appendChild(script);
|
||||
console.log('Loaded dev script with HMR and CSS: /src/components/Wrapper/auto-mount.ts');
|
||||
}
|
||||
}
|
||||
|
||||
// Function to load manifest and inject resources (production mode)
|
||||
async function loadManifestResources() {
|
||||
try {
|
||||
// Fetch the manifest
|
||||
const response = await fetch('/dist/manifest.json');
|
||||
if (!response.ok) {
|
||||
console.warn('Manifest not found, attempting direct load');
|
||||
// Fallback: try to load files directly without manifest
|
||||
loadDirectResources();
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = await response.json();
|
||||
console.log('Loaded manifest:', manifest);
|
||||
|
||||
// Process each entry in the manifest
|
||||
Object.entries(manifest).forEach(([key, entry]) => {
|
||||
if (!entry || !entry.file) return;
|
||||
|
||||
const filePath = `/dist/${entry.file}`;
|
||||
const extension = entry.file.split('.').pop();
|
||||
|
||||
// Create unique ID for deduplication
|
||||
const elementId = `unraid-${key.replace(/[^a-zA-Z0-9-]/g, '-')}`;
|
||||
|
||||
// Check if already loaded
|
||||
if (document.getElementById(elementId)) {
|
||||
console.log(`Resource already loaded: ${elementId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load JavaScript files
|
||||
if (extension === 'js' || extension === 'mjs') {
|
||||
const script = document.createElement('script');
|
||||
script.id = elementId;
|
||||
script.type = 'module';
|
||||
script.src = filePath;
|
||||
script.setAttribute('data-unraid', '1');
|
||||
document.head.appendChild(script);
|
||||
console.log(`Loaded script: ${filePath}`);
|
||||
|
||||
// Load associated CSS files from manifest
|
||||
if (entry.css && Array.isArray(entry.css)) {
|
||||
entry.css.forEach((cssFile, index) => {
|
||||
const cssPath = `/dist/${cssFile}`;
|
||||
const cssId = `${elementId}-css-${index}`;
|
||||
|
||||
if (!document.getElementById(cssId)) {
|
||||
const link = document.createElement('link');
|
||||
link.id = cssId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = cssPath;
|
||||
link.setAttribute('data-unraid', '1');
|
||||
document.head.appendChild(link);
|
||||
console.log(`Loaded CSS: ${cssPath}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// Load standalone CSS files
|
||||
else if (extension === 'css') {
|
||||
const link = document.createElement('link');
|
||||
link.id = elementId;
|
||||
link.rel = 'stylesheet';
|
||||
link.href = filePath;
|
||||
link.setAttribute('data-unraid', '1');
|
||||
document.head.appendChild(link);
|
||||
console.log(`Loaded CSS: ${filePath}`);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading manifest:', error);
|
||||
// Fallback to direct loading
|
||||
loadDirectResources();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback function to load resources directly without manifest
|
||||
function loadDirectResources() {
|
||||
console.log('Loading resources directly (fallback mode)');
|
||||
|
||||
// Load the main CSS file
|
||||
if (!document.getElementById('unraid-standalone-css')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'unraid-standalone-css';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/dist/standalone-apps.css';
|
||||
link.setAttribute('data-unraid', '1');
|
||||
document.head.appendChild(link);
|
||||
console.log('Loaded CSS: /dist/standalone-apps.css');
|
||||
}
|
||||
|
||||
// Load the main JS file
|
||||
if (!document.getElementById('unraid-standalone-js')) {
|
||||
const script = document.createElement('script');
|
||||
script.id = 'unraid-standalone-js';
|
||||
script.type = 'module';
|
||||
script.src = '/dist/standalone-apps.js';
|
||||
script.setAttribute('data-unraid', '1');
|
||||
document.head.appendChild(script);
|
||||
console.log('Loaded script: /dist/standalone-apps.js');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicate resources (same logic as PHP)
|
||||
function removeDuplicates() {
|
||||
const elements = document.querySelectorAll('[data-unraid="1"]');
|
||||
const seen = {};
|
||||
|
||||
elements.forEach((el) => {
|
||||
if (seen[el.id]) {
|
||||
el.remove();
|
||||
console.log(`Removed duplicate: ${el.id}`);
|
||||
} else {
|
||||
seen[el.id] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize - choose dev or production mode
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (isDevMode) {
|
||||
loadDevResources().then(removeDuplicates);
|
||||
} else {
|
||||
loadManifestResources().then(removeDuplicates);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (isDevMode) {
|
||||
loadDevResources().then(removeDuplicates);
|
||||
} else {
|
||||
loadManifestResources().then(removeDuplicates);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,354 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OS Management - Unraid Component Test</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.header {
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.info-bar {
|
||||
background: white;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.version-info {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
}
|
||||
.version-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.version-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.version-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #1f2937;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.card-description {
|
||||
color: #6b7280;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-indicator.up-to-date {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.status-indicator.update-available {
|
||||
background: #fed7aa;
|
||||
color: #92400e;
|
||||
}
|
||||
.status-indicator.checking {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.main-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header with OS Version -->
|
||||
<div class="header">
|
||||
<div class="container">
|
||||
<h1>
|
||||
💿 OS Management
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<!-- System Information Bar -->
|
||||
<div class="info-bar">
|
||||
<div class="version-info">
|
||||
<div class="version-item">
|
||||
<span class="version-label">Current Version</span>
|
||||
<span class="version-value" id="current-version">6.12.4</span>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<span class="version-label">Latest Available</span>
|
||||
<span class="version-value" id="latest-version">6.12.5</span>
|
||||
</div>
|
||||
<div class="version-item">
|
||||
<span class="version-label">Update Channel</span>
|
||||
<span class="version-value" id="update-channel">Stable</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-indicator update-available" id="update-status">
|
||||
<span>●</span> Update Available
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Grid -->
|
||||
<div class="main-grid">
|
||||
<!-- Update OS Card -->
|
||||
<div class="card">
|
||||
<h2>⬆️ System Updates</h2>
|
||||
<p class="card-description">Check for and install Unraid OS updates</p>
|
||||
<unraid-update-os></unraid-update-os>
|
||||
</div>
|
||||
|
||||
<!-- Downgrade OS Card -->
|
||||
<div class="card">
|
||||
<h2>⬇️ System Downgrade</h2>
|
||||
<p class="card-description">Rollback to a previous Unraid OS version</p>
|
||||
<unraid-downgrade-os></unraid-downgrade-os>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Controls -->
|
||||
<div class="card" style="margin-top: 20px; background: #1f2937;">
|
||||
<h2 style="color: white;">🧪 Test Scenarios</h2>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px;">
|
||||
<button class="test-btn" id="check-updates">Check for Updates</button>
|
||||
<button class="test-btn" id="simulate-update">Simulate Update Available</button>
|
||||
<button class="test-btn" id="simulate-current">Simulate Up-to-date</button>
|
||||
<button class="test-btn" id="change-channel">Switch Channel (Beta)</button>
|
||||
<button class="test-btn" id="simulate-download">Simulate Download Progress</button>
|
||||
<button class="test-btn" id="simulate-install">Simulate Installation</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 20px;">
|
||||
<div style="background: black; color: #10b981; padding: 15px; border-radius: 6px; font-family: monospace; font-size: 12px; min-height: 120px; max-height: 200px; overflow-y: auto;" id="console">
|
||||
> OS Management Test Console
|
||||
> Ready for testing...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.test-btn {
|
||||
padding: 10px 16px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.test-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #2563eb);
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Global Modals -->
|
||||
<unraid-modals></unraid-modals>
|
||||
|
||||
<!-- Load manifest -->
|
||||
<script src="/test-pages/load-manifest.js"></script>
|
||||
|
||||
<!-- Test Logic -->
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
const $console = $('#console');
|
||||
let currentVersion = '6.12.4';
|
||||
let latestVersion = '6.12.5';
|
||||
let updateChannel = 'stable';
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const prefix = type === 'error' ? '❌' : type === 'success' ? '✅' : '>';
|
||||
$console.append('\n' + prefix + ' [' + timestamp + '] ' + message);
|
||||
$console.scrollTop($console[0].scrollHeight);
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
$('#check-updates').on('click', function() {
|
||||
log('Checking for updates...');
|
||||
$('#update-status').removeClass('up-to-date update-available').addClass('checking');
|
||||
$('#update-status').html('<span>⟳</span> Checking...');
|
||||
|
||||
setTimeout(function() {
|
||||
if (currentVersion !== latestVersion) {
|
||||
$('#update-status').removeClass('checking up-to-date').addClass('update-available');
|
||||
$('#update-status').html('<span>●</span> Update Available');
|
||||
log('Update available: ' + latestVersion, 'success');
|
||||
} else {
|
||||
$('#update-status').removeClass('checking update-available').addClass('up-to-date');
|
||||
$('#update-status').html('<span>✓</span> Up to Date');
|
||||
log('System is up to date', 'success');
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Simulate update available
|
||||
$('#simulate-update').on('click', function() {
|
||||
latestVersion = '6.12.6';
|
||||
$('#latest-version').text(latestVersion);
|
||||
$('#update-status').removeClass('up-to-date checking').addClass('update-available');
|
||||
$('#update-status').html('<span>●</span> Update Available');
|
||||
log('Simulated update available: ' + latestVersion);
|
||||
|
||||
// Trigger component update
|
||||
$('unraid-update-os').attr('latest-version', latestVersion);
|
||||
});
|
||||
|
||||
// Simulate up-to-date
|
||||
$('#simulate-current').on('click', function() {
|
||||
currentVersion = latestVersion = '6.12.5';
|
||||
$('#current-version').text(currentVersion);
|
||||
$('#latest-version').text(latestVersion);
|
||||
$('#update-status').removeClass('update-available checking').addClass('up-to-date');
|
||||
$('#update-status').html('<span>✓</span> Up to Date');
|
||||
log('System is now up to date');
|
||||
});
|
||||
|
||||
// Change channel
|
||||
$('#change-channel').on('click', function() {
|
||||
updateChannel = updateChannel === 'stable' ? 'beta' : 'stable';
|
||||
$('#update-channel').text(updateChannel.charAt(0).toUpperCase() + updateChannel.slice(1));
|
||||
log('Switched to ' + updateChannel + ' channel');
|
||||
|
||||
if (updateChannel === 'beta') {
|
||||
latestVersion = '6.13.0-beta1';
|
||||
$('#latest-version').text(latestVersion);
|
||||
log('Beta version available: ' + latestVersion);
|
||||
} else {
|
||||
latestVersion = '6.12.5';
|
||||
$('#latest-version').text(latestVersion);
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate download progress
|
||||
$('#simulate-download').on('click', function() {
|
||||
log('Starting download simulation...');
|
||||
|
||||
const $card = $(this).closest('.card');
|
||||
if (!$card.find('.progress-bar').length) {
|
||||
$card.append('<div class="progress-bar"><div class="progress-fill" style="width: 0%">0%</div></div>');
|
||||
}
|
||||
|
||||
let progress = 0;
|
||||
const interval = setInterval(function() {
|
||||
progress += 10;
|
||||
$('.progress-fill').css('width', progress + '%').text(progress + '%');
|
||||
log('Download progress: ' + progress + '%');
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
log('Download complete!', 'success');
|
||||
setTimeout(function() {
|
||||
$('.progress-bar').fadeOut();
|
||||
}, 2000);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Simulate installation
|
||||
$('#simulate-install').on('click', function() {
|
||||
log('Preparing installation...');
|
||||
log('Creating backup...');
|
||||
|
||||
setTimeout(function() {
|
||||
log('Backup complete', 'success');
|
||||
log('Installing update...');
|
||||
|
||||
setTimeout(function() {
|
||||
log('Installation complete!', 'success');
|
||||
log('System will restart in 30 seconds...');
|
||||
currentVersion = latestVersion;
|
||||
$('#current-version').text(currentVersion);
|
||||
}, 3000);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Listen for component events
|
||||
$(document).on('unraid:update-started', function(e, data) {
|
||||
log('Update started: ' + data.version);
|
||||
});
|
||||
|
||||
$(document).on('unraid:update-complete', function(e, data) {
|
||||
log('Update complete: ' + data.version, 'success');
|
||||
});
|
||||
|
||||
log('OS Management test page initialized');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Settings - Unraid Component Test</title>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
.header {
|
||||
background: white;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
}
|
||||
.tabs {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
}
|
||||
.tab.active {
|
||||
border-bottom-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.settings-section {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin-top: 0;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
.setting-item {
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.setting-label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.setting-description {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Settings Container -->
|
||||
<div class="settings-container" style="max-width: 1200px; margin: 0 auto;">
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="general">
|
||||
General
|
||||
</div>
|
||||
<div class="tab" data-tab="connect">
|
||||
Connect
|
||||
</div>
|
||||
<div class="tab" data-tab="registration">
|
||||
Registration
|
||||
</div>
|
||||
<div class="tab" data-tab="api">
|
||||
API Keys
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<!-- General Settings -->
|
||||
<div class="tab-content" data-tab-content="general" style="display: block;">
|
||||
<div class="settings-section">
|
||||
<h2>Appearance</h2>
|
||||
<div class="setting-item">
|
||||
<div class="setting-label">Theme</div>
|
||||
<div class="setting-description">Choose between light and dark theme</div>
|
||||
<unraid-theme-switcher current="white"></unraid-theme-switcher>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>System Information</h2>
|
||||
<div class="setting-item">
|
||||
<div class="setting-label">OS Version</div>
|
||||
<unraid-header-os-version></unraid-header-os-version>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connect Settings -->
|
||||
<div class="tab-content" data-tab-content="connect" style="display: none;">
|
||||
<div class="settings-section">
|
||||
<h2>Unraid Connect Settings</h2>
|
||||
<unraid-connect-settings></unraid-connect-settings>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration -->
|
||||
<div class="tab-content" data-tab-content="registration" style="display: none;">
|
||||
<div class="settings-section">
|
||||
<h2>System Registration</h2>
|
||||
<unraid-registration></unraid-registration>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys -->
|
||||
<div class="tab-content" data-tab-content="api" style="display: none;">
|
||||
<div class="settings-section">
|
||||
<h2>API Key Management</h2>
|
||||
<unraid-api-key-manager></unraid-api-key-manager>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h2>API Key Authorization</h2>
|
||||
<unraid-api-key-authorize></unraid-api-key-authorize>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- End settings-container -->
|
||||
|
||||
<!-- Global Modals -->
|
||||
<unraid-modals></unraid-modals>
|
||||
|
||||
<!-- Load the manifest and inject resources (mimics PHP extractor) -->
|
||||
<script src="/test-pages/load-manifest.js"></script>
|
||||
<script src="/test-pages/test-server-state.js"></script>
|
||||
<script src="/test-pages/shared-header.js"></script>
|
||||
|
||||
<!-- jQuery tab functionality and component interaction -->
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// Tab switching functionality
|
||||
$('.tab').on('click', function() {
|
||||
var tabName = $(this).data('tab');
|
||||
|
||||
// Update active tab styling
|
||||
$('.tab').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
|
||||
// Show/hide content
|
||||
$('.tab-content').hide();
|
||||
$('.tab-content[data-tab-content="' + tabName + '"]').fadeIn();
|
||||
});
|
||||
|
||||
// Example: Programmatically update settings from jQuery
|
||||
// This mimics how Unraid might update settings after saving
|
||||
window.updateConnectSettings = function(settings) {
|
||||
// You could pass this to the Vue component
|
||||
console.log('Updating connect settings:', settings);
|
||||
// The component would listen for attribute changes
|
||||
$('unraid-connect-settings').attr('settings', JSON.stringify(settings));
|
||||
};
|
||||
|
||||
// Example: Listen for events from Vue components
|
||||
$(document).on('unraid:settings-saved', function(e, data) {
|
||||
console.log('Settings saved:', data);
|
||||
// Show a jQuery notification or update the UI
|
||||
showSuccessMessage('Settings saved successfully!');
|
||||
});
|
||||
|
||||
// Simple success message function (like Unraid's)
|
||||
function showSuccessMessage(message) {
|
||||
var $msg = $('<div class="success-message" style="position: fixed; top: 20px; right: 20px; background: #10b981; color: white; padding: 12px 20px; border-radius: 4px; z-index: 9999;">' + message + '</div>');
|
||||
$('body').append($msg);
|
||||
setTimeout(function() {
|
||||
$msg.fadeOut(function() {
|
||||
$(this).remove();
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Example: Load settings via AJAX and update components
|
||||
function loadSettings() {
|
||||
// Simulate AJAX call
|
||||
setTimeout(function() {
|
||||
var settings = {
|
||||
connect: { enabled: true, url: 'https://connect.unraid.net' },
|
||||
theme: 'dark',
|
||||
registration: { key: 'XXXX-XXXX-XXXX' }
|
||||
};
|
||||
|
||||
// Update components with loaded data
|
||||
$('unraid-connect-settings').attr('initial-settings', JSON.stringify(settings.connect));
|
||||
$('unraid-registration').attr('registration-data', JSON.stringify(settings.registration));
|
||||
|
||||
console.log('Settings loaded via jQuery');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Load settings on page load
|
||||
loadSettings();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,378 @@
|
||||
// Shared header component for all test pages
|
||||
// This provides consistent navigation and server state management
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Function to inject the shared header HTML
|
||||
window.injectSharedHeader = function () {
|
||||
const headerHTML = `
|
||||
<!-- Shared Test Header -->
|
||||
<div id="shared-test-header" class="test-header">
|
||||
<div class="test-header-main">
|
||||
<div class="test-header-left">
|
||||
<h1 class="test-header-title">
|
||||
<a href="/test-pages/" style="color: inherit; text-decoration: none;">
|
||||
🧪 Unraid Component Test
|
||||
</a>
|
||||
</h1>
|
||||
<span class="test-header-subtitle" id="page-title"></span>
|
||||
</div>
|
||||
<div class="test-header-right">
|
||||
<div class="server-state-selector">
|
||||
<label>Server State:</label>
|
||||
<select id="server-state-select">
|
||||
<option value="default">Default (Pro)</option>
|
||||
<option value="trial">Trial</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="refresh-state" class="header-btn">↻ Refresh</button>
|
||||
<button id="toggle-console" class="header-btn">📋 Console</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="test-header-nav">
|
||||
<a href="/test-pages/" class="nav-link">Home</a>
|
||||
<a href="/test-pages/all-components.html" class="nav-link">All Components</a>
|
||||
<a href="/test-pages/dashboard.html" class="nav-link">Dashboard</a>
|
||||
<a href="/test-pages/settings.html" class="nav-link">Settings</a>
|
||||
<a href="/test-pages/authentication.html" class="nav-link">Authentication</a>
|
||||
<a href="/test-pages/os-management.html" class="nav-link">OS Management</a>
|
||||
</nav>
|
||||
|
||||
<!-- Console (hidden by default) -->
|
||||
<div id="test-console" class="test-console" style="display: none;">
|
||||
<div class="console-header">
|
||||
<span>Test Console</span>
|
||||
<button id="clear-console" class="console-btn">Clear</button>
|
||||
</div>
|
||||
<div id="console-output" class="console-output">
|
||||
> Console ready...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Global font size overrides to match Unraid's 10px base */
|
||||
:root {
|
||||
--text-xs: 0.75rem !important;
|
||||
--text-sm: 0.875rem !important;
|
||||
--text-base: 1rem !important;
|
||||
--text-lg: 1.125rem !important;
|
||||
--text-xl: 1.25rem !important;
|
||||
--text-2xl: 1.5rem !important;
|
||||
--text-3xl: 1.875rem !important;
|
||||
--text-4xl: 2.25rem !important;
|
||||
--text-5xl: 3rem !important;
|
||||
--text-6xl: 3.75rem !important;
|
||||
--text-7xl: 4.5rem !important;
|
||||
--text-8xl: 6rem !important;
|
||||
--text-9xl: 8rem !important;
|
||||
}
|
||||
|
||||
/* Force Tailwind text size classes to use our values */
|
||||
.text-xs { font-size: 0.75rem !important; line-height: 1rem !important; }
|
||||
.text-sm { font-size: 0.875rem !important; line-height: 1.25rem !important; }
|
||||
.text-base { font-size: 1rem !important; line-height: 1.5rem !important; }
|
||||
.text-lg { font-size: 1.125rem !important; line-height: 1.75rem !important; }
|
||||
.text-xl { font-size: 1.25rem !important; line-height: 1.75rem !important; }
|
||||
.text-2xl { font-size: 1.5rem !important; line-height: 2rem !important; }
|
||||
.text-3xl { font-size: 1.875rem !important; line-height: 2.25rem !important; }
|
||||
.text-4xl { font-size: 2.25rem !important; line-height: 2.5rem !important; }
|
||||
.text-5xl { font-size: 3rem !important; line-height: 1 !important; }
|
||||
.text-6xl { font-size: 3.75rem !important; line-height: 1 !important; }
|
||||
.text-7xl { font-size: 4.5rem !important; line-height: 1 !important; }
|
||||
.text-8xl { font-size: 6rem !important; line-height: 1 !important; }
|
||||
.text-9xl { font-size: 8rem !important; line-height: 1 !important; }
|
||||
|
||||
.test-header {
|
||||
background: white;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
margin: -20px -20px 20px -20px;
|
||||
}
|
||||
|
||||
.test-header-main {
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.test-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.test-header-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.test-header-subtitle {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
padding-left: 15px;
|
||||
border-left: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.test-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.server-state-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 15px;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.server-state-selector label {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.server-state-selector select {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
padding: 6px 12px;
|
||||
background: #f3f4f6;
|
||||
color: #4b5563;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.test-header-nav {
|
||||
background: #f9fafb;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 10px 15px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #1f2937;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
|
||||
.test-console {
|
||||
background: #1f2937;
|
||||
border-top: 1px solid #374151;
|
||||
}
|
||||
|
||||
.console-header {
|
||||
padding: 8px 20px;
|
||||
background: #111827;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.console-btn {
|
||||
padding: 4px 10px;
|
||||
background: #374151;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.console-btn:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.console-output {
|
||||
padding: 15px 20px;
|
||||
color: #10b981;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.test-header-main {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.test-header-nav {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
// Insert at the beginning of body
|
||||
const bodyFirstChild = document.body.firstChild;
|
||||
const headerContainer = document.createElement('div');
|
||||
headerContainer.innerHTML = headerHTML;
|
||||
document.body.insertBefore(headerContainer.firstChild, bodyFirstChild);
|
||||
|
||||
// Add padding to body to account for sticky header
|
||||
document.body.style.paddingTop = '20px';
|
||||
};
|
||||
|
||||
// Function to initialize header functionality
|
||||
window.initializeSharedHeader = function (pageTitle) {
|
||||
// Set page title
|
||||
if (pageTitle) {
|
||||
const titleElement = document.getElementById('page-title');
|
||||
if (titleElement) {
|
||||
titleElement.textContent = pageTitle;
|
||||
}
|
||||
}
|
||||
|
||||
// Mark active navigation link
|
||||
const currentPath = window.location.pathname;
|
||||
document.querySelectorAll('.nav-link').forEach((link) => {
|
||||
if (link.getAttribute('href') === currentPath) {
|
||||
link.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// Console functionality
|
||||
const consoleOutput = document.getElementById('console-output');
|
||||
const testConsole = document.getElementById('test-console');
|
||||
|
||||
window.testLog = function (message, type = 'info') {
|
||||
if (!consoleOutput) return;
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const prefix = type === 'error' ? '❌' : type === 'success' ? '✅' : '>';
|
||||
const color = type === 'error' ? '#ef4444' : type === 'success' ? '#10b981' : '#10b981';
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.style.color = color;
|
||||
logLine.textContent = `${prefix} [${timestamp}] ${message}`;
|
||||
consoleOutput.appendChild(logLine);
|
||||
consoleOutput.scrollTop = consoleOutput.scrollHeight;
|
||||
};
|
||||
|
||||
// Toggle console
|
||||
document.getElementById('toggle-console')?.addEventListener('click', function () {
|
||||
if (testConsole) {
|
||||
const isVisible = testConsole.style.display !== 'none';
|
||||
testConsole.style.display = isVisible ? 'none' : 'block';
|
||||
this.textContent = isVisible ? '📋 Console' : '📋 Hide';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear console
|
||||
document.getElementById('clear-console')?.addEventListener('click', function () {
|
||||
if (consoleOutput) {
|
||||
consoleOutput.innerHTML = '> Console cleared...';
|
||||
}
|
||||
});
|
||||
|
||||
// Server state selector
|
||||
const stateSelect = document.getElementById('server-state-select');
|
||||
if (stateSelect && window.applyServerStateToComponents) {
|
||||
stateSelect.addEventListener('change', function () {
|
||||
const selectedState = this.value;
|
||||
window.applyServerStateToComponents(selectedState);
|
||||
window.testLog(`Server state changed to: ${selectedState}`, 'success');
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh state button
|
||||
document.getElementById('refresh-state')?.addEventListener('click', function () {
|
||||
const currentState = stateSelect?.value || 'default';
|
||||
if (window.applyServerStateToComponents) {
|
||||
window.applyServerStateToComponents(currentState);
|
||||
window.testLog('Server state refreshed', 'success');
|
||||
}
|
||||
});
|
||||
|
||||
// Initial log
|
||||
window.testLog('Test page initialized');
|
||||
|
||||
// Load and apply server state after a delay
|
||||
setTimeout(function () {
|
||||
if (window.applyServerStateToComponents) {
|
||||
window.applyServerStateToComponents('default');
|
||||
window.testLog('Server state applied to components', 'success');
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Function to set base font size to match Unraid's 10px
|
||||
window.setBaseFontSize = function () {
|
||||
const baseFontStyle = document.createElement('style');
|
||||
baseFontStyle.id = 'unraid-base-font';
|
||||
baseFontStyle.innerHTML = `
|
||||
/* Match Unraid's 10px base font size */
|
||||
html {
|
||||
font-size: 10px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(baseFontStyle);
|
||||
};
|
||||
|
||||
// Auto-inject and initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
window.setBaseFontSize();
|
||||
window.injectSharedHeader();
|
||||
// Get page title from existing h1 or title tag
|
||||
const existingTitle =
|
||||
document.querySelector('h1')?.textContent ||
|
||||
document.title.replace(' - Unraid Component Test', '');
|
||||
window.initializeSharedHeader(existingTitle);
|
||||
});
|
||||
} else {
|
||||
window.setBaseFontSize();
|
||||
window.injectSharedHeader();
|
||||
const existingTitle =
|
||||
document.querySelector('h1')?.textContent ||
|
||||
document.title.replace(' - Unraid Component Test', '');
|
||||
window.initializeSharedHeader(existingTitle);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,292 @@
|
||||
// Test server state configuration for HTML test pages
|
||||
// This provides mock server data similar to what Unraid OS would provide
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Default server state matching the Unraid server data structure
|
||||
window.testServerState = {
|
||||
default: {
|
||||
avatar: 'https://source.unsplash.com/300x300/?portrait',
|
||||
config: {
|
||||
id: 'config-id',
|
||||
error: null,
|
||||
valid: true,
|
||||
},
|
||||
connectPluginInstalled: 'dynamix.unraid.net.staging.plg',
|
||||
description: 'Test Server',
|
||||
deviceCount: 3,
|
||||
expireTime: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days from now
|
||||
flashBackupActivated: true,
|
||||
flashProduct: 'SanDisk_3.2Gen1',
|
||||
flashVendor: 'USB',
|
||||
guid: '1111-1111-TEST-GUIDGUIDGUID',
|
||||
inIframe: false,
|
||||
keyfile: '',
|
||||
lanIp: '192.168.1.100',
|
||||
license: 'Pro',
|
||||
locale: 'en_US',
|
||||
name: 'TestServer',
|
||||
osVersion: '6.12.4',
|
||||
osVersionBranch: 'stable',
|
||||
registered: true,
|
||||
regGen: 0,
|
||||
regTm: Date.now() - 2 * 24 * 60 * 60 * 1000, // 2 days ago
|
||||
regTo: 'Test User',
|
||||
regTy: 'Pro',
|
||||
regDevs: -1, // unlimited for Pro
|
||||
regExp: undefined, // no expiration for Pro
|
||||
regGuid: '1111-1111-TEST-GUIDGUIDGUID',
|
||||
site: window.location.origin,
|
||||
ssoEnabled: true,
|
||||
state: 'PRO',
|
||||
theme: {
|
||||
banner: false,
|
||||
bannerGradient: false,
|
||||
bgColor: '',
|
||||
descriptionShow: true,
|
||||
metaColor: '',
|
||||
name: 'white',
|
||||
textColor: '',
|
||||
},
|
||||
uptime: Date.now() - 60 * 60 * 1000, // 1 hour of uptime
|
||||
username: 'admin',
|
||||
wanFQDN: '',
|
||||
wanIp: '203.0.113.42',
|
||||
updateOsResponse: {
|
||||
version: '6.12.5',
|
||||
name: 'Unraid 6.12.5',
|
||||
date: '2024-01-15',
|
||||
isNewer: true,
|
||||
isEligible: true,
|
||||
changelog: 'https://docs.unraid.net/unraid-os/release-notes/6.12.5/',
|
||||
sha256: '2f5debaf80549029cf6dfab0db59180e7e3391c059e6521aace7971419c9c4bf',
|
||||
},
|
||||
},
|
||||
|
||||
// Trial state configuration
|
||||
trial: {
|
||||
avatar: '',
|
||||
config: {
|
||||
id: 'config-id',
|
||||
error: null,
|
||||
valid: false,
|
||||
},
|
||||
connectPluginInstalled: null,
|
||||
description: 'Trial Server',
|
||||
deviceCount: 0,
|
||||
expireTime: Date.now() + 24 * 60 * 60 * 1000, // 24 hours from now
|
||||
flashBackupActivated: false,
|
||||
flashProduct: 'Generic_USB',
|
||||
flashVendor: 'USB',
|
||||
guid: '2222-2222-TRIAL-GUIDGUID',
|
||||
inIframe: false,
|
||||
keyfile: '',
|
||||
lanIp: '192.168.1.101',
|
||||
license: '',
|
||||
locale: 'en_US',
|
||||
name: 'TrialServer',
|
||||
osVersion: '6.12.4',
|
||||
osVersionBranch: 'stable',
|
||||
registered: false,
|
||||
regGen: 0,
|
||||
regTm: Date.now(),
|
||||
regTo: '',
|
||||
regTy: 'Trial',
|
||||
regDevs: 0,
|
||||
regExp: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days trial
|
||||
regGuid: '',
|
||||
site: window.location.origin,
|
||||
ssoEnabled: false,
|
||||
state: 'TRIAL',
|
||||
theme: {
|
||||
banner: false,
|
||||
bannerGradient: false,
|
||||
bgColor: '',
|
||||
descriptionShow: true,
|
||||
metaColor: '',
|
||||
name: 'white',
|
||||
textColor: '',
|
||||
},
|
||||
uptime: Date.now() - 30 * 60 * 1000, // 30 minutes of uptime
|
||||
username: 'root',
|
||||
wanFQDN: '',
|
||||
wanIp: '',
|
||||
},
|
||||
|
||||
// Expired state configuration
|
||||
expired: {
|
||||
avatar: '',
|
||||
config: {
|
||||
id: 'config-id',
|
||||
error: 'License expired',
|
||||
valid: false,
|
||||
},
|
||||
connectPluginInstalled: 'dynamix.unraid.net.staging.plg',
|
||||
description: 'Expired Server',
|
||||
deviceCount: 6,
|
||||
expireTime: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days ago
|
||||
flashBackupActivated: false,
|
||||
flashProduct: 'SanDisk_3.2Gen1',
|
||||
flashVendor: 'USB',
|
||||
guid: '3333-3333-EXPIRED-GUIDGUID',
|
||||
inIframe: false,
|
||||
keyfile: 'EXPIRED_KEY',
|
||||
lanIp: '192.168.1.102',
|
||||
license: 'Basic',
|
||||
locale: 'en_US',
|
||||
name: 'ExpiredServer',
|
||||
osVersion: '6.11.5',
|
||||
osVersionBranch: 'stable',
|
||||
registered: true,
|
||||
regGen: 0,
|
||||
regTm: Date.now() - 400 * 24 * 60 * 60 * 1000, // 400 days ago
|
||||
regTo: 'Expired User',
|
||||
regTy: 'Basic',
|
||||
regDevs: 6,
|
||||
regExp: Date.now() - 7 * 24 * 60 * 60 * 1000, // expired 7 days ago
|
||||
regGuid: '3333-3333-EXPIRED-GUIDGUID',
|
||||
site: window.location.origin,
|
||||
ssoEnabled: false,
|
||||
state: 'EEXPIRED',
|
||||
theme: {
|
||||
banner: false,
|
||||
bannerGradient: false,
|
||||
bgColor: '',
|
||||
descriptionShow: true,
|
||||
metaColor: '',
|
||||
name: 'white',
|
||||
textColor: '',
|
||||
},
|
||||
uptime: Date.now() - 7 * 24 * 60 * 60 * 1000, // 7 days of uptime
|
||||
username: 'admin',
|
||||
wanFQDN: '',
|
||||
wanIp: '',
|
||||
},
|
||||
};
|
||||
|
||||
// Function to get current server state
|
||||
window.getTestServerState = function (type = 'default') {
|
||||
return window.testServerState[type] || window.testServerState.default;
|
||||
};
|
||||
|
||||
// Function to update server state for testing
|
||||
window.updateTestServerState = function (type = 'default', updates = {}) {
|
||||
if (!window.testServerState[type]) {
|
||||
console.warn('Unknown server state type:', type);
|
||||
return;
|
||||
}
|
||||
|
||||
window.testServerState[type] = {
|
||||
...window.testServerState[type],
|
||||
...updates,
|
||||
};
|
||||
|
||||
// Trigger update event for components
|
||||
const event = new CustomEvent('unraid:server-state-updated', {
|
||||
detail: {
|
||||
type: type,
|
||||
state: window.testServerState[type],
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
console.log('Server state updated:', type, updates);
|
||||
};
|
||||
|
||||
// Track if we've already applied state to prevent duplicates
|
||||
const appliedStateElements = new WeakSet();
|
||||
|
||||
// Function to pass server state to components via attributes
|
||||
window.applyServerStateToComponents = function (type = 'default') {
|
||||
const state = window.getTestServerState(type);
|
||||
const stateJson = JSON.stringify(state);
|
||||
|
||||
// Apply to common components that need server state
|
||||
const componentsNeedingState = [
|
||||
'unraid-user-profile',
|
||||
'unraid-header-os-version',
|
||||
'unraid-registration',
|
||||
'unraid-connect-settings',
|
||||
'unraid-update-os',
|
||||
'unraid-downgrade-os',
|
||||
];
|
||||
|
||||
componentsNeedingState.forEach((selector) => {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
elements.forEach((el) => {
|
||||
// Skip if we've already applied state to this element
|
||||
if (appliedStateElements.has(el)) {
|
||||
console.log(`Skipping duplicate state application to ${selector}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the element already has the same state
|
||||
const currentState = el.getAttribute('server');
|
||||
if (currentState === stateJson) {
|
||||
console.log(`${selector} already has current state, skipping`);
|
||||
appliedStateElements.add(el);
|
||||
return;
|
||||
}
|
||||
|
||||
el.setAttribute('server', stateJson);
|
||||
appliedStateElements.add(el);
|
||||
console.log(`Applied server state to ${selector}`);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Auto-apply default server state when DOM is ready
|
||||
let hasInitiallyApplied = false;
|
||||
|
||||
function applyInitialState() {
|
||||
if (hasInitiallyApplied) return;
|
||||
hasInitiallyApplied = true;
|
||||
|
||||
// Apply state with a delay to ensure components are mounted
|
||||
setTimeout(function () {
|
||||
window.applyServerStateToComponents('default');
|
||||
|
||||
// Set up mutation observer to handle dynamically added components
|
||||
const observer = new MutationObserver(function (mutations) {
|
||||
let shouldApply = false;
|
||||
|
||||
mutations.forEach(function (mutation) {
|
||||
mutation.addedNodes.forEach(function (node) {
|
||||
if (node.nodeType === 1) {
|
||||
// Element node
|
||||
const tagName = node.tagName?.toLowerCase();
|
||||
if (tagName && tagName.startsWith('unraid-')) {
|
||||
shouldApply = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Apply state to new components if any were added
|
||||
if (shouldApply) {
|
||||
// Small delay to let components initialize
|
||||
setTimeout(function () {
|
||||
window.applyServerStateToComponents('default');
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
console.log('Server state observer initialized');
|
||||
}, 1000); // Increased delay to ensure components are fully mounted
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', applyInitialState);
|
||||
} else {
|
||||
applyInitialState();
|
||||
}
|
||||
|
||||
console.log('Test server state helper loaded. Available states:', Object.keys(window.testServerState));
|
||||
})();
|
||||
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const distPath = '.nuxt/standalone-apps';
|
||||
const distPath = 'dist';
|
||||
const manifestPath = path.join(distPath, 'standalone.manifest.json');
|
||||
|
||||
// Check if directory exists
|
||||
@@ -16,7 +15,7 @@ if (!fs.existsSync(distPath)) {
|
||||
const files = fs.readdirSync(distPath);
|
||||
const manifest = {};
|
||||
|
||||
files.forEach(file => {
|
||||
files.forEach((file) => {
|
||||
if (file.endsWith('.js') || file.endsWith('.css')) {
|
||||
const key = file.replace(/\.(js|css)$/, '.$1');
|
||||
manifest[key] = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const fs = require('fs');
|
||||
import fs from 'fs';
|
||||
|
||||
// Read the JSON file
|
||||
const filePath = '../web/.nuxt/nuxt-custom-elements/dist/unraid-components/manifest.json';
|
||||
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const rootDir = join(__dirname, '..', '..');
|
||||
const uiSrcDir = join(rootDir, 'unraid-ui', 'src');
|
||||
const uiDistDir = join(rootDir, 'unraid-ui', 'dist');
|
||||
const uiDistIndexFile = join(uiDistDir, 'index.js');
|
||||
|
||||
function getLatestModificationTime(dir) {
|
||||
const result = execSync(
|
||||
`find "${dir}" -type f -name "*.ts" -o -name "*.tsx" -o -name "*.vue" -o -name "*.css" | xargs stat -f "%m" 2>/dev/null | sort -rn | head -1 || echo 0`,
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
shell: true,
|
||||
}
|
||||
).trim();
|
||||
|
||||
return parseInt(result) || 0;
|
||||
}
|
||||
|
||||
function shouldRebuild() {
|
||||
// If dist doesn't exist, we need to build
|
||||
if (!existsSync(uiDistIndexFile)) {
|
||||
console.log('UI library dist not found, building...');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the modification time of the dist index file
|
||||
const distModTime = statSync(uiDistIndexFile).mtimeMs / 1000;
|
||||
|
||||
// Get the latest modification time from source files
|
||||
const srcModTime = getLatestModificationTime(uiSrcDir);
|
||||
|
||||
// Also check package.json, vite.config.ts, etc.
|
||||
const configFiles = [
|
||||
join(rootDir, 'unraid-ui', 'package.json'),
|
||||
join(rootDir, 'unraid-ui', 'vite.config.ts'),
|
||||
join(rootDir, 'unraid-ui', 'tsconfig.json'),
|
||||
];
|
||||
|
||||
let latestConfigModTime = 0;
|
||||
for (const file of configFiles) {
|
||||
if (existsSync(file)) {
|
||||
const modTime = statSync(file).mtimeMs / 1000;
|
||||
if (modTime > latestConfigModTime) {
|
||||
latestConfigModTime = modTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const latestSrcTime = Math.max(srcModTime, latestConfigModTime);
|
||||
|
||||
if (latestSrcTime > distModTime) {
|
||||
console.log('UI library source files changed, rebuilding...');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('UI library is up to date, skipping build.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (shouldRebuild()) {
|
||||
console.log('Building @unraid/ui...');
|
||||
execSync('pnpm --filter=@unraid/ui build', {
|
||||
stdio: 'inherit',
|
||||
cwd: rootDir,
|
||||
});
|
||||
console.log('UI library build complete.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error building UI library:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ fi
|
||||
server_name="$1"
|
||||
|
||||
# Source directory paths
|
||||
standalone_directory=".nuxt/standalone-apps/"
|
||||
standalone_directory="dist/"
|
||||
|
||||
# Check what we have to deploy
|
||||
has_standalone=false
|
||||
|
||||
@@ -1,696 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const ignore = require('ignore');
|
||||
const readline = require('readline');
|
||||
const diff = require('diff');
|
||||
const crypto = require('crypto');
|
||||
let chalk;
|
||||
(async () => {
|
||||
chalk = await import('chalk');
|
||||
})();
|
||||
|
||||
const CONSTANTS = {
|
||||
PATHS: {
|
||||
IGNORE_LIST: path.join(__dirname, '.sync-webgui-repo-ignored-files.json'),
|
||||
NEW_FILES: path.join(__dirname, '.sync-webgui-repo-new-files.json'),
|
||||
STATE: path.join(__dirname, '.sync-webgui-repo-state.json'),
|
||||
},
|
||||
IGNORE_PATTERNS: [/\.md$/i, /\.ico$/i, /\.cfg$/i, /\.json$/i, /^banner\.png$/i],
|
||||
PLUGIN_PATHS: {
|
||||
API: 'plugin/source/dynamix.unraid.net/usr/local/emhttp/plugins',
|
||||
WEBGUI: 'emhttp/plugins',
|
||||
},
|
||||
WEB_COMPONENTS: {
|
||||
API_WEB_BUILD_PATH: 'web/.nuxt/nuxt-custom-elements/dist/unraid-components',
|
||||
API_UI_BUILD_PATH: 'unraid-ui/dist-wc',
|
||||
WEBGUI_BASE_PATH: 'emhttp/plugins/dynamix.my.servers/unraid-components',
|
||||
WEBGUI_WEB_SUBPATH: 'nuxt',
|
||||
WEBGUI_UI_SUBPATH: 'uui',
|
||||
},
|
||||
};
|
||||
|
||||
const FileSystem = {
|
||||
readJsonFile(path, defaultValue = {}) {
|
||||
try {
|
||||
return fs.existsSync(path) ? JSON.parse(fs.readFileSync(path, 'utf8')) : defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
|
||||
writeJsonFile(path, data) {
|
||||
fs.writeFileSync(path, JSON.stringify(data, null, 2));
|
||||
},
|
||||
|
||||
ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
},
|
||||
|
||||
copyFile(source, dest) {
|
||||
try {
|
||||
const destDir = path.dirname(dest);
|
||||
this.ensureDir(destDir);
|
||||
fs.copyFileSync(source, dest);
|
||||
return true;
|
||||
} catch (err) {
|
||||
UI.log(`Failed to copy ${source} to ${dest}: ${err.message}`, 'error');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getFileHash(filePath) {
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const hashSum = crypto.createHash('sha256');
|
||||
hashSum.update(fileBuffer);
|
||||
return hashSum.digest('hex');
|
||||
},
|
||||
|
||||
copyDirectory(source, destination) {
|
||||
this.ensureDir(destination);
|
||||
fs.readdirSync(source).forEach((file) => {
|
||||
const sourcePath = path.join(source, file);
|
||||
const destPath = path.join(destination, file);
|
||||
const stats = fs.statSync(sourcePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
this.copyDirectory(sourcePath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(sourcePath, destPath);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const UI = {
|
||||
rl: readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
}),
|
||||
|
||||
async question(query) {
|
||||
return new Promise((resolve) => this.rl.question(query, resolve));
|
||||
},
|
||||
|
||||
async confirm(query, defaultYes = true) {
|
||||
const answer = await this.question(`${query} (${defaultYes ? 'Y/n' : 'y/N'}) `);
|
||||
return defaultYes ? answer.toLowerCase() !== 'n' : answer.toLowerCase() === 'y';
|
||||
},
|
||||
|
||||
log(message, type) {
|
||||
const icons = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️',
|
||||
skip: '⏭️',
|
||||
new: '✨',
|
||||
};
|
||||
console.log(`${icons[type] || ''} ${message}`);
|
||||
},
|
||||
|
||||
playSound() {
|
||||
const sounds = {
|
||||
darwin: 'afplay /System/Library/Sounds/Glass.aiff',
|
||||
linux: 'paplay /usr/share/sounds/freedesktop/stereo/complete.oga',
|
||||
win32:
|
||||
'powershell.exe -c "(New-Object Media.SoundPlayer \'C:\\Windows\\Media\\Windows Default.wav\').PlaySync()"',
|
||||
};
|
||||
const sound = sounds[process.platform];
|
||||
if (sound) require('child_process').exec(sound);
|
||||
},
|
||||
};
|
||||
|
||||
const State = {
|
||||
loadIgnoredFiles() {
|
||||
return FileSystem.readJsonFile(CONSTANTS.PATHS.IGNORE_LIST, []);
|
||||
},
|
||||
|
||||
saveIgnoredFiles(files) {
|
||||
FileSystem.writeJsonFile(CONSTANTS.PATHS.IGNORE_LIST, files);
|
||||
},
|
||||
|
||||
loadPaths() {
|
||||
return FileSystem.readJsonFile(CONSTANTS.PATHS.STATE);
|
||||
},
|
||||
|
||||
savePaths(paths) {
|
||||
Object.keys(paths).forEach((key) => {
|
||||
if (typeof paths[key] === 'string') {
|
||||
paths[key] = paths[key].endsWith('/') ? paths[key] : paths[key] + '/';
|
||||
}
|
||||
});
|
||||
FileSystem.writeJsonFile(CONSTANTS.PATHS.STATE, paths);
|
||||
},
|
||||
|
||||
getNewFiles() {
|
||||
const data = FileSystem.readJsonFile(CONSTANTS.PATHS.NEW_FILES, { newFiles: {} });
|
||||
return data.newFiles;
|
||||
},
|
||||
|
||||
saveNewFiles(files) {
|
||||
FileSystem.writeJsonFile(CONSTANTS.PATHS.NEW_FILES, {
|
||||
timestamp: new Date().toISOString(),
|
||||
newFiles: files,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const FileOps = {
|
||||
loadGitignore(dirPath) {
|
||||
const gitignorePath = path.join(dirPath, '.gitignore');
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
const ig = ignore();
|
||||
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
|
||||
return ig;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
showDiff(apiFile, webguiFile) {
|
||||
const content1 = fs.readFileSync(apiFile, 'utf8');
|
||||
const content2 = fs.readFileSync(webguiFile, 'utf8');
|
||||
|
||||
const differences = diff.createPatch(
|
||||
path.basename(apiFile),
|
||||
content2,
|
||||
content1,
|
||||
'webgui version',
|
||||
'api version'
|
||||
);
|
||||
|
||||
if (differences.split('\n').length > 5) {
|
||||
console.log('\nDiff for', chalk.default.cyan(path.basename(apiFile)));
|
||||
console.log(chalk.default.red('--- webgui:'), webguiFile);
|
||||
console.log(chalk.default.green('+++ api: '), apiFile);
|
||||
|
||||
differences
|
||||
.split('\n')
|
||||
.slice(5)
|
||||
.forEach((line) => {
|
||||
if (line.startsWith('+')) console.log(chalk.default.green(line));
|
||||
else if (line.startsWith('-')) console.log(chalk.default.red(line));
|
||||
else if (line.startsWith('@')) console.log(chalk.default.cyan(line));
|
||||
else console.log(line);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async handleFileDiff(apiFile, webguiFile) {
|
||||
if (!this.showDiff(apiFile, webguiFile)) return 'identical';
|
||||
|
||||
const answer = await UI.question(
|
||||
'What should I do fam? (w=copy to webgui/a=copy to API/s=skip) '
|
||||
);
|
||||
switch (answer.toLowerCase()) {
|
||||
case 'w':
|
||||
return FileSystem.copyFile(apiFile, webguiFile) ? 'webgui' : 'error';
|
||||
case 'a':
|
||||
return FileSystem.copyFile(webguiFile, apiFile) ? 'api' : 'error';
|
||||
default:
|
||||
return 'skip';
|
||||
}
|
||||
},
|
||||
|
||||
walkDirectory(currentPath, baseDir, projectFiles, gitignoreRules) {
|
||||
const files = fs.readdirSync(currentPath);
|
||||
|
||||
files.forEach((file) => {
|
||||
if (file.startsWith('.')) return;
|
||||
|
||||
const fullPath = path.join(currentPath, file);
|
||||
const relativePath = path.relative(baseDir, fullPath);
|
||||
|
||||
if (relativePath.includes('test') || relativePath.includes('tests')) return;
|
||||
if (CONSTANTS.IGNORE_PATTERNS.some((pattern) => pattern.test(file))) return;
|
||||
if (gitignoreRules?.ignores(relativePath)) return;
|
||||
|
||||
const lstat = fs.lstatSync(fullPath);
|
||||
if (lstat.isSymbolicLink()) return;
|
||||
|
||||
const stats = fs.statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
this.walkDirectory(fullPath, baseDir, projectFiles, gitignoreRules);
|
||||
} else {
|
||||
if (!projectFiles.has(file)) {
|
||||
projectFiles.set(file, []);
|
||||
}
|
||||
projectFiles.get(file).push(fullPath);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const Features = {
|
||||
async setupPaths() {
|
||||
const paths = State.loadPaths();
|
||||
let changed = false;
|
||||
|
||||
if (
|
||||
!paths.apiProjectDir ||
|
||||
!(await UI.confirm(`Use last API repo path (${paths.apiProjectDir})?`))
|
||||
) {
|
||||
paths.apiProjectDir = await UI.question('Enter the path to your API repo: ');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (
|
||||
!paths.webguiProjectDir ||
|
||||
!(await UI.confirm(`Use last webgui repo path (${paths.webguiProjectDir})?`))
|
||||
) {
|
||||
paths.webguiProjectDir = await UI.question('Enter the path to your webgui repo: ');
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
State.savePaths(paths);
|
||||
}
|
||||
|
||||
return paths;
|
||||
},
|
||||
|
||||
async handleWebComponentBuild() {
|
||||
const webDir = path.join(global.apiProjectDir, 'web');
|
||||
if (!fs.existsSync(webDir)) {
|
||||
UI.log('Web directory not found in API repo!', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
UI.log('Building web components...', 'info');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const buildProcess = exec('pnpm run build', { cwd: webDir });
|
||||
buildProcess.stdout.on('data', (data) => process.stdout.write(data));
|
||||
buildProcess.stderr.on('data', (data) => process.stderr.write(data));
|
||||
buildProcess.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
UI.log('Web components build completed successfully!', 'success');
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Web components build failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
UI.log(`Error during build: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async handleUiComponentBuild() {
|
||||
const uiDir = path.join(global.apiProjectDir, 'unraid-ui');
|
||||
if (!fs.existsSync(uiDir)) {
|
||||
UI.log('Unraid UI directory not found in API repo!', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
UI.log('Building UI components...', 'info');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const buildProcess = exec('pnpm run build && pnpm run build:wc', { cwd: uiDir });
|
||||
buildProcess.stdout.on('data', (data) => process.stdout.write(data));
|
||||
buildProcess.stderr.on('data', (data) => process.stderr.write(data));
|
||||
buildProcess.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
UI.log('UI build completed successfully!', 'success');
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`UI build failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
UI.log(`Error during build: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async handleWebComponentSync() {
|
||||
const apiWebPath = path.join(global.apiProjectDir, CONSTANTS.WEB_COMPONENTS.API_WEB_BUILD_PATH);
|
||||
const webguiBasePath = path.join(global.webguiProjectDir, CONSTANTS.WEB_COMPONENTS.WEBGUI_BASE_PATH);
|
||||
const webguiWebPath = path.join(webguiBasePath, CONSTANTS.WEB_COMPONENTS.WEBGUI_WEB_SUBPATH);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(apiWebPath)) {
|
||||
UI.log('Web components source directory not found! Did you build the web components?', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
UI.log('Removing old web components...', 'info');
|
||||
fs.rmSync(webguiWebPath, { recursive: true, force: true });
|
||||
|
||||
UI.log('Copying new web components...', 'info');
|
||||
FileSystem.copyDirectory(apiWebPath, webguiWebPath);
|
||||
|
||||
const indexPath = path.join(webguiWebPath, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
UI.log('Removing irrelevant index.html...', 'info');
|
||||
fs.unlinkSync(indexPath);
|
||||
}
|
||||
|
||||
UI.playSound();
|
||||
UI.log('Web components copied successfully!', 'success');
|
||||
} catch (err) {
|
||||
UI.log(`Error during sync: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async handleUiComponentSync() {
|
||||
const apiUiPath = path.join(global.apiProjectDir, CONSTANTS.WEB_COMPONENTS.API_UI_BUILD_PATH);
|
||||
const webguiBasePath = path.join(global.webguiProjectDir, CONSTANTS.WEB_COMPONENTS.WEBGUI_BASE_PATH);
|
||||
const webguiUiPath = path.join(webguiBasePath, CONSTANTS.WEB_COMPONENTS.WEBGUI_UI_SUBPATH);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(apiUiPath)) {
|
||||
UI.log('Unraid UI source directory not found!', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
UI.log('Removing old UI components...', 'info');
|
||||
fs.rmSync(webguiUiPath, { recursive: true, force: true });
|
||||
|
||||
UI.log('Copying new UI components...', 'info');
|
||||
FileSystem.copyDirectory(apiUiPath, webguiUiPath);
|
||||
|
||||
UI.playSound();
|
||||
UI.log('UI components copied successfully!', 'success');
|
||||
} catch (err) {
|
||||
UI.log(`Error during sync: ${err.message}`, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
findMatchingFiles(apiProjectDir, webguiProjectDir) {
|
||||
const matches = new Map();
|
||||
const apiFiles = new Map();
|
||||
const webguiFiles = new Map();
|
||||
|
||||
const gitignore1 = FileOps.loadGitignore(apiProjectDir);
|
||||
const gitignore2 = FileOps.loadGitignore(webguiProjectDir);
|
||||
|
||||
FileOps.walkDirectory(apiProjectDir, apiProjectDir, apiFiles, gitignore1);
|
||||
FileOps.walkDirectory(webguiProjectDir, webguiProjectDir, webguiFiles, gitignore2);
|
||||
|
||||
apiFiles.forEach((paths1, filename) => {
|
||||
if (webguiFiles.has(filename)) {
|
||||
matches.set(filename, [...paths1, ...webguiFiles.get(filename)]);
|
||||
}
|
||||
});
|
||||
|
||||
return matches;
|
||||
},
|
||||
|
||||
findMissingPluginFiles(apiProjectDir, webguiProjectDir, ignoredFiles) {
|
||||
const missingFiles = new Map();
|
||||
|
||||
if (!apiProjectDir || !webguiProjectDir) {
|
||||
UI.log('API project and webgui project directories are required!', 'error');
|
||||
return missingFiles;
|
||||
}
|
||||
|
||||
const apiPluginsPath = path.join(apiProjectDir, CONSTANTS.PLUGIN_PATHS.API);
|
||||
const webguiPluginsPath = path.join(webguiProjectDir, CONSTANTS.PLUGIN_PATHS.WEBGUI);
|
||||
|
||||
if (!fs.existsSync(apiPluginsPath)) {
|
||||
UI.log('API plugins directory not found: ' + apiPluginsPath, 'error');
|
||||
return missingFiles;
|
||||
}
|
||||
|
||||
const gitignore1 = FileOps.loadGitignore(apiProjectDir);
|
||||
|
||||
function walkDir(currentPath, baseDir) {
|
||||
if (!fs.existsSync(currentPath)) {
|
||||
UI.log(`Directory doesn't exist: ${currentPath}`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
UI.log(`Checking directory: ${path.relative(apiProjectDir, currentPath)}`, 'info');
|
||||
|
||||
fs.readdirSync(currentPath).forEach((file) => {
|
||||
if (file.startsWith('.')) {
|
||||
UI.log(`Skipping dot file/dir: ${file}`, 'skip');
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = path.join(currentPath, file);
|
||||
const relativePath = path.relative(apiPluginsPath, fullPath);
|
||||
|
||||
if (CONSTANTS.IGNORE_PATTERNS.some((pattern) => pattern.test(file))) {
|
||||
UI.log(`Skipping ignored pattern: ${file}`, 'skip');
|
||||
return;
|
||||
}
|
||||
|
||||
if (gitignore1?.ignores(relativePath)) {
|
||||
UI.log(`Skipping gitignored file: ${file}`, 'skip');
|
||||
return;
|
||||
}
|
||||
|
||||
const lstat = fs.lstatSync(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
UI.log(`Skipping symlink: ${file}`, 'skip');
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
UI.log(`Found subdirectory: ${file}`, 'info');
|
||||
walkDir(fullPath, baseDir);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ignoredFiles.includes(file)) {
|
||||
UI.log(`Skipping manually ignored file: ${file}`, 'skip');
|
||||
return;
|
||||
}
|
||||
|
||||
const webguiPath = path.join(webguiPluginsPath, relativePath);
|
||||
if (!fs.existsSync(webguiPath)) {
|
||||
UI.log(`Found new file: ${relativePath}`, 'new');
|
||||
missingFiles.set(relativePath, {
|
||||
source: fullPath,
|
||||
destinationPath: webguiPath,
|
||||
relativePath,
|
||||
});
|
||||
} else {
|
||||
UI.log(`File exists in both: ${relativePath}`, 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
UI.log('\nStarting directory scan...', 'info');
|
||||
UI.log(`API plugins path: ${apiPluginsPath}`, 'info');
|
||||
UI.log(`Webgui plugins path: ${webguiPluginsPath}\n`, 'info');
|
||||
|
||||
try {
|
||||
walkDir(apiPluginsPath, apiPluginsPath);
|
||||
if (missingFiles.size > 0) {
|
||||
State.saveNewFiles(
|
||||
Object.fromEntries(
|
||||
Array.from(missingFiles).map(([relativePath, info]) => [relativePath, info])
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
UI.log(`Error while scanning directories: ${err.message}`, 'error');
|
||||
}
|
||||
|
||||
return missingFiles;
|
||||
},
|
||||
|
||||
async handleNewFiles() {
|
||||
const newFiles = State.getNewFiles();
|
||||
const fileCount = Object.keys(newFiles).length;
|
||||
|
||||
if (fileCount === 0) {
|
||||
UI.log('No new files to copy bruv!', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
UI.log(`Found ${fileCount} files to review:`, 'info');
|
||||
|
||||
const handledFiles = new Set();
|
||||
const ignoredFiles = State.loadIgnoredFiles();
|
||||
|
||||
for (const [relativePath, info] of Object.entries(newFiles)) {
|
||||
console.log(`\nFile: ${relativePath}`);
|
||||
console.log(`From: ${info.source}`);
|
||||
console.log(`To: ${info.destinationPath}`);
|
||||
|
||||
const answer = await UI.question(
|
||||
'What should I do fam? (w=copy to webgui/i=ignore forever/s=skip/q=quit) '
|
||||
);
|
||||
|
||||
switch (answer.toLowerCase()) {
|
||||
case 'w':
|
||||
if (FileSystem.copyFile(info.source, info.destinationPath)) {
|
||||
UI.log(`Copied: ${relativePath}`, 'success');
|
||||
handledFiles.add(relativePath);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'i':
|
||||
ignoredFiles.push(path.basename(relativePath));
|
||||
State.saveIgnoredFiles(ignoredFiles);
|
||||
UI.log(`Added ${path.basename(relativePath)} to ignore list`, 'info');
|
||||
handledFiles.add(relativePath);
|
||||
break;
|
||||
|
||||
case 'q':
|
||||
UI.log('Stopping here fam!', 'info');
|
||||
break;
|
||||
|
||||
default:
|
||||
UI.log(`Skipped: ${relativePath}`, 'skip');
|
||||
handledFiles.add(relativePath);
|
||||
break;
|
||||
}
|
||||
|
||||
if (answer.toLowerCase() === 'q') break;
|
||||
}
|
||||
|
||||
const updatedNewFiles = Object.fromEntries(
|
||||
Object.entries(newFiles).filter(([relativePath]) => !handledFiles.has(relativePath))
|
||||
);
|
||||
|
||||
State.saveNewFiles(updatedNewFiles);
|
||||
|
||||
const remainingCount = Object.keys(updatedNewFiles).length;
|
||||
UI.log('All done for now bruv! 🔥', 'success');
|
||||
if (remainingCount > 0) {
|
||||
UI.log(`${remainingCount} files left to handle next time.`, 'info');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const Menu = {
|
||||
async show() {
|
||||
while (true) {
|
||||
try {
|
||||
console.log('\nWhat you trying to do fam?');
|
||||
console.log('1. Find new plugin files in API project');
|
||||
console.log('2. Handle new plugin files in API project');
|
||||
console.log('3. Sync shared files between API and webgui');
|
||||
console.log('4. Build UI components');
|
||||
console.log('5. Sync UI components');
|
||||
console.log('6. Build web components');
|
||||
console.log('7. Sync web components');
|
||||
console.log('8. Exit\n');
|
||||
|
||||
const answer = await UI.question('Choose an option (1-8): ');
|
||||
|
||||
switch (answer) {
|
||||
case '1': {
|
||||
UI.log('Checking plugin directories for missing files bruv...', 'info');
|
||||
const ignoredFiles = State.loadIgnoredFiles();
|
||||
const missingFiles = Features.findMissingPluginFiles(
|
||||
global.apiProjectDir,
|
||||
global.webguiProjectDir,
|
||||
ignoredFiles
|
||||
);
|
||||
|
||||
if (missingFiles.size > 0) {
|
||||
UI.log(`Found ${missingFiles.size} new files! 🔍`, 'info');
|
||||
if (await UI.confirm('Want to handle these new files now fam?', false)) {
|
||||
await Features.handleNewFiles();
|
||||
} else {
|
||||
UI.log('Safe, you can handle them later with option 2!', 'info');
|
||||
}
|
||||
} else {
|
||||
UI.log('\n');
|
||||
UI.log('No new files found bruv! 👌', 'success');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case '2':
|
||||
await Features.handleNewFiles();
|
||||
break;
|
||||
|
||||
case '3': {
|
||||
UI.log('Checking for matching files bruv...', 'info');
|
||||
const matchingFiles = Features.findMatchingFiles(
|
||||
global.apiProjectDir,
|
||||
global.webguiProjectDir
|
||||
);
|
||||
|
||||
if (matchingFiles.size === 0) {
|
||||
UI.log('No matching files found fam!', 'info');
|
||||
} else {
|
||||
UI.log(`Found ${matchingFiles.size} matching files:\n`, 'info');
|
||||
|
||||
for (const [filename, paths] of matchingFiles) {
|
||||
const [apiPath, webguiPath] = paths;
|
||||
console.log(`File: ${filename}`);
|
||||
|
||||
const apiHash = FileSystem.getFileHash(apiPath);
|
||||
const webguiHash = FileSystem.getFileHash(webguiPath);
|
||||
|
||||
if (apiHash !== webguiHash) {
|
||||
await FileOps.handleFileDiff(apiPath, webguiPath);
|
||||
} else {
|
||||
UI.log('Files are identical', 'success');
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case '4':
|
||||
await Features.handleUiComponentBuild();
|
||||
break;
|
||||
|
||||
case '5':
|
||||
await Features.handleUiComponentSync();
|
||||
break;
|
||||
|
||||
case '6':
|
||||
await Features.handleWebComponentBuild();
|
||||
break;
|
||||
|
||||
case '7':
|
||||
await Features.handleWebComponentSync();
|
||||
break;
|
||||
|
||||
case '8':
|
||||
UI.log('Safe bruv, catch you later! 👋', 'success');
|
||||
UI.rl.close();
|
||||
process.exit(0);
|
||||
return;
|
||||
|
||||
default:
|
||||
UI.log("Nah fam, that's not a valid option!", 'error');
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
UI.log(error.message, 'error');
|
||||
UI.rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const App = {
|
||||
async init() {
|
||||
try {
|
||||
const paths = await Features.setupPaths();
|
||||
global.apiProjectDir = paths.apiProjectDir;
|
||||
global.webguiProjectDir = paths.webguiProjectDir;
|
||||
await Menu.show();
|
||||
} catch (error) {
|
||||
UI.log(error.message, 'error');
|
||||
UI.rl.close();
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
App.init().catch((error) => {
|
||||
UI.log(`Something went wrong fam: ${error.message}`, 'error');
|
||||
UI.rl.close();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Recursively find JS files in a directory
|
||||
@@ -15,7 +14,7 @@ function findJSFiles(dir, jsFiles = []) {
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
findJSFiles(fullPath, jsFiles);
|
||||
} else if (item.endsWith('.js')) {
|
||||
@@ -36,18 +35,18 @@ function validateCustomElementsCSS() {
|
||||
const standaloneDir = '.nuxt/standalone-apps';
|
||||
let jsFiles = findJSFiles(standaloneDir);
|
||||
let usingStandalone = true;
|
||||
|
||||
|
||||
// Fallback to custom elements if standalone doesn't exist
|
||||
if (jsFiles.length === 0) {
|
||||
const customElementsDir = '.nuxt/nuxt-custom-elements/dist';
|
||||
jsFiles = findJSFiles(customElementsDir);
|
||||
usingStandalone = false;
|
||||
|
||||
|
||||
if (jsFiles.length === 0) {
|
||||
throw new Error('No JS files found in standalone apps or custom elements dist');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log(`📦 Using ${usingStandalone ? 'standalone apps' : 'custom elements'} bundle`);
|
||||
|
||||
// Find the largest JS file (likely the main bundle with inlined CSS)
|
||||
@@ -60,50 +59,50 @@ function validateCustomElementsCSS() {
|
||||
|
||||
// Read the JS content
|
||||
const jsContent = fs.readFileSync(jsFile, 'utf8');
|
||||
|
||||
|
||||
// Define required Tailwind indicators (looking for inlined CSS in JS)
|
||||
// Updated patterns to work with minified CSS (no spaces)
|
||||
const requiredIndicators = [
|
||||
{
|
||||
name: 'Tailwind utility classes (inline)',
|
||||
pattern: /\.flex\s*\{[^}]*display:\s*flex|\.flex{display:flex/,
|
||||
description: 'Basic Tailwind utility classes inlined'
|
||||
description: 'Basic Tailwind utility classes inlined',
|
||||
},
|
||||
{
|
||||
name: 'Tailwind margin utilities (inline)',
|
||||
pattern: /\.m-\d+\s*\{[^}]*margin:|\.m-\d+{[^}]*margin:/,
|
||||
description: 'Tailwind margin utilities inlined'
|
||||
description: 'Tailwind margin utilities inlined',
|
||||
},
|
||||
{
|
||||
name: 'Tailwind padding utilities (inline)',
|
||||
pattern: /\.p-\d+\s*\{[^}]*padding:|\.p-\d+{[^}]*padding:/,
|
||||
description: 'Tailwind padding utilities inlined'
|
||||
description: 'Tailwind padding utilities inlined',
|
||||
},
|
||||
{
|
||||
name: 'Tailwind color utilities (inline)',
|
||||
pattern: /\.text-\w+\s*\{[^}]*color:|\.text-\w+{[^}]*color:/,
|
||||
description: 'Tailwind text color utilities inlined'
|
||||
description: 'Tailwind text color utilities inlined',
|
||||
},
|
||||
{
|
||||
name: 'Tailwind background utilities (inline)',
|
||||
pattern: /\.bg-\w+\s*\{[^}]*background|\.bg-\w+{[^}]*background/,
|
||||
description: 'Tailwind background utilities inlined'
|
||||
description: 'Tailwind background utilities inlined',
|
||||
},
|
||||
{
|
||||
name: 'CSS custom properties',
|
||||
pattern: /--[\w-]+:\s*[^;]+;|--[\w-]+:[^;]+;/,
|
||||
description: 'CSS custom properties (variables)'
|
||||
description: 'CSS custom properties (variables)',
|
||||
},
|
||||
{
|
||||
name: 'Responsive breakpoints',
|
||||
pattern: /@media\s*\([^)]*min-width|@media\([^)]*min-width/,
|
||||
description: 'Responsive media queries'
|
||||
description: 'Responsive media queries',
|
||||
},
|
||||
{
|
||||
name: 'CSS reset styles',
|
||||
pattern: /\*[^}]*box-sizing|box-sizing[^}]*border-box/,
|
||||
description: 'Tailwind CSS reset/normalize styles'
|
||||
}
|
||||
description: 'Tailwind CSS reset/normalize styles',
|
||||
},
|
||||
];
|
||||
|
||||
// Validate each indicator
|
||||
@@ -115,7 +114,7 @@ function validateCustomElementsCSS() {
|
||||
results.push({
|
||||
name: indicator.name,
|
||||
description: indicator.description,
|
||||
passed: found
|
||||
passed: found,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
@@ -126,7 +125,7 @@ function validateCustomElementsCSS() {
|
||||
// Report results
|
||||
console.log('\n📊 Validation Results:');
|
||||
console.log('====================');
|
||||
|
||||
|
||||
for (const result of results) {
|
||||
const status = result.passed ? '✅' : '❌';
|
||||
console.log(`${status} ${result.name}`);
|
||||
@@ -138,9 +137,11 @@ function validateCustomElementsCSS() {
|
||||
// File size check
|
||||
const fileSizeKB = Math.round(fs.statSync(jsFile).size / 1024);
|
||||
console.log(`\n📏 JS bundle size: ${fileSizeKB} KB`);
|
||||
|
||||
|
||||
if (fileSizeKB < 1000) {
|
||||
console.log('⚠️ WARNING: JS bundle seems too small, inlined Tailwind styles might not be included');
|
||||
console.log(
|
||||
'⚠️ WARNING: JS bundle seems too small, inlined Tailwind styles might not be included'
|
||||
);
|
||||
allPassed = false;
|
||||
} else {
|
||||
console.log('✅ JS bundle size looks good');
|
||||
@@ -158,7 +159,6 @@ function validateCustomElementsCSS() {
|
||||
console.log(' - CSS is not being injected into shadow DOM components');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ ERROR during validation:', error.message);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { defineEventHandler, readBody } from 'h3';
|
||||
import { exec } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
const { token } = body;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
error: 'Token is required',
|
||||
success: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute the Unraid API CLI command using command:raw to avoid build output
|
||||
const cliCommand = `cd ../api && pnpm command sso validate-token "${token}" 2>&1`;
|
||||
const { stdout, stderr } = await execAsync(cliCommand, {
|
||||
timeout: 30000,
|
||||
env: { ...process.env, NODE_ENV: 'production' } // Suppress debug output
|
||||
});
|
||||
|
||||
// Extract JSON from the output (last line that looks like JSON)
|
||||
const lines = stdout.trim().split('\n');
|
||||
let parsedOutput = null;
|
||||
|
||||
// Look for JSON output from the end
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('{') && line.endsWith('}')) {
|
||||
try {
|
||||
parsedOutput = JSON.parse(line);
|
||||
break;
|
||||
} catch {
|
||||
// Continue looking
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsedOutput) {
|
||||
parsedOutput = stdout;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
stdout: parsedOutput,
|
||||
stderr,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (execError) {
|
||||
// Extract JSON from error output
|
||||
const error = execError as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = error.stdout || '';
|
||||
const lines = output.trim().split('\n');
|
||||
let parsedOutput = null;
|
||||
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith('{') && line.endsWith('}')) {
|
||||
try {
|
||||
parsedOutput = JSON.parse(line);
|
||||
break;
|
||||
} catch {
|
||||
// Continue looking
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsedOutput) {
|
||||
parsedOutput = output;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || 'Command failed',
|
||||
stdout: parsedOutput,
|
||||
stderr: error.stderr || '',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "../.nuxt/tsconfig.server.json"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
// Simple redirect to test pages
|
||||
// The Vue app is primarily used for component development
|
||||
// All testing happens in HTML-based test pages at /test-pages/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="redirect-container">
|
||||
<h1>Unraid Components</h1>
|
||||
<p>Redirecting to test pages...</p>
|
||||
<a href="/test-pages/">Go to Test Pages →</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.redirect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user