Initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
.DS_Store
|
||||
338
README.md
Normal file
338
README.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# WLED Central Controller
|
||||
|
||||
A web-based central controller for multiple WLED instances, allowing you to discover, manage, group, and schedule actions across your WLED devices.
|
||||
|
||||
## Features
|
||||
|
||||
- **Device Management**: Add, edit, and monitor multiple WLED devices
|
||||
- **Grouping**: Organize devices into logical groups (e.g., "Living Room", "Garden")
|
||||
- **Group Actions**: Apply presets and playlists to individual devices or groups
|
||||
- **Scheduling**: Automate preset/playlist changes with cron-based schedules
|
||||
- **UK Timezone Support**: All times displayed in Europe/London timezone (dd/MM/yyyy HH:mm format)
|
||||
- **RESTful API**: Complete backend API for integration with other systems
|
||||
- **Modern UI**: Clean, responsive web interface built with React
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Backend
|
||||
- Node.js v20+ with TypeScript
|
||||
- Express.js for HTTP API
|
||||
- SQLite via Prisma ORM
|
||||
- node-cron for scheduling
|
||||
- Zod for validation
|
||||
|
||||
### Frontend
|
||||
- React 18 with TypeScript
|
||||
- Vite build tool
|
||||
- React Router for navigation
|
||||
- Luxon for date/time handling
|
||||
|
||||
## Quick Start with Docker
|
||||
|
||||
The easiest way to run the WLED Controller is using Docker Compose:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
cd wled-controller
|
||||
|
||||
# Start the services
|
||||
docker-compose up -d
|
||||
|
||||
# Access the web UI
|
||||
# Open http://localhost in your browser
|
||||
```
|
||||
|
||||
The backend API will be available at `http://localhost:3000/api`.
|
||||
|
||||
## Manual Setup
|
||||
|
||||
### Backend Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Generate Prisma Client
|
||||
npm run prisma:generate
|
||||
|
||||
# Run database migrations
|
||||
npm run prisma:migrate
|
||||
|
||||
# Development mode
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Copy environment file
|
||||
cp .env.example .env
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Development mode
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Backend Environment Variables
|
||||
|
||||
Create a `.env` file in the `backend` directory:
|
||||
|
||||
```env
|
||||
PORT=3000
|
||||
DATABASE_URL="file:./dev.db"
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
### Frontend Environment Variables
|
||||
|
||||
Create a `.env` file in the `frontend` directory:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
```
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### 1. Add Devices
|
||||
|
||||
1. Navigate to the **Devices** page
|
||||
2. Click **Add Device**
|
||||
3. Enter device details:
|
||||
- Name (e.g., "Kitchen Strip")
|
||||
- IP Address (e.g., "192.168.1.50")
|
||||
- Port (default: 80)
|
||||
- Enabled checkbox
|
||||
4. Use **Ping** to verify connectivity
|
||||
|
||||
### 2. Create Groups
|
||||
|
||||
1. Navigate to the **Groups** page
|
||||
2. Click **Create Group**
|
||||
3. Enter group name
|
||||
4. Select devices to include
|
||||
5. Save
|
||||
|
||||
### 3. Control Groups
|
||||
|
||||
1. Click **Control** on any group
|
||||
2. Apply a preset:
|
||||
- Enter preset ID
|
||||
- Click **Apply Preset**
|
||||
3. Or apply a playlist:
|
||||
- Enter comma-separated preset IDs
|
||||
- Configure duration, transition, repeat options
|
||||
- Click **Apply Playlist**
|
||||
|
||||
### 4. Schedule Actions
|
||||
|
||||
1. Navigate to the **Schedules** page
|
||||
2. Click **Create Schedule**
|
||||
3. Configure:
|
||||
- Name
|
||||
- Group
|
||||
- Type (Preset or Playlist)
|
||||
- Cron expression (e.g., `30 18 * * *` for 6:30 PM daily)
|
||||
- Timezone (default: Europe/London)
|
||||
- Action payload (preset ID or playlist config)
|
||||
4. Enable/disable schedules with the toggle
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Devices
|
||||
|
||||
- `GET /api/devices` - List all devices
|
||||
- `POST /api/devices` - Create device
|
||||
- `GET /api/devices/:id` - Get device
|
||||
- `PUT /api/devices/:id` - Update device
|
||||
- `DELETE /api/devices/:id` - Delete device
|
||||
- `POST /api/devices/:id/ping` - Ping device
|
||||
|
||||
### Groups
|
||||
|
||||
- `GET /api/groups` - List all groups
|
||||
- `POST /api/groups` - Create group
|
||||
- `GET /api/groups/:id` - Get group
|
||||
- `PUT /api/groups/:id` - Update group
|
||||
- `DELETE /api/groups/:id` - Delete group
|
||||
- `POST /api/groups/:id/preset` - Apply preset to group
|
||||
- `POST /api/groups/:id/playlist` - Apply playlist to group
|
||||
|
||||
### Schedules
|
||||
|
||||
- `GET /api/schedules` - List all schedules
|
||||
- `POST /api/schedules` - Create schedule
|
||||
- `GET /api/schedules/:id` - Get schedule
|
||||
- `PUT /api/schedules/:id` - Update schedule
|
||||
- `DELETE /api/schedules/:id` - Delete schedule
|
||||
|
||||
## WLED Integration
|
||||
|
||||
The controller communicates with WLED devices over HTTP using the JSON API:
|
||||
|
||||
- `/json/info` - Device information
|
||||
- `/json/state` - Device state and control
|
||||
|
||||
### Preset Application
|
||||
|
||||
```json
|
||||
POST /json/state
|
||||
{
|
||||
"ps": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Playlist Application
|
||||
|
||||
```json
|
||||
POST /json/state
|
||||
{
|
||||
"playlist": {
|
||||
"ps": [1, 2, 3],
|
||||
"dur": [30, 30, 30],
|
||||
"transition": 0,
|
||||
"repeat": 0,
|
||||
"end": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cron Expression Format
|
||||
|
||||
Schedules use standard cron syntax:
|
||||
|
||||
```
|
||||
* * * * *
|
||||
┬ ┬ ┬ ┬ ┬
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └─ day of week (0-7, 0 and 7 are Sunday)
|
||||
│ │ │ └────── month (1-12)
|
||||
│ │ └─────────── day of month (1-31)
|
||||
│ └──────────────── hour (0-23)
|
||||
└───────────────────── minute (0-59)
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
- `30 18 * * *` - Every day at 6:30 PM
|
||||
- `0 9 * * 1-5` - Weekdays at 9:00 AM
|
||||
- `*/15 * * * *` - Every 15 minutes
|
||||
- `0 22 * * 0` - Sundays at 10:00 PM
|
||||
|
||||
## Development
|
||||
|
||||
### Backend Tests
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm test
|
||||
```
|
||||
|
||||
### Code Formatting
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
npm run format
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm run format
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
npm run lint
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Behind a Reverse Proxy
|
||||
|
||||
The application is designed to work behind HTTPS reverse proxies. Configure your reverse proxy to:
|
||||
|
||||
1. Serve the frontend on your domain
|
||||
2. Proxy `/api/*` requests to the backend
|
||||
3. Set appropriate CORS headers if needed
|
||||
|
||||
Example Nginx configuration:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name wled.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:80;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:3000;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Device Not Reachable
|
||||
|
||||
- Verify the device IP address is correct
|
||||
- Ensure the device is on the same network
|
||||
- Check firewall rules
|
||||
- Use the **Ping** button to test connectivity
|
||||
|
||||
### Schedule Not Triggering
|
||||
|
||||
- Verify the cron expression is correct
|
||||
- Check the schedule is enabled
|
||||
- Ensure the group has enabled devices
|
||||
- Review backend logs for errors
|
||||
|
||||
### Database Issues
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Reset database
|
||||
rm dev.db
|
||||
npm run prisma:migrate
|
||||
|
||||
# View database
|
||||
npm run prisma:studio
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please open an issue or pull request.
|
||||
3
backend/.env.example
Normal file
3
backend/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
PORT=3000
|
||||
DATABASE_URL="file:./dev.db"
|
||||
LOG_LEVEL=info
|
||||
15
backend/.eslintrc.json
Normal file
15
backend/.eslintrc.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off"
|
||||
}
|
||||
}
|
||||
6
backend/.gitignore
vendored
Normal file
6
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.db
|
||||
*.db-journal
|
||||
.env
|
||||
.DS_Store
|
||||
7
backend/.prettierrc.json
Normal file
7
backend/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY prisma ./prisma/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Generate Prisma Client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start
|
||||
CMD ["npm", "start"]
|
||||
11
backend/jest.config.js
Normal file
11
backend/jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/*.test.ts',
|
||||
],
|
||||
};
|
||||
7389
backend/package-lock.json
generated
Normal file
7389
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
backend/package.json
Normal file
52
backend/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "wled-controller-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "WLED Central Controller Backend",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:studio": "prisma studio",
|
||||
"test": "jest",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"format": "prettier --write \"src/**/*.ts\""
|
||||
},
|
||||
"keywords": [
|
||||
"wled",
|
||||
"controller",
|
||||
"home-automation"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"luxon": "^3.4.4",
|
||||
"node-cron": "^3.0.3",
|
||||
"p-limit": "^5.0.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/luxon": "^3.3.7",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.0",
|
||||
"@typescript-eslint/parser": "^6.18.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.11",
|
||||
"prettier": "^3.1.1",
|
||||
"prisma": "^5.8.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
44
backend/prisma/migrations/20251208105610_init/migration.sql
Normal file
44
backend/prisma/migrations/20251208105610_init/migration.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Device" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"ipAddress" TEXT NOT NULL,
|
||||
"port" INTEGER NOT NULL DEFAULT 80,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastSeenAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Group" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GroupDevice" (
|
||||
"groupId" TEXT NOT NULL,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("groupId", "deviceId"),
|
||||
CONSTRAINT "GroupDevice_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "GroupDevice_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Schedule" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"cronExpression" TEXT NOT NULL,
|
||||
"timezone" TEXT NOT NULL DEFAULT 'Europe/London',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"actionPayload" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Schedule_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Schedule" ADD COLUMN "endCronExpression" TEXT;
|
||||
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
||||
57
backend/prisma/schema.prisma
Normal file
57
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,57 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Device {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
ipAddress String
|
||||
port Int @default(80)
|
||||
enabled Boolean @default(true)
|
||||
lastSeenAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
groups GroupDevice[]
|
||||
}
|
||||
|
||||
model Group {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
devices GroupDevice[]
|
||||
schedules Schedule[]
|
||||
}
|
||||
|
||||
model GroupDevice {
|
||||
groupId String
|
||||
deviceId String
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([groupId, deviceId])
|
||||
}
|
||||
|
||||
model Schedule {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
groupId String
|
||||
type String // 'PRESET' | 'PLAYLIST'
|
||||
cronExpression String
|
||||
endCronExpression String? // Optional: turn off lights at this time
|
||||
timezone String @default("Europe/London")
|
||||
enabled Boolean @default(true)
|
||||
actionPayload String // JSON string
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id])
|
||||
}
|
||||
36
backend/src/app.ts
Normal file
36
backend/src/app.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import devicesRouter from './routes/devices';
|
||||
import groupsRouter from './routes/groups';
|
||||
import schedulesRouter from './routes/schedules';
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/devices', devicesRouter);
|
||||
app.use('/api/groups', groupsRouter);
|
||||
app.use('/api/schedules', schedulesRouter);
|
||||
|
||||
// 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' });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
5
backend/src/config/index.ts
Normal file
5
backend/src/config/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
databaseUrl: process.env.DATABASE_URL || 'file:./dev.db',
|
||||
logLevel: process.env.LOG_LEVEL || 'info',
|
||||
};
|
||||
205
backend/src/routes/devices.ts
Normal file
205
backend/src/routes/devices.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { deviceService } from '../services/deviceService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const createDeviceSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
ipAddress: z.string().min(1, 'IP address is required'),
|
||||
port: z.number().int().positive().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const updateDeviceSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
ipAddress: z.string().min(1).optional(),
|
||||
port: z.number().int().positive().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// GET /api/devices
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const devices = await deviceService.getAllDevices();
|
||||
res.json(devices);
|
||||
} catch (error) {
|
||||
console.error('Error fetching devices:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch devices' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/devices/:id
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const device = await deviceService.getDeviceById(req.params.id);
|
||||
if (!device) {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
|
||||
}
|
||||
res.json(device);
|
||||
} catch (error) {
|
||||
console.error('Error fetching device:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch device' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = createDeviceSchema.parse(req.body);
|
||||
const device = await deviceService.createDevice(body);
|
||||
res.status(201).json(device);
|
||||
} 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 device:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to create device' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/devices/:id
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = updateDeviceSchema.parse(req.body);
|
||||
const device = await deviceService.updateDevice(req.params.id, body);
|
||||
res.json(device);
|
||||
} 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: 'Device not found' });
|
||||
}
|
||||
console.error('Error updating device:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to update device' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/devices/:id
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await deviceService.deleteDevice(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: 'Device not found' });
|
||||
}
|
||||
console.error('Error deleting device:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to delete device' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/:id/ping
|
||||
router.post('/:id/ping', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await deviceService.pingDevice(req.params.id);
|
||||
if (result.status === 'error') {
|
||||
return res.status(502).json(result);
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Device not found') {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
|
||||
}
|
||||
console.error('Error pinging device:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to ping device' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/devices/:id/details
|
||||
router.get('/:id/details', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const details = await deviceService.getDeviceDetails(req.params.id);
|
||||
res.json(details);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Device not found') {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
|
||||
}
|
||||
console.error('Error fetching device details:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch device details' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/devices/:id/presets
|
||||
router.get('/:id/presets', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const presets = await deviceService.getDevicePresets(req.params.id);
|
||||
res.json(presets);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Device not found') {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
|
||||
}
|
||||
console.error('Error fetching device presets:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch presets' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/devices/status/all
|
||||
router.get('/status/all', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const devices = await deviceService.getAllDevicesWithStatus();
|
||||
res.json(devices);
|
||||
} catch (error) {
|
||||
console.error('Error fetching devices with status:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch devices status' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/:id/turn-on
|
||||
router.post('/:id/turn-on', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await deviceService.turnOnDevice(req.params.id);
|
||||
res.json({ status: 'ok', message: 'Device turned on' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Device not found') {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
|
||||
}
|
||||
console.error('Error turning on device:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to turn on device' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/:id/turn-off
|
||||
router.post('/:id/turn-off', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await deviceService.turnOffDevice(req.params.id);
|
||||
res.json({ status: 'ok', message: 'Device turned off' });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Device not found') {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Device not found' });
|
||||
}
|
||||
console.error('Error turning off device:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to turn off device' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/all/turn-on
|
||||
router.post('/all/turn-on', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await deviceService.turnOnAllDevices();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error turning on all devices:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to turn on all devices' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices/all/turn-off
|
||||
router.post('/all/turn-off', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await deviceService.turnOffAllDevices();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error turning off all devices:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to turn off all devices' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
173
backend/src/routes/groups.ts
Normal file
173
backend/src/routes/groups.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { groupService } from '../services/groupService';
|
||||
import { WledPlaylist } from '../wled/types';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const createGroupSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
deviceIds: z.array(z.string()).min(1, 'At least one device is required'),
|
||||
});
|
||||
|
||||
const updateGroupSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
deviceIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const applyPresetSchema = z.object({
|
||||
presetId: z.number().int().positive('Preset ID must be a positive number'),
|
||||
});
|
||||
|
||||
const applyPlaylistSchema = z.object({
|
||||
ps: z.array(z.number().int()).min(1, 'At least one preset is required'),
|
||||
dur: z.union([z.number().int(), z.array(z.number().int())]).optional(),
|
||||
transition: z.union([z.number().int(), z.array(z.number().int())]).optional(),
|
||||
repeat: z.number().int().optional(),
|
||||
end: z.number().int().optional(),
|
||||
});
|
||||
|
||||
// GET /api/groups
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const groups = await groupService.getAllGroups();
|
||||
res.json(groups);
|
||||
} catch (error) {
|
||||
console.error('Error fetching groups:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch groups' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/groups/:id
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const group = await groupService.getGroupById(req.params.id);
|
||||
if (!group) {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
|
||||
}
|
||||
res.json(group);
|
||||
} catch (error) {
|
||||
console.error('Error fetching group:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch group' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/groups
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = createGroupSchema.parse(req.body);
|
||||
const group = await groupService.createGroup(body);
|
||||
res.status(201).json(group);
|
||||
} 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(400).json({ error: 'ValidationError', message: error.message });
|
||||
}
|
||||
console.error('Error creating group:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to create group' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/groups/:id
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = updateGroupSchema.parse(req.body);
|
||||
const group = await groupService.updateGroup(req.params.id, body);
|
||||
res.json(group);
|
||||
} 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(400).json({ error: 'ValidationError', message: error.message });
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('Record to update not found')) {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
|
||||
}
|
||||
console.error('Error updating group:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to update group' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/groups/:id
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await groupService.deleteGroup(req.params.id);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('active schedules')) {
|
||||
return res.status(409).json({ error: 'Conflict', message: error.message });
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('Record to delete does not exist')) {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
|
||||
}
|
||||
console.error('Error deleting group:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to delete group' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/groups/:id/preset
|
||||
router.post('/:id/preset', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = applyPresetSchema.parse(req.body);
|
||||
const result = await groupService.applyPresetToGroup(req.params.id, body.presetId);
|
||||
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 === 'Group not found') {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
|
||||
}
|
||||
console.error('Error applying preset to group:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to apply preset' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/groups/:id/playlist
|
||||
router.post('/:id/playlist', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = applyPlaylistSchema.parse(req.body) as WledPlaylist;
|
||||
const result = await groupService.applyPlaylistToGroup(req.params.id, body);
|
||||
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 === 'Group not found') {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Group not found' });
|
||||
}
|
||||
console.error('Error applying playlist to group:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to apply playlist' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
176
backend/src/routes/schedules.ts
Normal file
176
backend/src/routes/schedules.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { scheduleService, ScheduleType } from '../services/scheduleService';
|
||||
import { schedulerService } from '../services/schedulerService';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const presetPayloadSchema = z.object({
|
||||
presetId: z.number().int().positive('Preset ID must be a positive number'),
|
||||
});
|
||||
|
||||
const playlistPayloadSchema = z.object({
|
||||
ps: z.array(z.number().int()).min(1, 'At least one preset is required'),
|
||||
dur: z.union([z.number().int(), z.array(z.number().int())]).optional(),
|
||||
transition: z.union([z.number().int(), z.array(z.number().int())]).optional(),
|
||||
repeat: z.number().int().optional(),
|
||||
end: z.number().int().optional(),
|
||||
});
|
||||
|
||||
const createScheduleSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
groupId: z.string().min(1, 'Group ID is required'),
|
||||
type: z.enum(['PRESET', 'PLAYLIST']),
|
||||
cronExpression: z.string().min(1, 'Cron expression is required'),
|
||||
endCronExpression: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
actionPayload: z.union([presetPayloadSchema, playlistPayloadSchema]),
|
||||
});
|
||||
|
||||
const updateScheduleSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
groupId: z.string().min(1).optional(),
|
||||
type: z.enum(['PRESET', 'PLAYLIST']).optional(),
|
||||
cronExpression: z.string().min(1).optional(),
|
||||
endCronExpression: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
actionPayload: z.union([presetPayloadSchema, playlistPayloadSchema]).optional(),
|
||||
});
|
||||
|
||||
// GET /api/schedules
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const schedules = await scheduleService.getAllSchedules();
|
||||
// Parse actionPayload for each schedule
|
||||
const schedulesWithParsedPayload = schedules.map((s) => ({
|
||||
...s,
|
||||
actionPayload: scheduleService.parseActionPayload(s),
|
||||
}));
|
||||
res.json(schedulesWithParsedPayload);
|
||||
} catch (error) {
|
||||
console.error('Error fetching schedules:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch schedules' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/schedules/:id
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const schedule = await scheduleService.getScheduleById(req.params.id);
|
||||
if (!schedule) {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Schedule not found' });
|
||||
}
|
||||
res.json({
|
||||
...schedule,
|
||||
actionPayload: scheduleService.parseActionPayload(schedule),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching schedule:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to fetch schedule' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/schedules
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = createScheduleSchema.parse(req.body);
|
||||
|
||||
// Validate actionPayload based on type
|
||||
if (body.type === 'PRESET') {
|
||||
presetPayloadSchema.parse(body.actionPayload);
|
||||
} else if (body.type === 'PLAYLIST') {
|
||||
playlistPayloadSchema.parse(body.actionPayload);
|
||||
}
|
||||
|
||||
const schedule = await scheduleService.createSchedule({
|
||||
...body,
|
||||
type: body.type as ScheduleType,
|
||||
});
|
||||
|
||||
// Register with scheduler immediately
|
||||
await schedulerService.registerSchedule(schedule.id);
|
||||
|
||||
res.status(201).json({
|
||||
...schedule,
|
||||
actionPayload: scheduleService.parseActionPayload(schedule),
|
||||
});
|
||||
} 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(400).json({ error: 'ValidationError', message: error.message });
|
||||
}
|
||||
console.error('Error creating schedule:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to create schedule' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/schedules/:id
|
||||
router.put('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const body = updateScheduleSchema.parse(req.body);
|
||||
|
||||
// Validate actionPayload based on type if both are provided
|
||||
if (body.type && body.actionPayload) {
|
||||
if (body.type === 'PRESET') {
|
||||
presetPayloadSchema.parse(body.actionPayload);
|
||||
} else if (body.type === 'PLAYLIST') {
|
||||
playlistPayloadSchema.parse(body.actionPayload);
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = await scheduleService.updateSchedule(req.params.id, {
|
||||
...body,
|
||||
type: body.type as ScheduleType | undefined,
|
||||
});
|
||||
|
||||
// Update scheduler
|
||||
await schedulerService.registerSchedule(schedule.id);
|
||||
|
||||
res.json({
|
||||
...schedule,
|
||||
actionPayload: scheduleService.parseActionPayload(schedule),
|
||||
});
|
||||
} 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(400).json({ error: 'ValidationError', message: error.message });
|
||||
}
|
||||
if (error instanceof Error && error.message.includes('Record to update not found')) {
|
||||
return res.status(404).json({ error: 'NotFound', message: 'Schedule not found' });
|
||||
}
|
||||
console.error('Error updating schedule:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to update schedule' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/schedules/:id
|
||||
router.delete('/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await scheduleService.deleteSchedule(req.params.id);
|
||||
|
||||
// Unregister from scheduler
|
||||
schedulerService.unregisterSchedule(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: 'Schedule not found' });
|
||||
}
|
||||
console.error('Error deleting schedule:', error);
|
||||
res.status(500).json({ error: 'InternalError', message: 'Failed to delete schedule' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
54
backend/src/server.ts
Normal file
54
backend/src/server.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createApp } from './app';
|
||||
import { config } from './config';
|
||||
import { schedulerService } from './services/schedulerService';
|
||||
import { prisma } from './utils/prisma';
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('Starting WLED Controller Backend...');
|
||||
|
||||
// Initialize scheduler
|
||||
await schedulerService.initialize();
|
||||
|
||||
// Create Express app
|
||||
const app = createApp();
|
||||
|
||||
// Start server
|
||||
const server = app.listen(config.port, () => {
|
||||
console.log(`Server running on port ${config.port}`);
|
||||
console.log(`Health check: http://localhost:${config.port}/health`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
|
||||
// Stop scheduler
|
||||
await schedulerService.shutdown();
|
||||
|
||||
// Close database connection
|
||||
await prisma.$disconnect();
|
||||
|
||||
// Close server
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force exit after 10 seconds
|
||||
setTimeout(() => {
|
||||
console.error('Forced shutdown after timeout');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
221
backend/src/services/deviceService.ts
Normal file
221
backend/src/services/deviceService.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Device } from '@prisma/client';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { WledClient } from '../wled/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
export interface CreateDeviceInput {
|
||||
name: string;
|
||||
ipAddress: string;
|
||||
port?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDeviceInput {
|
||||
name?: string;
|
||||
ipAddress?: string;
|
||||
port?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class DeviceService {
|
||||
async getAllDevices(): Promise<Device[]> {
|
||||
return prisma.device.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getDeviceById(id: string): Promise<Device | null> {
|
||||
return prisma.device.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async createDevice(input: CreateDeviceInput): Promise<Device> {
|
||||
return prisma.device.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
ipAddress: input.ipAddress,
|
||||
port: input.port ?? 80,
|
||||
enabled: input.enabled ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateDevice(id: string, input: UpdateDeviceInput): Promise<Device> {
|
||||
return prisma.device.update({
|
||||
where: { id },
|
||||
data: input,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDevice(id: string): Promise<void> {
|
||||
await prisma.device.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async pingDevice(id: string): Promise<{ status: 'ok' | 'error'; info?: unknown; error?: string }> {
|
||||
const device = await this.getDeviceById(id);
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const client = new WledClient(device.ipAddress, device.port);
|
||||
|
||||
try {
|
||||
const info = await client.getInfo();
|
||||
|
||||
// Update lastSeenAt
|
||||
await prisma.device.update({
|
||||
where: { id },
|
||||
data: { lastSeenAt: DateTime.now().toJSDate() },
|
||||
});
|
||||
|
||||
return { status: 'ok', info };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { status: 'error', error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
async getDeviceDetails(id: string): Promise<{
|
||||
device: Device;
|
||||
info: unknown;
|
||||
state: unknown;
|
||||
presets: unknown;
|
||||
effects: string[];
|
||||
palettes: string[];
|
||||
}> {
|
||||
const device = await this.getDeviceById(id);
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const client = new WledClient(device.ipAddress, device.port);
|
||||
|
||||
const [info, state, presets, effects, palettes] = await Promise.all([
|
||||
client.getInfo().catch(() => null),
|
||||
client.getState().catch(() => null),
|
||||
client.getPresets().catch(() => ({})),
|
||||
client.getEffects().catch(() => []),
|
||||
client.getPalettes().catch(() => []),
|
||||
]);
|
||||
|
||||
// Update lastSeenAt if we got a response
|
||||
if (info || state) {
|
||||
await this.updateLastSeen(id);
|
||||
}
|
||||
|
||||
return {
|
||||
device,
|
||||
info,
|
||||
state,
|
||||
presets,
|
||||
effects,
|
||||
palettes,
|
||||
};
|
||||
}
|
||||
|
||||
async getDevicePresets(id: string): Promise<unknown> {
|
||||
const device = await this.getDeviceById(id);
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const client = new WledClient(device.ipAddress, device.port);
|
||||
const presets = await client.getPresets();
|
||||
|
||||
await this.updateLastSeen(id);
|
||||
|
||||
return presets;
|
||||
}
|
||||
|
||||
async getAllDevicesWithStatus(): Promise<Array<Device & { currentState?: unknown; info?: unknown }>> {
|
||||
const devices = await this.getAllDevices();
|
||||
|
||||
const devicesWithStatus = await Promise.all(
|
||||
devices.map(async (device) => {
|
||||
if (!device.enabled) {
|
||||
return device;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new WledClient(device.ipAddress, device.port);
|
||||
const [state, info] = await Promise.allSettled([
|
||||
client.getState(),
|
||||
client.getInfo(),
|
||||
]);
|
||||
|
||||
return {
|
||||
...device,
|
||||
currentState: state.status === 'fulfilled' ? state.value : undefined,
|
||||
info: info.status === 'fulfilled' ? info.value : undefined,
|
||||
};
|
||||
} catch {
|
||||
return device;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return devicesWithStatus;
|
||||
}
|
||||
|
||||
async turnOnDevice(id: string): Promise<void> {
|
||||
const device = await this.getDeviceById(id);
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const client = new WledClient(device.ipAddress, device.port);
|
||||
await client.turnOn();
|
||||
await this.updateLastSeen(id);
|
||||
}
|
||||
|
||||
async turnOffDevice(id: string): Promise<void> {
|
||||
const device = await this.getDeviceById(id);
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const client = new WledClient(device.ipAddress, device.port);
|
||||
await client.turnOff();
|
||||
await this.updateLastSeen(id);
|
||||
}
|
||||
|
||||
async turnOnAllDevices(): Promise<{ success: number; failed: number }> {
|
||||
const devices = await this.getAllDevices();
|
||||
const enabledDevices = devices.filter(d => d.enabled);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
enabledDevices.map(device => this.turnOnDevice(device.id))
|
||||
);
|
||||
|
||||
const success = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
async turnOffAllDevices(): Promise<{ success: number; failed: number }> {
|
||||
const devices = await this.getAllDevices();
|
||||
const enabledDevices = devices.filter(d => d.enabled);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
enabledDevices.map(device => this.turnOffDevice(device.id))
|
||||
);
|
||||
|
||||
const success = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
async updateLastSeen(id: string): Promise<void> {
|
||||
await prisma.device.update({
|
||||
where: { id },
|
||||
data: { lastSeenAt: DateTime.now().toJSDate() },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const deviceService = new DeviceService();
|
||||
298
backend/src/services/groupService.ts
Normal file
298
backend/src/services/groupService.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import { Group, Device } from '@prisma/client';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { WledClient } from '../wled/client';
|
||||
import { WledPlaylist } from '../wled/types';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
export interface GroupWithDevices extends Group {
|
||||
devices: Device[];
|
||||
}
|
||||
|
||||
export interface CreateGroupInput {
|
||||
name: string;
|
||||
deviceIds: string[];
|
||||
}
|
||||
|
||||
export interface UpdateGroupInput {
|
||||
name?: string;
|
||||
deviceIds?: string[];
|
||||
}
|
||||
|
||||
export interface GroupActionResult {
|
||||
status: 'ok';
|
||||
groupId: string;
|
||||
results: {
|
||||
success: string[];
|
||||
failed: Array<{ deviceId: string; error: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export class GroupService {
|
||||
async getAllGroups(): Promise<GroupWithDevices[]> {
|
||||
const groups = await prisma.group.findMany({
|
||||
include: {
|
||||
devices: {
|
||||
include: {
|
||||
device: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
devices: group.devices.map((gd) => gd.device),
|
||||
}));
|
||||
}
|
||||
|
||||
async getGroupById(id: string): Promise<GroupWithDevices | null> {
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
devices: {
|
||||
include: {
|
||||
device: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!group) return null;
|
||||
|
||||
return {
|
||||
...group,
|
||||
devices: group.devices.map((gd) => gd.device),
|
||||
};
|
||||
}
|
||||
|
||||
async createGroup(input: CreateGroupInput): Promise<GroupWithDevices> {
|
||||
// Verify all devices exist
|
||||
const devices = await prisma.device.findMany({
|
||||
where: { id: { in: input.deviceIds } },
|
||||
});
|
||||
|
||||
if (devices.length !== input.deviceIds.length) {
|
||||
throw new Error('One or more device IDs not found');
|
||||
}
|
||||
|
||||
const group = await prisma.group.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
devices: {
|
||||
create: input.deviceIds.map((deviceId) => ({ deviceId })),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
devices: {
|
||||
include: {
|
||||
device: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...group,
|
||||
devices: group.devices.map((gd) => gd.device),
|
||||
};
|
||||
}
|
||||
|
||||
async updateGroup(id: string, input: UpdateGroupInput): Promise<GroupWithDevices> {
|
||||
// If deviceIds provided, verify they exist
|
||||
if (input.deviceIds) {
|
||||
const devices = await prisma.device.findMany({
|
||||
where: { id: { in: input.deviceIds } },
|
||||
});
|
||||
|
||||
if (devices.length !== input.deviceIds.length) {
|
||||
throw new Error('One or more device IDs not found');
|
||||
}
|
||||
|
||||
// Replace membership
|
||||
await prisma.groupDevice.deleteMany({
|
||||
where: { groupId: id },
|
||||
});
|
||||
|
||||
await prisma.groupDevice.createMany({
|
||||
data: input.deviceIds.map((deviceId) => ({ groupId: id, deviceId })),
|
||||
});
|
||||
}
|
||||
|
||||
const group = await prisma.group.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: input.name,
|
||||
},
|
||||
include: {
|
||||
devices: {
|
||||
include: {
|
||||
device: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...group,
|
||||
devices: group.devices.map((gd) => gd.device),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteGroup(id: string): Promise<void> {
|
||||
// Check for active schedules
|
||||
const schedules = await prisma.schedule.findMany({
|
||||
where: { groupId: id },
|
||||
});
|
||||
|
||||
if (schedules.length > 0) {
|
||||
throw new Error('Cannot delete group with active schedules');
|
||||
}
|
||||
|
||||
await prisma.group.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async applyPresetToGroup(
|
||||
groupId: string,
|
||||
presetId: number
|
||||
): 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); // Limit concurrency to 10
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
enabledDevices.map((device) =>
|
||||
limit(async () => {
|
||||
const client = new WledClient(device.ipAddress, device.port);
|
||||
await client.applyPreset(presetId);
|
||||
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 applyPlaylistToGroup(
|
||||
groupId: string,
|
||||
playlist: WledPlaylist
|
||||
): 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); // Limit concurrency to 10
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
enabledDevices.map((device) =>
|
||||
limit(async () => {
|
||||
const client = new WledClient(device.ipAddress, device.port);
|
||||
await client.applyPlaylist(playlist);
|
||||
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 turnOffGroup(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.turnOff();
|
||||
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) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const client = new WledClient(firstEnabledDevice.ipAddress, firstEnabledDevice.port);
|
||||
try {
|
||||
return await client.getPresets();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const groupService = new GroupService();
|
||||
153
backend/src/services/scheduleService.ts
Normal file
153
backend/src/services/scheduleService.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Schedule } from '@prisma/client';
|
||||
import { prisma } from '../utils/prisma';
|
||||
import { WledPlaylist } from '../wled/types';
|
||||
|
||||
export type ScheduleType = 'PRESET' | 'PLAYLIST';
|
||||
|
||||
export interface PresetActionPayload {
|
||||
presetId: number;
|
||||
}
|
||||
|
||||
export interface PlaylistActionPayload extends WledPlaylist {}
|
||||
|
||||
export type ActionPayload = PresetActionPayload | PlaylistActionPayload;
|
||||
|
||||
export interface CreateScheduleInput {
|
||||
name: string;
|
||||
groupId: string;
|
||||
type: ScheduleType;
|
||||
cronExpression: string;
|
||||
endCronExpression?: string;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
actionPayload: ActionPayload;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleInput {
|
||||
name?: string;
|
||||
groupId?: string;
|
||||
type?: ScheduleType;
|
||||
cronExpression?: string;
|
||||
endCronExpression?: string;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
actionPayload?: ActionPayload;
|
||||
}
|
||||
|
||||
export interface ScheduleWithGroup extends Omit<Schedule, 'group'> {
|
||||
group: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ScheduleService {
|
||||
async getAllSchedules(): Promise<ScheduleWithGroup[]> {
|
||||
return prisma.schedule.findMany({
|
||||
include: {
|
||||
group: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getScheduleById(id: string): Promise<ScheduleWithGroup | null> {
|
||||
return prisma.schedule.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
group: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createSchedule(input: CreateScheduleInput): Promise<ScheduleWithGroup> {
|
||||
// Verify group exists
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id: input.groupId },
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
return prisma.schedule.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
groupId: input.groupId,
|
||||
type: input.type,
|
||||
cronExpression: input.cronExpression,
|
||||
endCronExpression: input.endCronExpression,
|
||||
timezone: input.timezone ?? 'Europe/London',
|
||||
enabled: input.enabled ?? true,
|
||||
actionPayload: JSON.stringify(input.actionPayload),
|
||||
},
|
||||
include: {
|
||||
group: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateSchedule(id: string, input: UpdateScheduleInput): Promise<ScheduleWithGroup> {
|
||||
// If groupId changed, verify it exists
|
||||
if (input.groupId) {
|
||||
const group = await prisma.group.findUnique({
|
||||
where: { id: input.groupId },
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.groupId !== undefined) updateData.groupId = input.groupId;
|
||||
if (input.type !== undefined) updateData.type = input.type;
|
||||
if (input.cronExpression !== undefined) updateData.cronExpression = input.cronExpression;
|
||||
if (input.endCronExpression !== undefined) updateData.endCronExpression = input.endCronExpression;
|
||||
if (input.timezone !== undefined) updateData.timezone = input.timezone;
|
||||
if (input.enabled !== undefined) updateData.enabled = input.enabled;
|
||||
if (input.actionPayload !== undefined)
|
||||
updateData.actionPayload = JSON.stringify(input.actionPayload);
|
||||
|
||||
return prisma.schedule.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
group: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSchedule(id: string): Promise<void> {
|
||||
await prisma.schedule.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
parseActionPayload(schedule: Schedule): ActionPayload {
|
||||
return JSON.parse(schedule.actionPayload);
|
||||
}
|
||||
}
|
||||
|
||||
export const scheduleService = new ScheduleService();
|
||||
140
backend/src/services/schedulerService.ts
Normal file
140
backend/src/services/schedulerService.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import cron from 'node-cron';
|
||||
import { scheduleService, PresetActionPayload } from './scheduleService';
|
||||
import { groupService } from './groupService';
|
||||
import { WledPlaylist } from '../wled/types';
|
||||
|
||||
interface ScheduledTask {
|
||||
startTask: cron.ScheduledTask;
|
||||
endTask?: cron.ScheduledTask;
|
||||
scheduleId: string;
|
||||
}
|
||||
|
||||
export class SchedulerService {
|
||||
private tasks: Map<string, ScheduledTask> = new Map();
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
console.log('Initializing scheduler...');
|
||||
const schedules = await scheduleService.getAllSchedules();
|
||||
const enabledSchedules = schedules.filter((s) => s.enabled);
|
||||
|
||||
console.log(`Found ${enabledSchedules.length} enabled schedules`);
|
||||
|
||||
for (const schedule of enabledSchedules) {
|
||||
this.registerSchedule(schedule.id);
|
||||
}
|
||||
}
|
||||
|
||||
async registerSchedule(scheduleId: string): Promise<void> {
|
||||
// Remove existing task if any
|
||||
this.unregisterSchedule(scheduleId);
|
||||
|
||||
const schedule = await scheduleService.getScheduleById(scheduleId);
|
||||
if (!schedule || !schedule.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Register start task
|
||||
const startTask = cron.schedule(
|
||||
schedule.cronExpression,
|
||||
async () => {
|
||||
console.log(`Executing schedule: ${schedule.name} (${schedule.id})`);
|
||||
await this.executeSchedule(scheduleId);
|
||||
},
|
||||
{
|
||||
timezone: schedule.timezone,
|
||||
scheduled: true,
|
||||
}
|
||||
);
|
||||
|
||||
const scheduledTask: ScheduledTask = {
|
||||
startTask,
|
||||
scheduleId
|
||||
};
|
||||
|
||||
// Register end task if endCronExpression exists
|
||||
if (schedule.endCronExpression) {
|
||||
const endTask = cron.schedule(
|
||||
schedule.endCronExpression,
|
||||
async () => {
|
||||
console.log(`Executing end schedule (turning off): ${schedule.name} (${schedule.id})`);
|
||||
await this.turnOffGroup(schedule.groupId);
|
||||
},
|
||||
{
|
||||
timezone: schedule.timezone,
|
||||
scheduled: true,
|
||||
}
|
||||
);
|
||||
scheduledTask.endTask = endTask;
|
||||
console.log(
|
||||
`Registered schedule: ${schedule.name} with start: ${schedule.cronExpression}, end: ${schedule.endCronExpression} (${schedule.timezone})`
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`Registered schedule: ${schedule.name} with cron: ${schedule.cronExpression} (${schedule.timezone})`
|
||||
);
|
||||
}
|
||||
|
||||
this.tasks.set(scheduleId, scheduledTask);
|
||||
} catch (error) {
|
||||
console.error(`Failed to register schedule ${scheduleId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
unregisterSchedule(scheduleId: string): void {
|
||||
const existing = this.tasks.get(scheduleId);
|
||||
if (existing) {
|
||||
existing.startTask.stop();
|
||||
if (existing.endTask) {
|
||||
existing.endTask.stop();
|
||||
}
|
||||
this.tasks.delete(scheduleId);
|
||||
console.log(`Unregistered schedule: ${scheduleId}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeSchedule(scheduleId: string): Promise<void> {
|
||||
try {
|
||||
const schedule = await scheduleService.getScheduleById(scheduleId);
|
||||
if (!schedule) {
|
||||
console.error(`Schedule ${scheduleId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const actionPayload = scheduleService.parseActionPayload(schedule);
|
||||
|
||||
if (schedule.type === 'PRESET') {
|
||||
const payload = actionPayload as PresetActionPayload;
|
||||
console.log(`Applying preset ${payload.presetId} to group ${schedule.groupId}`);
|
||||
const result = await groupService.applyPresetToGroup(schedule.groupId, payload.presetId);
|
||||
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}`);
|
||||
const result = await groupService.applyPlaylistToGroup(schedule.groupId, payload);
|
||||
console.log(`Playlist applied. Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing schedule ${scheduleId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async turnOffGroup(groupId: string): Promise<void> {
|
||||
try {
|
||||
console.log(`Turning off group ${groupId}`);
|
||||
const result = await groupService.turnOffGroup(groupId);
|
||||
console.log(`Group turned off. Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
|
||||
} catch (error) {
|
||||
console.error(`Error turning off group ${groupId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
console.log('Shutting down scheduler...');
|
||||
for (const [scheduleId] of this.tasks) {
|
||||
this.unregisterSchedule(scheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const schedulerService = new SchedulerService();
|
||||
3
backend/src/utils/prisma.ts
Normal file
3
backend/src/utils/prisma.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
120
backend/src/wled/client.ts
Normal file
120
backend/src/wled/client.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { WledError, WledInfo, WledPlaylist, WledState, WledPresets, WledEffect, WledPalette } from './types';
|
||||
|
||||
export class WledClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(
|
||||
private ipAddress: string,
|
||||
private port: number = 80
|
||||
) {
|
||||
this.baseUrl = `http://${ipAddress}:${port}`;
|
||||
}
|
||||
|
||||
async getState(): Promise<WledState> {
|
||||
const url = `${this.baseUrl}/json/state`;
|
||||
return this.fetch<WledState>(url);
|
||||
}
|
||||
|
||||
async getInfo(): Promise<WledInfo> {
|
||||
const url = `${this.baseUrl}/json/info`;
|
||||
return this.fetch<WledInfo>(url);
|
||||
}
|
||||
|
||||
async getPresets(): Promise<WledPresets> {
|
||||
const url = `${this.baseUrl}/presets.json`;
|
||||
return this.fetch<WledPresets>(url);
|
||||
}
|
||||
|
||||
async getEffects(): Promise<string[]> {
|
||||
const url = `${this.baseUrl}/json/effects`;
|
||||
return this.fetch<string[]>(url);
|
||||
}
|
||||
|
||||
async getPalettes(): Promise<string[]> {
|
||||
const url = `${this.baseUrl}/json/palettes`;
|
||||
return this.fetch<string[]>(url);
|
||||
}
|
||||
|
||||
async applyPreset(presetId: number): Promise<void> {
|
||||
const url = `${this.baseUrl}/json/state`;
|
||||
await this.post(url, { ps: presetId });
|
||||
}
|
||||
|
||||
async applyPlaylist(playlist: WledPlaylist): Promise<void> {
|
||||
const url = `${this.baseUrl}/json/state`;
|
||||
await this.post(url, { playlist });
|
||||
}
|
||||
|
||||
async turnOn(): Promise<void> {
|
||||
const url = `${this.baseUrl}/json/state`;
|
||||
await this.post(url, { on: true });
|
||||
}
|
||||
|
||||
async turnOff(): Promise<void> {
|
||||
const url = `${this.baseUrl}/json/state`;
|
||||
await this.post(url, { on: false });
|
||||
}
|
||||
|
||||
private async fetch<T>(url: string): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(5000), // 5 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new WledError(
|
||||
`HTTP ${response.status} from WLED device`,
|
||||
url,
|
||||
response.status,
|
||||
text
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (error instanceof WledError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
throw new WledError(error.message, url);
|
||||
}
|
||||
throw new WledError('Unknown error', url);
|
||||
}
|
||||
}
|
||||
|
||||
private async post(url: string, body: unknown): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(5000), // 5 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new WledError(
|
||||
`HTTP ${response.status} from WLED device`,
|
||||
url,
|
||||
response.status,
|
||||
text
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WledError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
throw new WledError(error.message, url);
|
||||
}
|
||||
throw new WledError('Unknown error', url);
|
||||
}
|
||||
}
|
||||
}
|
||||
116
backend/src/wled/types.ts
Normal file
116
backend/src/wled/types.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
export interface WledPlaylist {
|
||||
ps: number[]; // preset IDs
|
||||
dur?: number[] | number; // tenths of seconds
|
||||
transition?: number[] | number;
|
||||
repeat?: number; // 0 = infinite
|
||||
end?: number; // preset to set when finished
|
||||
}
|
||||
|
||||
export interface WledSegment {
|
||||
id: number;
|
||||
start: number;
|
||||
stop: number;
|
||||
len: number;
|
||||
grp?: number;
|
||||
spc?: number;
|
||||
of?: number;
|
||||
on: boolean;
|
||||
bri: number;
|
||||
col: number[][];
|
||||
fx: number;
|
||||
sx: number;
|
||||
ix: number;
|
||||
pal: number;
|
||||
sel: boolean;
|
||||
rev: boolean;
|
||||
mi: boolean;
|
||||
n?: string;
|
||||
}
|
||||
|
||||
export interface WledState {
|
||||
on?: boolean;
|
||||
bri?: number;
|
||||
ps?: number;
|
||||
pl?: number;
|
||||
transition?: number;
|
||||
mainseg?: number;
|
||||
seg?: WledSegment[];
|
||||
playlist?: WledPlaylist;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WledInfo {
|
||||
ver?: string;
|
||||
vid?: number;
|
||||
leds?: {
|
||||
count?: number;
|
||||
maxpwr?: number;
|
||||
maxseg?: number;
|
||||
seglc?: number[];
|
||||
lc?: number;
|
||||
rgbw?: boolean;
|
||||
wv?: number;
|
||||
cct?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
name?: string;
|
||||
udpport?: number;
|
||||
live?: boolean;
|
||||
fxcount?: number;
|
||||
palcount?: number;
|
||||
wifi?: {
|
||||
bssid?: string;
|
||||
rssi?: number;
|
||||
signal?: number;
|
||||
channel?: number;
|
||||
};
|
||||
fs?: {
|
||||
u?: number;
|
||||
t?: number;
|
||||
pmt?: number;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WledPreset {
|
||||
n?: string;
|
||||
ps?: number;
|
||||
on?: boolean;
|
||||
bri?: number;
|
||||
transition?: number;
|
||||
mainseg?: number;
|
||||
seg?: Partial<WledSegment>[];
|
||||
win?: string;
|
||||
ib?: boolean;
|
||||
o?: boolean;
|
||||
v?: boolean;
|
||||
rb?: boolean;
|
||||
time?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WledPresets {
|
||||
[key: string]: WledPreset | string;
|
||||
}
|
||||
|
||||
export interface WledEffect {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface WledPalette {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class WledError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public url: string,
|
||||
public statusCode?: number,
|
||||
public responseText?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'WledError';
|
||||
}
|
||||
}
|
||||
20
backend/tsconfig.json
Normal file
20
backend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
27
docker-compose.yml
Normal file
27
docker-compose.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: wled-backend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- DATABASE_URL=file:/data/wled.db
|
||||
- LOG_LEVEL=info
|
||||
volumes:
|
||||
- wled-data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: wled-frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
wled-data:
|
||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:3000/api
|
||||
15
frontend/.eslintrc.json
Normal file
15
frontend/.eslintrc.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn"
|
||||
}
|
||||
}
|
||||
4
frontend/.gitignore
vendored
Normal file
4
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env.local
|
||||
.DS_Store
|
||||
7
frontend/.prettierrc.json
Normal file
7
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM node:20-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Production image
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WLED Controller</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
3503
frontend/package-lock.json
generated
Normal file
3503
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "wled-controller-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"luxon": "^3.4.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.3.7",
|
||||
"@types/react": "^18.2.47",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.18.0",
|
||||
"@typescript-eslint/parser": "^6.18.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"prettier": "^3.1.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
303
frontend/src/App.css
Normal file
303
frontend/src/App.css
Normal file
@@ -0,0 +1,303 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background-color: #2c3e50;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.sidebar h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.sidebar nav ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar nav li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sidebar nav a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.sidebar nav a:hover,
|
||||
.sidebar nav a.active {
|
||||
background-color: #34495e;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: #34495e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.4s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider {
|
||||
background-color: #2ecc71;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #c33;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #efe;
|
||||
color: #3c3;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
46
frontend/src/App.tsx
Normal file
46
frontend/src/App.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { BrowserRouter, Routes, Route, Link, Navigate } from 'react-router-dom';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { DevicesPage } from './pages/DevicesPage';
|
||||
import { GroupsPage } from './pages/GroupsPage';
|
||||
import { SchedulesPage } from './pages/SchedulesPage';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<h1>WLED Controller</h1>
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/dashboard">Dashboard</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/devices">Devices</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/groups">Groups</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/schedules">Schedules</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/devices" element={<DevicesPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/schedules" element={<SchedulesPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
65
frontend/src/api/client.ts
Normal file
65
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public data?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
data.message || `HTTP ${response.status}`,
|
||||
response.status,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ApiError('Network error', 0);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||
|
||||
post: <T>(endpoint: string, body?: unknown) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
put: <T>(endpoint: string, body?: unknown) =>
|
||||
fetchApi<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}),
|
||||
|
||||
delete: <T>(endpoint: string) => fetchApi<T>(endpoint, { method: 'DELETE' }),
|
||||
};
|
||||
39
frontend/src/api/devices.ts
Normal file
39
frontend/src/api/devices.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { apiClient } from './client';
|
||||
import { Device, PingResult } from './types';
|
||||
|
||||
export interface CreateDeviceInput {
|
||||
name: string;
|
||||
ipAddress: string;
|
||||
port?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateDeviceInput {
|
||||
name?: string;
|
||||
ipAddress?: string;
|
||||
port?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const deviceApi = {
|
||||
getAll: () => apiClient.get<Device[]>('/devices'),
|
||||
|
||||
getById: (id: string) => apiClient.get<Device>(`/devices/${id}`),
|
||||
|
||||
create: (data: CreateDeviceInput) => apiClient.post<Device>('/devices', data),
|
||||
|
||||
update: (id: string, data: UpdateDeviceInput) =>
|
||||
apiClient.put<Device>(`/devices/${id}`, data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/devices/${id}`),
|
||||
|
||||
ping: (id: string) => apiClient.post<PingResult>(`/devices/${id}/ping`),
|
||||
|
||||
turnOn: (id: string) => apiClient.post(`/devices/${id}/turn-on`),
|
||||
|
||||
turnOff: (id: string) => apiClient.post(`/devices/${id}/turn-off`),
|
||||
|
||||
turnOnAll: () => apiClient.post('/devices/all/turn-on'),
|
||||
|
||||
turnOffAll: () => apiClient.post('/devices/all/turn-off'),
|
||||
};
|
||||
31
frontend/src/api/groups.ts
Normal file
31
frontend/src/api/groups.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { apiClient } from './client';
|
||||
import { Group, GroupActionResult, PlaylistActionPayload } from './types';
|
||||
|
||||
export interface CreateGroupInput {
|
||||
name: string;
|
||||
deviceIds: string[];
|
||||
}
|
||||
|
||||
export interface UpdateGroupInput {
|
||||
name?: string;
|
||||
deviceIds?: string[];
|
||||
}
|
||||
|
||||
export const groupApi = {
|
||||
getAll: () => apiClient.get<Group[]>('/groups'),
|
||||
|
||||
getById: (id: string) => apiClient.get<Group>(`/groups/${id}`),
|
||||
|
||||
create: (data: CreateGroupInput) => apiClient.post<Group>('/groups', data),
|
||||
|
||||
update: (id: string, data: UpdateGroupInput) =>
|
||||
apiClient.put<Group>(`/groups/${id}`, data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/groups/${id}`),
|
||||
|
||||
applyPreset: (groupId: string, presetId: number) =>
|
||||
apiClient.post<GroupActionResult>(`/groups/${groupId}/preset`, { presetId }),
|
||||
|
||||
applyPlaylist: (groupId: string, playlist: PlaylistActionPayload) =>
|
||||
apiClient.post<GroupActionResult>(`/groups/${groupId}/playlist`, playlist),
|
||||
};
|
||||
37
frontend/src/api/schedules.ts
Normal file
37
frontend/src/api/schedules.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { apiClient } from './client';
|
||||
import { Schedule, PresetActionPayload, PlaylistActionPayload } from './types';
|
||||
|
||||
export interface CreateScheduleInput {
|
||||
name: string;
|
||||
groupId: string;
|
||||
type: 'PRESET' | 'PLAYLIST';
|
||||
cronExpression: string;
|
||||
endCronExpression?: string;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
actionPayload: PresetActionPayload | PlaylistActionPayload;
|
||||
}
|
||||
|
||||
export interface UpdateScheduleInput {
|
||||
name?: string;
|
||||
groupId?: string;
|
||||
type?: 'PRESET' | 'PLAYLIST';
|
||||
cronExpression?: string;
|
||||
endCronExpression?: string;
|
||||
timezone?: string;
|
||||
enabled?: boolean;
|
||||
actionPayload?: PresetActionPayload | PlaylistActionPayload;
|
||||
}
|
||||
|
||||
export const scheduleApi = {
|
||||
getAll: () => apiClient.get<Schedule[]>('/schedules'),
|
||||
|
||||
getById: (id: string) => apiClient.get<Schedule>(`/schedules/${id}`),
|
||||
|
||||
create: (data: CreateScheduleInput) => apiClient.post<Schedule>('/schedules', data),
|
||||
|
||||
update: (id: string, data: UpdateScheduleInput) =>
|
||||
apiClient.put<Schedule>(`/schedules/${id}`, data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/schedules/${id}`),
|
||||
};
|
||||
63
frontend/src/api/types.ts
Normal file
63
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
ipAddress: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
lastSeenAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
devices: Device[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
id: string;
|
||||
name: string;
|
||||
groupId: string;
|
||||
type: 'PRESET' | 'PLAYLIST';
|
||||
cronExpression: string;
|
||||
endCronExpression?: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
actionPayload: PresetActionPayload | PlaylistActionPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
group: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PresetActionPayload {
|
||||
presetId: number;
|
||||
}
|
||||
|
||||
export interface PlaylistActionPayload {
|
||||
ps: number[];
|
||||
dur?: number[] | number;
|
||||
transition?: number[] | number;
|
||||
repeat?: number;
|
||||
end?: number;
|
||||
}
|
||||
|
||||
export interface GroupActionResult {
|
||||
status: 'ok';
|
||||
groupId: string;
|
||||
results: {
|
||||
success: string[];
|
||||
failed: Array<{ deviceId: string; error: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PingResult {
|
||||
status: 'ok' | 'error';
|
||||
info?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
255
frontend/src/components/ScheduleComponents.tsx
Normal file
255
frontend/src/components/ScheduleComponents.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { timePickerToCron, cronToTimePicker, DAYS_OF_WEEK, TimePickerValue } from '../utils/timePicker';
|
||||
|
||||
interface TimePickerProps {
|
||||
value: string; // cron expression
|
||||
onChange: (cron: string) => void;
|
||||
}
|
||||
|
||||
export function TimePicker({ value, onChange }: TimePickerProps) {
|
||||
const [mode, setMode] = useState<'simple' | 'advanced'>('simple');
|
||||
const [timeValue, setTimeValue] = useState<TimePickerValue>(() => {
|
||||
const parsed = cronToTimePicker(value);
|
||||
return parsed || { hour: 18, minute: 30, daysOfWeek: [0, 1, 2, 3, 4, 5, 6] };
|
||||
});
|
||||
|
||||
const handleTimeChange = (newValue: Partial<TimePickerValue>) => {
|
||||
const updated = { ...timeValue, ...newValue };
|
||||
setTimeValue(updated);
|
||||
onChange(timePickerToCron(updated));
|
||||
};
|
||||
|
||||
const toggleDay = (day: number) => {
|
||||
const days = timeValue.daysOfWeek.includes(day)
|
||||
? timeValue.daysOfWeek.filter(d => d !== day)
|
||||
: [...timeValue.daysOfWeek, day].sort((a, b) => a - b);
|
||||
|
||||
handleTimeChange({ daysOfWeek: days.length > 0 ? days : [0, 1, 2, 3, 4, 5, 6] });
|
||||
};
|
||||
|
||||
const selectAllDays = () => {
|
||||
handleTimeChange({ daysOfWeek: [0, 1, 2, 3, 4, 5, 6] });
|
||||
};
|
||||
|
||||
const selectWeekdays = () => {
|
||||
handleTimeChange({ daysOfWeek: [1, 2, 3, 4, 5] });
|
||||
};
|
||||
|
||||
const selectWeekends = () => {
|
||||
handleTimeChange({ daysOfWeek: [0, 6] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<input
|
||||
type="radio"
|
||||
checked={mode === 'simple'}
|
||||
onChange={() => setMode('simple')}
|
||||
/>
|
||||
Simple Time Picker
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<input
|
||||
type="radio"
|
||||
checked={mode === 'advanced'}
|
||||
onChange={() => setMode('advanced')}
|
||||
/>
|
||||
Advanced (Cron Expression)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mode === 'simple' ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px', alignItems: 'center' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontSize: '14px' }}>Hour</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
value={timeValue.hour}
|
||||
onChange={(e) => handleTimeChange({ hour: parseInt(e.target.value) })}
|
||||
style={{ width: '70px', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '5px', fontSize: '14px' }}>Minute</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={timeValue.minute}
|
||||
onChange={(e) => handleTimeChange({ minute: parseInt(e.target.value) })}
|
||||
style={{ width: '70px', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginLeft: '20px', fontSize: '24px', fontWeight: 'bold' }}>
|
||||
{String(timeValue.hour).padStart(2, '0')}:{String(timeValue.minute).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '500' }}>Days of Week</label>
|
||||
<div style={{ display: 'flex', gap: '5px', marginBottom: '10px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={selectAllDays}
|
||||
>
|
||||
All Days
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={selectWeekdays}
|
||||
>
|
||||
Weekdays
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={selectWeekends}
|
||||
>
|
||||
Weekends
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{DAYS_OF_WEEK.map((day) => (
|
||||
<button
|
||||
key={day.value}
|
||||
type="button"
|
||||
onClick={() => toggleDay(day.value)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
border: '2px solid',
|
||||
borderColor: timeValue.daysOfWeek.includes(day.value) ? '#3498db' : '#ddd',
|
||||
backgroundColor: timeValue.daysOfWeek.includes(day.value) ? '#3498db' : 'white',
|
||||
color: timeValue.daysOfWeek.includes(day.value) ? 'white' : '#333',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '10px', fontSize: '12px', color: '#7f8c8d' }}>
|
||||
Cron: {timePickerToCron(timeValue)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="0 18 * * *"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div style={{ marginTop: '5px', fontSize: '12px', color: '#7f8c8d' }}>
|
||||
Format: minute hour day month dayofweek
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PresetSelectorProps {
|
||||
groupId: string;
|
||||
selectedPresets: number[];
|
||||
onChange: (presets: number[]) => void;
|
||||
mode: 'single' | 'multiple';
|
||||
}
|
||||
|
||||
export function PresetSelector({ groupId, selectedPresets, onChange, mode }: PresetSelectorProps) {
|
||||
const [presets, setPresets] = useState<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupId) {
|
||||
loadPresets();
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/groups/${groupId}/presets`);
|
||||
const data = await response.json();
|
||||
setPresets(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load presets:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (presetId: number) => {
|
||||
if (mode === 'single') {
|
||||
onChange([presetId]);
|
||||
} else {
|
||||
if (selectedPresets.includes(presetId)) {
|
||||
onChange(selectedPresets.filter(id => id !== presetId));
|
||||
} else {
|
||||
onChange([...selectedPresets, presetId]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '20px', textAlign: 'center' }}>Loading presets...</div>;
|
||||
|
||||
const presetEntries = Object.entries(presets)
|
||||
.filter(([key]) => key !== '0')
|
||||
.map(([key, value]) => {
|
||||
const id = parseInt(key);
|
||||
const name = typeof value === 'object' && value.n ? value.n : `Preset ${id}`;
|
||||
return { id, name };
|
||||
})
|
||||
.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={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
padding: '10px'
|
||||
}}>
|
||||
{presetEntries.map(({ id, name }) => (
|
||||
<div
|
||||
key={id}
|
||||
onClick={() => handleToggle(id)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
marginBottom: '5px',
|
||||
border: '2px solid',
|
||||
borderColor: selectedPresets.includes(id) ? '#3498db' : '#ecf0f1',
|
||||
backgroundColor: selectedPresets.includes(id) ? '#ebf5fb' : 'white',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>#{id}</strong> {name}
|
||||
</span>
|
||||
{mode === 'multiple' && selectedPresets.includes(id) && (
|
||||
<span style={{ color: '#3498db' }}>✓</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
294
frontend/src/pages/DashboardPage.tsx
Normal file
294
frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
import { deviceApi } from '../api/devices';
|
||||
|
||||
interface DeviceStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
ipAddress: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
lastSeenAt: string | null;
|
||||
currentState?: {
|
||||
on?: boolean;
|
||||
bri?: number;
|
||||
ps?: number;
|
||||
seg?: Array<{
|
||||
id: number;
|
||||
start: number;
|
||||
stop: number;
|
||||
len: number;
|
||||
on: boolean;
|
||||
bri: number;
|
||||
n?: string;
|
||||
}>;
|
||||
};
|
||||
info?: {
|
||||
ver?: string;
|
||||
leds?: {
|
||||
count?: number;
|
||||
maxseg?: number;
|
||||
};
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [devices, setDevices] = useState<DeviceStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
loadDevices();
|
||||
}, 5000); // Refresh every 5 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh]);
|
||||
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/devices/status/all');
|
||||
const data = await response.json();
|
||||
setDevices(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load device status:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading">Loading dashboard...</div>;
|
||||
|
||||
const onlineDevices = devices.filter(d => d.enabled && d.currentState).length;
|
||||
const totalEnabled = devices.filter(d => d.enabled).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>Dashboard</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
/>
|
||||
Auto-refresh
|
||||
</label>
|
||||
<button className="btn btn-secondary" onClick={loadDevices}>
|
||||
Refresh Now
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deviceApi.turnOnAll();
|
||||
setTimeout(loadDevices, 500);
|
||||
} catch (err) {
|
||||
alert('Failed to turn on all devices');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Turn All On
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deviceApi.turnOffAll();
|
||||
setTimeout(loadDevices, 500);
|
||||
} catch (err) {
|
||||
alert('Failed to turn off all devices');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Turn All Off
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px', marginBottom: '30px' }}>
|
||||
<div className="card">
|
||||
<h3>Total Devices</h3>
|
||||
<div style={{ fontSize: '48px', fontWeight: 'bold', color: '#3498db' }}>{devices.length}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Online</h3>
|
||||
<div style={{ fontSize: '48px', fontWeight: 'bold', color: '#2ecc71' }}>
|
||||
{onlineDevices} / {totalEnabled}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Total LEDs</h3>
|
||||
<div style={{ fontSize: '48px', fontWeight: 'bold', color: '#9b59b6' }}>
|
||||
{devices.reduce((sum, d) => sum + (d.info?.leds?.count || 0), 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '20px' }}>
|
||||
{devices.map((device) => (
|
||||
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeviceCardProps {
|
||||
device: DeviceStatus;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function DeviceCard({ device, onRefresh }: DeviceCardProps) {
|
||||
const isOnline = device.enabled && device.currentState !== undefined;
|
||||
const isOn = device.currentState?.on ?? false;
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleTurnOn = async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await deviceApi.turnOn(device.id);
|
||||
setTimeout(onRefresh, 500);
|
||||
} catch (err) {
|
||||
alert('Failed to turn on device');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTurnOff = async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await deviceApi.turnOff(device.id);
|
||||
setTimeout(onRefresh, 500);
|
||||
} catch (err) {
|
||||
alert('Failed to turn off device');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: '15px', right: '15px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isOnline ? '#2ecc71' : '#e74c3c',
|
||||
}}
|
||||
title={isOnline ? 'Online' : 'Offline'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: '10px', paddingRight: '30px' }}>{device.name}</h3>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginBottom: '15px' }}>
|
||||
{device.ipAddress}:{device.port}
|
||||
</div>
|
||||
|
||||
{isOnline && (
|
||||
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
className="btn btn-small btn-primary"
|
||||
onClick={handleTurnOn}
|
||||
disabled={busy || isOn}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Turn On
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={handleTurnOff}
|
||||
disabled={busy || !isOn}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
Turn Off
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOnline ? (
|
||||
<>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>Status:</strong>{' '}
|
||||
<span style={{ color: isOn ? '#2ecc71' : '#95a5a6' }}>
|
||||
{isOn ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{device.currentState?.bri !== undefined && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>Brightness:</strong> {Math.round((device.currentState.bri / 255) * 100)}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.currentState?.ps !== undefined && device.currentState.ps > 0 && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>Preset:</strong> #{device.currentState.ps}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.info?.leds?.count !== undefined && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>LEDs:</strong> {device.info.leds.count}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.info?.ver && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>Version:</strong> {device.info.ver}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.currentState?.seg && device.currentState.seg.length > 0 && (
|
||||
<div style={{ marginTop: '15px' }}>
|
||||
<strong>Segments ({device.currentState.seg.length}):</strong>
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
{device.currentState.seg.map((seg) => (
|
||||
<div
|
||||
key={seg.id}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
padding: '6px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
marginBottom: '4px',
|
||||
borderRadius: '4px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{seg.n || `Segment ${seg.id}`} ({seg.len} LEDs)
|
||||
</span>
|
||||
<span style={{ color: seg.on ? '#2ecc71' : '#95a5a6' }}>
|
||||
{seg.on ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: '#95a5a6', fontStyle: 'italic' }}>
|
||||
{device.enabled ? 'Device offline or unreachable' : 'Device disabled'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.lastSeenAt && (
|
||||
<div style={{ marginTop: '15px', fontSize: '12px', color: '#95a5a6', borderTop: '1px solid #ecf0f1', paddingTop: '10px' }}>
|
||||
Last seen: {formatDateTime(device.lastSeenAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
frontend/src/pages/DevicesPage.tsx
Normal file
252
frontend/src/pages/DevicesPage.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { deviceApi } from '../api/devices';
|
||||
import { Device } from '../api/types';
|
||||
import { formatDateTime } from '../utils/dateTime';
|
||||
|
||||
export function DevicesPage() {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingDevice, setEditingDevice] = useState<Device | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
}, []);
|
||||
|
||||
const loadDevices = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await deviceApi.getAll();
|
||||
setDevices(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load devices');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePing = async (id: string) => {
|
||||
try {
|
||||
const result = await deviceApi.ping(id);
|
||||
if (result.status === 'ok') {
|
||||
alert('Device is reachable!');
|
||||
loadDevices();
|
||||
} else {
|
||||
alert(`Device error: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to ping device');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (device: Device) => {
|
||||
try {
|
||||
await deviceApi.update(device.id, { enabled: !device.enabled });
|
||||
loadDevices();
|
||||
} catch (err) {
|
||||
alert('Failed to update device');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this device?')) return;
|
||||
|
||||
try {
|
||||
await deviceApi.delete(id);
|
||||
loadDevices();
|
||||
} catch (err) {
|
||||
alert('Failed to delete device');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingDevice(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (device: Device) => {
|
||||
setEditingDevice(device);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading">Loading devices...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>Devices</h2>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Port</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((device) => (
|
||||
<tr key={device.id}>
|
||||
<td>{device.name}</td>
|
||||
<td>{device.ipAddress}</td>
|
||||
<td>{device.port}</td>
|
||||
<td>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={device.enabled}
|
||||
onChange={() => handleToggleEnabled(device)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>{formatDateTime(device.lastSeenAt)}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => handlePing(device.id)}
|
||||
style={{ marginRight: '5px' }}
|
||||
>
|
||||
Ping
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => openEditModal(device)}
|
||||
style={{ marginRight: '5px' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={() => handleDelete(device.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showModal && (
|
||||
<DeviceModal
|
||||
device={editingDevice}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={() => {
|
||||
setShowModal(false);
|
||||
loadDevices();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DeviceModalProps {
|
||||
device: Device | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function DeviceModal({ device, onClose, onSave }: DeviceModalProps) {
|
||||
const [name, setName] = useState(device?.name || '');
|
||||
const [ipAddress, setIpAddress] = useState(device?.ipAddress || '');
|
||||
const [port, setPort] = useState(device?.port || 80);
|
||||
const [enabled, setEnabled] = useState(device?.enabled ?? true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
if (device) {
|
||||
await deviceApi.update(device.id, { name, ipAddress, port, enabled });
|
||||
} else {
|
||||
await deviceApi.create({ name, ipAddress, port, enabled });
|
||||
}
|
||||
|
||||
onSave();
|
||||
} catch (err) {
|
||||
alert('Failed to save device');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{device ? 'Edit Device' : 'Add Device'}</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)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>IP Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ipAddress}
|
||||
onChange={(e) => setIpAddress(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
{' '}Enabled
|
||||
</label>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
360
frontend/src/pages/GroupsPage.tsx
Normal file
360
frontend/src/pages/GroupsPage.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { groupApi } from '../api/groups';
|
||||
import { deviceApi } from '../api/devices';
|
||||
import { Group, Device } from '../api/types';
|
||||
|
||||
export function GroupsPage() {
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
}, []);
|
||||
|
||||
const loadGroups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await groupApi.getAll();
|
||||
setGroups(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load groups');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this group?')) return;
|
||||
|
||||
try {
|
||||
await groupApi.delete(id);
|
||||
loadGroups();
|
||||
} catch (err) {
|
||||
alert('Failed to delete group. It may have active schedules.');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingGroup(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (group: Group) => {
|
||||
setEditingGroup(group);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading">Loading groups...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>Groups</h2>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Devices</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groups.map((group) => (
|
||||
<tr key={group.id}>
|
||||
<td>{group.name}</td>
|
||||
<td>{group.devices.length} device(s)</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => setSelectedGroup(group)}
|
||||
style={{ marginRight: '5px' }}
|
||||
>
|
||||
Control
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => openEditModal(group)}
|
||||
style={{ marginRight: '5px' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={() => handleDelete(group.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showModal && (
|
||||
<GroupModal
|
||||
group={editingGroup}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={() => {
|
||||
setShowModal(false);
|
||||
loadGroups();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedGroup && (
|
||||
<GroupControlModal
|
||||
group={selectedGroup}
|
||||
onClose={() => setSelectedGroup(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupModalProps {
|
||||
group: Group | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function GroupModal({ group, onClose, onSave }: GroupModalProps) {
|
||||
const [name, setName] = useState(group?.name || '');
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>(
|
||||
group?.devices.map(d => d.id) || []
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevices();
|
||||
}, []);
|
||||
|
||||
const loadDevices = async () => {
|
||||
const data = await deviceApi.getAll();
|
||||
setDevices(data);
|
||||
};
|
||||
|
||||
const handleToggleDevice = (deviceId: string) => {
|
||||
setSelectedDeviceIds(prev =>
|
||||
prev.includes(deviceId)
|
||||
? prev.filter(id => id !== deviceId)
|
||||
: [...prev, deviceId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (selectedDeviceIds.length === 0) {
|
||||
alert('Please select at least one device');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
if (group) {
|
||||
await groupApi.update(group.id, { name, deviceIds: selectedDeviceIds });
|
||||
} else {
|
||||
await groupApi.create({ name, deviceIds: selectedDeviceIds });
|
||||
}
|
||||
|
||||
onSave();
|
||||
} catch (err) {
|
||||
alert('Failed to save group');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{group ? 'Edit Group' : 'Create Group'}</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)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Devices *</label>
|
||||
<div className="checkbox-group">
|
||||
{devices.map(device => (
|
||||
<label key={device.id} className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDeviceIds.includes(device.id)}
|
||||
onChange={() => handleToggleDevice(device.id)}
|
||||
/>
|
||||
{device.name} ({device.ipAddress})
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
interface GroupControlModalProps {
|
||||
group: Group;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function GroupControlModal({ group, onClose }: GroupControlModalProps) {
|
||||
const [presetId, setPresetId] = useState('');
|
||||
const [playlistPresets, setPlaylistPresets] = useState('');
|
||||
const [playlistDur, setPlaylistDur] = useState('');
|
||||
const [playlistTransition, setPlaylistTransition] = useState('');
|
||||
const [playlistRepeat, setPlaylistRepeat] = useState('0');
|
||||
const [playlistEnd, setPlaylistEnd] = useState('');
|
||||
|
||||
const handleApplyPreset = async () => {
|
||||
if (!presetId) {
|
||||
alert('Please enter a preset ID');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await groupApi.applyPreset(group.id, parseInt(presetId));
|
||||
alert(`Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
|
||||
} catch (err) {
|
||||
alert('Failed to apply preset');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyPlaylist = async () => {
|
||||
if (!playlistPresets) {
|
||||
alert('Please enter preset IDs');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ps = playlistPresets.split(',').map(s => parseInt(s.trim()));
|
||||
const dur = playlistDur ? playlistDur.split(',').map(s => parseInt(s.trim())) : undefined;
|
||||
const transition = playlistTransition ? playlistTransition.split(',').map(s => parseInt(s.trim())) : undefined;
|
||||
|
||||
const result = await groupApi.applyPlaylist(group.id, {
|
||||
ps,
|
||||
dur: dur && dur.length === 1 ? dur[0] : dur,
|
||||
transition: transition && transition.length === 1 ? transition[0] : transition,
|
||||
repeat: parseInt(playlistRepeat),
|
||||
end: playlistEnd ? parseInt(playlistEnd) : undefined,
|
||||
});
|
||||
|
||||
alert(`Success: ${result.results.success.length}, Failed: ${result.results.failed.length}`);
|
||||
} catch (err) {
|
||||
alert('Failed to apply playlist');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>Control Group: {group.name}</h3>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Apply Preset</h3>
|
||||
<div className="form-group">
|
||||
<label>Preset ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={presetId}
|
||||
onChange={(e) => setPresetId(e.target.value)}
|
||||
placeholder="e.g., 1"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleApplyPreset}>
|
||||
Apply Preset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Apply Playlist</h3>
|
||||
<div className="form-group">
|
||||
<label>Preset IDs (comma-separated) *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={playlistPresets}
|
||||
onChange={(e) => setPlaylistPresets(e.target.value)}
|
||||
placeholder="e.g., 1,2,3"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Duration (tenths of seconds, comma-separated or single value)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={playlistDur}
|
||||
onChange={(e) => setPlaylistDur(e.target.value)}
|
||||
placeholder="e.g., 30,30,30 or 30"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Transition (comma-separated or single value)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={playlistTransition}
|
||||
onChange={(e) => setPlaylistTransition(e.target.value)}
|
||||
placeholder="e.g., 0"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Repeat (0 = infinite)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playlistRepeat}
|
||||
onChange={(e) => setPlaylistRepeat(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>End Preset ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playlistEnd}
|
||||
onChange={(e) => setPlaylistEnd(e.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleApplyPlaylist}>
|
||||
Apply Playlist
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
395
frontend/src/pages/SchedulesPage.tsx
Normal file
395
frontend/src/pages/SchedulesPage.tsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSchedules();
|
||||
}, []);
|
||||
|
||||
const loadSchedules = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await scheduleApi.getAll();
|
||||
setSchedules(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load schedules');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (schedule: Schedule) => {
|
||||
try {
|
||||
await scheduleApi.update(schedule.id, { enabled: !schedule.enabled });
|
||||
loadSchedules();
|
||||
} catch (err) {
|
||||
alert('Failed to update schedule');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this schedule?')) return;
|
||||
|
||||
try {
|
||||
await scheduleApi.delete(id);
|
||||
loadSchedules();
|
||||
} catch (err) {
|
||||
alert('Failed to delete schedule');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingSchedule(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEditModal = (schedule: Schedule) => {
|
||||
setEditingSchedule(schedule);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading">Loading schedules...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h2>Schedules</h2>
|
||||
<button className="btn btn-primary" onClick={openCreateModal}>
|
||||
Create Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Group</th>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>Timezone</th>
|
||||
<th>Enabled</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schedules.map((schedule) => (
|
||||
<tr key={schedule.id}>
|
||||
<td>{schedule.name}</td>
|
||||
<td>{schedule.group.name}</td>
|
||||
<td>{schedule.type}</td>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: '12px' }}>{schedule.cronExpression}</td>
|
||||
<td>{schedule.timezone}</td>
|
||||
<td>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={schedule.enabled}
|
||||
onChange={() => handleToggleEnabled(schedule)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-small btn-secondary"
|
||||
onClick={() => openEditModal(schedule)}
|
||||
style={{ marginRight: '5px' }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-small btn-danger"
|
||||
onClick={() => handleDelete(schedule.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showModal && (
|
||||
<ScheduleModal
|
||||
schedule={editingSchedule}
|
||||
onClose={() => setShowModal(false)}
|
||||
onSave={() => {
|
||||
setShowModal(false);
|
||||
loadSchedules();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScheduleModalProps {
|
||||
schedule: Schedule | null;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function ScheduleModal({ schedule, onClose, onSave }: ScheduleModalProps) {
|
||||
const [name, setName] = useState(schedule?.name || '');
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [groupId, setGroupId] = useState(schedule?.groupId || '');
|
||||
const [type, setType] = useState<'PRESET' | 'PLAYLIST'>(schedule?.type || 'PRESET');
|
||||
const [cronExpression, setCronExpression] = useState(schedule?.cronExpression || '30 18 * * *');
|
||||
const [endCronExpression, setEndCronExpression] = useState(schedule?.endCronExpression || '');
|
||||
const [timezone, setTimezone] = useState(schedule?.timezone || 'Europe/London');
|
||||
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
|
||||
|
||||
// Preset fields
|
||||
const [selectedPresets, setSelectedPresets] = useState<number[]>(() => {
|
||||
if (!schedule) return [];
|
||||
if (schedule.type === 'PRESET') {
|
||||
return [(schedule.actionPayload as PresetActionPayload).presetId];
|
||||
}
|
||||
if (schedule.type === 'PLAYLIST') {
|
||||
return (schedule.actionPayload as PlaylistActionPayload).ps;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Playlist fields
|
||||
const [playlistDur, setPlaylistDur] = useState('30');
|
||||
const [playlistTransition, setPlaylistTransition] = useState('0');
|
||||
const [playlistRepeat, setPlaylistRepeat] = useState('0');
|
||||
const [playlistEnd, setPlaylistEnd] = useState('');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadGroups();
|
||||
}, []);
|
||||
|
||||
const loadGroups = async () => {
|
||||
const data = await groupApi.getAll();
|
||||
setGroups(data);
|
||||
if (data.length > 0 && !groupId) {
|
||||
setGroupId(data[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!groupId) {
|
||||
alert('Please select a group');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPresets.length === 0) {
|
||||
alert('Please select at least one preset');
|
||||
return;
|
||||
}
|
||||
|
||||
let actionPayload: PresetActionPayload | PlaylistActionPayload;
|
||||
|
||||
if (type === 'PRESET') {
|
||||
actionPayload = { presetId: selectedPresets[0] };
|
||||
} else {
|
||||
const dur = parseInt(playlistDur);
|
||||
const transition = parseInt(playlistTransition);
|
||||
|
||||
actionPayload = {
|
||||
ps: selectedPresets,
|
||||
dur: isNaN(dur) ? 30 : dur,
|
||||
transition: isNaN(transition) ? 0 : transition,
|
||||
repeat: parseInt(playlistRepeat) || 0,
|
||||
end: playlistEnd ? parseInt(playlistEnd) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
if (schedule) {
|
||||
await scheduleApi.update(schedule.id, {
|
||||
name,
|
||||
groupId,
|
||||
type,
|
||||
cronExpression,
|
||||
endCronExpression: endCronExpression || undefined,
|
||||
timezone,
|
||||
enabled,
|
||||
actionPayload,
|
||||
});
|
||||
} else {
|
||||
await scheduleApi.create({
|
||||
name,
|
||||
groupId,
|
||||
type,
|
||||
cronExpression,
|
||||
endCronExpression: endCronExpression || undefined,
|
||||
timezone,
|
||||
enabled,
|
||||
actionPayload,
|
||||
});
|
||||
}
|
||||
|
||||
onSave();
|
||||
} catch (err) {
|
||||
alert('Failed to save schedule');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '700px' }}>
|
||||
<div className="modal-header">
|
||||
<h3>{schedule ? 'Edit Schedule' : 'Create Schedule'}</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)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Type *</label>
|
||||
<select value={type} onChange={(e) => setType(e.target.value as 'PRESET' | 'PLAYLIST')} required>
|
||||
<option value="PRESET">Single Preset</option>
|
||||
<option value="PLAYLIST">Playlist (Multiple Presets)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Schedule Time *</label>
|
||||
<TimePicker value={cronExpression} onChange={setCronExpression} />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>End Time (Turn Off) - Optional</label>
|
||||
<TimePicker value={endCronExpression} onChange={setEndCronExpression} />
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
|
||||
Leave empty if you don't want lights to automatically turn off
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Timezone</label>
|
||||
<select value={timezone} onChange={(e) => setTimezone(e.target.value)}>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="America/Los_Angeles">America/Los_Angeles</option>
|
||||
<option value="Europe/Paris">Europe/Paris</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
{' '}Enabled
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>{type === 'PRESET' ? 'Select Preset *' : 'Select Presets for Playlist *'}</label>
|
||||
{groupId ? (
|
||||
<PresetSelector
|
||||
groupId={groupId}
|
||||
selectedPresets={selectedPresets}
|
||||
onChange={setSelectedPresets}
|
||||
mode={type === 'PRESET' ? 'single' : 'multiple'}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>
|
||||
Please select a group first
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{type === 'PLAYLIST' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label>Duration (tenths of seconds per preset)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playlistDur}
|
||||
onChange={(e) => setPlaylistDur(e.target.value)}
|
||||
placeholder="30"
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
|
||||
Default 30 = 3 seconds per preset
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Transition</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playlistTransition}
|
||||
onChange={(e) => setPlaylistTransition(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Repeat (0 = infinite)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playlistRepeat}
|
||||
onChange={(e) => setPlaylistRepeat(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>End Preset ID (optional)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={playlistEnd}
|
||||
onChange={(e) => setPlaylistEnd(e.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/utils/dateTime.ts
Normal file
15
frontend/src/utils/dateTime.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
export function formatDateTime(dateString: string | null): string {
|
||||
if (!dateString) return '-';
|
||||
|
||||
const dt = DateTime.fromISO(dateString, { zone: 'Europe/London' });
|
||||
return dt.toFormat('dd/MM/yyyy HH:mm');
|
||||
}
|
||||
|
||||
export function formatDateTimeFromDate(date: Date | null): string {
|
||||
if (!date) return '-';
|
||||
|
||||
const dt = DateTime.fromJSDate(date, { zone: 'Europe/London' });
|
||||
return dt.toFormat('dd/MM/yyyy HH:mm');
|
||||
}
|
||||
61
frontend/src/utils/timePicker.ts
Normal file
61
frontend/src/utils/timePicker.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface TimePickerValue {
|
||||
hour: number;
|
||||
minute: number;
|
||||
daysOfWeek: number[]; // 0-6, 0 = Sunday
|
||||
}
|
||||
|
||||
export function timePickerToCron(value: TimePickerValue): string {
|
||||
const { hour, minute, daysOfWeek } = value;
|
||||
|
||||
if (daysOfWeek.length === 0 || daysOfWeek.length === 7) {
|
||||
// Every day
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
// Specific days
|
||||
const days = daysOfWeek.sort((a, b) => a - b).join(',');
|
||||
return `${minute} ${hour} * * ${days}`;
|
||||
}
|
||||
|
||||
export function cronToTimePicker(cron: string): TimePickerValue | null {
|
||||
try {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 5) return null;
|
||||
|
||||
const [minutePart, hourPart, , , dayPart] = parts;
|
||||
|
||||
const minute = parseInt(minutePart);
|
||||
const hour = parseInt(hourPart);
|
||||
|
||||
if (isNaN(minute) || isNaN(hour)) return null;
|
||||
|
||||
let daysOfWeek: number[] = [];
|
||||
|
||||
if (dayPart === '*') {
|
||||
// Every day
|
||||
daysOfWeek = [0, 1, 2, 3, 4, 5, 6];
|
||||
} else if (dayPart.includes(',')) {
|
||||
// Specific days
|
||||
daysOfWeek = dayPart.split(',').map(d => parseInt(d)).filter(d => !isNaN(d) && d >= 0 && d <= 6);
|
||||
} else {
|
||||
const day = parseInt(dayPart);
|
||||
if (!isNaN(day) && day >= 0 && day <= 6) {
|
||||
daysOfWeek = [day];
|
||||
}
|
||||
}
|
||||
|
||||
return { hour, minute, daysOfWeek };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const DAYS_OF_WEEK = [
|
||||
{ value: 0, label: 'Sun' },
|
||||
{ value: 1, label: 'Mon' },
|
||||
{ value: 2, label: 'Tue' },
|
||||
{ value: 3, label: 'Wed' },
|
||||
{ value: 4, label: 'Thu' },
|
||||
{ value: 5, label: 'Fri' },
|
||||
{ value: 6, label: 'Sat' },
|
||||
];
|
||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
50
setup.ps1
Normal file
50
setup.ps1
Normal file
@@ -0,0 +1,50 @@
|
||||
# WLED Controller Setup Script
|
||||
|
||||
Write-Host "Setting up WLED Controller..." -ForegroundColor Green
|
||||
|
||||
# Backend setup
|
||||
Write-Host "`nSetting up backend..." -ForegroundColor Cyan
|
||||
Set-Location backend
|
||||
|
||||
if (Test-Path ".env") {
|
||||
Write-Host "Backend .env file already exists" -ForegroundColor Yellow
|
||||
} else {
|
||||
Copy-Item .env.example .env
|
||||
Write-Host "Created backend .env file" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "Installing backend dependencies..."
|
||||
npm install
|
||||
|
||||
Write-Host "Generating Prisma client..."
|
||||
npm run prisma:generate
|
||||
|
||||
Write-Host "Running database migrations..."
|
||||
npm run prisma:migrate
|
||||
|
||||
# Frontend setup
|
||||
Write-Host "`nSetting up frontend..." -ForegroundColor Cyan
|
||||
Set-Location ../frontend
|
||||
|
||||
if (Test-Path ".env") {
|
||||
Write-Host "Frontend .env file already exists" -ForegroundColor Yellow
|
||||
} else {
|
||||
Copy-Item .env.example .env
|
||||
Write-Host "Created frontend .env file" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "Installing frontend dependencies..."
|
||||
npm install
|
||||
|
||||
Set-Location ..
|
||||
|
||||
Write-Host "`n========================================" -ForegroundColor Green
|
||||
Write-Host "Setup complete!" -ForegroundColor Green
|
||||
Write-Host "========================================" -ForegroundColor Green
|
||||
Write-Host "`nTo start the backend (in backend directory):"
|
||||
Write-Host " npm run dev" -ForegroundColor Cyan
|
||||
Write-Host "`nTo start the frontend (in frontend directory):"
|
||||
Write-Host " npm run dev" -ForegroundColor Cyan
|
||||
Write-Host "`nOr use Docker Compose:"
|
||||
Write-Host " docker-compose up -d" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Reference in New Issue
Block a user