From 77bf4ffd052390cdf021eb3cb61990e1884fa566 Mon Sep 17 00:00:00 2001 From: Oli Passey Date: Sun, 21 Dec 2025 16:54:13 +0000 Subject: [PATCH] proxy backend --- .gitignore | 4 + BUILD.md | 136 +++ backend/package-lock.json | 922 +++++++++++++++++- backend/package.json | 22 +- .../migration.sql | 13 + backend/prisma/schema.prisma | 13 + backend/src/app.ts | 19 +- backend/src/routes/devices.ts | 109 +++ backend/src/routes/groups.ts | 28 +- backend/src/routes/quickActions.ts | 132 +++ backend/src/server.ts | 2 +- backend/src/services/deviceService.ts | 57 ++ backend/src/services/discoveryService.ts | 168 ++++ backend/src/services/groupService.ts | 69 +- backend/src/services/quickActionService.ts | 151 +++ backend/src/services/schedulerService.ts | 22 +- backend/src/wled/client.ts | 14 +- build.bat | 50 + frontend/src/App.tsx | 5 + frontend/src/api/client.ts | 8 +- frontend/src/api/devices.ts | 34 + frontend/src/api/groups.ts | 3 + frontend/src/api/quickActions.ts | 51 + frontend/src/components/QuickActionsPanel.tsx | 132 +++ .../src/components/ScheduleComponents.tsx | 32 +- frontend/src/pages/DashboardPage.tsx | 273 +++++- frontend/src/pages/DevicesPage.tsx | 251 ++++- frontend/src/pages/QuickActionsPage.tsx | 367 +++++++ frontend/src/pages/SchedulesPage.tsx | 1 - frontend/vite.config.ts | 1 + 30 files changed, 3010 insertions(+), 79 deletions(-) create mode 100644 BUILD.md create mode 100644 backend/prisma/migrations/20251216110220_add_quick_actions/migration.sql create mode 100644 backend/src/routes/quickActions.ts create mode 100644 backend/src/services/discoveryService.ts create mode 100644 backend/src/services/quickActionService.ts create mode 100644 build.bat create mode 100644 frontend/src/api/quickActions.ts create mode 100644 frontend/src/components/QuickActionsPanel.tsx create mode 100644 frontend/src/pages/QuickActionsPage.tsx diff --git a/.gitignore b/.gitignore index 078f14e..87be0e7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ dist/ *.db-journal .env .DS_Store + +# Build artifacts +/backend/public/ +/release/ diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..a9641ee --- /dev/null +++ b/BUILD.md @@ -0,0 +1,136 @@ +# Building WLED Controller for Windows + +This guide explains how to build a standalone Windows executable for WLED Controller. + +## Prerequisites + +- Node.js 20 or higher +- npm + +## Build Steps + +### 1. Install Dependencies + +First, install dependencies for both frontend and backend: + +```powershell +# Install backend dependencies +cd backend +npm install + +# Install frontend dependencies +cd ../frontend +npm install +``` + +### 2. Run Database Migrations + +Ensure your database is set up: + +```powershell +cd backend +npm run prisma:migrate +npm run prisma:generate +``` + +### 3. Build the Executable + +From the backend directory, run: + +```powershell +cd backend +npm run package +``` + +This command will: +1. Build the frontend and copy it to `backend/public` +2. Build the backend TypeScript code to JavaScript +3. Package everything into a single Windows executable + +The executable will be created at: `release/wled-controller.exe` + +## Running the Executable + +### First Time Setup + +1. Copy the `wled-controller.exe` file to your desired location +2. In the same directory, create a `prisma` folder +3. Copy the `backend/prisma/dev.db` file (your database) to the same directory as the exe + +Your directory structure should look like: +``` +your-install-folder/ +├── wled-controller.exe +└── prisma/ + └── dev.db +``` + +### Running + +Simply double-click `wled-controller.exe` or run from command line: + +```powershell +.\wled-controller.exe +``` + +The application will start on port 3000 (configurable via PORT environment variable). + +Access the web interface at: http://localhost:3000 + +## Configuration + +You can set environment variables before running: + +```powershell +# Set custom port +$env:PORT=8080 +.\wled-controller.exe + +# Set custom database path +$env:DATABASE_URL="file:./custom-path/wled.db" +.\wled-controller.exe +``` + +## Troubleshooting + +### Database Not Found + +If you see database errors, ensure: +- The `prisma` folder exists in the same directory as the exe +- The `dev.db` file is in the `prisma` folder +- File permissions allow reading/writing + +### Port Already in Use + +If port 3000 is already in use, set a different port: + +```powershell +$env:PORT=3001 +.\wled-controller.exe +``` + +### Missing Dependencies + +The executable includes all Node.js dependencies, but requires: +- Windows 10 or higher (64-bit) +- No additional runtime needed + +## Development vs Production + +- **Development**: Use `npm run dev` in both frontend and backend directories +- **Production Build**: Use `npm run package` to create the executable + +## File Sizes + +The packaged executable will be approximately 80-100 MB due to: +- Node.js runtime +- All npm dependencies +- Frontend static files +- Prisma binaries + +## Notes + +- The executable is self-contained and includes the Node.js runtime +- All frontend files are served from the built-in web server +- Database file can be backed up by copying the `dev.db` file +- Scheduler tasks will run automatically when the executable starts diff --git a/backend/package-lock.json b/backend/package-lock.json index 9905ac9..fd73988 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,13 +10,17 @@ "license": "MIT", "dependencies": { "@prisma/client": "^5.8.0", + "bonjour-service": "^1.3.0", "cors": "^2.8.5", "express": "^4.18.2", "luxon": "^3.4.4", "node-cron": "^3.0.3", - "p-limit": "^5.0.0", + "p-limit": "^4.0.0", "zod": "^3.22.4" }, + "bin": { + "wled-controller-backend": "dist/server.js" + }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", @@ -29,6 +33,7 @@ "eslint": "^8.56.0", "jest": "^29.7.0", "nodemon": "^3.1.11", + "pkg": "^5.8.1", "prettier": "^3.1.1", "prisma": "^5.8.0", "ts-jest": "^29.1.1", @@ -1627,6 +1632,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2315,6 +2326,19 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2431,6 +2455,16 @@ "node": ">=8" } }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2564,6 +2598,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.9.4", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", @@ -2587,6 +2642,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -2626,6 +2708,16 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -2706,6 +2798,31 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2857,6 +2974,13 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -2983,6 +3107,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -3058,6 +3189,22 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dedent": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", @@ -3073,6 +3220,16 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3109,6 +3266,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3152,6 +3319,18 @@ "node": ">=8" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3221,6 +3400,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3563,6 +3752,16 @@ "node": ">= 0.8.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -3645,7 +3844,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -3828,6 +4026,40 @@ "node": ">= 0.6" } }, + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3952,6 +4184,13 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -4096,6 +4335,16 @@ "uglify-js": "^3.1.4" } }, + "node_modules/has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4157,6 +4406,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4179,6 +4442,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4261,6 +4545,30 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/into-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-6.0.0.tgz", + "integrity": "sha512-XHbaOAvP+uFKUFsOgoNPRjLkwB+I22JFPFe5OjTkQ0nwgj6+pSjb4NmB6VMxaPshLiOf+zcpOCBQuLwC1KHhZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "from2": "^2.3.0", + "p-is-promise": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4382,6 +4690,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5204,6 +5519,19 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5447,6 +5775,19 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -5473,12 +5814,79 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/multistream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/multistream/-/multistream-4.1.0.tgz", + "integrity": "sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "once": "^1.4.0", + "readable-stream": "^3.6.0" + } + }, + "node_modules/multistream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5502,6 +5910,19 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-cron": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", @@ -5514,6 +5935,27 @@ "node": ">=6.0.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5704,16 +6146,26 @@ "node": ">= 0.8.0" } }, + "node_modules/p-is-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", + "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", "license": "MIT", "dependencies": { "yocto-queue": "^1.0.0" }, "engines": { - "node": ">=18" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5898,6 +6350,40 @@ "node": ">= 6" } }, + "node_modules/pkg": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/pkg/-/pkg-5.8.1.tgz", + "integrity": "sha512-CjBWtFStCfIiT4Bde9QpJy0KeH19jCfwZRJqHFDFXfhUklCx8JoFmMj3wgnEYIwGmZVNkhsStPHEOnrtrQhEXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "7.18.2", + "@babel/parser": "7.18.4", + "@babel/types": "7.19.0", + "chalk": "^4.1.2", + "fs-extra": "^9.1.0", + "globby": "^11.1.0", + "into-stream": "^6.0.0", + "is-core-module": "2.9.0", + "minimist": "^1.2.6", + "multistream": "^4.1.0", + "pkg-fetch": "3.4.2", + "prebuild-install": "7.1.1", + "resolve": "^1.22.0", + "stream-meter": "^1.0.4" + }, + "bin": { + "pkg": "lib-es5/bin.js" + }, + "peerDependencies": { + "node-notifier": ">=9.0.1" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -5967,6 +6453,163 @@ "node": ">=8" } }, + "node_modules/pkg-fetch": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/pkg-fetch/-/pkg-fetch-3.4.2.tgz", + "integrity": "sha512-0+uijmzYcnhC0hStDjm/cl2VYdrmVVBpe7Q8k9YBojxmR5tG8mvR9/nooQq3QSXiQqORDVOTY3XqMEqJVIzkHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "fs-extra": "^9.1.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.6", + "progress": "^2.0.3", + "semver": "^7.3.5", + "tar-fs": "^2.1.1", + "yargs": "^16.2.0" + }, + "bin": { + "pkg-fetch": "lib-es5/bin.js" + } + }, + "node_modules/pkg-fetch/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/pkg-fetch/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pkg-fetch/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/pkg/node_modules/@babel/generator": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.18.2.tgz", + "integrity": "sha512-W1lG5vUwFvfMd8HVXqdfbuG7RuaSrTCCD8cl8fP8wOivdbtbIg2Db3IWUcgvfxKbbn6ZBGYRW/Zk1MIwK49mgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.18.2", + "@jridgewell/gen-mapping": "^0.3.0", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/pkg/node_modules/@babel/parser": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.18.4.tgz", + "integrity": "sha512-FDge0dFazETFcxGw/EXzOkN8uJp0PC7Qbm+Pe9T+av2zlBpOgunFHkQPPn+eRuClU73JF+98D531UgayY89tow==", + "dev": true, + "license": "MIT", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pkg/node_modules/@babel/types": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.0.tgz", + "integrity": "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.18.10", + "@babel/helper-validator-identifier": "^7.18.6", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/pkg/node_modules/is-core-module": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", + "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/pkg/node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", + "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6041,6 +6684,23 @@ "fsevents": "2.3.3" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6075,6 +6735,17 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6162,6 +6833,32 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -6169,6 +6866,29 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -6617,6 +7337,53 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -6707,6 +7474,33 @@ "node": ">= 0.8" } }, + "node_modules/stream-meter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/stream-meter/-/stream-meter-1.0.4.tgz", + "integrity": "sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.1.4" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -6808,6 +7602,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6854,6 +7693,12 @@ "dev": true, "license": "MIT" }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6861,6 +7706,16 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6893,6 +7748,13 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -7036,6 +7898,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7127,6 +8002,16 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -7177,6 +8062,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -7236,6 +8128,24 @@ "makeerror": "1.0.12" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index ee5234a..c8c15ce 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,9 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", + "build:frontend": "cd ../frontend && npm run build && xcopy /E /I /Y dist ..\\backend\\public", + "build:full": "npm run build:frontend && npm run build", + "package": "npm run build:full && pkg . --targets node18-win-x64 --output ../release/wled-controller.exe", "test": "jest", "lint": "eslint src --ext .ts", "format": "prettier --write \"src/**/*.ts\"" @@ -23,11 +26,12 @@ "license": "MIT", "dependencies": { "@prisma/client": "^5.8.0", + "bonjour-service": "^1.3.0", "cors": "^2.8.5", "express": "^4.18.2", "luxon": "^3.4.4", "node-cron": "^3.0.3", - "p-limit": "^5.0.0", + "p-limit": "^4.0.0", "zod": "^3.22.4" }, "devDependencies": { @@ -47,6 +51,20 @@ "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "tsx": "^4.7.0", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "pkg": "^5.8.1" + }, + "bin": "dist/server.js", + "pkg": { + "assets": [ + "prisma/schema.prisma", + "node_modules/@prisma/client/**/*", + "node_modules/.prisma/**/*", + "public/**/*" + ], + "outputPath": "../release", + "targets": [ + "node18-win-x64" + ] } } diff --git a/backend/prisma/migrations/20251216110220_add_quick_actions/migration.sql b/backend/prisma/migrations/20251216110220_add_quick_actions/migration.sql new file mode 100644 index 0000000..c99512c --- /dev/null +++ b/backend/prisma/migrations/20251216110220_add_quick_actions/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "QuickAction" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "icon" TEXT, + "groupId" TEXT, + "deviceId" TEXT, + "actionType" TEXT NOT NULL, + "actionPayload" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8c18381..8b304e0 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -55,3 +55,16 @@ model Schedule { group Group @relation(fields: [groupId], references: [id]) } + +model QuickAction { + id String @id @default(uuid()) + name String + icon String? // Optional icon/emoji + groupId String? // Optional: if action targets a group + deviceId String? // Optional: if action targets a device + actionType String // 'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS' + actionPayload String // JSON string + order Int @default(0) // For sorting + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/backend/src/app.ts b/backend/src/app.ts index bdb913e..c669c7d 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,8 +1,10 @@ import express from 'express'; import cors from 'cors'; +import path from 'path'; import devicesRouter from './routes/devices'; import groupsRouter from './routes/groups'; import schedulesRouter from './routes/schedules'; +import quickActionsRouter from './routes/quickActions'; export function createApp() { const app = express(); @@ -17,19 +19,28 @@ export function createApp() { next(); }); - // Routes + // API Routes app.use('/api/devices', devicesRouter); app.use('/api/groups', groupsRouter); app.use('/api/schedules', schedulesRouter); + app.use('/api/quick-actions', quickActionsRouter); // Health check app.get('/health', (req, res) => { res.json({ status: 'ok' }); }); - // 404 handler - app.use((req, res) => { - res.status(404).json({ error: 'NotFound', message: 'Route not found' }); + // Serve static frontend files in production + const frontendPath = path.join(__dirname, '..', 'public'); + app.use(express.static(frontendPath)); + + // Serve index.html for all non-API routes (SPA support) + app.get('*', (req, res) => { + if (!req.path.startsWith('/api')) { + res.sendFile(path.join(frontendPath, 'index.html')); + } else { + res.status(404).json({ error: 'NotFound', message: 'API route not found' }); + } }); return app; diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 8317332..0b4816b 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from 'express'; import { z } from 'zod'; import { deviceService } from '../services/deviceService'; +import { discoveryService } from '../services/discoveryService'; const router = Router(); @@ -202,4 +203,112 @@ router.post('/all/turn-off', async (req: Request, res: Response) => { } }); +// POST /api/devices/:id/sync +router.post('/:id/sync', async (req: Request, res: Response) => { + try { + const { targetIds } = z.object({ + targetIds: z.array(z.string()).min(1, 'At least one target device is required'), + }).parse(req.body); + + const result = await deviceService.syncDeviceState(req.params.id, targetIds); + res.json(result); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ + error: 'ValidationError', + details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })), + }); + } + if (error instanceof Error && error.message.includes('not found')) { + return res.status(404).json({ error: 'NotFound', message: error.message }); + } + console.error('Error syncing device state:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to sync device state' }); + } +}); + +// POST /api/devices/:sourceId/copy-to/:targetId +router.post('/:sourceId/copy-to/:targetId', async (req: Request, res: Response) => { + try { + await deviceService.copyDeviceConfig(req.params.sourceId, req.params.targetId); + res.json({ status: 'ok', message: 'Configuration copied successfully' }); + } catch (error) { + if (error instanceof Error && error.message.includes('not found')) { + return res.status(404).json({ error: 'NotFound', message: error.message }); + } + console.error('Error copying device config:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to copy configuration' }); + } +}); + +// POST /api/devices/discover/mdns +router.post('/discover/mdns', async (req: Request, res: Response) => { + try { + const { timeout } = z.object({ + timeout: z.number().int().positive().optional(), + }).parse(req.body); + + const discoveredDevices = await discoveryService.discoverDevices(timeout); + res.json({ devices: discoveredDevices, count: discoveredDevices.length }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ + error: 'ValidationError', + details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })), + }); + } + console.error('Error discovering devices:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to discover devices' }); + } +}); + +// POST /api/devices/discover/scan +router.post('/discover/scan', async (req: Request, res: Response) => { + try { + const { baseIp, startRange, endRange } = z.object({ + baseIp: z.string().regex(/^\d{1,3}\.\d{1,3}\.\d{1,3}$/, 'Invalid base IP format (e.g., 192.168.1)'), + startRange: z.number().int().min(1).max(254).optional(), + endRange: z.number().int().min(1).max(254).optional(), + }).parse(req.body); + + const discoveredDevices = await discoveryService.scanIpRange(baseIp, startRange, endRange); + res.json({ devices: discoveredDevices, count: discoveredDevices.length }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ + error: 'ValidationError', + details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })), + }); + } + console.error('Error scanning IP range:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to scan IP range' }); + } +}); + +// POST /api/devices/discover/verify +router.post('/discover/verify', async (req: Request, res: Response) => { + try { + const { ipAddress, port } = z.object({ + ipAddress: z.string().min(1, 'IP address is required'), + port: z.number().int().positive().optional(), + }).parse(req.body); + + const device = await discoveryService.verifyDevice(ipAddress, port); + if (device) { + res.json({ found: true, device }); + } else { + res.json({ found: false, message: 'No WLED device found at this address' }); + } + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ + error: 'ValidationError', + details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })), + }); + } + console.error('Error verifying device:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to verify device' }); + } +}); + export default router; diff --git a/backend/src/routes/groups.ts b/backend/src/routes/groups.ts index cca4001..b366b5c 100644 --- a/backend/src/routes/groups.ts +++ b/backend/src/routes/groups.ts @@ -38,6 +38,20 @@ router.get('/', async (req: Request, res: Response) => { } }); +// GET /api/groups/:id/presets - Must come before /:id to avoid matching +router.get('/:id/presets', async (req: Request, res: Response) => { + try { + const presets = await groupService.getGroupPresets(req.params.id); + res.json(presets); + } catch (error) { + if (error instanceof Error && error.message === 'Group not found') { + return res.status(404).json({ error: 'NotFound', message: 'Group not found' }); + } + console.error('Error fetching group presets:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to fetch presets' }); + } +}); + // GET /api/groups/:id router.get('/:id', async (req: Request, res: Response) => { try { @@ -156,18 +170,4 @@ router.post('/:id/playlist', async (req: Request, res: Response) => { } }); -// GET /api/groups/:id/presets -router.get('/:id/presets', async (req: Request, res: Response) => { - try { - const presets = await groupService.getGroupPresets(req.params.id); - res.json(presets); - } catch (error) { - if (error instanceof Error && error.message === 'Group not found') { - return res.status(404).json({ error: 'NotFound', message: 'Group not found' }); - } - console.error('Error fetching group presets:', error); - res.status(500).json({ error: 'InternalError', message: 'Failed to fetch presets' }); - } -}); - export default router; diff --git a/backend/src/routes/quickActions.ts b/backend/src/routes/quickActions.ts new file mode 100644 index 0000000..4a753db --- /dev/null +++ b/backend/src/routes/quickActions.ts @@ -0,0 +1,132 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { quickActionService, QuickActionType } from '../services/quickActionService'; + +const router = Router(); + +const createQuickActionSchema = z.object({ + name: z.string().min(1, 'Name is required'), + icon: z.string().optional(), + groupId: z.string().optional(), + deviceId: z.string().optional(), + actionType: z.enum(['PRESET', 'PLAYLIST', 'TURN_ON', 'TURN_OFF', 'BRIGHTNESS']), + actionPayload: z.unknown().optional(), + order: z.number().int().optional(), +}); + +const updateQuickActionSchema = z.object({ + name: z.string().min(1).optional(), + icon: z.string().optional(), + groupId: z.string().optional(), + deviceId: z.string().optional(), + actionType: z.enum(['PRESET', 'PLAYLIST', 'TURN_ON', 'TURN_OFF', 'BRIGHTNESS']).optional(), + actionPayload: z.unknown().optional(), + order: z.number().int().optional(), +}); + +// GET /api/quick-actions +router.get('/', async (req: Request, res: Response) => { + try { + const actions = await quickActionService.getAllQuickActions(); + const actionsWithParsedPayload = actions.map((a) => ({ + ...a, + actionPayload: quickActionService.parseActionPayload(a), + })); + res.json(actionsWithParsedPayload); + } catch (error) { + console.error('Error fetching quick actions:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to fetch quick actions' }); + } +}); + +// GET /api/quick-actions/:id +router.get('/:id', async (req: Request, res: Response) => { + try { + const action = await quickActionService.getQuickActionById(req.params.id); + if (!action) { + return res.status(404).json({ error: 'NotFound', message: 'Quick action not found' }); + } + res.json({ + ...action, + actionPayload: quickActionService.parseActionPayload(action), + }); + } catch (error) { + console.error('Error fetching quick action:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to fetch quick action' }); + } +}); + +// POST /api/quick-actions +router.post('/', async (req: Request, res: Response) => { + try { + const body = createQuickActionSchema.parse(req.body); + const action = await quickActionService.createQuickAction(body); + res.status(201).json({ + ...action, + actionPayload: quickActionService.parseActionPayload(action), + }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ + error: 'ValidationError', + details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })), + }); + } + console.error('Error creating quick action:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to create quick action' }); + } +}); + +// PUT /api/quick-actions/:id +router.put('/:id', async (req: Request, res: Response) => { + try { + const body = updateQuickActionSchema.parse(req.body); + const action = await quickActionService.updateQuickAction(req.params.id, body); + res.json({ + ...action, + actionPayload: quickActionService.parseActionPayload(action), + }); + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ + error: 'ValidationError', + details: error.errors.map((e) => ({ field: e.path.join('.'), message: e.message })), + }); + } + if (error instanceof Error && error.message.includes('Record to update not found')) { + return res.status(404).json({ error: 'NotFound', message: 'Quick action not found' }); + } + console.error('Error updating quick action:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to update quick action' }); + } +}); + +// DELETE /api/quick-actions/:id +router.delete('/:id', async (req: Request, res: Response) => { + try { + await quickActionService.deleteQuickAction(req.params.id); + res.status(204).send(); + } catch (error) { + if (error instanceof Error && error.message.includes('Record to delete does not exist')) { + return res.status(404).json({ error: 'NotFound', message: 'Quick action not found' }); + } + console.error('Error deleting quick action:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to delete quick action' }); + } +}); + +// POST /api/quick-actions/:id/execute +router.post('/:id/execute', async (req: Request, res: Response) => { + try { + const result = await quickActionService.executeQuickAction(req.params.id); + res.json(result); + } catch (error) { + if (error instanceof Error && error.message === 'Quick action not found') { + return res.status(404).json({ error: 'NotFound', message: 'Quick action not found' }); + } + console.error('Error executing quick action:', error); + res.status(500).json({ error: 'InternalError', message: 'Failed to execute quick action' }); + } +}); + +export default router; diff --git a/backend/src/server.ts b/backend/src/server.ts index 2b3c939..0e2c079 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -14,7 +14,7 @@ async function main() { const app = createApp(); // Start server - const server = app.listen(config.port, () => { + const server = app.listen(config.port, '0.0.0.0', () => { console.log(`Server running on port ${config.port}`); console.log(`Health check: http://localhost:${config.port}/health`); }); diff --git a/backend/src/services/deviceService.ts b/backend/src/services/deviceService.ts index 7a164d9..aa7d621 100644 --- a/backend/src/services/deviceService.ts +++ b/backend/src/services/deviceService.ts @@ -210,6 +210,63 @@ export class DeviceService { return { success, failed }; } + async syncDeviceState(sourceId: string, targetIds: string[]): Promise<{ success: string[]; failed: Array<{ deviceId: string; error: string }> }> { + const sourceDevice = await this.getDeviceById(sourceId); + if (!sourceDevice) { + throw new Error('Source device not found'); + } + + // Get current state from source device + const sourceClient = new WledClient(sourceDevice.ipAddress, sourceDevice.port); + const sourceState = await sourceClient.getState(); + + const results = await Promise.allSettled( + targetIds.map(async (targetId) => { + const targetDevice = await this.getDeviceById(targetId); + if (!targetDevice) { + throw new Error('Target device not found'); + } + const targetClient = new WledClient(targetDevice.ipAddress, targetDevice.port); + await targetClient.applyState(sourceState); + return targetId; + }) + ); + + const success: string[] = []; + const failed: Array<{ deviceId: string; error: string }> = []; + + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + success.push(result.value); + } else { + failed.push({ + deviceId: targetIds[index], + error: result.reason?.message || 'Unknown error' + }); + } + }); + + return { success, failed }; + } + + async copyDeviceConfig(sourceId: string, targetId: string): Promise { + const sourceDevice = await this.getDeviceById(sourceId); + const targetDevice = await this.getDeviceById(targetId); + + if (!sourceDevice) throw new Error('Source device not found'); + if (!targetDevice) throw new Error('Target device not found'); + + const sourceClient = new WledClient(sourceDevice.ipAddress, sourceDevice.port); + const [sourceState, sourcePresets] = await Promise.all([ + sourceClient.getState(), + sourceClient.getPresets(), + ]); + + const targetClient = new WledClient(targetDevice.ipAddress, targetDevice.port); + await targetClient.applyState(sourceState); + // Note: Preset copying would require WLED API support for uploading presets + } + async updateLastSeen(id: string): Promise { await prisma.device.update({ where: { id }, diff --git a/backend/src/services/discoveryService.ts b/backend/src/services/discoveryService.ts new file mode 100644 index 0000000..773fa56 --- /dev/null +++ b/backend/src/services/discoveryService.ts @@ -0,0 +1,168 @@ +import Bonjour from 'bonjour-service'; +import { WledClient } from '../wled/client'; + +export interface DiscoveredDevice { + name: string; + ipAddress: string; + port: number; + host: string; + type: string; +} + +export class DiscoveryService { + private bonjour: Bonjour; + + constructor() { + this.bonjour = new Bonjour(); + } + + /** + * Discover WLED devices on the network using mDNS + * @param timeout - How long to search for devices (in milliseconds) + * @returns Array of discovered devices + */ + async discoverDevices(timeout: number = 5000): Promise { + return new Promise((resolve) => { + const discoveredDevices: DiscoveredDevice[] = []; + const seenAddresses = new Set(); + + // Search for WLED devices using mDNS service discovery + // WLED devices advertise themselves as _http._tcp + const browser = this.bonjour.find({ type: 'http' }); + + browser.on('up', (service) => { + // WLED devices typically have "wled" in their name or txt records + const isWled = + service.name?.toLowerCase().includes('wled') || + service.txt?.ver?.startsWith('0.') || // WLED version format + service.txt?.name !== undefined; + + if (isWled && service.addresses && service.addresses.length > 0) { + // Use the first IPv4 address + const ipv4Address = service.addresses.find((addr: string) => + addr.includes('.') && !addr.startsWith('169.254') + ); + + if (ipv4Address && !seenAddresses.has(ipv4Address)) { + seenAddresses.add(ipv4Address); + + discoveredDevices.push({ + name: service.name || service.host || 'Unknown WLED Device', + ipAddress: ipv4Address, + port: service.port || 80, + host: service.host || ipv4Address, + type: service.type || 'http', + }); + } + } + }); + + // Stop searching after timeout + setTimeout(() => { + browser.stop(); + resolve(discoveredDevices); + }, timeout); + }); + } + + /** + * Scan a range of IP addresses for WLED devices + * This is a fallback method when mDNS doesn't work + * @param baseIp - Base IP address (e.g., "192.168.1") + * @param startRange - Start of range (e.g., 1) + * @param endRange - End of range (e.g., 254) + * @returns Array of discovered devices + */ + async scanIpRange( + baseIp: string, + startRange: number = 1, + endRange: number = 254 + ): Promise { + const discoveredDevices: DiscoveredDevice[] = []; + const promises: Promise[] = []; + + for (let i = startRange; i <= endRange; i++) { + const ipAddress = `${baseIp}.${i}`; + + promises.push( + (async () => { + try { + const client = new WledClient(ipAddress, 80); + const info = await Promise.race([ + client.getInfo(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 2000) + ), + ]) as any; + + if (info && info.name) { + discoveredDevices.push({ + name: info.name, + ipAddress, + port: 80, + host: ipAddress, + type: 'wled', + }); + } + } catch (err) { + // Device not found or not responding, ignore + } + })() + ); + + // Process in batches of 10 to avoid overwhelming the network + if (promises.length >= 10) { + await Promise.all(promises); + promises.length = 0; + } + } + + // Wait for remaining promises + if (promises.length > 0) { + await Promise.all(promises); + } + + return discoveredDevices; + } + + /** + * Verify a device at a specific IP address is a WLED device + * @param ipAddress - IP address to check + * @param port - Port number (default: 80) + * @returns Device info if it's a WLED device, null otherwise + */ + async verifyDevice( + ipAddress: string, + port: number = 80 + ): Promise { + try { + const client = new WledClient(ipAddress, port); + const info = await Promise.race([ + client.getInfo(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), 3000) + ), + ]) as any; + + if (info && info.name) { + return { + name: info.name, + ipAddress, + port, + host: ipAddress, + type: 'wled', + }; + } + + return null; + } catch (err) { + return null; + } + } + + destroy() { + this.bonjour.destroy(); + } +} + +export const discoveryService = new DiscoveryService(); diff --git a/backend/src/services/groupService.ts b/backend/src/services/groupService.ts index 5bb75c9..0244b50 100644 --- a/backend/src/services/groupService.ts +++ b/backend/src/services/groupService.ts @@ -274,24 +274,77 @@ export class GroupService { }; } + async turnOnGroup(groupId: string): Promise { + const group = await this.getGroupById(groupId); + if (!group) { + throw new Error('Group not found'); + } + + const enabledDevices = group.devices.filter((d) => d.enabled); + const limit = pLimit(10); + + const results = await Promise.allSettled( + enabledDevices.map((device) => + limit(async () => { + const client = new WledClient(device.ipAddress, device.port); + await client.turnOn(); + return device.id; + }) + ) + ); + + const success: string[] = []; + const failed: Array<{ deviceId: string; error: string }> = []; + + results.forEach((result, index) => { + const deviceId = enabledDevices[index].id; + if (result.status === 'fulfilled') { + success.push(deviceId); + } else { + failed.push({ deviceId, error: result.reason?.message || 'Unknown error' }); + } + }); + + return { + status: 'ok', + groupId, + results: { success, failed }, + }; + } + async getGroupPresets(groupId: string): Promise { const group = await this.getGroupById(groupId); if (!group) { throw new Error('Group not found'); } - // Get presets from the first enabled device in the group - const firstEnabledDevice = group.devices.find(d => d.enabled); - if (!firstEnabledDevice) { + console.log(`[getGroupPresets] Group ${groupId} has ${group.devices.length} devices`); + + // Try to get presets from enabled devices + const enabledDevices = group.devices.filter(d => d.enabled); + if (enabledDevices.length === 0) { + console.log('[getGroupPresets] No enabled devices in group'); return {}; } - const client = new WledClient(firstEnabledDevice.ipAddress, firstEnabledDevice.port); - try { - return await client.getPresets(); - } catch { - return {}; + // Try each enabled device until one succeeds + for (const device of enabledDevices) { + console.log(`[getGroupPresets] Trying device ${device.name} (${device.ipAddress}:${device.port})`); + + const client = new WledClient(device.ipAddress, device.port); + try { + const presets = await client.getPresets(); + console.log(`[getGroupPresets] Successfully got presets from ${device.name}:`, Object.keys(presets).length, 'presets'); + return presets; + } catch (error) { + console.log(`[getGroupPresets] Failed to get presets from ${device.name}:`, error instanceof Error ? error.message : 'Unknown error'); + // Continue to next device + } } + + // If all devices failed, return empty + console.log('[getGroupPresets] All devices failed to respond'); + return {}; } } diff --git a/backend/src/services/quickActionService.ts b/backend/src/services/quickActionService.ts new file mode 100644 index 0000000..be1e9d3 --- /dev/null +++ b/backend/src/services/quickActionService.ts @@ -0,0 +1,151 @@ +import { QuickAction } from '@prisma/client'; +import { prisma } from '../utils/prisma'; +import { deviceService } from './deviceService'; +import { groupService } from './groupService'; + +export type QuickActionType = 'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS'; + +export interface CreateQuickActionInput { + name: string; + icon?: string; + groupId?: string; + deviceId?: string; + actionType: QuickActionType; + actionPayload?: unknown; + order?: number; +} + +export interface UpdateQuickActionInput { + name?: string; + icon?: string; + groupId?: string; + deviceId?: string; + actionType?: QuickActionType; + actionPayload?: unknown; + order?: number; +} + +export class QuickActionService { + async getAllQuickActions(): Promise { + return prisma.quickAction.findMany({ + orderBy: { order: 'asc' }, + }); + } + + async getQuickActionById(id: string): Promise { + return prisma.quickAction.findUnique({ + where: { id }, + }); + } + + async createQuickAction(input: CreateQuickActionInput): Promise { + return prisma.quickAction.create({ + data: { + name: input.name, + icon: input.icon, + groupId: input.groupId, + deviceId: input.deviceId, + actionType: input.actionType, + actionPayload: JSON.stringify(input.actionPayload || {}), + order: input.order ?? 0, + }, + }); + } + + async updateQuickAction(id: string, input: UpdateQuickActionInput): Promise { + const updateData: Record = {}; + if (input.name !== undefined) updateData.name = input.name; + if (input.icon !== undefined) updateData.icon = input.icon; + if (input.groupId !== undefined) updateData.groupId = input.groupId; + if (input.deviceId !== undefined) updateData.deviceId = input.deviceId; + if (input.actionType !== undefined) updateData.actionType = input.actionType; + if (input.actionPayload !== undefined) updateData.actionPayload = JSON.stringify(input.actionPayload); + if (input.order !== undefined) updateData.order = input.order; + + return prisma.quickAction.update({ + where: { id }, + data: updateData, + }); + } + + async deleteQuickAction(id: string): Promise { + await prisma.quickAction.delete({ + where: { id }, + }); + } + + async executeQuickAction(id: string): Promise<{ status: string; message: string }> { + const action = await this.getQuickActionById(id); + if (!action) { + throw new Error('Quick action not found'); + } + + const payload = JSON.parse(action.actionPayload); + + switch (action.actionType) { + case 'TURN_ON': + if (action.deviceId) { + await deviceService.turnOnDevice(action.deviceId); + } else if (action.groupId) { + await groupService.turnOnGroup(action.groupId); + } + return { status: 'ok', message: 'Turned on successfully' }; + + case 'TURN_OFF': + if (action.deviceId) { + await deviceService.turnOffDevice(action.deviceId); + } else if (action.groupId) { + await groupService.turnOffGroup(action.groupId); + } + return { status: 'ok', message: 'Turned off successfully' }; + + case 'PRESET': + if (action.groupId) { + await groupService.applyPresetToGroup(action.groupId, payload.presetId); + } else if (action.deviceId) { + const device = await deviceService.getDeviceById(action.deviceId); + if (!device) throw new Error('Device not found'); + const client = await import('../wled/client'); + const wledClient = new client.WledClient(device.ipAddress, device.port); + await wledClient.applyPreset(payload.presetId); + } + return { status: 'ok', message: 'Preset applied successfully' }; + + case 'PLAYLIST': + if (action.groupId) { + await groupService.applyPlaylistToGroup(action.groupId, payload); + } + return { status: 'ok', message: 'Playlist applied successfully' }; + + case 'BRIGHTNESS': + if (action.deviceId) { + const device = await deviceService.getDeviceById(action.deviceId); + if (!device) throw new Error('Device not found'); + const client = await import('../wled/client'); + const wledClient = new client.WledClient(device.ipAddress, device.port); + await wledClient.setBrightness(payload.brightness); + } else if (action.groupId) { + // Apply brightness to all devices in group + const group = await groupService.getGroupById(action.groupId); + if (!group) throw new Error('Group not found'); + await Promise.all( + group.devices.map(async (device) => { + const client = await import('../wled/client'); + const wledClient = new client.WledClient(device.ipAddress, device.port); + await wledClient.setBrightness(payload.brightness); + }) + ); + } + return { status: 'ok', message: 'Brightness set successfully' }; + + default: + throw new Error('Unknown action type'); + } + } + + parseActionPayload(action: QuickAction): unknown { + return JSON.parse(action.actionPayload); + } +} + +export const quickActionService = new QuickActionService(); diff --git a/backend/src/services/schedulerService.ts b/backend/src/services/schedulerService.ts index 3283957..6846fda 100644 --- a/backend/src/services/schedulerService.ts +++ b/backend/src/services/schedulerService.ts @@ -30,9 +30,20 @@ export class SchedulerService { const schedule = await scheduleService.getScheduleById(scheduleId); if (!schedule || !schedule.enabled) { + console.log(`[registerSchedule] Schedule ${scheduleId} is ${!schedule ? 'not found' : 'disabled'}`); return; } + console.log(`[registerSchedule] Registering schedule:`, { + id: schedule.id, + name: schedule.name, + type: schedule.type, + cron: schedule.cronExpression, + endCron: schedule.endCronExpression, + timezone: schedule.timezone, + actionPayload: schedule.actionPayload + }); + try { // Register start task const startTask = cron.schedule( @@ -101,7 +112,16 @@ export class SchedulerService { return; } + console.log(`[executeSchedule] Schedule details:`, { + id: schedule.id, + name: schedule.name, + type: schedule.type, + groupId: schedule.groupId, + actionPayload: schedule.actionPayload + }); + const actionPayload = scheduleService.parseActionPayload(schedule); + console.log(`[executeSchedule] Parsed action payload:`, actionPayload); if (schedule.type === 'PRESET') { const payload = actionPayload as PresetActionPayload; @@ -110,7 +130,7 @@ export class SchedulerService { console.log(`Preset applied. Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`); } else if (schedule.type === 'PLAYLIST') { const payload = actionPayload as WledPlaylist; - console.log(`Applying playlist to group ${schedule.groupId}`); + console.log(`Applying playlist to group ${schedule.groupId}`, payload); const result = await groupService.applyPlaylistToGroup(schedule.groupId, payload); console.log(`Playlist applied. Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`); } diff --git a/backend/src/wled/client.ts b/backend/src/wled/client.ts index dde92dd..a0802b4 100644 --- a/backend/src/wled/client.ts +++ b/backend/src/wled/client.ts @@ -37,12 +37,12 @@ export class WledClient { async applyPreset(presetId: number): Promise { const url = `${this.baseUrl}/json/state`; - await this.post(url, { ps: presetId }); + await this.post(url, { ps: presetId, on: true }); } async applyPlaylist(playlist: WledPlaylist): Promise { const url = `${this.baseUrl}/json/state`; - await this.post(url, { playlist }); + await this.post(url, { playlist, on: true }); } async turnOn(): Promise { @@ -55,6 +55,16 @@ export class WledClient { await this.post(url, { on: false }); } + async applyState(state: Partial): Promise { + const url = `${this.baseUrl}/json/state`; + await this.post(url, state); + } + + async setBrightness(brightness: number): Promise { + const url = `${this.baseUrl}/json/state`; + await this.post(url, { bri: brightness }); + } + private async fetch(url: string): Promise { try { const response = await fetch(url, { diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..86ab11e --- /dev/null +++ b/build.bat @@ -0,0 +1,50 @@ +@echo off +echo Building WLED Controller for Windows... +echo. + +cd backend + +echo [1/4] Installing backend dependencies... +call npm install +if errorlevel 1 goto error + +echo. +echo [2/4] Installing frontend dependencies... +cd ..\frontend +call npm install +if errorlevel 1 goto error + +echo. +echo [3/4] Running Prisma generate... +cd ..\backend +call npm run prisma:generate +if errorlevel 1 goto error + +echo. +echo [4/4] Building and packaging executable... +call npm run package +if errorlevel 1 goto error + +echo. +echo ======================================== +echo Build complete! +echo Executable location: release\wled-controller.exe +echo ======================================== +echo. +echo To run: +echo 1. Copy release\wled-controller.exe to your desired location +echo 2. Copy backend\prisma\dev.db to the same folder (create prisma subfolder) +echo 3. Run wled-controller.exe +echo. +pause +goto end + +:error +echo. +echo ======================================== +echo Build failed! Please check the errors above. +echo ======================================== +pause +exit /b 1 + +:end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 95130be..cb88795 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { DashboardPage } from './pages/DashboardPage'; import { DevicesPage } from './pages/DevicesPage'; import { GroupsPage } from './pages/GroupsPage'; import { SchedulesPage } from './pages/SchedulesPage'; +import { QuickActionsPage } from './pages/QuickActionsPage'; import './App.css'; function App() { @@ -25,6 +26,9 @@ function App() {
  • Schedules
  • +
  • + Quick Actions +
  • @@ -36,6 +40,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7a15ab0..54e0761 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,10 @@ -const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'; +// Use relative path for API calls - works for both local and network access +// When accessed from network, API calls will go to same host as frontend +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ( + import.meta.env.DEV + ? '/api' // In dev mode, use Vite proxy + : '/api' // In production, use relative path (backend serves frontend) +); export class ApiError extends Error { constructor( diff --git a/frontend/src/api/devices.ts b/frontend/src/api/devices.ts index 0dc413e..84ac298 100644 --- a/frontend/src/api/devices.ts +++ b/frontend/src/api/devices.ts @@ -15,6 +15,25 @@ export interface UpdateDeviceInput { enabled?: boolean; } +export interface DiscoveredDevice { + name: string; + ipAddress: string; + port: number; + host: string; + type: string; +} + +export interface DiscoveryResult { + devices: DiscoveredDevice[]; + count: number; +} + +export interface VerifyResult { + found: boolean; + device?: DiscoveredDevice; + message?: string; +} + export const deviceApi = { getAll: () => apiClient.get('/devices'), @@ -36,4 +55,19 @@ export const deviceApi = { turnOnAll: () => apiClient.post('/devices/all/turn-on'), turnOffAll: () => apiClient.post('/devices/all/turn-off'), + + syncState: (sourceId: string, targetIds: string[]) => + apiClient.post(`/devices/${sourceId}/sync`, { targetIds }), + + copyConfig: (sourceId: string, targetId: string) => + apiClient.post(`/devices/${sourceId}/copy-to/${targetId}`), + + discoverMdns: (timeout?: number) => + apiClient.post('/devices/discover/mdns', { timeout }), + + scanIpRange: (baseIp: string, startRange?: number, endRange?: number) => + apiClient.post('/devices/discover/scan', { baseIp, startRange, endRange }), + + verifyDevice: (ipAddress: string, port?: number) => + apiClient.post('/devices/discover/verify', { ipAddress, port }), }; diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts index 9003064..d876c7c 100644 --- a/frontend/src/api/groups.ts +++ b/frontend/src/api/groups.ts @@ -28,4 +28,7 @@ export const groupApi = { applyPlaylist: (groupId: string, playlist: PlaylistActionPayload) => apiClient.post(`/groups/${groupId}/playlist`, playlist), + + getPresets: (groupId: string) => + apiClient.get>(`/groups/${groupId}/presets`), }; diff --git a/frontend/src/api/quickActions.ts b/frontend/src/api/quickActions.ts new file mode 100644 index 0000000..20aa5fc --- /dev/null +++ b/frontend/src/api/quickActions.ts @@ -0,0 +1,51 @@ +import { apiClient } from './client'; + +export type QuickActionType = 'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS'; + +export interface QuickAction { + id: string; + name: string; + icon?: string; + groupId?: string; + deviceId?: string; + actionType: QuickActionType; + actionPayload: unknown; + order: number; + createdAt: string; + updatedAt: string; +} + +export interface CreateQuickActionInput { + name: string; + icon?: string; + groupId?: string; + deviceId?: string; + actionType: QuickActionType; + actionPayload: unknown; + order?: number; +} + +export interface UpdateQuickActionInput { + name?: string; + icon?: string; + groupId?: string; + deviceId?: string; + actionType?: QuickActionType; + actionPayload?: unknown; + order?: number; +} + +export const quickActionApi = { + getAll: () => apiClient.get('/quick-actions'), + + getById: (id: string) => apiClient.get(`/quick-actions/${id}`), + + create: (data: CreateQuickActionInput) => apiClient.post('/quick-actions', data), + + update: (id: string, data: UpdateQuickActionInput) => + apiClient.put(`/quick-actions/${id}`, data), + + delete: (id: string) => apiClient.delete(`/quick-actions/${id}`), + + execute: (id: string) => apiClient.post(`/quick-actions/${id}/execute`), +}; diff --git a/frontend/src/components/QuickActionsPanel.tsx b/frontend/src/components/QuickActionsPanel.tsx new file mode 100644 index 0000000..f06dc08 --- /dev/null +++ b/frontend/src/components/QuickActionsPanel.tsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react'; +import { quickActionApi, QuickAction } from '../api/quickActions'; + +export function QuickActionsPanel() { + const [actions, setActions] = useState([]); + const [loading, setLoading] = useState(true); + const [executing, setExecuting] = useState(null); + + useEffect(() => { + loadActions(); + }, []); + + const loadActions = async () => { + try { + const data = await quickActionApi.getAll(); + setActions(data); + } catch (error) { + console.error('Failed to load quick actions:', error); + } finally { + setLoading(false); + } + }; + + const executeAction = async (id: string) => { + try { + setExecuting(id); + await quickActionApi.execute(id); + } catch (error) { + alert('Failed to execute action'); + } finally { + setExecuting(null); + } + }; + + if (loading) return
    Loading quick actions...
    ; + + if (actions.length === 0) { + return ( +
    +
    +
    No quick actions configured
    +
    + Quick actions let you trigger presets, playlists, or control devices with one click +
    +
    + ); + } + + return ( +
    + {actions.map((action) => ( + + ))} +
    + ); +} + +function getActionColor(actionType: string): string { + switch (actionType) { + case 'TURN_ON': + return '#2ecc71'; + case 'TURN_OFF': + return '#e74c3c'; + case 'PRESET': + return '#3498db'; + case 'PLAYLIST': + return '#9b59b6'; + case 'BRIGHTNESS': + return '#f39c12'; + default: + return '#95a5a6'; + } +} + +function getDefaultIcon(actionType: string): string { + switch (actionType) { + case 'TURN_ON': + return '💡'; + case 'TURN_OFF': + return '🌙'; + case 'PRESET': + return '🎨'; + case 'PLAYLIST': + return '🎬'; + case 'BRIGHTNESS': + return '☀️'; + default: + return '⚡'; + } +} + +function getActionDescription(action: QuickAction): string { + const target = action.groupId ? 'Group' : action.deviceId ? 'Device' : 'All'; + return `${action.actionType} - ${target}`; +} diff --git a/frontend/src/components/ScheduleComponents.tsx b/frontend/src/components/ScheduleComponents.tsx index bea44ba..afe8502 100644 --- a/frontend/src/components/ScheduleComponents.tsx +++ b/frontend/src/components/ScheduleComponents.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { timePickerToCron, cronToTimePicker, DAYS_OF_WEEK, TimePickerValue } from '../utils/timePicker'; +import { groupApi } from '../api/groups'; interface TimePickerProps { value: string; // cron expression @@ -170,6 +171,7 @@ interface PresetSelectorProps { export function PresetSelector({ groupId, selectedPresets, onChange, mode }: PresetSelectorProps) { const [presets, setPresets] = useState>({}); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { if (groupId) { @@ -180,11 +182,13 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre const loadPresets = async () => { try { setLoading(true); - const response = await fetch(`/api/groups/${groupId}/presets`); - const data = await response.json(); + setError(null); + const data = await groupApi.getPresets(groupId); setPresets(data); } catch (error) { console.error('Failed to load presets:', error); + setError(error instanceof Error ? error.message : 'Failed to load presets'); + setPresets({}); } finally { setLoading(false); } @@ -204,6 +208,21 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre if (loading) return
    Loading presets...
    ; + if (error) { + return ( +
    +
    Error loading presets: {error}
    + +
    + ); + } + const presetEntries = Object.entries(presets) .filter(([key]) => key !== '0') .map(([key, value]) => { @@ -214,7 +233,14 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre .sort((a, b) => a.id - b.id); if (presetEntries.length === 0) { - return
    No presets available
    ; + return ( +
    +
    No presets available
    +
    + Make sure the group has devices and at least one device has presets configured +
    +
    + ); } return ( diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index b945d1a..3654cd3 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { formatDateTime } from '../utils/dateTime'; import { deviceApi } from '../api/devices'; +import { QuickActionsPanel } from '../components/QuickActionsPanel'; interface DeviceStatus { id: string; @@ -33,10 +34,16 @@ interface DeviceStatus { }; } +interface DeviceWithStatus extends DeviceStatus { + connectionStatus: 'online' | 'warning' | 'offline'; + lastSuccessfulPing?: number; +} + export function DashboardPage() { - const [devices, setDevices] = useState([]); + const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(true); const [autoRefresh, setAutoRefresh] = useState(true); + const [deviceHistory, setDeviceHistory] = useState>(new Map()); useEffect(() => { loadDevices(); @@ -55,8 +62,42 @@ export function DashboardPage() { const loadDevices = async () => { try { const response = await fetch('/api/devices/status/all'); - const data = await response.json(); - setDevices(data); + const data = await response.json() as DeviceStatus[]; + + const now = Date.now(); + const updatedHistory = new Map(deviceHistory); + + // Update devices with connection status based on current state and history + const devicesWithStatus: DeviceWithStatus[] = data.map(device => { + const hasCurrentState = device.enabled && device.currentState !== undefined; + const lastSuccess = updatedHistory.get(device.id); + + // Update last successful ping time + if (hasCurrentState) { + updatedHistory.set(device.id, now); + } + + // Determine connection status + let connectionStatus: 'online' | 'warning' | 'offline'; + if (hasCurrentState) { + connectionStatus = 'online'; + } else if (lastSuccess && (now - lastSuccess) < 60000) { + // Device was online within the last 60 seconds - show as warning + connectionStatus = 'warning'; + } else { + // Device hasn't responded in over 60 seconds - show as offline + connectionStatus = 'offline'; + } + + return { + ...device, + connectionStatus, + lastSuccessfulPing: updatedHistory.get(device.id) + }; + }); + + setDeviceHistory(updatedHistory); + setDevices(devicesWithStatus); } catch (error) { console.error('Failed to load device status:', error); } finally { @@ -66,7 +107,7 @@ export function DashboardPage() { if (loading) return
    Loading dashboard...
    ; - const onlineDevices = devices.filter(d => d.enabled && d.currentState).length; + const onlineDevices = devices.filter(d => d.enabled && d.connectionStatus === 'online').length; const totalEnabled = devices.filter(d => d.enabled).length; return ( @@ -114,6 +155,11 @@ export function DashboardPage() { +
    +

    Quick Actions

    + +
    +

    Total Devices

    @@ -133,9 +179,10 @@ export function DashboardPage() {
    +

    Devices

    {devices.map((device) => ( - + ))}
    @@ -143,14 +190,24 @@ export function DashboardPage() { } interface DeviceCardProps { - device: DeviceStatus; + device: DeviceWithStatus; onRefresh: () => void; + allDevices: DeviceWithStatus[]; } -function DeviceCard({ device, onRefresh }: DeviceCardProps) { - const isOnline = device.enabled && device.currentState !== undefined; +function DeviceCard({ device, onRefresh, allDevices }: DeviceCardProps) { + const isOnline = device.connectionStatus === 'online'; + const isWarning = device.connectionStatus === 'warning'; const isOn = device.currentState?.on ?? false; const [busy, setBusy] = useState(false); + const [showSync, setShowSync] = useState(false); + + // Get status color + const getStatusColor = () => { + if (device.connectionStatus === 'online') return '#2ecc71'; // Green + if (device.connectionStatus === 'warning') return '#f39c12'; // Orange + return '#e74c3c'; // Red + }; const handleTurnOn = async () => { try { @@ -177,47 +234,105 @@ function DeviceCard({ device, onRefresh }: DeviceCardProps) { }; return ( -
    +

    {device.name}

    -
    - {device.ipAddress}:{device.port} + - {isOnline && ( -
    + {/* Always show controls for enabled devices, even if temporarily offline */} + {device.enabled && ( + <> +
    + + +
    - -
    + {showSync && isOnline && ( + d.id !== device.id && d.enabled)} + onSync={async (targetIds) => { + try { + setBusy(true); + await deviceApi.syncState(device.id, targetIds); + setTimeout(onRefresh, 500); + alert('State synced successfully!'); + } catch (err) { + alert('Failed to sync state'); + } finally { + setBusy(false); + } + }} + /> + )} + )} - {isOnline ? ( + {/* Show device info if we have current state OR recent successful ping */} + {(isOnline || isWarning) && device.currentState ? ( <> + {isWarning && ( +
    + ⚠️ Connection unstable - retrying... +
    + )} +
    Status:{' '} @@ -292,3 +407,101 @@ function DeviceCard({ device, onRefresh }: DeviceCardProps) {
    ); } + +interface SyncPanelProps { + sourceDevice: DeviceStatus; + allDevices: DeviceStatus[]; + onSync: (targetIds: string[]) => Promise; +} + +function SyncPanel({ sourceDevice, allDevices, onSync }: SyncPanelProps) { + const [selectedDevices, setSelectedDevices] = useState([]); + + const toggleDevice = (deviceId: string) => { + setSelectedDevices(prev => + prev.includes(deviceId) + ? prev.filter(id => id !== deviceId) + : [...prev, deviceId] + ); + }; + + const selectAll = () => { + setSelectedDevices(allDevices.map(d => d.id)); + }; + + const selectNone = () => { + setSelectedDevices([]); + }; + + return ( +
    +
    + Copy current state from "{sourceDevice.name}" to: +
    +
    + + +
    +
    + {allDevices.length === 0 ? ( +
    + No other devices available +
    + ) : ( + allDevices.map(device => ( + + )) + )} +
    + +
    + ); +} diff --git a/frontend/src/pages/DevicesPage.tsx b/frontend/src/pages/DevicesPage.tsx index da8ebca..1965623 100644 --- a/frontend/src/pages/DevicesPage.tsx +++ b/frontend/src/pages/DevicesPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { deviceApi } from '../api/devices'; +import { deviceApi, DiscoveredDevice } from '../api/devices'; import { Device } from '../api/types'; import { formatDateTime } from '../utils/dateTime'; @@ -9,6 +9,8 @@ export function DevicesPage() { const [error, setError] = useState(null); const [showModal, setShowModal] = useState(false); const [editingDevice, setEditingDevice] = useState(null); + const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all'); + const [showDiscovery, setShowDiscovery] = useState(false); useEffect(() => { loadDevices(); @@ -73,13 +75,33 @@ export function DevicesPage() { if (loading) return
    Loading devices...
    ; + const filteredDevices = devices.filter(device => { + if (filter === 'enabled') return device.enabled; + if (filter === 'disabled') return !device.enabled; + return true; + }); + return (
    -

    Devices

    - +

    Devices ({devices.length})

    +
    + + + +
    {error &&
    {error}
    } @@ -96,7 +118,7 @@ export function DevicesPage() { - {devices.map((device) => ( + {filteredDevices.map((device) => ( {device.name} {device.ipAddress} @@ -149,6 +171,223 @@ export function DevicesPage() { }} /> )} + + {showDiscovery && ( + setShowDiscovery(false)} + onDeviceAdded={() => { + setShowDiscovery(false); + loadDevices(); + }} + /> + )} +
    + ); +} + +interface DiscoveryModalProps { + onClose: () => void; + onDeviceAdded: () => void; +} + +function DiscoveryModal({ onClose, onDeviceAdded }: DiscoveryModalProps) { + const [discoveryMethod, setDiscoveryMethod] = useState<'mdns' | 'scan'>('mdns'); + const [discovering, setDiscovering] = useState(false); + const [discoveredDevices, setDiscoveredDevices] = useState([]); + const [baseIp, setBaseIp] = useState('192.168.1'); + const [startRange, setStartRange] = useState(1); + const [endRange, setEndRange] = useState(254); + const [selectedDevices, setSelectedDevices] = useState>(new Set()); + + const handleDiscover = async () => { + try { + setDiscovering(true); + setDiscoveredDevices([]); + setSelectedDevices(new Set()); + + let result; + if (discoveryMethod === 'mdns') { + result = await deviceApi.discoverMdns(5000); + } else { + result = await deviceApi.scanIpRange(baseIp, startRange, endRange); + } + + setDiscoveredDevices(result.devices); + + if (result.devices.length === 0) { + alert('No WLED devices found'); + } + } catch (err) { + alert('Failed to discover devices'); + } finally { + setDiscovering(false); + } + }; + + const toggleDeviceSelection = (ipAddress: string) => { + const newSelection = new Set(selectedDevices); + if (newSelection.has(ipAddress)) { + newSelection.delete(ipAddress); + } else { + newSelection.add(ipAddress); + } + setSelectedDevices(newSelection); + }; + + const handleAddSelected = async () => { + const devicesToAdd = discoveredDevices.filter(d => selectedDevices.has(d.ipAddress)); + + if (devicesToAdd.length === 0) { + alert('Please select at least one device to add'); + return; + } + + try { + for (const device of devicesToAdd) { + await deviceApi.create({ + name: device.name, + ipAddress: device.ipAddress, + port: device.port, + enabled: true, + }); + } + + alert(`Successfully added ${devicesToAdd.length} device(s)`); + onDeviceAdded(); + } catch (err) { + alert('Failed to add devices'); + } + }; + + return ( +
    +
    e.stopPropagation()}> +
    +

    Discover WLED Devices

    + +
    + +
    +
    + + +
    + + {discoveryMethod === 'scan' && ( + <> +
    + + setBaseIp(e.target.value)} + placeholder="192.168.1" + disabled={discovering} + /> +
    +
    +
    + + setStartRange(parseInt(e.target.value))} + min="1" + max="254" + disabled={discovering} + /> +
    +
    + + setEndRange(parseInt(e.target.value))} + min="1" + max="254" + disabled={discovering} + /> +
    +
    + + )} + + +
    + + {discoveredDevices.length > 0 && ( + <> +

    Found {discoveredDevices.length} device(s)

    +
    + + + + + + + + + + + {discoveredDevices.map((device) => ( + + + + + + + ))} + +
    + { + if (e.target.checked) { + setSelectedDevices(new Set(discoveredDevices.map(d => d.ipAddress))); + } else { + setSelectedDevices(new Set()); + } + }} + /> + NameIP AddressPort
    + toggleDeviceSelection(device.ipAddress)} + /> + {device.name}{device.ipAddress}{device.port}
    +
    + +
    + + +
    + + )} +
    ); } diff --git a/frontend/src/pages/QuickActionsPage.tsx b/frontend/src/pages/QuickActionsPage.tsx new file mode 100644 index 0000000..1036a24 --- /dev/null +++ b/frontend/src/pages/QuickActionsPage.tsx @@ -0,0 +1,367 @@ +import { useState, useEffect } from 'react'; +import { quickActionApi, QuickAction, CreateQuickActionInput } from '../api/quickActions'; +import { groupApi } from '../api/groups'; +import { deviceApi } from '../api/devices'; +import { Group, Device } from '../api/types'; + +export function QuickActionsPage() { + const [actions, setActions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showModal, setShowModal] = useState(false); + const [editingAction, setEditingAction] = useState(null); + + useEffect(() => { + loadActions(); + }, []); + + const loadActions = async () => { + try { + setLoading(true); + const data = await quickActionApi.getAll(); + setActions(data); + setError(null); + } catch (err) { + setError('Failed to load quick actions'); + } finally { + setLoading(false); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm('Are you sure you want to delete this quick action?')) return; + + try { + await quickActionApi.delete(id); + loadActions(); + } catch (err) { + alert('Failed to delete quick action'); + } + }; + + const openCreateModal = () => { + setEditingAction(null); + setShowModal(true); + }; + + const openEditModal = (action: QuickAction) => { + setEditingAction(action); + setShowModal(true); + }; + + if (loading) return
    Loading quick actions...
    ; + + return ( +
    +
    +

    Quick Actions ({actions.length})

    + +
    + + {error &&
    {error}
    } + + {actions.length === 0 ? ( +
    +
    +
    No quick actions configured
    +
    + Quick actions let you trigger presets, playlists, or control devices with one click from the dashboard +
    + +
    + ) : ( + + + + + + + + + + + + + {actions.map((action) => ( + + + + + + + + + ))} + +
    OrderNameIconTypeTargetActions
    {action.order}{action.name}{action.icon || '⚡'} + + {action.actionType} + + + {action.groupId ? 'Group' : action.deviceId ? 'Device' : 'All Devices'} + + + +
    + )} + + {showModal && ( + setShowModal(false)} + onSave={() => { + setShowModal(false); + loadActions(); + }} + /> + )} +
    + ); +} + +interface QuickActionModalProps { + action: QuickAction | null; + onClose: () => void; + onSave: () => void; +} + +function QuickActionModal({ action, onClose, onSave }: QuickActionModalProps) { + const [name, setName] = useState(action?.name || ''); + const [icon, setIcon] = useState(action?.icon || ''); + const [actionType, setActionType] = useState<'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS'>( + action?.actionType || 'TURN_ON' + ); + const [targetType, setTargetType] = useState<'group' | 'device' | 'all'>( + action?.groupId ? 'group' : action?.deviceId ? 'device' : 'all' + ); + const [groupId, setGroupId] = useState(action?.groupId || ''); + const [deviceId, setDeviceId] = useState(action?.deviceId || ''); + const [presetId, setPresetId] = useState('1'); + const [brightness, setBrightness] = useState('128'); + const [order, setOrder] = useState(action?.order?.toString() || '0'); + + const [groups, setGroups] = useState([]); + const [devices, setDevices] = useState([]); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + loadGroupsAndDevices(); + if (action?.actionPayload) { + const payload = action.actionPayload as any; + if (payload.presetId) setPresetId(payload.presetId.toString()); + if (payload.brightness) setBrightness(payload.brightness.toString()); + } + }, []); + + const loadGroupsAndDevices = async () => { + const [groupsData, devicesData] = await Promise.all([ + groupApi.getAll(), + deviceApi.getAll(), + ]); + setGroups(groupsData); + setDevices(devicesData); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + let actionPayload: any = {}; + + if (actionType === 'PRESET') { + actionPayload = { presetId: parseInt(presetId) }; + } else if (actionType === 'BRIGHTNESS') { + actionPayload = { brightness: parseInt(brightness) }; + } + + const data: CreateQuickActionInput = { + name, + icon: icon || undefined, + actionType, + groupId: targetType === 'group' ? groupId : undefined, + deviceId: targetType === 'device' ? deviceId : undefined, + actionPayload, + order: parseInt(order), + }; + + try { + setSubmitting(true); + + if (action) { + await quickActionApi.update(action.id, data); + } else { + await quickActionApi.create(data); + } + + onSave(); + } catch (err) { + alert('Failed to save quick action'); + } finally { + setSubmitting(false); + } + }; + + return ( +
    +
    e.stopPropagation()} style={{ maxWidth: '600px' }}> +
    +

    {action ? 'Edit Quick Action' : 'Create Quick Action'}

    + +
    + +
    +
    + + setName(e.target.value)} + placeholder="e.g., Bedtime Lights" + required + /> +
    + +
    + + setIcon(e.target.value)} + placeholder="e.g., 🌙 💡 🎨" + maxLength={2} + /> +
    + Leave empty for default icon +
    +
    + +
    + + +
    + + {actionType === 'PRESET' && ( +
    + + setPresetId(e.target.value)} + min="1" + required + /> +
    + )} + + {actionType === 'BRIGHTNESS' && ( +
    + + setBrightness(e.target.value)} + min="0" + max="255" + required + /> +
    + {Math.round((parseInt(brightness) / 255) * 100)}% +
    +
    + )} + +
    + + +
    + + {targetType === 'group' && ( +
    + + +
    + )} + + {targetType === 'device' && ( +
    + + +
    + )} + +
    + + setOrder(e.target.value)} + min="0" + /> +
    + Lower numbers appear first +
    +
    + +
    + + +
    +
    +
    +
    + ); +} + +function getActionColor(actionType: string): string { + switch (actionType) { + case 'TURN_ON': return '#2ecc71'; + case 'TURN_OFF': return '#e74c3c'; + case 'PRESET': return '#3498db'; + case 'PLAYLIST': return '#9b59b6'; + case 'BRIGHTNESS': return '#f39c12'; + default: return '#95a5a6'; + } +} diff --git a/frontend/src/pages/SchedulesPage.tsx b/frontend/src/pages/SchedulesPage.tsx index a762cf1..394758a 100644 --- a/frontend/src/pages/SchedulesPage.tsx +++ b/frontend/src/pages/SchedulesPage.tsx @@ -3,7 +3,6 @@ import { scheduleApi } from '../api/schedules'; import { groupApi } from '../api/groups'; import { Schedule, Group, PresetActionPayload, PlaylistActionPayload } from '../api/types'; import { TimePicker, PresetSelector } from '../components/ScheduleComponents'; -import { timePickerToCron } from '../utils/timePicker'; export function SchedulesPage() { const [schedules, setSchedules] = useState([]); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 55584f1..602a69a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { + host: '0.0.0.0', port: 5173, proxy: { '/api': {