<!-- 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

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`);

View File

@@ -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();
});
});
});

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;
}

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

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

View File

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