<!-- 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:
Eli Bosley
2025-09-08 10:04:49 -04:00
committed by GitHub
parent f0cffbdc7a
commit af5ca11860
310 changed files with 14576 additions and 10011 deletions
+1 -1
View File
@@ -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
+1 -2
View File
@@ -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
+5
View File
@@ -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
+82 -11
View File
@@ -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
View File
@@ -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';
+96
View File
@@ -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);
}
+14 -14
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.18.2",
"version": "4.19.1",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],
+6
View File
@@ -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 {
+4 -21
View File
@@ -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'];
};
@@ -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='$(&quot;#drop&quot;).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>
@@ -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:
@@ -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='$(&quot;#drop&quot;).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>
@@ -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);
+1
View 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",
+1
View File
@@ -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
View File
@@ -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();
});
});
});
+173
View File
@@ -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;
}
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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 )
+320 -4927
View File
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"
+2
View File
@@ -1,2 +1,4 @@
.env.*
!.env.staging
!.env.example
!.env.production
+66 -59
View File
@@ -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],
+1 -1
View File
@@ -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 () => {
+2 -2
View File
@@ -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('&lt;script&gt;');
expect(result).not.toContain('<img');
expect(result).toContain('&lt;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);
+5 -6
View File
@@ -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);
+5 -8
View File
@@ -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);
+82 -78
View File
@@ -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');
+23 -19
View File
@@ -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);
});
});
+18 -5
View File
@@ -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(),
@@ -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>"`;
+17 -14
View File
@@ -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()),
};
}
+1 -1
View File
@@ -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 = [
+7 -5
View File
@@ -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', () => {
+1 -1
View File
@@ -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';
+45 -15
View File
@@ -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);
});
});
});
+40 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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>
-90
View File
@@ -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;
}
}
+62
View File
@@ -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
View File
@@ -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,
+127
View File
@@ -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']
}
}
-33
View File
@@ -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>
-82
View File
@@ -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>
-10
View File
@@ -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 -->
-11
View File
@@ -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>
-3
View File
@@ -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">&bull;</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);
}
}
}
});
}
-130
View File
@@ -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,
});
};
});
}
+3 -15
View File
@@ -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.
*/
+4 -35
View File
@@ -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>;
+2 -2
View File
@@ -1,2 +1,2 @@
export * from "./fragment-masking";
export * from "./gql";
export * from './fragment-masking';
export * from './gql';
-37
View File
@@ -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
View File
@@ -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
},
},
];
+13
View File
@@ -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>
-68
View File
@@ -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>
+42
View File
@@ -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>
-211
View File
@@ -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
View File
@@ -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"
-115
View File
@@ -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&current_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>
-213
View File
@@ -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>
-67
View File
@@ -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>
-7
View File
@@ -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);
});
-20
View File
@@ -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);
});
+365
View File
@@ -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">&lt;unraid-auth&gt;</span>
<div class="component-mount">
<unraid-auth></unraid-auth>
</div>
</div>
<div class="component-card">
<h3>User Profile</h3>
<span class="selector">&lt;unraid-user-profile&gt;</span>
<div class="component-mount">
<unraid-user-profile></unraid-user-profile>
</div>
</div>
<div class="component-card">
<h3>SSO Button</h3>
<span class="selector">&lt;unraid-sso-button&gt;</span>
<div class="component-mount">
<unraid-sso-button></unraid-sso-button>
</div>
</div>
<div class="component-card">
<h3>Registration</h3>
<span class="selector">&lt;unraid-registration&gt;</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">&lt;unraid-connect-settings&gt;</span>
<div class="component-mount">
<unraid-connect-settings></unraid-connect-settings>
</div>
</div>
<div class="component-card">
<h3>Theme Switcher</h3>
<span class="selector">&lt;unraid-theme-switcher&gt;</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">&lt;unraid-header-os-version&gt;</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">&lt;unraid-wan-ip-check&gt;</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">&lt;unraid-update-os&gt;</span>
<div class="component-mount">
<unraid-update-os></unraid-update-os>
</div>
</div>
<div class="component-card">
<h3>Downgrade OS</h3>
<span class="selector">&lt;unraid-downgrade-os&gt;</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">&lt;unraid-api-key-manager&gt;</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">&lt;unraid-api-key-authorize&gt;</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">&lt;unraid-download-api-logs&gt;</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">&lt;unraid-log-viewer&gt;</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">&lt;unraid-modals&gt;</span>
<div class="component-mount">
<unraid-modals></unraid-modals>
</div>
</div>
<div class="component-card">
<h3>Welcome Modal</h3>
<span class="selector">&lt;unraid-welcome-modal&gt;</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">&lt;unraid-dev-modal-test&gt;</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">&lt;unraid-toaster&gt;</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>
+217
View File
@@ -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>
+203
View File
@@ -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>
+247
View File
@@ -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>
+163
View File
@@ -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);
}
}
})();
+354
View File
@@ -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>
+225
View File
@@ -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>
+378
View File
@@ -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);
}
})();
+292
View File
@@ -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';
+80
View File
@@ -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);
}
+1 -1
View File
@@ -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
-696
View File
@@ -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);
});
+22 -22
View File
@@ -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()
};
}
});
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "../.nuxt/tsconfig.server.json"
}
+55
View File
@@ -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