feat: Add MQTT and settings event logging, implement event pagination

- Add event logging for MQTT connection states
- Add event logging for settings changes
- Make event list more compact
- Implement pagination for event list with 'Show More' button
This commit is contained in:
Josh Finlay 2025-01-08 07:46:08 +10:00
parent 70f7e4fd84
commit f6ca88f012
4 changed files with 150 additions and 38 deletions

View File

@ -25,6 +25,18 @@ logger = logging.getLogger(__name__)
app = FastAPI()
ha_mqtt = HomeAssistantMQTT()
# Set up MQTT event logging
async def log_mqtt_event(action: str, success: bool = True):
"""Log MQTT events to the database"""
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
(datetime.utcnow().isoformat(), action, "MQTT", success)
)
await db.commit()
ha_mqtt.set_event_callback(log_mqtt_event)
# CORS middleware
app.add_middleware(
CORSMiddleware,
@ -271,16 +283,31 @@ async def get_status():
}
@app.get("/api/events")
async def get_events(limit: int = 10):
"""Get recent gate events"""
async def get_events(limit: int = 10, offset: int = 0):
"""Get recent gate events with pagination"""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
# Get total count
cursor = await db.execute("SELECT COUNT(*) as count FROM events")
row = await cursor.fetchone()
total_count = row['count']
# Get paginated events
cursor = await db.execute(
"SELECT * FROM events ORDER BY timestamp DESC LIMIT ?",
(limit,)
"""
SELECT * FROM events
ORDER BY timestamp DESC
LIMIT ? OFFSET ?
""",
(limit, offset)
)
events = await cursor.fetchall()
return [dict(event) for event in events]
return {
"events": [dict(event) for event in events],
"total": total_count,
"hasMore": (offset + limit) < total_count
}
@app.get("/api/settings")
async def get_settings():
@ -308,6 +335,7 @@ async def get_settings():
@app.post("/api/settings")
async def update_settings(settings: Settings):
"""Update settings"""
try:
async with aiosqlite.connect(DB_PATH) as db:
# Update each setting
for key, value in settings.dict().items():
@ -328,7 +356,44 @@ async def update_settings(settings: Settings):
# Enable/disable MQTT based on settings
ha_mqtt.enable(settings.mqtt.get("enabled", False))
# Log settings update event with details
changes = []
if settings.maxOpenTimeSeconds:
changes.append(f"Max Open Time: {settings.maxOpenTimeSeconds}s")
if settings.triggerDuration:
changes.append(f"Trigger Duration: {settings.triggerDuration}ms")
if settings.mqtt:
mqtt_status = "Enabled" if settings.mqtt.get("enabled") else "Disabled"
changes.append(f"MQTT: {mqtt_status}")
if settings.mqtt.get("enabled"):
changes.append(f"Broker: {settings.mqtt['broker']}:{settings.mqtt['port']}")
await db.execute(
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
(
datetime.utcnow().isoformat(),
f"Settings Updated ({'; '.join(changes)})",
"Settings",
True
)
)
await db.commit()
return {"success": True}
except Exception as e:
# Log failure event
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
(
datetime.utcnow().isoformat(),
f"Settings Update Failed: {str(e)}",
"Settings",
False
)
)
await db.commit()
raise HTTPException(status_code=500, detail=str(e))
# Serve static files
app.mount("/", StaticFiles(directory="../public", html=True), name="static")

View File

