Compare commits

...

12 Commits

Author SHA1 Message Date
github-actions[bot]
c39b0b267c chore(main): release 4.29.2 (#1857)
🤖 I have created a release *beep* *boop*
---


## [4.29.2](https://github.com/unraid/api/compare/v4.29.1...v4.29.2)
(2025-12-19)


### Bug Fixes

* unraid-connect plugin not loaded when connect is installed
([#1856](https://github.com/unraid/api/issues/1856))
([73135b8](73135b8328))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-19 15:13:29 -05:00
Pujit Mehrotra
73135b8328 fix: unraid-connect plugin not loaded when connect is installed (#1856)
Previously, api plugins could only be installed as `peerDependencies` in
the api. This change allows them to be listed as `dependencies` as well.
This makes plugin loading (eg loading Connect) more robust.

Tests:

- [x] Re-logging on 7.3.0-beta.0.5
2025-12-19 15:06:52 -05:00
github-actions[bot]
e42d619b6d chore(main): release 4.29.1 (#1854)
🤖 I have created a release *beep* *boop*
---


## [4.29.1](https://github.com/unraid/api/compare/v4.29.0...v4.29.1)
(2025-12-19)


### Bug Fixes

* revert replace docker overview table with web component (7.3+)
([#1853](https://github.com/unraid/api/issues/1853))
([560db88](560db880cc))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-19 12:19:44 -05:00
Eli Bosley
560db880cc fix: revert replace docker overview table with web component (7.3+) (#1853)
Reverts unraid/api#1764
2025-12-19 12:12:41 -05:00
github-actions[bot]
d6055f102b chore(main): release 4.29.0 (#1849)
🤖 I have created a release *beep* *boop*
---


## [4.29.0](https://github.com/unraid/api/compare/v4.28.2...v4.29.0)
(2025-12-19)


### Features

* replace docker overview table with web component (7.3+)
([#1764](https://github.com/unraid/api/issues/1764))
([277ac42](277ac42046))


### Bug Fixes

* handle race condition between guid loading and license check
([#1847](https://github.com/unraid/api/issues/1847))
([8b155d1](8b155d1f1c))
* resolve issue with "Continue" button when updating
([#1852](https://github.com/unraid/api/issues/1852))
([d099e75](d099e7521d))
* update myservers config references to connect config references
([#1810](https://github.com/unraid/api/issues/1810))
([e1e3ea7](e1e3ea7eb6))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-19 11:53:48 -05:00
Eli Bosley
d099e7521d fix: resolve issue with "Continue" button when updating (#1852)
- Replaced BrandLoading with BrandButton in UpdateOs component for
better user interaction.
- Updated test cases to reflect changes in rendering logic, ensuring the
account button is displayed when no reboot is pending.
- Added functionality to navigate to account update when the button is
clicked.
- Introduced WEBGUI_REDIRECT URL for handling update installations in
the store logic.
2025-12-19 11:44:19 -05:00
Pujit Mehrotra
bb9b539732 chore: fix local plugin builds & docs (#1851)
Raised by [MitchellThompkins](https://github.com/MitchellThompkins) in
#1848

- Documents how to use Docker to build a local Connect plugin
- Local Plugin flow will now build workspace packages before proceeding
with plugin infra + build
- Removes recommendation to run `pnpm build:watch` from root, as this
race conditions and build cache issues.
- Makes `pnpm dev` from root parallel, preventing servers from blocking
each other.

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

## Summary by CodeRabbit

* **Documentation**
* Updated development workflow documentation to emphasize Docker-based
plugin builds
* Restructured development modes into three workflows: local Docker
builds, direct deployment, and development servers
  * Updated build and deployment instructions

* **Chores**
  * Modified dev script for parallel execution
  * Refactored build scripts with improved dependency handling

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-18 16:33:37 -05:00
Pujit Mehrotra
0e44e73bf7 chore(web): mv predev call to prebuild step (#1850)
Fixes #1848

## Background

The `build:dev` script is used for the `unraid:deploy` workflow, and it
implicitly triggered the `predev` script to build the `unraid-ui`
package as needed.

`web` builds depend on `unraid-ui`. In the past, `unraid-ui` was built
during `pnpm install` via a `prepare` step in its `package.json`.
However, this approach doesn't ensure that `web` builds correctly; stale
`unraid-ui` builds could cause false-positives.

So, instead of doing that, we call `predev` from `prebuild`, ensuring
that both local builds and the `unraid:deploy` workflow lazily get the
correct build of `unraid-ui`.
2025-12-18 11:50:17 -05:00
Pujit Mehrotra
277ac42046 feat: replace docker overview table with web component (7.3+) (#1764)
## Summary

Introduces a new Vue-based Docker container management interface
replacing the legacy webgui table.

### Container Management
- Start, stop, pause, resume, and remove containers via GraphQL
mutations
- Bulk actions for managing multiple containers at once
- Container update detection with one-click updates
- Real-time container statistics (CPU, memory, I/O)

### Organization & Navigation
- Folder-based container organization with drag-and-drop support
- Accessible reordering via keyboard controls
- Customizable column visibility with persistent preferences
- Column resizing and reordering
- Filtering and search across container properties

### Auto-start Configuration
- Dedicated autostart view with delay configuration
- Drag-and-drop reordering of start/stop sequences

### Logs & Console
- Integrated log viewer with filtering and download
- Persistent console sessions with shell selection
- Slideover panel for quick access

### Networking
- Port conflict detection and alerts
- Tailscale integration for container networking status
- LAN IP and port information display

### Additional Features
- Orphaned container detection and cleanup
- Template mapping management
- Critical notifications system
- WebUI visit links with Tailscale support

<sub>PR Summary by Claude Opus 4.5</sub>
2025-12-18 11:11:05 -05:00
Pujit Mehrotra
e1e3ea7eb6 fix: update myservers config references to connect config references (#1810)
`myservers.cfg` no longer gets written to or read (except for migration
purposes), so it'd be better to read from the new values instead of
continuing to use the old ones @elibosley @Squidly271 .

unless i'm missing something! see #1805

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

* **New Features**
* Switches to a centralized remote-access configuration with a legacy
fallback and richer client-side handling.
* Optional GraphQL submission path for applying remote settings when
available.

* **Bug Fixes**
* Normalized boolean and port handling to prevent incorrect values
reaching the UI.
* Improved error handling and UI state restoration during save/apply
flows.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-18 10:34:06 -05:00
Pujit Mehrotra
8b155d1f1c fix: handle race condition between guid loading and license check (#1847)
On errors, a `console.error` message should be emitted from the browser
console, tagged `[ReplaceCheck.check]`.

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

## Summary by CodeRabbit

* **New Features**
* Added retry capability for license eligibility checks with a
contextual "Retry" button that appears in error states.

* **Bug Fixes**
* Fixed license status initialization to correctly default to ready
state.
* Enhanced error messaging with specific messages for different failure
scenarios (missing credentials, access denied, server errors).
* Improved status display handling to prevent potential runtime errors.

* **Localization**
  * Added "Retry" text translation.

* **Tests**
  * Updated and added tests for reset functionality and error handling.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-12-18 08:51:01 -05:00
github-actions[bot]
d13a1f6174 chore(main): release 4.28.2 (#1845)
🤖 I have created a release *beep* *boop*
---


## [4.28.2](https://github.com/unraid/api/compare/v4.28.1...v4.28.2)
(2025-12-16)


### Bug Fixes

* **api:** timeout on startup on 7.0 and 6.12
([#1844](https://github.com/unraid/api/issues/1844))
([e243ae8](e243ae836e))

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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-16 11:47:31 -05:00
24 changed files with 460 additions and 151 deletions

View File

@@ -1 +1 @@
{".":"4.28.1"}
{".":"4.29.2"}

View File

@@ -1,5 +1,40 @@
# Changelog
## [4.29.2](https://github.com/unraid/api/compare/v4.29.1...v4.29.2) (2025-12-19)
### Bug Fixes
* unraid-connect plugin not loaded when connect is installed ([#1856](https://github.com/unraid/api/issues/1856)) ([73135b8](https://github.com/unraid/api/commit/73135b832801f5c76d60020161492e4770958c3d))
## [4.29.1](https://github.com/unraid/api/compare/v4.29.0...v4.29.1) (2025-12-19)
### Bug Fixes
* revert replace docker overview table with web component (7.3+) ([#1853](https://github.com/unraid/api/issues/1853)) ([560db88](https://github.com/unraid/api/commit/560db880cc138324f9ff8753f7209b683a84c045))
## [4.29.0](https://github.com/unraid/api/compare/v4.28.2...v4.29.0) (2025-12-19)
### Features
* replace docker overview table with web component (7.3+) ([#1764](https://github.com/unraid/api/issues/1764)) ([277ac42](https://github.com/unraid/api/commit/277ac420464379e7ee6739c4530271caf7717503))
### Bug Fixes
* handle race condition between guid loading and license check ([#1847](https://github.com/unraid/api/issues/1847)) ([8b155d1](https://github.com/unraid/api/commit/8b155d1f1c99bb19efbc9614e000d852e9f0c12d))
* resolve issue with "Continue" button when updating ([#1852](https://github.com/unraid/api/issues/1852)) ([d099e75](https://github.com/unraid/api/commit/d099e7521d2062bb9cf84f340e46b169dd2492c5))
* update myservers config references to connect config references ([#1810](https://github.com/unraid/api/issues/1810)) ([e1e3ea7](https://github.com/unraid/api/commit/e1e3ea7eb68cc6840f67a8aec937fd3740e75b28))
## [4.28.2](https://github.com/unraid/api/compare/v4.28.1...v4.28.2) (2025-12-16)
### Bug Fixes
* **api:** timeout on startup on 7.0 and 6.12 ([#1844](https://github.com/unraid/api/issues/1844)) ([e243ae8](https://github.com/unraid/api/commit/e243ae836ec1a7fde37dceeb106cc693b20ec82b))
## [4.28.1](https://github.com/unraid/api/compare/v4.28.0...v4.28.1) (2025-12-16)

View File

@@ -62,15 +62,18 @@ To build all packages in the monorepo:
pnpm build
```
### Watch Mode Building
### Plugin Building (Docker Required)
For continuous building during development:
The plugin build requires Docker. This command automatically builds all dependencies (API, web) before starting Docker:
```bash
pnpm build:watch
cd plugin
pnpm run docker:build-and-run
# Then inside the container:
pnpm build
```
This is useful when you want to see your changes reflected without manually rebuilding. This will also allow you to install a local plugin to test your changes.
This serves the plugin at `http://YOUR_IP:5858/` for installation on your Unraid server.
### Package-Specific Building

View File

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

View File

@@ -83,6 +83,10 @@ try {
if (parsedPackageJson.dependencies?.[dep]) {
delete parsedPackageJson.dependencies[dep];
}
// Also strip from peerDependencies (npm doesn't understand workspace: protocol)
if (parsedPackageJson.peerDependencies?.[dep]) {
delete parsedPackageJson.peerDependencies[dep];
}
});
}

View File

@@ -91,13 +91,9 @@ export class PluginService {
return name;
})
);
const { peerDependencies } = getPackageJson();
// All api plugins must be installed as peer dependencies of the unraid-api package
if (!peerDependencies) {
PluginService.logger.warn('Unraid-API peer dependencies not found; skipping plugins.');
return [];
}
const pluginTuples = Object.entries(peerDependencies).filter(
const { peerDependencies = {}, dependencies = {} } = getPackageJson();
const allDependencies = { ...peerDependencies, ...dependencies };
const pluginTuples = Object.entries(allDependencies).filter(
(entry): entry is [string, string] => {
const [pkgName, version] = entry;
return pluginNames.has(pkgName) && typeof version === 'string';

View File

@@ -1,13 +1,13 @@
{
"name": "unraid-monorepo",
"private": true,
"version": "4.28.1",
"version": "4.29.2",
"scripts": {
"build": "pnpm -r build",
"build:watch": "pnpm -r --parallel --filter '!@unraid/ui' build:watch",
"codegen": "pnpm -r codegen",
"i18n:extract": "pnpm --filter @unraid/api i18n:extract && pnpm --filter @unraid/web i18n:extract",
"dev": "pnpm -r dev",
"dev": "pnpm -r --parallel dev",
"unraid:deploy": "pnpm -r unraid:deploy",
"test": "pnpm -r test",
"test:watch": "pnpm -r --parallel test:watch",

View File

@@ -18,7 +18,8 @@
"dist"
],
"scripts": {
"build": "rimraf dist && tsc --project tsconfig.build.json",
"build": "pnpm clean && tsc --project tsconfig.build.json",
"clean": "rimraf dist",
"prepare": "npm run build",
"test": "vitest run",
"test:watch": "vitest",

View File

@@ -4,50 +4,32 @@ Tool for building and testing Unraid plugins locally as well as packaging them f
## Development Workflow
### 1. Watch for Changes
### 1. Build the Plugin
The watch command will automatically sync changes from the API, UI components, and web app into the plugin source:
```bash
# Start watching all components
pnpm run watch:all
# Or run individual watchers:
pnpm run api:watch # Watch API changes
pnpm run ui:watch # Watch Unraid UI component changes
pnpm run wc:watch # Watch web component changes
```
This will copy:
- API files to `./source/dynamix.unraid.net/usr/local/unraid-api`
- UI components to `./source/dynamix.unraid.net/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components`
- Web components to the same UI directory
### 2. Build the Plugin
> **Note:** Building the plugin requires Docker.
Once your changes are ready, build the plugin package:
```bash
# Build using Docker - on non-Linux systems
# Start Docker container (builds dependencies automatically)
pnpm run docker:build-and-run
# Or build with the build script
pnpm run build:validate
# Inside the container, build the plugin
pnpm build
```
This will create the plugin files in `./deploy/release/`
This will:
### 3. Serve and Install
1. Build the API release (`api/deploy/release/`)
2. Build the web standalone components (`web/dist/`)
3. Start Docker container with HTTP server on port 5858
4. Build the plugin package (when you run `pnpm build`)
Start a local HTTP server to serve the plugin files:
The plugin files will be created in `./deploy/` and served automatically.
```bash
# Serve the plugin files
pnpm run http-server
```
### 2. Install on Unraid
Then install the plugin on your Unraid development machine by visiting:
Install the plugin on your Unraid development machine by visiting:
`http://SERVER_NAME.local/Plugins`
@@ -59,8 +41,7 @@ Replace `SERVER_NAME` with your development machine's hostname.
## Development Tips
- Run watchers in a separate terminal while developing
- The http-server includes CORS headers for local development
- The HTTP server includes CORS headers for local development
- Check the Unraid system log for plugin installation issues
## Environment Setup
@@ -81,22 +62,10 @@ Replace `SERVER_NAME` with your development machine's hostname.
### Build Commands
- `build` - Build the plugin package
- `build:validate` - Build with environment validation
- `build` - Build the plugin package (run inside Docker container)
- `docker:build` - Build the Docker container
- `docker:run` - Run the builder in Docker
- `docker:build-and-run` - Build and run in Docker
### Watch Commands
- `watch:all` - Watch all component changes
- `api:watch` - Watch API changes
- `ui:watch` - Watch UI component changes
- `wc:watch` - Watch web component changes
### Server Commands
- `http-server` - Serve the plugin files locally
- `docker:build-and-run` - Build dependencies and start Docker container
### Environment Commands

View File

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

View File

@@ -33,6 +33,23 @@ if [ ! -d "$WEB_DIST_DIR" ]; then
mkdir -p "$WEB_DIST_DIR"
fi
# Build dependencies before starting Docker (always rebuild to prevent staleness)
echo "Building dependencies..."
echo "Building API release..."
if ! (cd .. && pnpm --filter @unraid/api build:release); then
echo "Error: API build failed. Aborting."
exit 1
fi
echo "Building web standalone..."
if ! (cd .. && pnpm --filter @unraid/web build); then
echo "Error: Web build failed. Aborting."
exit 1
fi
echo "Dependencies built successfully."
# Stop any running plugin-builder container first
echo "Stopping any running plugin-builder containers..."
docker ps -q --filter "name=${CONTAINER_NAME}" | xargs -r docker stop

View File

@@ -15,12 +15,29 @@ Tag="globe"
*/
require_once "$docroot/plugins/dynamix.my.servers/include/state.php";
require_once "$docroot/plugins/dynamix.my.servers/include/api-config.php";
require_once "$docroot/plugins/dynamix.my.servers/include/connect-config.php";
require_once "$docroot/webGui/include/Wrappers.php";
$serverState = new ServerState();
$keyfile = $serverState->keyfileBase64;
$myServersFlashCfg = $serverState->myServersFlashCfg;
$connectConfig = ConnectConfig::getConfig();
$legacyRemoteCfg = $serverState->myServersFlashCfg['remote'] ?? [];
$remoteDynamicRemoteAccessType = $connectConfig['dynamicRemoteAccessType'] ?? ($legacyRemoteCfg['dynamicRemoteAccessType'] ?? null);
$remoteWanAccessRaw = $connectConfig['wanaccess'] ?? ($legacyRemoteCfg['wanaccess'] ?? null);
$remoteUpnpEnabledRaw = $connectConfig['upnpEnabled'] ?? ($legacyRemoteCfg['upnpEnabled'] ?? null);
$remoteWanPortRaw = $connectConfig['wanport'] ?? ($legacyRemoteCfg['wanport'] ?? null);
$wanaccessEnabled = filter_var($remoteWanAccessRaw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($wanaccessEnabled === null) {
$wanaccessEnabled = false;
}
$upnpEnabled = filter_var($remoteUpnpEnabledRaw, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($upnpEnabled === null) {
$upnpEnabled = false;
}
$remoteWanPort = is_numeric($remoteWanPortRaw) ? (int)$remoteWanPortRaw : 0;
$showT2Fa = (file_exists('/boot/config/plugins/dynamix.my.servers/showT2Fa'));
@@ -37,9 +54,7 @@ $passwd_result = exec('/usr/bin/passwd --status root');
$boolWebUIAuth = $isRegistered && (($passwd_result !== false) && (substr($passwd_result, 0, 6) == 'root P'));
// Helper to determine the current value for the remote access input
$dynamicRemoteAccessType = $myServersFlashCfg['remote']['dynamicRemoteAccessType'] ?? null;
$upnpEnabled = ($myServersFlashCfg['remote']['upnpEnabled'] ?? null) === 'yes';
$wanaccessEnabled = ($myServersFlashCfg['remote']['wanaccess'] ?? null) === 'yes';
$dynamicRemoteAccessType = $remoteDynamicRemoteAccessType ?? null;
$currentRemoteAccessValue = 'OFF';
if ($dynamicRemoteAccessType === 'STATIC') {
@@ -59,6 +74,12 @@ if ($dynamicRemoteAccessType === 'STATIC') {
$enableRemoteT2fa = $showT2Fa && $currentRemoteAccessValue !== 'OFF' && $hasMyUnraidNetCert;
$enableLocalT2fa = $showT2Fa && $var['USE_SSL'] === 'auto' && $hasMyUnraidNetCert;
$shade="shade-".($display['theme']??'unk');
$wanAccessOriginal = $remoteWanAccessRaw;
if (is_bool($wanAccessOriginal)) {
$wanAccessOriginal = $wanAccessOriginal ? 'yes' : 'no';
} elseif (!is_string($wanAccessOriginal)) {
$wanAccessOriginal = '';
}
?>
<style>
div.shade-white{background-color:#ededed;margin-top:10px;padding:8px 0 3px 0}
@@ -68,13 +89,18 @@ div.shade-gray{background-color:#121510;margin-top:10px;padding:8px 0 3px 0}
</style>
<script>
const hasMyUnraidNetCert = <?=($hasMyUnraidNetCert ? 'true' : 'false')?>;
const wanAccessOrg = "<?=$myServersFlashCfg['remote']['wanaccess'] ?? null?>";
const wanAccessOrg = "<?=$wanAccessOriginal?>";
function registerServer(button) {
const $remoteAccessInput = $('#remoteAccess');
const $remoteAccessManualPort = $('#wanport');
const parsePort = (val) => {
const parsed = parseInt(val, 10);
return isNaN(parsed) ? null : parsed;
};
let computedRemoteAccessConfig = null;
switch ($remoteAccessInput.val()) {
case 'ALWAYS_MANUAL':
@@ -119,19 +145,64 @@ function registerServer(button) {
break;
}
const enableLocalT2fa = <?=($enableLocalT2fa ? 'true' : 'false')?>;
const enableRemoteT2fa = $remoteAccessInput.val() !== 'OFF' && hasMyUnraidNetCert;
const enableLocalT2fa = <?=($enableLocalT2fa ? 'true' : 'false')?>;
const enableRemoteT2fa = $remoteAccessInput.val() !== 'OFF' && hasMyUnraidNetCert;
var postobj = {
"#cfg": "/boot/config/plugins/dynamix.my.servers/myservers.cfg",
...(computedRemoteAccessConfig ? computedRemoteAccessConfig : {}),
// only allow 'yes' value when fields are enabled
"local_2Fa": enableLocalT2fa ? $('#local2fa').val() : 'no',
"remote_2Fa": enableRemoteT2fa ? $('#remote2fa').val() : 'no',
};
const postobj = {
"#cfg": "/boot/config/plugins/dynamix.my.servers/myservers.cfg",
...(computedRemoteAccessConfig ? computedRemoteAccessConfig : {}),
// only allow 'yes' value when fields are enabled
"local_2Fa": enableLocalT2fa ? $('#local2fa').val() : 'no',
"remote_2Fa": enableRemoteT2fa ? $('#remote2fa').val() : 'no',
};
$(button).prop("disabled", true).html("_(Applying)_ <i class=\"fa fa-spinner fa-spin\" aria-hidden=\"true\"></i>");
$.post('/webGui/include/Dispatcher.php', postobj, function(data2) {
const buildConnectSettingsInput = () => {
const selection = $remoteAccessInput.val();
switch (selection) {
case 'ALWAYS_MANUAL':
return { accessType: 'ALWAYS', forwardType: 'STATIC', port: parsePort($remoteAccessManualPort.val()) };
case 'ALWAYS_UPNP':
return { accessType: 'ALWAYS', forwardType: 'UPNP', port: null };
case 'DYNAMIC_UPNP':
return { accessType: 'DYNAMIC', forwardType: 'UPNP', port: null };
case 'DYNAMIC_MANUAL':
return { accessType: 'DYNAMIC', forwardType: 'STATIC', port: parsePort($remoteAccessManualPort.val()) };
default:
return { accessType: 'DISABLED', forwardType: 'STATIC', port: null };
}
};
const $button = $(button);
const originalLabel = $button.html();
$button.prop("disabled", true).html("_(Applying)_ <i class=\"fa fa-spinner fa-spin\" aria-hidden=\"true\"></i>");
const saveLegacyConfig = new Promise((resolve, reject) => {
$.post('/webGui/include/Dispatcher.php', postobj).done(resolve).fail(reject);
});
const apolloClient = window.apolloClient;
const gql = window.gql || window.graphqlParse;
const mutations = [saveLegacyConfig];
if (apolloClient && gql) {
const updateConnectSettingsMutation = gql(`
mutation UpdateConnectSettings($input: ConnectSettingsInput!) {
updateApiSettings(input: $input) {
accessType
forwardType
port
}
}
`);
mutations.push(
apolloClient.mutate({
mutation: updateConnectSettingsMutation,
variables: { input: buildConnectSettingsInput() },
})
);
}
Promise.all(mutations).then(function() {
<?if(!$isRegistered):?>
swal({
title: "",
@@ -150,7 +221,22 @@ function registerServer(button) {
button.form.submit();
}, delay);
<?endif?>
});
}).catch(function(error) {
let message = "_(Sorry, an error occurred)_";
if (error && error.responseJSON && error.responseJSON.error) {
message = error.responseJSON.error;
} else if (error && error.message) {
message = error.message;
}
$button.prop("disabled", false).html(originalLabel);
swal({
title: "Oops",
text: message,
type: "error",
html: true,
confirmButtonText: "_(Ok)_"
});
});
}
@@ -196,7 +282,7 @@ function dnsCheckServer(button) {
} else {
swal({
title: "Oops",
text: "<?=sprintf(_("The Unraid server is unreachable from outside your network. Be sure you have configured your router to forward port") . " <strong style='font-weight: bold'>%u/TCP</strong> " . _("to the Unraid server at") . " <strong style='font-weight: bold'>%s</strong> " . _("port") . " <strong style='font-weight: bold'>%u</strong>", $myServersFlashCfg['remote']['wanport']??"", htmlspecialchars($eth0['IPADDR:0']??''), $var['PORTSSL']??443)?>",
text: "<?=sprintf(_("The Unraid server is unreachable from outside your network. Be sure you have configured your router to forward port") . " <strong style='font-weight: bold'>%u/TCP</strong> " . _("to the Unraid server at") . " <strong style='font-weight: bold'>%s</strong> " . _("port") . " <strong style='font-weight: bold'>%u</strong>", $remoteWanPort, htmlspecialchars($eth0['IPADDR:0']??''), $var['PORTSSL']??443)?>",
type: "error",
html: true,
confirmButtonText: "_(Ok)_"

View File

@@ -210,22 +210,34 @@ Once you have your key pair, add your public SSH key to your Unraid server:
### Development Modes
The project supports two development modes:
#### Mode 1: Local Plugin Build (Docker)
#### Mode 1: Build Watcher with Local Plugin
This mode builds the plugin continuously and serves it locally for installation on your Unraid server:
Build and test a full plugin locally using Docker:
```sh
# From the root directory (api/)
pnpm build:watch
cd plugin
pnpm run docker:build-and-run
# Then inside the container:
pnpm build
```
This command will output a local plugin URL that you can install on your Unraid server by navigating to Plugins → Install Plugin. Be aware it will take a *while* to build the first time.
This builds all dependencies (API, web), starts a Docker container, and serves the plugin at `http://YOUR_IP:5858/`. Install it on your Unraid server via Plugins → Install Plugin.
#### Mode 2: Development Servers
#### Mode 2: Direct Deployment
For active development with hot-reload:
Deploy individual packages directly to an Unraid server for faster iteration:
```sh
# Deploy API changes
cd api && pnpm unraid:deploy <SERVER_IP>
# Deploy web changes
cd web && pnpm unraid:deploy <SERVER_IP>
```
#### Mode 3: Development Servers
For active development with hot-reload (no Unraid server needed):
```sh
# From the root directory - runs all dev servers concurrently
@@ -238,22 +250,11 @@ Or run individual development servers:
# API server (GraphQL backend at http://localhost:3001)
cd api && pnpm dev
# Web interface (Nuxt frontend at http://localhost:3000)
# Web interface (Nuxt frontend at http://localhost:3000)
cd web && pnpm dev
```
### Building the Full Plugin
To build the complete plugin package (.plg file):
```sh
# From the root directory (api/)
pnpm build:plugin
# The plugin will be created in plugin/dynamix.unraid.net.plg
```
To deploy the plugin to your Unraid server:
### Deploying to Unraid
```sh
# Replace SERVER_IP with your Unraid server's IP address

View File

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

View File

@@ -13,7 +13,9 @@ import { createTestI18n } from '../utils/i18n';
vi.mock('@unraid/ui', () => ({
PageContainer: { template: '<div><slot /></div>' },
BrandLoading: { template: '<div data-testid="brand-loading-mock">Loading...</div>' },
BrandButton: {
template: '<button v-bind="$attrs" @click="$emit(\'click\')"><slot /></button>',
},
}));
const mockAccountStore = {
@@ -97,7 +99,7 @@ describe('UpdateOs.standalone.vue', () => {
});
describe('Initial Rendering and onBeforeMount Logic', () => {
it('shows loader and calls updateOs when path matches and rebootType is empty', async () => {
it('shows account button and does not auto-redirect when path matches and rebootType is empty', async () => {
window.location.pathname = '/Tools/Update';
mockRebootType.value = '';
@@ -105,7 +107,7 @@ describe('UpdateOs.standalone.vue', () => {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
// Rely on @unraid/ui mock for PageContainer & BrandLoading
// Rely on @unraid/ui mock for PageContainer & BrandButton
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
},
@@ -114,17 +116,9 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick();
// 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
expect(wrapper.find('[data-testid="brand-loading-mock"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
// 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');
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
expect(wrapper.find('[data-testid="update-os-account-button"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(false);
});
it('shows status and does not call updateOs when path does not match', async () => {
@@ -145,8 +139,7 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick();
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
// 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="update-os-account-button"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
});
@@ -168,10 +161,30 @@ describe('UpdateOs.standalone.vue', () => {
await nextTick();
expect(mockAccountStore.updateOs).not.toHaveBeenCalled();
// 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="update-os-account-button"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="update-os-status"]').exists()).toBe(true);
});
it('navigates to account update when the button is clicked', async () => {
window.location.pathname = '/Tools/Update';
mockRebootType.value = '';
const wrapper = mount(UpdateOs, {
global: {
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
stubs: {
UpdateOsStatus: UpdateOsStatusStub,
UpdateOsThirdPartyDrivers: UpdateOsThirdPartyDriversStub,
},
},
});
await nextTick();
await wrapper.find('[data-testid="update-os-account-button"]').trigger('click');
expect(mockAccountStore.updateOs).toHaveBeenCalledWith(true);
});
});
describe('Rendering based on rebootType', () => {

View File

@@ -14,6 +14,10 @@ import { useServerStore } from '~/store/server';
vi.mock('@unraid/shared-callbacks', () => ({}));
vi.mock('@unraid/ui', () => ({
BrandLoading: {},
}));
vi.mock('~/composables/services/keyServer', () => ({
validateGuid: vi.fn(),
}));
@@ -62,7 +66,7 @@ describe('ReplaceRenew Store', () => {
expect(store.replaceStatus).toBe('ready');
});
it('should initialize with error state when guid is missing', () => {
it('should initialize with ready state even when guid is missing', () => {
vi.mocked(useServerStore).mockReturnValueOnce({
guid: undefined,
keyfile: mockKeyfile,
@@ -72,7 +76,8 @@ describe('ReplaceRenew Store', () => {
const newStore = useReplaceRenewStore();
expect(newStore.replaceStatus).toBe('error');
// Store now always initializes as 'ready' - errors are set when check() is called
expect(newStore.replaceStatus).toBe('ready');
});
});
@@ -138,6 +143,18 @@ describe('ReplaceRenew Store', () => {
expect(store.renewStatus).toBe('installing');
});
it('should reset all states with reset action', () => {
store.setReplaceStatus('error');
store.keyLinkedStatus = 'error';
store.error = { name: 'Error', message: 'Test error' };
store.reset();
expect(store.replaceStatus).toBe('ready');
expect(store.keyLinkedStatus).toBe('ready');
expect(store.error).toBeNull();
});
describe('check action', () => {
const mockResponse = {
hasNewerKeyfile: false,
@@ -326,8 +343,59 @@ describe('ReplaceRenew Store', () => {
await store.check();
expect(store.replaceStatus).toBe('error');
expect(store.keyLinkedStatus).toBe('error');
expect(console.error).toHaveBeenCalledWith('[ReplaceCheck.check]', testError);
expect(store.error).toEqual(testError);
expect(store.error).toEqual({ name: 'Error', message: 'Test error' });
});
it('should set error when guid is missing during check', async () => {
vi.mocked(useServerStore).mockReturnValue({
guid: '',
keyfile: mockKeyfile,
} as unknown as ReturnType<typeof useServerStore>);
setActivePinia(createPinia());
const testStore = useReplaceRenewStore();
await testStore.check();
expect(testStore.replaceStatus).toBe('error');
expect(testStore.keyLinkedStatus).toBe('error');
expect(testStore.error?.message).toBe('Flash GUID required to check replacement status');
});
it('should set error when keyfile is missing during check', async () => {
vi.mocked(useServerStore).mockReturnValue({
guid: mockGuid,
keyfile: '',
} as unknown as ReturnType<typeof useServerStore>);
setActivePinia(createPinia());
const testStore = useReplaceRenewStore();
await testStore.check();
expect(testStore.replaceStatus).toBe('error');
expect(testStore.keyLinkedStatus).toBe('error');
expect(testStore.error?.message).toBe('Keyfile required to check replacement status');
});
it('should provide descriptive error for 403 status', async () => {
const error403 = { response: { status: 403 }, message: 'Forbidden' };
vi.mocked(validateGuid).mockRejectedValueOnce(error403);
await store.check();
expect(store.error?.message).toBe('Access denied - license may be linked to another account');
});
it('should provide descriptive error for 500+ status', async () => {
const error500 = { response: { status: 500 }, message: 'Server Error' };
vi.mocked(validateGuid).mockRejectedValueOnce(error500);
await store.check();
expect(store.error?.message).toBe('Key server temporarily unavailable - please try again later');
});
});
});

View File

@@ -4,17 +4,41 @@
import { createPinia, setActivePinia } from 'pinia';
import { WEBGUI_REDIRECT } from '~/helpers/urls';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useUpdateOsStore } from '~/store/updateOs';
const mockSend = vi.fn();
vi.mock('@unraid/shared-callbacks', () => ({
useCallback: vi.fn(() => ({
send: vi.fn(),
send: mockSend,
watcher: vi.fn(),
})),
}));
vi.mock('~/composables/preventClose', () => ({
addPreventClose: vi.fn(),
removePreventClose: vi.fn(),
}));
vi.mock('~/store/account', () => ({
useAccountStore: () => ({
accountActionStatus: 'ready',
}),
}));
vi.mock('~/store/installKey', () => ({
useInstallKeyStore: () => ({
keyInstallStatus: 'ready',
}),
}));
vi.mock('~/store/updateOsActions', () => ({
useUpdateOsActionsStore: () => ({}),
}));
vi.mock('~/composables/services/webgui', () => {
return {
WebguiCheckForUpdate: vi.fn().mockResolvedValue({
@@ -104,6 +128,40 @@ describe('UpdateOs Store', () => {
expect(store.updateOsModalVisible).toBe(false);
});
it('should send update install through redirect.htm', () => {
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
configurable: true,
value: {
...originalLocation,
origin: 'https://littlebox.tail45affd.ts.net',
href: 'https://littlebox.tail45affd.ts.net/Plugins',
},
});
store.fetchAndConfirmInstall('test-sha256');
const expectedUrl = new URL(WEBGUI_REDIRECT, window.location.origin).toString();
expect(mockSend).toHaveBeenCalledWith(
expectedUrl,
[
{
sha256: 'test-sha256',
type: 'updateOs',
},
],
undefined,
'forUpc'
);
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
});
it('should handle errors when checking for updates', async () => {
const { WebguiCheckForUpdate } = await import('~/composables/services/webgui');

View File

@@ -1,6 +1,6 @@
{
"name": "@unraid/web",
"version": "4.28.1",
"version": "4.29.2",
"private": true,
"type": "module",
"license": "GPL-2.0-or-later",
@@ -11,8 +11,8 @@
"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",
"prebuild": "pnpm predev",
"build": "NODE_ENV=production vite build && pnpm run manifest-ts",
"prebuild:watch": "pnpm predev",
"build:watch": "vite build --watch && pnpm run manifest-ts",

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { ArrowTopRightOnSquareIcon, KeyIcon } from '@heroicons/vue/24/solid';
import { ArrowPathIcon, ArrowTopRightOnSquareIcon, KeyIcon } from '@heroicons/vue/24/solid';
import { Badge, BrandButton } from '@unraid/ui';
import { DOCS_REGISTRATION_REPLACE_KEY } from '~/helpers/urls';
@@ -11,20 +12,30 @@ import { useReplaceRenewStore } from '~/store/replaceRenew';
const { t } = useI18n();
const replaceRenewStore = useReplaceRenewStore();
const { replaceStatusOutput } = storeToRefs(replaceRenewStore);
const isError = computed(() => replaceStatusOutput.value?.variant === 'red');
const showButton = computed(() => !replaceStatusOutput.value || isError.value);
const handleCheck = () => {
if (isError.value) {
replaceRenewStore.reset();
}
replaceRenewStore.check(true);
};
</script>
<template>
<div class="flex flex-wrap items-center justify-between gap-2">
<BrandButton
v-if="!replaceStatusOutput"
:icon="KeyIcon"
:text="t('registration.replaceCheck.checkEligibility')"
v-if="showButton"
:icon="isError ? ArrowPathIcon : KeyIcon"
:text="isError ? t('common.retry') : t('registration.replaceCheck.checkEligibility')"
class="grow"
@click="replaceRenewStore.check"
@click="handleCheck"
/>
<Badge v-else :variant="replaceStatusOutput.variant" :icon="replaceStatusOutput.icon" size="md">
{{ t(replaceStatusOutput.text ?? 'Unknown') }}
<Badge v-else :variant="replaceStatusOutput?.variant" :icon="replaceStatusOutput?.icon" size="md">
{{ t(replaceStatusOutput?.text ?? 'Unknown') }}
</Badge>
<span class="inline-flex flex-wrap items-center justify-end gap-2">

View File

@@ -19,7 +19,8 @@ import { computed, onBeforeMount } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import { BrandLoading, PageContainer } from '@unraid/ui';
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import { BrandButton, PageContainer } from '@unraid/ui';
import { WEBGUI_TOOLS_UPDATE } from '~/helpers/urls';
import UpdateOsStatus from '~/components/UpdateOs/Status.vue';
@@ -47,25 +48,42 @@ const subtitle = computed(() => {
return '';
});
/** when we're not prompting for reboot /Tools/Update will automatically send the user to account.unraid.net/server/update-os */
const showLoader = computed(
() => window.location.pathname === WEBGUI_TOOLS_UPDATE && rebootType.value === ''
// Show a prompt to continue in the Account app when no reboot is pending.
const showRedirectPrompt = computed(
() =>
typeof window !== 'undefined' &&
window.location.pathname === WEBGUI_TOOLS_UPDATE &&
rebootType.value === ''
);
const openAccountUpdate = () => {
accountStore.updateOs(true);
};
onBeforeMount(() => {
if (showLoader.value) {
accountStore.updateOs(true);
}
serverStore.setRebootVersion(props.rebootVersion);
});
</script>
<template>
<PageContainer>
<div v-show="showLoader">
<BrandLoading class="mx-auto my-12 max-w-[160px]" />
<div
v-if="showRedirectPrompt"
class="mx-auto flex max-w-[720px] flex-col items-center gap-4 py-8 text-center"
>
<h1 class="text-2xl font-semibold">{{ t('updateOs.updateUnraidOs') }}</h1>
<p class="text-base leading-relaxed opacity-75">
{{ t('updateOs.update.receiveTheLatestAndGreatestFor') }}
</p>
<BrandButton
data-testid="update-os-account-button"
:icon-right="ArrowTopRightOnSquareIcon"
@click="openAccountUpdate"
>
{{ t('updateOs.update.viewAvailableUpdates') }}
</BrandButton>
</div>
<div v-show="!showLoader">
<div v-else>
<UpdateOsStatus
:show-update-check="true"
:title="t('updateOs.updateUnraidOs')"

View File

@@ -22,6 +22,7 @@ const UNRAID_NET_SUPPORT = new URL('/support', UNRAID_NET);
const WEBGUI_GRAPHQL = '/graphql';
const WEBGUI_SETTINGS_MANAGMENT_ACCESS = '/Settings/ManagementAccess';
const WEBGUI_CONNECT_SETTINGS = `${WEBGUI_SETTINGS_MANAGMENT_ACCESS}#UnraidNetSettings`;
const WEBGUI_REDIRECT = '/redirect.htm';
const WEBGUI_TOOLS_DOWNGRADE = '/Tools/Downgrade';
const WEBGUI_TOOLS_REGISTRATION = '/Tools/Registration';
const WEBGUI_TOOLS_UPDATE = '/Tools/Update';
@@ -66,6 +67,7 @@ export {
DOCS_REGISTRATION_LICENSING,
DOCS_REGISTRATION_REPLACE_KEY,
WEBGUI_CONNECT_SETTINGS,
WEBGUI_REDIRECT,
WEBGUI_GRAPHQL,
WEBGUI_SETTINGS_MANAGMENT_ACCESS,
WEBGUI_TOOLS_DOWNGRADE,

View File

@@ -30,6 +30,7 @@
"common.installed": "Installed",
"common.installing": "Installing",
"common.learnMore": "Learn More",
"common.retry": "Retry",
"common.loading2": "Loading…",
"common.success": "Success!",
"common.unknown": "Unknown",

View File

@@ -95,9 +95,7 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
renewStatus.value = status;
};
const replaceStatus = ref<'checking' | 'eligible' | 'error' | 'ineligible' | 'ready'>(
guid.value ? 'ready' : 'error'
);
const replaceStatus = ref<'checking' | 'eligible' | 'error' | 'ineligible' | 'ready'>('ready');
const setReplaceStatus = (status: typeof replaceStatus.value) => {
replaceStatus.value = status;
};
@@ -169,11 +167,15 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
const check = async (skipCache: boolean = false) => {
if (!guid.value) {
setReplaceStatus('error');
setKeyLinked('error');
error.value = { name: 'Error', message: 'Flash GUID required to check replacement status' };
return;
}
if (!keyfile.value) {
setReplaceStatus('error');
setKeyLinked('error');
error.value = { name: 'Error', message: 'Keyfile required to check replacement status' };
return;
}
try {
@@ -240,11 +242,32 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
} catch (err) {
const catchError = err as WretchError;
setReplaceStatus('error');
error.value = catchError?.message ? catchError : { name: 'Error', message: 'Unknown error' };
setKeyLinked('error');
let errorMessage = 'Unknown error';
if (catchError?.response?.status === 401) {
errorMessage = 'Authentication failed - please sign in again';
} else if (catchError?.response?.status === 403) {
errorMessage = 'Access denied - license may be linked to another account';
} else if (catchError?.response?.status && catchError.response.status >= 500) {
errorMessage = 'Key server temporarily unavailable - please try again later';
} else if (catchError?.message) {
errorMessage = catchError.message;
} else if (typeof navigator !== 'undefined' && !navigator.onLine) {
errorMessage = 'No internet connection';
}
error.value = { name: 'Error', message: errorMessage };
console.error('[ReplaceCheck.check]', catchError);
}
};
const reset = () => {
replaceStatus.value = 'ready';
keyLinkedStatus.value = 'ready';
error.value = null;
};
return {
// state
keyLinkedStatus,
@@ -255,6 +278,7 @@ export const useReplaceRenewStore = defineStore('replaceRenewCheck', () => {
// actions
check,
purgeValidationResponse,
reset,
setReplaceStatus,
setRenewStatus,
error,

View File

@@ -1,6 +1,7 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { WEBGUI_REDIRECT } from '~/helpers/urls';
import dayjs, { extend } from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import relativeTime from 'dayjs/plugin/relativeTime';
@@ -71,8 +72,9 @@ export const useUpdateOsStore = defineStore('updateOs', () => {
// fetchAndConfirmInstall logic
const callbackStore = useCallbackActionsStore();
const fetchAndConfirmInstall = (sha256: string) => {
const redirectUrl = new URL(WEBGUI_REDIRECT, window.location.origin).toString();
callbackStore.send(
window.location.href,
redirectUrl,
[
{
sha256,