proxy backend
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,3 +4,7 @@ dist/
|
||||
*.db-journal
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
# Build artifacts
|
||||
/backend/public/
|
||||
/release/
|
||||
|
||||
136
BUILD.md
Normal file
136
BUILD.md
Normal file
@@ -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
|
||||
922
backend/package-lock.json
generated
922
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
132
backend/src/routes/quickActions.ts
Normal file
132
backend/src/routes/quickActions.ts
Normal file
@@ -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;
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await prisma.device.update({
|
||||
where: { id },
|
||||
|
||||
168
backend/src/services/discoveryService.ts
Normal file
168
backend/src/services/discoveryService.ts
Normal file
@@ -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<DiscoveredDevice[]> {
|
||||
return new Promise((resolve) => {
|
||||
const discoveredDevices: DiscoveredDevice[] = [];
|
||||
const seenAddresses = new Set<string>();
|
||||
|
||||
// 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<DiscoveredDevice[]> {
|
||||
const discoveredDevices: DiscoveredDevice[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
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<DiscoveredDevice | null> {
|
||||
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();
|
||||
@@ -274,24 +274,77 @@ export class GroupService {
|
||||
};
|
||||
}
|
||||
|
||||
async turnOnGroup(groupId: string): Promise<GroupActionResult> {
|
||||
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<unknown> {
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
151
backend/src/services/quickActionService.ts
Normal file
151
backend/src/services/quickActionService.ts
Normal file
@@ -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<QuickAction[]> {
|
||||
return prisma.quickAction.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getQuickActionById(id: string): Promise<QuickAction | null> {
|
||||
return prisma.quickAction.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async createQuickAction(input: CreateQuickActionInput): Promise<QuickAction> {
|
||||
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<QuickAction> {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
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<void> {
|
||||
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();
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -37,12 +37,12 @@ export class WledClient {
|
||||
|
||||
async applyPreset(presetId: number): Promise<void> {
|
||||
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<void> {
|
||||
const url = `${this.baseUrl}/json/state`;
|
||||
await this.post(url, { playlist });
|
||||
await this.post(url, { playlist, on: true });
|
||||
}
|
||||
|
||||
async turnOn(): Promise<void> {
|
||||
@@ -55,6 +55,16 @@ export class WledClient {
|
||||
await this.post(url, { on: false });
|
||||
}
|
||||
|
||||
async applyState(state: Partial<WledState>): Promise<void> {
|
||||
const url = `${this.baseUrl}/json/state`;
|
||||
await this.post(url, state);
|
||||
}
|
||||
|
||||
async setBrightness(brightness: number): Promise<void> {
|
||||
const url = `${this.baseUrl}/json/state`;
|
||||
await this.post(url, { bri: brightness });
|
||||
}
|
||||
|
||||
private async fetch<T>(url: string): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
|
||||
50
build.bat
Normal file
50
build.bat
Normal file
@@ -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
|
||||
@@ -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() {
|
||||
<li>
|
||||
<Link to="/schedules">Schedules</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/quick-actions">Quick Actions</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -36,6 +40,7 @@ function App() {
|
||||
<Route path="/devices" element={<DevicesPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/schedules" element={<SchedulesPage />} />
|
||||
<Route path="/quick-actions" element={<QuickActionsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Device[]>('/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<DiscoveryResult>('/devices/discover/mdns', { timeout }),
|
||||
|
||||
scanIpRange: (baseIp: string, startRange?: number, endRange?: number) =>
|
||||
apiClient.post<DiscoveryResult>('/devices/discover/scan', { baseIp, startRange, endRange }),
|
||||
|
||||
verifyDevice: (ipAddress: string, port?: number) =>
|
||||
apiClient.post<VerifyResult>('/devices/discover/verify', { ipAddress, port }),
|
||||
};
|
||||
|
||||
@@ -28,4 +28,7 @@ export const groupApi = {
|
||||
|
||||
applyPlaylist: (groupId: string, playlist: PlaylistActionPayload) =>
|
||||
apiClient.post<GroupActionResult>(`/groups/${groupId}/playlist`, playlist),
|
||||
|
||||
getPresets: (groupId: string) =>
|
||||
apiClient.get<Record<string, any>>(`/groups/${groupId}/presets`),
|
||||
};
|
||||
|
||||
51
frontend/src/api/quickActions.ts
Normal file
51
frontend/src/api/quickActions.ts
Normal file
@@ -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<QuickAction[]>('/quick-actions'),
|
||||
|
||||
getById: (id: string) => apiClient.get<QuickAction>(`/quick-actions/${id}`),
|
||||
|
||||
create: (data: CreateQuickActionInput) => apiClient.post<QuickAction>('/quick-actions', data),
|
||||
|
||||
update: (id: string, data: UpdateQuickActionInput) =>
|
||||
apiClient.put<QuickAction>(`/quick-actions/${id}`, data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/quick-actions/${id}`),
|
||||
|
||||
execute: (id: string) => apiClient.post(`/quick-actions/${id}/execute`),
|
||||
};
|
||||
132
frontend/src/components/QuickActionsPanel.tsx
Normal file
132
frontend/src/components/QuickActionsPanel.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { quickActionApi, QuickAction } from '../api/quickActions';
|
||||
|
||||
export function QuickActionsPanel() {
|
||||
const [actions, setActions] = useState<QuickAction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [executing, setExecuting] = useState<string | null>(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 <div>Loading quick actions...</div>;
|
||||
|
||||
if (actions.length === 0) {
|
||||
return (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '40px', color: '#95a5a6' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '10px' }}>⚡</div>
|
||||
<div>No quick actions configured</div>
|
||||
<div style={{ fontSize: '14px', marginTop: '5px' }}>
|
||||
Quick actions let you trigger presets, playlists, or control devices with one click
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '15px' }}>
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
className="btn btn-large"
|
||||
onClick={() => executeAction(action.id)}
|
||||
disabled={executing === action.id}
|
||||
style={{
|
||||
height: '100px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
backgroundColor: getActionColor(action.actionType),
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
title={getActionDescription(action)}
|
||||
>
|
||||
<div style={{ fontSize: '32px' }}>
|
||||
{action.icon || getDefaultIcon(action.actionType)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', lineHeight: '1.2' }}>
|
||||
{action.name}
|
||||
</div>
|
||||
{executing === action.id && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '5px',
|
||||
right: '5px',
|
||||
fontSize: '16px',
|
||||
}}>
|
||||
⏳
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 <div style={{ padding: '20px', textAlign: 'center' }}>Loading presets...</div>;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#e74c3c' }}>
|
||||
<div>Error loading presets: {error}</div>
|
||||
<button
|
||||
onClick={loadPresets}
|
||||
className="btn btn-small btn-secondary"
|
||||
style={{ marginTop: '10px' }}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>No presets available</div>;
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>
|
||||
<div>No presets available</div>
|
||||
<div style={{ fontSize: '12px', marginTop: '5px' }}>
|
||||
Make sure the group has devices and at least one device has presets configured
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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<DeviceStatus[]>([]);
|
||||
const [devices, setDevices] = useState<DeviceWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [deviceHistory, setDeviceHistory] = useState<Map<string, number>>(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 <div className="loading">Loading dashboard...</div>;
|
||||
|
||||
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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h3 style={{ marginBottom: '15px' }}>Quick Actions</h3>
|
||||
<QuickActionsPanel />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px', marginBottom: '30px' }}>
|
||||
<div className="card">
|
||||
<h3>Total Devices</h3>
|
||||
@@ -133,9 +179,10 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: '15px' }}>Devices</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '20px' }}>
|
||||
{devices.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} />
|
||||
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} allDevices={devices} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="card" style={{ position: 'relative' }}>
|
||||
<div className="card" style={{ position: 'relative', opacity: device.connectionStatus === 'offline' ? 0.6 : 1 }}>
|
||||
<div style={{ position: 'absolute', top: '15px', right: '15px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isOnline ? '#2ecc71' : '#e74c3c',
|
||||
backgroundColor: getStatusColor(),
|
||||
}}
|
||||
title={isOnline ? 'Online' : 'Offline'}
|
||||
title={
|
||||
device.connectionStatus === 'online'
|
||||
? 'Online'
|
||||
: device.connectionStatus === 'warning'
|
||||
? 'Connection unstable'
|
||||
: 'Offline'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: '10px', paddingRight: '30px' }}>{device.name}</h3>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginBottom: '15px' }}>
|
||||
{device.ipAddress}:{device.port}
|
||||
<div style={{ fontSize: '12px', marginBottom: '15px' }}>
|
||||
<a
|
||||
href={`http://${device.ipAddress}:${device.port}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#3498db', textDecoration: 'none' }}
|
||||
onMouseOver={(e) => e.currentTarget.style.textDecoration = 'underline'}
|
||||
onMouseOut={(e) => e.currentTarget.style.textDecoration = 'none'}
|
||||
>
|
||||
{device.ipAddress}:{device.port}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isOnline && (
|
||||
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
|
||||
{/* Always show controls for enabled devices, even if temporarily offline */}
|
||||
{device.enabled && (
|
||||
<>
|
||||
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={handleTurnOn}
|
||||
disabled={busy || (isOnline && isOn)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Turn On
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={handleTurnOff}
|
||||
disabled={busy || (isOnline && !isOn)}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Turn Off
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={handleTurnOn}
|
||||
disabled={busy || isOn}
|
||||
style={{ flex: 1 }}
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => setShowSync(!showSync)}
|
||||
style={{ width: '100%', marginBottom: '15px' }}
|
||||
disabled={!isOnline}
|
||||
>
|
||||
Turn On
|
||||
{showSync ? 'Hide' : 'Sync to Other Devices'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={handleTurnOff}
|
||||
disabled={busy || !isOn}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Turn Off
|
||||
</button>
|
||||
</div>
|
||||
{showSync && isOnline && (
|
||||
<SyncPanel
|
||||
sourceDevice={device}
|
||||
allDevices={allDevices.filter(d => 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 && (
|
||||
<div style={{
|
||||
backgroundColor: '#fff3cd',
|
||||
color: '#856404',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '10px',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
⚠️ Connection unstable - retrying...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>Status:</strong>{' '}
|
||||
<span style={{ color: isOn ? '#2ecc71' : '#95a5a6' }}>
|
||||
@@ -292,3 +407,101 @@ function DeviceCard({ device, onRefresh }: DeviceCardProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SyncPanelProps {
|
||||
sourceDevice: DeviceStatus;
|
||||
allDevices: DeviceStatus[];
|
||||
onSync: (targetIds: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function SyncPanel({ sourceDevice, allDevices, onSync }: SyncPanelProps) {
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '15px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||
Copy current state from "{sourceDevice.name}" to:
|
||||
</div>
|
||||
<div style={{ marginBottom: '8px', display: 'flex', gap: '5px' }}>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={selectAll}
|
||||
style={{ fontSize: '11px', padding: '3px 8px' }}
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={selectNone}
|
||||
style={{ fontSize: '11px', padding: '3px 8px' }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ maxHeight: '150px', overflowY: 'auto', marginBottom: '8px' }}>
|
||||
{allDevices.length === 0 ? (
|
||||
<div style={{ fontSize: '12px', color: '#95a5a6', padding: '5px' }}>
|
||||
No other devices available
|
||||
</div>
|
||||
) : (
|
||||
allDevices.map(device => (
|
||||
<label
|
||||
key={device.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.includes(device.id)}
|
||||
onChange={() => toggleDevice(device.id)}
|
||||
/>
|
||||
<span>{device.name}</span>
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: '10px',
|
||||
color: device.currentState ? '#2ecc71' : '#e74c3c'
|
||||
}}>
|
||||
{device.currentState ? '●' : '○'}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={() => onSync(selectedDevices)}
|
||||
disabled={selectedDevices.length === 0}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Sync to {selectedDevices.length} Device{selectedDevices.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingDevice, setEditingDevice] = useState<Device | null>(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 <div className="loading">Loading devices...</div>;
|
||||
|
||||
const filteredDevices = devices.filter(device => {
|
||||
if (filter === 'enabled') return device.enabled;
|
||||
if (filter === 'disabled') return !device.enabled;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>Devices</h2>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Add Device
|
||||
</button>
|
||||
<h2>Devices ({devices.length})</h2>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as 'all' | 'enabled' | 'disabled')}
|
||||
style={{ padding: '8px' }}
|
||||
>
|
||||
<option value="all">All Devices ({devices.length})</option>
|
||||
<option value="enabled">Enabled ({devices.filter(d => d.enabled).length})</option>
|
||||
<option value="disabled">Disabled ({devices.filter(d => !d.enabled).length})</option>
|
||||
</select>
|
||||
<button className="btn btn-secondary" onClick={() => setShowDiscovery(true)}>
|
||||
🔍 Discover Devices
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
@@ -96,7 +118,7 @@ export function DevicesPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((device) => (
|
||||
{filteredDevices.map((device) => (
|
||||
<tr key={device.id}>
|
||||
<td>{device.name}</td>
|
||||
<td>{device.ipAddress}</td>
|
||||
@@ -149,6 +171,223 @@ export function DevicesPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDiscovery && (
|
||||
<DiscoveryModal
|
||||
onClose={() => setShowDiscovery(false)}
|
||||
onDeviceAdded={() => {
|
||||
setShowDiscovery(false);
|
||||
loadDevices();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<DiscoveredDevice[]>([]);
|
||||
const [baseIp, setBaseIp] = useState('192.168.1');
|
||||
const [startRange, setStartRange] = useState(1);
|
||||
const [endRange, setEndRange] = useState(254);
|
||||
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(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 (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-content" style={{ maxWidth: '700px' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Discover WLED Devices</h3>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div className="form-group">
|
||||
<label>Discovery Method</label>
|
||||
<select
|
||||
value={discoveryMethod}
|
||||
onChange={(e) => setDiscoveryMethod(e.target.value as 'mdns' | 'scan')}
|
||||
disabled={discovering}
|
||||
>
|
||||
<option value="mdns">mDNS (Automatic - Recommended)</option>
|
||||
<option value="scan">IP Range Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{discoveryMethod === 'scan' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>Base IP Address (e.g., 192.168.1)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseIp}
|
||||
onChange={(e) => setBaseIp(e.target.value)}
|
||||
placeholder="192.168.1"
|
||||
disabled={discovering}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-group">
|
||||
<label>Start Range</label>
|
||||
<input
|
||||
type="number"
|
||||
value={startRange}
|
||||
onChange={(e) => setStartRange(parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="254"
|
||||
disabled={discovering}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>End Range</label>
|
||||
<input
|
||||
type="number"
|
||||
value={endRange}
|
||||
onChange={(e) => setEndRange(parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="254"
|
||||
disabled={discovering}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleDiscover}
|
||||
disabled={discovering}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{discovering ? 'Discovering...' : '🔍 Start Discovery'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{discoveredDevices.length > 0 && (
|
||||
<>
|
||||
<h4>Found {discoveredDevices.length} device(s)</h4>
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto', marginBottom: '20px' }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.size === discoveredDevices.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDevices(new Set(discoveredDevices.map(d => d.ipAddress)));
|
||||
} else {
|
||||
setSelectedDevices(new Set());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{discoveredDevices.map((device) => (
|
||||
<tr key={device.ipAddress}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDevices.has(device.ipAddress)}
|
||||
onChange={() => toggleDeviceSelection(device.ipAddress)}
|
||||
/>
|
||||
</td>
|
||||
<td>{device.name}</td>
|
||||
<td>{device.ipAddress}</td>
|
||||
<td>{device.port}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button className="btn btn-secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleAddSelected}
|
||||
disabled={selectedDevices.size === 0}
|
||||
>
|
||||
Add Selected ({selectedDevices.size})
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
367
frontend/src/pages/QuickActionsPage.tsx
Normal file
367
frontend/src/pages/QuickActionsPage.tsx
Normal file
@@ -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<QuickAction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingAction, setEditingAction] = useState<QuickAction | null>(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 <div className="loading">Loading quick actions...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>Quick Actions ({actions.length})</h2>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Create Quick Action
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{actions.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '40px', color: '#95a5a6' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '10px' }}>⚡</div>
|
||||
<div style={{ marginBottom: '10px' }}>No quick actions configured</div>
|
||||
<div style={{ fontSize: '14px', marginBottom: '20px' }}>
|
||||
Quick actions let you trigger presets, playlists, or control devices with one click from the dashboard
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Create Your First Quick Action
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Order</th>
|
||||
<th>Name</th>
|
||||
<th>Icon</th>
|
||||
<th>Type</th>
|
||||
<th>Target</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{actions.map((action) => (
|
||||
<tr key={action.id}>
|
||||
<td>{action.order}</td>
|
||||
<td>{action.name}</td>
|
||||
<td style={{ fontSize: '24px' }}>{action.icon || '⚡'}</td>
|
||||
<td>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
backgroundColor: getActionColor(action.actionType),
|
||||
color: 'white'
|
||||
}}>
|
||||
{action.actionType}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{action.groupId ? 'Group' : action.deviceId ? 'Device' : 'All Devices'}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => openEditModal(action)}
|
||||
style={{ marginRight: '5px' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={() => handleDelete(action.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<QuickActionModal
|
||||
action={editingAction}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={() => {
|
||||
setShowModal(false);
|
||||
loadActions();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Group[]>([]);
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
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 (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '600px' }}>
|
||||
<div className="modal-header">
|
||||
<h3>{action ? 'Edit Quick Action' : 'Create Quick Action'}</h3>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Bedtime Lights"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Icon (Emoji)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="e.g., 🌙 💡 🎨"
|
||||
maxLength={2}
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
|
||||
Leave empty for default icon
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Action Type *</label>
|
||||
<select value={actionType} onChange={(e) => setActionType(e.target.value as any)} required>
|
||||
<option value="TURN_ON">Turn On</option>
|
||||
<option value="TURN_OFF">Turn Off</option>
|
||||
<option value="PRESET">Apply Preset</option>
|
||||
<option value="BRIGHTNESS">Set Brightness</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{actionType === 'PRESET' && (
|
||||
<div className="form-group">
|
||||
<label>Preset ID *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === 'BRIGHTNESS' && (
|
||||
<div className="form-group">
|
||||
<label>Brightness (0-255) *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={brightness}
|
||||
onChange={(e) => setBrightness(e.target.value)}
|
||||
min="0"
|
||||
max="255"
|
||||
required
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
|
||||
{Math.round((parseInt(brightness) / 255) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Target *</label>
|
||||
<select value={targetType} onChange={(e) => setTargetType(e.target.value as any)} required>
|
||||
<option value="all">All Devices</option>
|
||||
<option value="group">Specific Group</option>
|
||||
<option value="device">Specific Device</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{targetType === 'group' && (
|
||||
<div className="form-group">
|
||||
<label>Group *</label>
|
||||
<select value={groupId} onChange={(e) => setGroupId(e.target.value)} required>
|
||||
<option value="">Select a group</option>
|
||||
{groups.map(group => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetType === 'device' && (
|
||||
<div className="form-group">
|
||||
<label>Device *</label>
|
||||
<select value={deviceId} onChange={(e) => setDeviceId(e.target.value)} required>
|
||||
<option value="">Select a device</option>
|
||||
{devices.map(device => (
|
||||
<option key={device.id} value={device.id}>{device.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Display Order</label>
|
||||
<input
|
||||
type="number"
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
min="0"
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
|
||||
Lower numbers appear first
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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<Schedule[]>([]);
|
||||
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user