@ -31,6 +31,16 @@ class HomeAssistantMQTT:
self._connected = False
self._reconnect_task: Optional[asyncio.Task] = None
self._enabled = False
self._event_callback: Optional[Callable] = None
def set_event_callback(self, callback: Callable):
"""Set callback for logging events"""
self._event_callback = callback
async def _log_event(self, action: str, success: bool = True):
"""Log MQTT events if callback is set"""
if self._event_callback:
await self._event_callback(action, success)
def enable(self, enabled: bool = True):
"""Enable or disable MQTT integration"""
@ -41,10 +51,12 @@ class HomeAssistantMQTT:
self.set_command_callback(handle_mqtt_command)
# Start reconnection
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
asyncio.create_task(self._log_event("MQTT Enabled"))
elif not enabled and self._reconnect_task:
self._reconnect_task.cancel()
self._reconnect_task = None
asyncio.create_task(self.disconnect())
asyncio.create_task(self._log_event("MQTT Disabled"))
async def _reconnect_loop(self):
"""Continuously try to reconnect when connection is lost"""
@ -63,6 +75,7 @@ class HomeAssistantMQTT:
"""Callback for when connection is established"""
logger.info("Connected to MQTT broker")
self._connected = True
asyncio.create_task(self._log_event("MQTT Connected"))
asyncio.create_task(self._post_connect())
def on_message(self, client, topic, payload, qos, properties):
@ -78,6 +91,8 @@ class HomeAssistantMQTT:
"""Callback for when connection is lost"""
logger.info("Disconnected from MQTT broker")
self._connected = False
# Log disconnect event
asyncio.create_task(self._log_event("MQTT Disconnected"))
# Start reconnection if enabled
if self._enabled and not self._reconnect_task:
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
@ -115,6 +130,7 @@ class HomeAssistantMQTT:
logger.info("Initiating connection to MQTT broker")
except Exception as e:
logger.error(f"Failed to connect to MQTT broker: {e}")
await self._log_event("MQTT Connection Failed", success=False)
raise
async def disconnect(self):

View File

@ -6,6 +6,8 @@ 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',
@ -36,7 +38,9 @@ function App() {
api.getSettings(),
api.getGateStatus()
]);
setEvents(eventsData);
setEvents(eventsData.events);
setHasMoreEvents(eventsData.hasMore);
setTotalEvents(eventsData.total);
setSettings(settingsData);
setGateStatus(statusData);
} catch (err) {
@ -53,7 +57,9 @@ function App() {
const status = await api.getGateStatus();
setGateStatus(status);
const newEvents = await api.getEvents();
setEvents(newEvents);
setEvents(newEvents.events);
setHasMoreEvents(newEvents.hasMore);
setTotalEvents(newEvents.total);
} catch (err) {
console.error('Failed to update gate status:', err);
}
@ -68,7 +74,7 @@ function App() {
const result = await api.triggerGate();
if (result.success) {
const newEvents = await api.getEvents();
setEvents(newEvents);
setEvents(newEvents.events);
setGateStatus(prev => ({ ...prev, isOpen: result.currentStatus }));
}
} catch (err) {
@ -93,6 +99,18 @@ function App() {
}
};
const handleLoadMore = async () => {
try {
const moreEvents = await api.getEvents(10, events.length);
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-6 flex flex-col justify-center sm:py-12">
<div className="relative py-3 sm:max-w-xl sm:mx-auto">
@ -143,17 +161,30 @@ function App() {
)}
{/* Recent Events */}
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">Recent Events</h2>
<div className="space-y-4">
<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-48 overflow-y-auto">
{events.map((event, index) => (
<div key={index} className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">{formatDate(event.timestamp)}</p>
<p className="font-medium">{event.action}</p>
<p className="text-sm text-gray-500">Source: {event.source}</p>
<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>

View File

@ -20,10 +20,10 @@ export async function getGateStatus(): Promise<GateStatus> {
return response.json();
}
export async function getEvents(limit: number = 10): Promise<GateEvent[]> {
const response = await fetch(`${API_BASE}/events?limit=${limit}`);
export async function getEvents(limit: number = 10, offset: number = 0): Promise<{ events: GateEvent[], total: number, hasMore: boolean }> {
const response = await fetch(`${API_BASE}/events?limit=${limit}&offset=${offset}`);
if (!response.ok) {
throw new Error(`Failed to get events: ${response.statusText}`);
throw new Error('Failed to fetch events');
}
return response.json();
}