proxy backend

This commit is contained in:
Oli Passey
2025-12-21 16:54:13 +00:00
parent 1fb43156e8
commit 77bf4ffd05
30 changed files with 3010 additions and 79 deletions

View File

@@ -3,6 +3,7 @@ import { DashboardPage } from './pages/DashboardPage';
import { DevicesPage } from './pages/DevicesPage';
import { GroupsPage } from './pages/GroupsPage';
import { SchedulesPage } from './pages/SchedulesPage';
import { QuickActionsPage } from './pages/QuickActionsPage';
import './App.css';
function App() {
@@ -25,6 +26,9 @@ function App() {
<li>
<Link to="/schedules">Schedules</Link>
</li>
<li>
<Link to="/quick-actions">Quick Actions</Link>
</li>
</ul>
</nav>
</aside>
@@ -36,6 +40,7 @@ function App() {
<Route path="/devices" element={<DevicesPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/schedules" element={<SchedulesPage />} />
<Route path="/quick-actions" element={<QuickActionsPage />} />
</Routes>
</main>
</div>

View File

@@ -1,4 +1,10 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
// Use relative path for API calls - works for both local and network access
// When accessed from network, API calls will go to same host as frontend
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || (
import.meta.env.DEV
? '/api' // In dev mode, use Vite proxy
: '/api' // In production, use relative path (backend serves frontend)
);
export class ApiError extends Error {
constructor(

View File

@@ -15,6 +15,25 @@ export interface UpdateDeviceInput {
enabled?: boolean;
}
export interface DiscoveredDevice {
name: string;
ipAddress: string;
port: number;
host: string;
type: string;
}
export interface DiscoveryResult {
devices: DiscoveredDevice[];
count: number;
}
export interface VerifyResult {
found: boolean;
device?: DiscoveredDevice;
message?: string;
}
export const deviceApi = {
getAll: () => apiClient.get<Device[]>('/devices'),
@@ -36,4 +55,19 @@ export const deviceApi = {
turnOnAll: () => apiClient.post('/devices/all/turn-on'),
turnOffAll: () => apiClient.post('/devices/all/turn-off'),
syncState: (sourceId: string, targetIds: string[]) =>
apiClient.post(`/devices/${sourceId}/sync`, { targetIds }),
copyConfig: (sourceId: string, targetId: string) =>
apiClient.post(`/devices/${sourceId}/copy-to/${targetId}`),
discoverMdns: (timeout?: number) =>
apiClient.post<DiscoveryResult>('/devices/discover/mdns', { timeout }),
scanIpRange: (baseIp: string, startRange?: number, endRange?: number) =>
apiClient.post<DiscoveryResult>('/devices/discover/scan', { baseIp, startRange, endRange }),
verifyDevice: (ipAddress: string, port?: number) =>
apiClient.post<VerifyResult>('/devices/discover/verify', { ipAddress, port }),
};

View File

@@ -28,4 +28,7 @@ export const groupApi = {
applyPlaylist: (groupId: string, playlist: PlaylistActionPayload) =>
apiClient.post<GroupActionResult>(`/groups/${groupId}/playlist`, playlist),
getPresets: (groupId: string) =>
apiClient.get<Record<string, any>>(`/groups/${groupId}/presets`),
};

View File

@@ -0,0 +1,51 @@
import { apiClient } from './client';
export type QuickActionType = 'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS';
export interface QuickAction {
id: string;
name: string;
icon?: string;
groupId?: string;
deviceId?: string;
actionType: QuickActionType;
actionPayload: unknown;
order: number;
createdAt: string;
updatedAt: string;
}
export interface CreateQuickActionInput {
name: string;
icon?: string;
groupId?: string;
deviceId?: string;
actionType: QuickActionType;
actionPayload: unknown;
order?: number;
}
export interface UpdateQuickActionInput {
name?: string;
icon?: string;
groupId?: string;
deviceId?: string;
actionType?: QuickActionType;
actionPayload?: unknown;
order?: number;
}
export const quickActionApi = {
getAll: () => apiClient.get<QuickAction[]>('/quick-actions'),
getById: (id: string) => apiClient.get<QuickAction>(`/quick-actions/${id}`),
create: (data: CreateQuickActionInput) => apiClient.post<QuickAction>('/quick-actions', data),
update: (id: string, data: UpdateQuickActionInput) =>
apiClient.put<QuickAction>(`/quick-actions/${id}`, data),
delete: (id: string) => apiClient.delete(`/quick-actions/${id}`),
execute: (id: string) => apiClient.post(`/quick-actions/${id}/execute`),
};

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { quickActionApi, QuickAction } from '../api/quickActions';
export function QuickActionsPanel() {
const [actions, setActions] = useState<QuickAction[]>([]);
const [loading, setLoading] = useState(true);
const [executing, setExecuting] = useState<string | null>(null);
useEffect(() => {
loadActions();
}, []);
const loadActions = async () => {
try {
const data = await quickActionApi.getAll();
setActions(data);
} catch (error) {
console.error('Failed to load quick actions:', error);
} finally {
setLoading(false);
}
};
const executeAction = async (id: string) => {
try {
setExecuting(id);
await quickActionApi.execute(id);
} catch (error) {
alert('Failed to execute action');
} finally {
setExecuting(null);
}
};
if (loading) return <div>Loading quick actions...</div>;
if (actions.length === 0) {
return (
<div className="card" style={{ textAlign: 'center', padding: '40px', color: '#95a5a6' }}>
<div style={{ fontSize: '48px', marginBottom: '10px' }}></div>
<div>No quick actions configured</div>
<div style={{ fontSize: '14px', marginTop: '5px' }}>
Quick actions let you trigger presets, playlists, or control devices with one click
</div>
</div>
);
}
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: '15px' }}>
{actions.map((action) => (
<button
key={action.id}
className="btn btn-large"
onClick={() => executeAction(action.id)}
disabled={executing === action.id}
style={{
height: '100px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
fontSize: '14px',
fontWeight: '500',
backgroundColor: getActionColor(action.actionType),
color: 'white',
border: 'none',
position: 'relative',
}}
title={getActionDescription(action)}
>
<div style={{ fontSize: '32px' }}>
{action.icon || getDefaultIcon(action.actionType)}
</div>
<div style={{ textAlign: 'center', lineHeight: '1.2' }}>
{action.name}
</div>
{executing === action.id && (
<div style={{
position: 'absolute',
top: '5px',
right: '5px',
fontSize: '16px',
}}>
</div>
)}
</button>
))}
</div>
);
}
function getActionColor(actionType: string): string {
switch (actionType) {
case 'TURN_ON':
return '#2ecc71';
case 'TURN_OFF':
return '#e74c3c';
case 'PRESET':
return '#3498db';
case 'PLAYLIST':
return '#9b59b6';
case 'BRIGHTNESS':
return '#f39c12';
default:
return '#95a5a6';
}
}
function getDefaultIcon(actionType: string): string {
switch (actionType) {
case 'TURN_ON':
return '💡';
case 'TURN_OFF':
return '🌙';
case 'PRESET':
return '🎨';
case 'PLAYLIST':
return '🎬';
case 'BRIGHTNESS':
return '☀️';
default:
return '⚡';
}
}
function getActionDescription(action: QuickAction): string {
const target = action.groupId ? 'Group' : action.deviceId ? 'Device' : 'All';
return `${action.actionType} - ${target}`;
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { timePickerToCron, cronToTimePicker, DAYS_OF_WEEK, TimePickerValue } from '../utils/timePicker';
import { groupApi } from '../api/groups';
interface TimePickerProps {
value: string; // cron expression
@@ -170,6 +171,7 @@ interface PresetSelectorProps {
export function PresetSelector({ groupId, selectedPresets, onChange, mode }: PresetSelectorProps) {
const [presets, setPresets] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (groupId) {
@@ -180,11 +182,13 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre
const loadPresets = async () => {
try {
setLoading(true);
const response = await fetch(`/api/groups/${groupId}/presets`);
const data = await response.json();
setError(null);
const data = await groupApi.getPresets(groupId);
setPresets(data);
} catch (error) {
console.error('Failed to load presets:', error);
setError(error instanceof Error ? error.message : 'Failed to load presets');
setPresets({});
} finally {
setLoading(false);
}
@@ -204,6 +208,21 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre
if (loading) return <div style={{ padding: '20px', textAlign: 'center' }}>Loading presets...</div>;
if (error) {
return (
<div style={{ padding: '20px', textAlign: 'center', color: '#e74c3c' }}>
<div>Error loading presets: {error}</div>
<button
onClick={loadPresets}
className="btn btn-small btn-secondary"
style={{ marginTop: '10px' }}
>
Retry
</button>
</div>
);
}
const presetEntries = Object.entries(presets)
.filter(([key]) => key !== '0')
.map(([key, value]) => {
@@ -214,7 +233,14 @@ export function PresetSelector({ groupId, selectedPresets, onChange, mode }: Pre
.sort((a, b) => a.id - b.id);
if (presetEntries.length === 0) {
return <div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>No presets available</div>;
return (
<div style={{ padding: '20px', textAlign: 'center', color: '#95a5a6' }}>
<div>No presets available</div>
<div style={{ fontSize: '12px', marginTop: '5px' }}>
Make sure the group has devices and at least one device has presets configured
</div>
</div>
);
}
return (

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { formatDateTime } from '../utils/dateTime';
import { deviceApi } from '../api/devices';
import { QuickActionsPanel } from '../components/QuickActionsPanel';
interface DeviceStatus {
id: string;
@@ -33,10 +34,16 @@ interface DeviceStatus {
};
}
interface DeviceWithStatus extends DeviceStatus {
connectionStatus: 'online' | 'warning' | 'offline';
lastSuccessfulPing?: number;
}
export function DashboardPage() {
const [devices, setDevices] = useState<DeviceStatus[]>([]);
const [devices, setDevices] = useState<DeviceWithStatus[]>([]);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [deviceHistory, setDeviceHistory] = useState<Map<string, number>>(new Map());
useEffect(() => {
loadDevices();
@@ -55,8 +62,42 @@ export function DashboardPage() {
const loadDevices = async () => {
try {
const response = await fetch('/api/devices/status/all');
const data = await response.json();
setDevices(data);
const data = await response.json() as DeviceStatus[];
const now = Date.now();
const updatedHistory = new Map(deviceHistory);
// Update devices with connection status based on current state and history
const devicesWithStatus: DeviceWithStatus[] = data.map(device => {
const hasCurrentState = device.enabled && device.currentState !== undefined;
const lastSuccess = updatedHistory.get(device.id);
// Update last successful ping time
if (hasCurrentState) {
updatedHistory.set(device.id, now);
}
// Determine connection status
let connectionStatus: 'online' | 'warning' | 'offline';
if (hasCurrentState) {
connectionStatus = 'online';
} else if (lastSuccess && (now - lastSuccess) < 60000) {
// Device was online within the last 60 seconds - show as warning
connectionStatus = 'warning';
} else {
// Device hasn't responded in over 60 seconds - show as offline
connectionStatus = 'offline';
}
return {
...device,
connectionStatus,
lastSuccessfulPing: updatedHistory.get(device.id)
};
});
setDeviceHistory(updatedHistory);
setDevices(devicesWithStatus);
} catch (error) {
console.error('Failed to load device status:', error);
} finally {
@@ -66,7 +107,7 @@ export function DashboardPage() {
if (loading) return <div className="loading">Loading dashboard...</div>;
const onlineDevices = devices.filter(d => d.enabled && d.currentState).length;
const onlineDevices = devices.filter(d => d.enabled && d.connectionStatus === 'online').length;
const totalEnabled = devices.filter(d => d.enabled).length;
return (
@@ -114,6 +155,11 @@ export function DashboardPage() {
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<h3 style={{ marginBottom: '15px' }}>Quick Actions</h3>
<QuickActionsPanel />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px', marginBottom: '30px' }}>
<div className="card">
<h3>Total Devices</h3>
@@ -133,9 +179,10 @@ export function DashboardPage() {
</div>
</div>
<h3 style={{ marginBottom: '15px' }}>Devices</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))', gap: '20px' }}>
{devices.map((device) => (
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} />
<DeviceCard key={device.id} device={device} onRefresh={loadDevices} allDevices={devices} />
))}
</div>
</div>
@@ -143,14 +190,24 @@ export function DashboardPage() {
}
interface DeviceCardProps {
device: DeviceStatus;
device: DeviceWithStatus;
onRefresh: () => void;
allDevices: DeviceWithStatus[];
}
function DeviceCard({ device, onRefresh }: DeviceCardProps) {
const isOnline = device.enabled && device.currentState !== undefined;
function DeviceCard({ device, onRefresh, allDevices }: DeviceCardProps) {
const isOnline = device.connectionStatus === 'online';
const isWarning = device.connectionStatus === 'warning';
const isOn = device.currentState?.on ?? false;
const [busy, setBusy] = useState(false);
const [showSync, setShowSync] = useState(false);
// Get status color
const getStatusColor = () => {
if (device.connectionStatus === 'online') return '#2ecc71'; // Green
if (device.connectionStatus === 'warning') return '#f39c12'; // Orange
return '#e74c3c'; // Red
};
const handleTurnOn = async () => {
try {
@@ -177,47 +234,105 @@ function DeviceCard({ device, onRefresh }: DeviceCardProps) {
};
return (
<div className="card" style={{ position: 'relative' }}>
<div className="card" style={{ position: 'relative', opacity: device.connectionStatus === 'offline' ? 0.6 : 1 }}>
<div style={{ position: 'absolute', top: '15px', right: '15px' }}>
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: isOnline ? '#2ecc71' : '#e74c3c',
backgroundColor: getStatusColor(),
}}
title={isOnline ? 'Online' : 'Offline'}
title={
device.connectionStatus === 'online'
? 'Online'
: device.connectionStatus === 'warning'
? 'Connection unstable'
: 'Offline'
}
/>
</div>
<h3 style={{ marginBottom: '10px', paddingRight: '30px' }}>{device.name}</h3>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginBottom: '15px' }}>
{device.ipAddress}:{device.port}
<div style={{ fontSize: '12px', marginBottom: '15px' }}>
<a
href={`http://${device.ipAddress}:${device.port}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#3498db', textDecoration: 'none' }}
onMouseOver={(e) => e.currentTarget.style.textDecoration = 'underline'}
onMouseOut={(e) => e.currentTarget.style.textDecoration = 'none'}
>
{device.ipAddress}:{device.port}
</a>
</div>
{isOnline && (
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
{/* Always show controls for enabled devices, even if temporarily offline */}
{device.enabled && (
<>
<div style={{ marginBottom: '15px', display: 'flex', gap: '8px' }}>
<button
className="btn btn-small btn-primary"
onClick={handleTurnOn}
disabled={busy || (isOnline && isOn)}
style={{ flex: 1 }}
>
Turn On
</button>
<button
className="btn btn-small btn-danger"
onClick={handleTurnOff}
disabled={busy || (isOnline && !isOn)}
style={{ flex: 1 }}
>
Turn Off
</button>
</div>
<button
className="btn btn-small btn-primary"
onClick={handleTurnOn}
disabled={busy || isOn}
style={{ flex: 1 }}
className="btn btn-small btn-secondary"
onClick={() => setShowSync(!showSync)}
style={{ width: '100%', marginBottom: '15px' }}
disabled={!isOnline}
>
Turn On
{showSync ? 'Hide' : 'Sync to Other Devices'}
</button>
<button
className="btn btn-small btn-danger"
onClick={handleTurnOff}
disabled={busy || !isOn}
style={{ flex: 1 }}
>
Turn Off
</button>
</div>
{showSync && isOnline && (
<SyncPanel
sourceDevice={device}
allDevices={allDevices.filter(d => d.id !== device.id && d.enabled)}
onSync={async (targetIds) => {
try {
setBusy(true);
await deviceApi.syncState(device.id, targetIds);
setTimeout(onRefresh, 500);
alert('State synced successfully!');
} catch (err) {
alert('Failed to sync state');
} finally {
setBusy(false);
}
}}
/>
)}
</>
)}
{isOnline ? (
{/* Show device info if we have current state OR recent successful ping */}
{(isOnline || isWarning) && device.currentState ? (
<>
{isWarning && (
<div style={{
backgroundColor: '#fff3cd',
color: '#856404',
padding: '8px',
borderRadius: '4px',
marginBottom: '10px',
fontSize: '12px'
}}>
Connection unstable - retrying...
</div>
)}
<div style={{ marginBottom: '10px' }}>
<strong>Status:</strong>{' '}
<span style={{ color: isOn ? '#2ecc71' : '#95a5a6' }}>
@@ -292,3 +407,101 @@ function DeviceCard({ device, onRefresh }: DeviceCardProps) {
</div>
);
}
interface SyncPanelProps {
sourceDevice: DeviceStatus;
allDevices: DeviceStatus[];
onSync: (targetIds: string[]) => Promise<void>;
}
function SyncPanel({ sourceDevice, allDevices, onSync }: SyncPanelProps) {
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const toggleDevice = (deviceId: string) => {
setSelectedDevices(prev =>
prev.includes(deviceId)
? prev.filter(id => id !== deviceId)
: [...prev, deviceId]
);
};
const selectAll = () => {
setSelectedDevices(allDevices.map(d => d.id));
};
const selectNone = () => {
setSelectedDevices([]);
};
return (
<div style={{
padding: '10px',
backgroundColor: '#f8f9fa',
borderRadius: '4px',
marginBottom: '15px'
}}>
<div style={{ fontSize: '12px', fontWeight: 'bold', marginBottom: '8px' }}>
Copy current state from "{sourceDevice.name}" to:
</div>
<div style={{ marginBottom: '8px', display: 'flex', gap: '5px' }}>
<button
className="btn btn-small btn-secondary"
onClick={selectAll}
style={{ fontSize: '11px', padding: '3px 8px' }}
>
Select All
</button>
<button
className="btn btn-small btn-secondary"
onClick={selectNone}
style={{ fontSize: '11px', padding: '3px 8px' }}
>
Clear
</button>
</div>
<div style={{ maxHeight: '150px', overflowY: 'auto', marginBottom: '8px' }}>
{allDevices.length === 0 ? (
<div style={{ fontSize: '12px', color: '#95a5a6', padding: '5px' }}>
No other devices available
</div>
) : (
allDevices.map(device => (
<label
key={device.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px',
fontSize: '12px',
cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={selectedDevices.includes(device.id)}
onChange={() => toggleDevice(device.id)}
/>
<span>{device.name}</span>
<span style={{
marginLeft: 'auto',
fontSize: '10px',
color: device.currentState ? '#2ecc71' : '#e74c3c'
}}>
{device.currentState ? '●' : '○'}
</span>
</label>
))
)}
</div>
<button
className="btn btn-small btn-primary"
onClick={() => onSync(selectedDevices)}
disabled={selectedDevices.length === 0}
style={{ width: '100%' }}
>
Sync to {selectedDevices.length} Device{selectedDevices.length !== 1 ? 's' : ''}
</button>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { deviceApi } from '../api/devices';
import { deviceApi, DiscoveredDevice } from '../api/devices';
import { Device } from '../api/types';
import { formatDateTime } from '../utils/dateTime';
@@ -9,6 +9,8 @@ export function DevicesPage() {
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingDevice, setEditingDevice] = useState<Device | null>(null);
const [filter, setFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
const [showDiscovery, setShowDiscovery] = useState(false);
useEffect(() => {
loadDevices();
@@ -73,13 +75,33 @@ export function DevicesPage() {
if (loading) return <div className="loading">Loading devices...</div>;
const filteredDevices = devices.filter(device => {
if (filter === 'enabled') return device.enabled;
if (filter === 'disabled') return !device.enabled;
return true;
});
return (
<div>
<div className="page-header">
<h2>Devices</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Add Device
</button>
<h2>Devices ({devices.length})</h2>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<select
value={filter}
onChange={(e) => setFilter(e.target.value as 'all' | 'enabled' | 'disabled')}
style={{ padding: '8px' }}
>
<option value="all">All Devices ({devices.length})</option>
<option value="enabled">Enabled ({devices.filter(d => d.enabled).length})</option>
<option value="disabled">Disabled ({devices.filter(d => !d.enabled).length})</option>
</select>
<button className="btn btn-secondary" onClick={() => setShowDiscovery(true)}>
🔍 Discover Devices
</button>
<button className="btn btn-primary" onClick={openCreateModal}>
Add Device
</button>
</div>
</div>
{error && <div className="error-message">{error}</div>}
@@ -96,7 +118,7 @@ export function DevicesPage() {
</tr>
</thead>
<tbody>
{devices.map((device) => (
{filteredDevices.map((device) => (
<tr key={device.id}>
<td>{device.name}</td>
<td>{device.ipAddress}</td>
@@ -149,6 +171,223 @@ export function DevicesPage() {
}}
/>
)}
{showDiscovery && (
<DiscoveryModal
onClose={() => setShowDiscovery(false)}
onDeviceAdded={() => {
setShowDiscovery(false);
loadDevices();
}}
/>
)}
</div>
);
}
interface DiscoveryModalProps {
onClose: () => void;
onDeviceAdded: () => void;
}
function DiscoveryModal({ onClose, onDeviceAdded }: DiscoveryModalProps) {
const [discoveryMethod, setDiscoveryMethod] = useState<'mdns' | 'scan'>('mdns');
const [discovering, setDiscovering] = useState(false);
const [discoveredDevices, setDiscoveredDevices] = useState<DiscoveredDevice[]>([]);
const [baseIp, setBaseIp] = useState('192.168.1');
const [startRange, setStartRange] = useState(1);
const [endRange, setEndRange] = useState(254);
const [selectedDevices, setSelectedDevices] = useState<Set<string>>(new Set());
const handleDiscover = async () => {
try {
setDiscovering(true);
setDiscoveredDevices([]);
setSelectedDevices(new Set());
let result;
if (discoveryMethod === 'mdns') {
result = await deviceApi.discoverMdns(5000);
} else {
result = await deviceApi.scanIpRange(baseIp, startRange, endRange);
}
setDiscoveredDevices(result.devices);
if (result.devices.length === 0) {
alert('No WLED devices found');
}
} catch (err) {
alert('Failed to discover devices');
} finally {
setDiscovering(false);
}
};
const toggleDeviceSelection = (ipAddress: string) => {
const newSelection = new Set(selectedDevices);
if (newSelection.has(ipAddress)) {
newSelection.delete(ipAddress);
} else {
newSelection.add(ipAddress);
}
setSelectedDevices(newSelection);
};
const handleAddSelected = async () => {
const devicesToAdd = discoveredDevices.filter(d => selectedDevices.has(d.ipAddress));
if (devicesToAdd.length === 0) {
alert('Please select at least one device to add');
return;
}
try {
for (const device of devicesToAdd) {
await deviceApi.create({
name: device.name,
ipAddress: device.ipAddress,
port: device.port,
enabled: true,
});
}
alert(`Successfully added ${devicesToAdd.length} device(s)`);
onDeviceAdded();
} catch (err) {
alert('Failed to add devices');
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: '700px' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Discover WLED Devices</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<div style={{ marginBottom: '20px' }}>
<div className="form-group">
<label>Discovery Method</label>
<select
value={discoveryMethod}
onChange={(e) => setDiscoveryMethod(e.target.value as 'mdns' | 'scan')}
disabled={discovering}
>
<option value="mdns">mDNS (Automatic - Recommended)</option>
<option value="scan">IP Range Scan</option>
</select>
</div>
{discoveryMethod === 'scan' && (
<>
<div className="form-group">
<label>Base IP Address (e.g., 192.168.1)</label>
<input
type="text"
value={baseIp}
onChange={(e) => setBaseIp(e.target.value)}
placeholder="192.168.1"
disabled={discovering}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<div className="form-group">
<label>Start Range</label>
<input
type="number"
value={startRange}
onChange={(e) => setStartRange(parseInt(e.target.value))}
min="1"
max="254"
disabled={discovering}
/>
</div>
<div className="form-group">
<label>End Range</label>
<input
type="number"
value={endRange}
onChange={(e) => setEndRange(parseInt(e.target.value))}
min="1"
max="254"
disabled={discovering}
/>
</div>
</div>
</>
)}
<button
className="btn btn-primary"
onClick={handleDiscover}
disabled={discovering}
style={{ width: '100%' }}
>
{discovering ? 'Discovering...' : '🔍 Start Discovery'}
</button>
</div>
{discoveredDevices.length > 0 && (
<>
<h4>Found {discoveredDevices.length} device(s)</h4>
<div style={{ maxHeight: '300px', overflowY: 'auto', marginBottom: '20px' }}>
<table>
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
checked={selectedDevices.size === discoveredDevices.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedDevices(new Set(discoveredDevices.map(d => d.ipAddress)));
} else {
setSelectedDevices(new Set());
}
}}
/>
</th>
<th>Name</th>
<th>IP Address</th>
<th>Port</th>
</tr>
</thead>
<tbody>
{discoveredDevices.map((device) => (
<tr key={device.ipAddress}>
<td>
<input
type="checkbox"
checked={selectedDevices.has(device.ipAddress)}
onChange={() => toggleDeviceSelection(device.ipAddress)}
/>
</td>
<td>{device.name}</td>
<td>{device.ipAddress}</td>
<td>{device.port}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="form-actions">
<button className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button
className="btn btn-primary"
onClick={handleAddSelected}
disabled={selectedDevices.size === 0}
>
Add Selected ({selectedDevices.size})
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,367 @@
import { useState, useEffect } from 'react';
import { quickActionApi, QuickAction, CreateQuickActionInput } from '../api/quickActions';
import { groupApi } from '../api/groups';
import { deviceApi } from '../api/devices';
import { Group, Device } from '../api/types';
export function QuickActionsPage() {
const [actions, setActions] = useState<QuickAction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [editingAction, setEditingAction] = useState<QuickAction | null>(null);
useEffect(() => {
loadActions();
}, []);
const loadActions = async () => {
try {
setLoading(true);
const data = await quickActionApi.getAll();
setActions(data);
setError(null);
} catch (err) {
setError('Failed to load quick actions');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this quick action?')) return;
try {
await quickActionApi.delete(id);
loadActions();
} catch (err) {
alert('Failed to delete quick action');
}
};
const openCreateModal = () => {
setEditingAction(null);
setShowModal(true);
};
const openEditModal = (action: QuickAction) => {
setEditingAction(action);
setShowModal(true);
};
if (loading) return <div className="loading">Loading quick actions...</div>;
return (
<div>
<div className="page-header">
<h2>Quick Actions ({actions.length})</h2>
<button className="btn btn-primary" onClick={openCreateModal}>
Create Quick Action
</button>
</div>
{error && <div className="error-message">{error}</div>}
{actions.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '40px', color: '#95a5a6' }}>
<div style={{ fontSize: '48px', marginBottom: '10px' }}></div>
<div style={{ marginBottom: '10px' }}>No quick actions configured</div>
<div style={{ fontSize: '14px', marginBottom: '20px' }}>
Quick actions let you trigger presets, playlists, or control devices with one click from the dashboard
</div>
<button className="btn btn-primary" onClick={openCreateModal}>
Create Your First Quick Action
</button>
</div>
) : (
<table>
<thead>
<tr>
<th>Order</th>
<th>Name</th>
<th>Icon</th>
<th>Type</th>
<th>Target</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{actions.map((action) => (
<tr key={action.id}>
<td>{action.order}</td>
<td>{action.name}</td>
<td style={{ fontSize: '24px' }}>{action.icon || '⚡'}</td>
<td>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
backgroundColor: getActionColor(action.actionType),
color: 'white'
}}>
{action.actionType}
</span>
</td>
<td>
{action.groupId ? 'Group' : action.deviceId ? 'Device' : 'All Devices'}
</td>
<td>
<button
className="btn btn-small btn-secondary"
onClick={() => openEditModal(action)}
style={{ marginRight: '5px' }}
>
Edit
</button>
<button
className="btn btn-small btn-danger"
onClick={() => handleDelete(action.id)}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{showModal && (
<QuickActionModal
action={editingAction}
onClose={() => setShowModal(false)}
onSave={() => {
setShowModal(false);
loadActions();
}}
/>
)}
</div>
);
}
interface QuickActionModalProps {
action: QuickAction | null;
onClose: () => void;
onSave: () => void;
}
function QuickActionModal({ action, onClose, onSave }: QuickActionModalProps) {
const [name, setName] = useState(action?.name || '');
const [icon, setIcon] = useState(action?.icon || '');
const [actionType, setActionType] = useState<'PRESET' | 'PLAYLIST' | 'TURN_ON' | 'TURN_OFF' | 'BRIGHTNESS'>(
action?.actionType || 'TURN_ON'
);
const [targetType, setTargetType] = useState<'group' | 'device' | 'all'>(
action?.groupId ? 'group' : action?.deviceId ? 'device' : 'all'
);
const [groupId, setGroupId] = useState(action?.groupId || '');
const [deviceId, setDeviceId] = useState(action?.deviceId || '');
const [presetId, setPresetId] = useState('1');
const [brightness, setBrightness] = useState('128');
const [order, setOrder] = useState(action?.order?.toString() || '0');
const [groups, setGroups] = useState<Group[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
loadGroupsAndDevices();
if (action?.actionPayload) {
const payload = action.actionPayload as any;
if (payload.presetId) setPresetId(payload.presetId.toString());
if (payload.brightness) setBrightness(payload.brightness.toString());
}
}, []);
const loadGroupsAndDevices = async () => {
const [groupsData, devicesData] = await Promise.all([
groupApi.getAll(),
deviceApi.getAll(),
]);
setGroups(groupsData);
setDevices(devicesData);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let actionPayload: any = {};
if (actionType === 'PRESET') {
actionPayload = { presetId: parseInt(presetId) };
} else if (actionType === 'BRIGHTNESS') {
actionPayload = { brightness: parseInt(brightness) };
}
const data: CreateQuickActionInput = {
name,
icon: icon || undefined,
actionType,
groupId: targetType === 'group' ? groupId : undefined,
deviceId: targetType === 'device' ? deviceId : undefined,
actionPayload,
order: parseInt(order),
};
try {
setSubmitting(true);
if (action) {
await quickActionApi.update(action.id, data);
} else {
await quickActionApi.create(data);
}
onSave();
} catch (err) {
alert('Failed to save quick action');
} finally {
setSubmitting(false);
}
};
return (
<div className="modal" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: '600px' }}>
<div className="modal-header">
<h3>{action ? 'Edit Quick Action' : 'Create Quick Action'}</h3>
<button className="close-btn" onClick={onClose}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Bedtime Lights"
required
/>
</div>
<div className="form-group">
<label>Icon (Emoji)</label>
<input
type="text"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="e.g., 🌙 💡 🎨"
maxLength={2}
/>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
Leave empty for default icon
</div>
</div>
<div className="form-group">
<label>Action Type *</label>
<select value={actionType} onChange={(e) => setActionType(e.target.value as any)} required>
<option value="TURN_ON">Turn On</option>
<option value="TURN_OFF">Turn Off</option>
<option value="PRESET">Apply Preset</option>
<option value="BRIGHTNESS">Set Brightness</option>
</select>
</div>
{actionType === 'PRESET' && (
<div className="form-group">
<label>Preset ID *</label>
<input
type="number"
value={presetId}
onChange={(e) => setPresetId(e.target.value)}
min="1"
required
/>
</div>
)}
{actionType === 'BRIGHTNESS' && (
<div className="form-group">
<label>Brightness (0-255) *</label>
<input
type="number"
value={brightness}
onChange={(e) => setBrightness(e.target.value)}
min="0"
max="255"
required
/>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
{Math.round((parseInt(brightness) / 255) * 100)}%
</div>
</div>
)}
<div className="form-group">
<label>Target *</label>
<select value={targetType} onChange={(e) => setTargetType(e.target.value as any)} required>
<option value="all">All Devices</option>
<option value="group">Specific Group</option>
<option value="device">Specific Device</option>
</select>
</div>
{targetType === 'group' && (
<div className="form-group">
<label>Group *</label>
<select value={groupId} onChange={(e) => setGroupId(e.target.value)} required>
<option value="">Select a group</option>
{groups.map(group => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
)}
{targetType === 'device' && (
<div className="form-group">
<label>Device *</label>
<select value={deviceId} onChange={(e) => setDeviceId(e.target.value)} required>
<option value="">Select a device</option>
{devices.map(device => (
<option key={device.id} value={device.id}>{device.name}</option>
))}
</select>
</div>
)}
<div className="form-group">
<label>Display Order</label>
<input
type="number"
value={order}
onChange={(e) => setOrder(e.target.value)}
min="0"
/>
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
Lower numbers appear first
</div>
</div>
<div className="form-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
);
}
function getActionColor(actionType: string): string {
switch (actionType) {
case 'TURN_ON': return '#2ecc71';
case 'TURN_OFF': return '#e74c3c';
case 'PRESET': return '#3498db';
case 'PLAYLIST': return '#9b59b6';
case 'BRIGHTNESS': return '#f39c12';
default: return '#95a5a6';
}
}

View File

@@ -3,7 +3,6 @@ import { scheduleApi } from '../api/schedules';
import { groupApi } from '../api/groups';
import { Schedule, Group, PresetActionPayload, PlaylistActionPayload } from '../api/types';
import { TimePicker, PresetSelector } from '../components/ScheduleComponents';
import { timePickerToCron } from '../utils/timePicker';
export function SchedulesPage() {
const [schedules, setSchedules] = useState<Schedule[]>([]);

View File

@@ -4,6 +4,7 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {