239 lines
8.3 KiB
TypeScript
239 lines
8.3 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { GateEvent, Settings, GateStatus } from './types';
|
|
import * as api from './api';
|
|
import { SettingsDialog } from './components/SettingsDialog';
|
|
import { Cog6ToothIcon } from '@heroicons/react/24/outline';
|
|
|
|
function App() {
|
|
const [events, setEvents] = useState<GateEvent[]>([]);
|
|
const [hasMoreEvents, setHasMoreEvents] = useState(false);
|
|
const [totalEvents, setTotalEvents] = useState(0);
|
|
const [settings, setSettings] = useState<Settings>({
|
|
maxOpenTimeSeconds: 300,
|
|
triggerDuration: 500,
|
|
mqtt: {
|
|
broker: 'localhost',
|
|
port: '1883',
|
|
username: '',
|
|
password: '',
|
|
clientId: 'gatekeeper',
|
|
enabled: false
|
|
},
|
|
gpio: {
|
|
gatePin: 17,
|
|
statusPin: 27
|
|
},
|
|
logging: {
|
|
level: 'WARNING',
|
|
maxBytes: 10 * 1024 * 1024, // 10MB
|
|
backupCount: 5
|
|
}
|
|
});
|
|
const [gateStatus, setGateStatus] = useState<GateStatus>({ isOpen: false, lastChanged: new Date().toISOString() });
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
|
|
const formatDate = (isoString: string) => {
|
|
const date = new Date(isoString);
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
try {
|
|
const [eventsData, settingsData, statusData] = await Promise.all([
|
|
api.getEvents(),
|
|
api.getSettings(),
|
|
api.getGateStatus()
|
|
]);
|
|
setEvents(eventsData.events);
|
|
setHasMoreEvents(eventsData.hasMore);
|
|
setTotalEvents(eventsData.total);
|
|
setSettings(settingsData);
|
|
setGateStatus(statusData);
|
|
} catch (err) {
|
|
setError('Failed to load data');
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
// Only update status and most recent events
|
|
const [statusData, eventsData] = await Promise.all([
|
|
api.getGateStatus(),
|
|
api.getEvents(10, 0)
|
|
]);
|
|
setGateStatus(statusData);
|
|
|
|
// Update only if we're on the first page and don't have more events loaded
|
|
if (events.length <= 10 && !hasMoreEvents) {
|
|
setEvents(eventsData.events);
|
|
setHasMoreEvents(eventsData.hasMore);
|
|
setTotalEvents(eventsData.total);
|
|
} else {
|
|
// If we have more events loaded, just prepend any new events
|
|
const newEvents = eventsData.events.filter(
|
|
newEvent => !events.some(
|
|
existingEvent =>
|
|
existingEvent.timestamp === newEvent.timestamp &&
|
|
existingEvent.action === newEvent.action
|
|
)
|
|
);
|
|
if (newEvents.length > 0) {
|
|
setEvents(prev => [...newEvents, ...prev]);
|
|
setTotalEvents(prev => prev + newEvents.length);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to update status:', err);
|
|
}
|
|
}, 1000);
|
|
|
|
loadData();
|
|
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const handleGateControl = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const result = await api.triggerGate();
|
|
if (result.success) {
|
|
const newEvents = await api.getEvents();
|
|
setEvents(newEvents.events);
|
|
setGateStatus(prev => ({ ...prev, isOpen: result.isOpen }));
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to trigger gate');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSettingsSave = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
await api.updateSettings(settings);
|
|
setIsSettingsOpen(false);
|
|
} catch (err) {
|
|
setError('Failed to update settings');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLoadMore = async () => {
|
|
try {
|
|
const moreEvents = await api.getEvents(10, events.length);
|
|
if (moreEvents.events.length > 0) {
|
|
setEvents(prev => [...prev, ...moreEvents.events]);
|
|
setHasMoreEvents(moreEvents.hasMore);
|
|
setTotalEvents(moreEvents.total);
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to load more events');
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-100 py-2 flex flex-col justify-center">
|
|
<div className="relative sm:max-w-xl sm:mx-auto">
|
|
<div className="relative px-4 py-6 bg-white shadow-lg sm:rounded-3xl sm:p-12">
|
|
<div className="max-w-md mx-auto">
|
|
<div className="divide-y divide-gray-200">
|
|
<div className="py-4 text-base leading-6 space-y-4 text-gray-700 sm:text-lg sm:leading-7">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h1 className="text-2xl font-bold text-gray-900">Gate Control</h1>
|
|
<button
|
|
onClick={() => setIsSettingsOpen(true)}
|
|
className="p-2 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
>
|
|
<Cog6ToothIcon className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Gate Status */}
|
|
<div className="mb-4">
|
|
<p className="text-lg font-semibold">
|
|
Status: <span className={gateStatus.isOpen ? "text-green-600" : "text-red-600"}>
|
|
{gateStatus.isOpen ? "Open" : "Closed"}
|
|
</span>
|
|
</p>
|
|
<p className="text-sm text-gray-500">
|
|
Last changed: {formatDate(gateStatus.lastChanged)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Gate Control Button */}
|
|
<div className="mt-4">
|
|
<button
|
|
onClick={handleGateControl}
|
|
disabled={loading}
|
|
className={`w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white
|
|
${loading ? 'bg-gray-400' : 'bg-blue-600 hover:bg-blue-700'}
|
|
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
|
|
>
|
|
{loading ? 'Processing...' : 'Trigger Gate'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error Display */}
|
|
{error && (
|
|
<div className="mt-4 p-4 bg-red-100 text-red-700 rounded-md">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Events */}
|
|
<div className="mt-6">
|
|
<h2 className="text-lg font-semibold mb-2">Recent Events</h2>
|
|
<div className="border rounded-lg divide-y divide-gray-200 max-h-96 overflow-y-auto">
|
|
{events.map((event, index) => (
|
|
<div key={index} className="px-3 py-2 hover:bg-gray-50 flex items-center justify-between text-sm">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{event.action}</span>
|
|
<span className="text-xs text-gray-500">({event.source})</span>
|
|
</div>
|
|
<div className="text-xs text-gray-500">{formatDate(event.timestamp)}</div>
|
|
</div>
|
|
<div className={`w-2 h-2 rounded-full ${event.success ? 'bg-green-500' : 'bg-red-500'}`} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
{hasMoreEvents && (
|
|
<button
|
|
onClick={handleLoadMore}
|
|
className="mt-2 w-full text-sm text-gray-600 hover:text-gray-900 py-1 border rounded-md hover:bg-gray-50"
|
|
>
|
|
Show More ({events.length} of {totalEvents})
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Settings Dialog */}
|
|
<SettingsDialog
|
|
isOpen={isSettingsOpen}
|
|
onClose={() => {
|
|
setIsSettingsOpen(false);
|
|
// loadSettings(); // Refresh settings after dialog closes
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|