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:
parent
70f7e4fd84
commit
f6ca88f012
111
backend/main.py
111
backend/main.py
|
|
@ -25,6 +25,18 @@ logger = logging.getLogger(__name__)
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
ha_mqtt = HomeAssistantMQTT()
|
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
|
# CORS middleware
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
@ -271,16 +283,31 @@ async def get_status():
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/events")
|
@app.get("/api/events")
|
||||||
async def get_events(limit: int = 10):
|
async def get_events(limit: int = 10, offset: int = 0):
|
||||||
"""Get recent gate events"""
|
"""Get recent gate events with pagination"""
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
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(
|
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()
|
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")
|
@app.get("/api/settings")
|
||||||
async def get_settings():
|
async def get_settings():
|
||||||
|
|
@ -308,27 +335,65 @@ async def get_settings():
|
||||||
@app.post("/api/settings")
|
@app.post("/api/settings")
|
||||||
async def update_settings(settings: Settings):
|
async def update_settings(settings: Settings):
|
||||||
"""Update settings"""
|
"""Update settings"""
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
try:
|
||||||
# Update each setting
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
for key, value in settings.dict().items():
|
# Update each setting
|
||||||
|
for key, value in settings.dict().items():
|
||||||
|
await db.execute(
|
||||||
|
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
||||||
|
(key, json.dumps(value))
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Update environment variables and MQTT connection
|
||||||
|
if settings.mqtt:
|
||||||
|
os.environ["MQTT_BROKER"] = settings.mqtt["broker"]
|
||||||
|
os.environ["MQTT_PORT"] = settings.mqtt["port"]
|
||||||
|
os.environ["MQTT_USERNAME"] = settings.mqtt["username"]
|
||||||
|
os.environ["MQTT_PASSWORD"] = settings.mqtt["password"]
|
||||||
|
os.environ["MQTT_CLIENT_ID"] = settings.mqtt["clientId"]
|
||||||
|
|
||||||
|
# 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(
|
await db.execute(
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
|
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
|
||||||
(key, json.dumps(value))
|
(
|
||||||
|
datetime.utcnow().isoformat(),
|
||||||
|
f"Settings Updated ({'; '.join(changes)})",
|
||||||
|
"Settings",
|
||||||
|
True
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Update environment variables and MQTT connection
|
return {"success": True}
|
||||||
if settings.mqtt:
|
except Exception as e:
|
||||||
os.environ["MQTT_BROKER"] = settings.mqtt["broker"]
|
# Log failure event
|
||||||
os.environ["MQTT_PORT"] = settings.mqtt["port"]
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
os.environ["MQTT_USERNAME"] = settings.mqtt["username"]
|
await db.execute(
|
||||||
os.environ["MQTT_PASSWORD"] = settings.mqtt["password"]
|
"INSERT INTO events (timestamp, action, source, success) VALUES (?, ?, ?, ?)",
|
||||||
os.environ["MQTT_CLIENT_ID"] = settings.mqtt["clientId"]
|
(
|
||||||
|
datetime.utcnow().isoformat(),
|
||||||
# Enable/disable MQTT based on settings
|
f"Settings Update Failed: {str(e)}",
|
||||||
ha_mqtt.enable(settings.mqtt.get("enabled", False))
|
"Settings",
|
||||||
|
False
|
||||||
return {"success": True}
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
# Serve static files
|
# Serve static files
|
||||||
app.mount("/", StaticFiles(directory="../public", html=True), name="static")
|
app.mount("/", StaticFiles(directory="../public", html=True), name="static")
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,16 @@ class HomeAssistantMQTT:
|
||||||
self._connected = False
|
self._connected = False
|
||||||
self._reconnect_task: Optional[asyncio.Task] = None
|
self._reconnect_task: Optional[asyncio.Task] = None
|
||||||
self._enabled = False
|
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):
|
def enable(self, enabled: bool = True):
|
||||||
"""Enable or disable MQTT integration"""
|
"""Enable or disable MQTT integration"""
|
||||||
|
|
@ -41,10 +51,12 @@ class HomeAssistantMQTT:
|
||||||
self.set_command_callback(handle_mqtt_command)
|
self.set_command_callback(handle_mqtt_command)
|
||||||
# Start reconnection
|
# Start reconnection
|
||||||
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
||||||
|
asyncio.create_task(self._log_event("MQTT Enabled"))
|
||||||
elif not enabled and self._reconnect_task:
|
elif not enabled and self._reconnect_task:
|
||||||
self._reconnect_task.cancel()
|
self._reconnect_task.cancel()
|
||||||
self._reconnect_task = None
|
self._reconnect_task = None
|
||||||
asyncio.create_task(self.disconnect())
|
asyncio.create_task(self.disconnect())
|
||||||
|
asyncio.create_task(self._log_event("MQTT Disabled"))
|
||||||
|
|
||||||
async def _reconnect_loop(self):
|
async def _reconnect_loop(self):
|
||||||
"""Continuously try to reconnect when connection is lost"""
|
"""Continuously try to reconnect when connection is lost"""
|
||||||
|
|
@ -63,6 +75,7 @@ class HomeAssistantMQTT:
|
||||||
"""Callback for when connection is established"""
|
"""Callback for when connection is established"""
|
||||||
logger.info("Connected to MQTT broker")
|
logger.info("Connected to MQTT broker")
|
||||||
self._connected = True
|
self._connected = True
|
||||||
|
asyncio.create_task(self._log_event("MQTT Connected"))
|
||||||
asyncio.create_task(self._post_connect())
|
asyncio.create_task(self._post_connect())
|
||||||
|
|
||||||
def on_message(self, client, topic, payload, qos, properties):
|
def on_message(self, client, topic, payload, qos, properties):
|
||||||
|
|
@ -78,6 +91,8 @@ class HomeAssistantMQTT:
|
||||||
"""Callback for when connection is lost"""
|
"""Callback for when connection is lost"""
|
||||||
logger.info("Disconnected from MQTT broker")
|
logger.info("Disconnected from MQTT broker")
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
# Log disconnect event
|
||||||
|
asyncio.create_task(self._log_event("MQTT Disconnected"))
|
||||||
# Start reconnection if enabled
|
# Start reconnection if enabled
|
||||||
if self._enabled and not self._reconnect_task:
|
if self._enabled and not self._reconnect_task:
|
||||||
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
||||||
|
|
@ -115,6 +130,7 @@ class HomeAssistantMQTT:
|
||||||
logger.info("Initiating connection to MQTT broker")
|
logger.info("Initiating connection to MQTT broker")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to connect to MQTT broker: {e}")
|
logger.error(f"Failed to connect to MQTT broker: {e}")
|
||||||
|
await self._log_event("MQTT Connection Failed", success=False)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self):
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { Cog6ToothIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [events, setEvents] = useState<GateEvent[]>([]);
|
const [events, setEvents] = useState<GateEvent[]>([]);
|
||||||
|
const [hasMoreEvents, setHasMoreEvents] = useState(false);
|
||||||
|
const [totalEvents, setTotalEvents] = useState(0);
|
||||||
const [settings, setSettings] = useState<Settings>({
|
const [settings, setSettings] = useState<Settings>({
|
||||||
maxOpenTimeSeconds: '300',
|
maxOpenTimeSeconds: '300',
|
||||||
triggerDuration: '500',
|
triggerDuration: '500',
|
||||||
|
|
@ -36,7 +38,9 @@ function App() {
|
||||||
api.getSettings(),
|
api.getSettings(),
|
||||||
api.getGateStatus()
|
api.getGateStatus()
|
||||||
]);
|
]);
|
||||||
setEvents(eventsData);
|
setEvents(eventsData.events);
|
||||||
|
setHasMoreEvents(eventsData.hasMore);
|
||||||
|
setTotalEvents(eventsData.total);
|
||||||
setSettings(settingsData);
|
setSettings(settingsData);
|
||||||
setGateStatus(statusData);
|
setGateStatus(statusData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -53,7 +57,9 @@ function App() {
|
||||||
const status = await api.getGateStatus();
|
const status = await api.getGateStatus();
|
||||||
setGateStatus(status);
|
setGateStatus(status);
|
||||||
const newEvents = await api.getEvents();
|
const newEvents = await api.getEvents();
|
||||||
setEvents(newEvents);
|
setEvents(newEvents.events);
|
||||||
|
setHasMoreEvents(newEvents.hasMore);
|
||||||
|
setTotalEvents(newEvents.total);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update gate status:', err);
|
console.error('Failed to update gate status:', err);
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +74,7 @@ function App() {
|
||||||
const result = await api.triggerGate();
|
const result = await api.triggerGate();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const newEvents = await api.getEvents();
|
const newEvents = await api.getEvents();
|
||||||
setEvents(newEvents);
|
setEvents(newEvents.events);
|
||||||
setGateStatus(prev => ({ ...prev, isOpen: result.currentStatus }));
|
setGateStatus(prev => ({ ...prev, isOpen: result.currentStatus }));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-100 py-6 flex flex-col justify-center sm:py-12">
|
<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">
|
<div className="relative py-3 sm:max-w-xl sm:mx-auto">
|
||||||
|
|
@ -143,17 +161,30 @@ function App() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recent Events */}
|
{/* Recent Events */}
|
||||||
<div className="mt-8">
|
<div className="mt-6">
|
||||||
<h2 className="text-xl font-semibold mb-4">Recent Events</h2>
|
<h2 className="text-lg font-semibold mb-2">Recent Events</h2>
|
||||||
<div className="space-y-4">
|
<div className="border rounded-lg divide-y divide-gray-200 max-h-48 overflow-y-auto">
|
||||||
{events.map((event, index) => (
|
{events.map((event, index) => (
|
||||||
<div key={index} className="p-4 bg-gray-50 rounded-lg">
|
<div key={index} className="px-3 py-2 hover:bg-gray-50 flex items-center justify-between text-sm">
|
||||||
<p className="text-sm text-gray-600">{formatDate(event.timestamp)}</p>
|
<div className="flex-1">
|
||||||
<p className="font-medium">{event.action}</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm text-gray-500">Source: {event.source}</p>
|
<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>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ export async function getGateStatus(): Promise<GateStatus> {
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEvents(limit: number = 10): Promise<GateEvent[]> {
|
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}`);
|
const response = await fetch(`${API_BASE}/events?limit=${limit}&offset=${offset}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to get events: ${response.statusText}`);
|
throw new Error('Failed to fetch events');
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue