dynamic dashboard initial

This commit is contained in:
2025-11-04 19:33:47 +01:00
parent 69e07056a1
commit ca623121a3
15 changed files with 1774 additions and 7 deletions

116
apps/ui/api_client.py Normal file
View File

@@ -0,0 +1,116 @@
"""
HTTP Client for fetching devices from the API Gateway.
"""
import logging
from typing import Optional
import httpx
logger = logging.getLogger(__name__)
def fetch_devices(api_base: str = "http://localhost:8001") -> list[dict]:
"""
Fetch devices from the API Gateway.
Args:
api_base: Base URL of the API Gateway (default: http://localhost:8001)
Returns:
List of device dictionaries. Each device contains at least:
- device_id (str): Unique device identifier
- type (str): Device type (e.g., "light")
- name (str): Human-readable device name
Returns empty list on error.
"""
url = f"{api_base}/devices"
try:
response = httpx.get(url, timeout=3.0)
response.raise_for_status()
devices = response.json()
# API returns a list directly
if not isinstance(devices, list):
logger.warning(f"Unexpected response format from {url}: expected list, got {type(devices)}")
return []
logger.info(f"Fetched {len(devices)} devices from API Gateway")
return devices
except httpx.TimeoutException:
logger.warning(f"Timeout while fetching devices from {url}")
return []
except httpx.HTTPStatusError as e:
logger.warning(f"HTTP error {e.response.status_code} while fetching devices from {url}")
return []
except httpx.RequestError as e:
logger.warning(f"Request error while fetching devices from {url}: {e}")
return []
except Exception as e:
logger.warning(f"Unexpected error while fetching devices from {url}: {e}")
return []
def fetch_layout(api_base: str = "http://localhost:8001") -> dict:
"""
Fetch UI layout from the API Gateway.
Args:
api_base: Base URL of the API Gateway (default: http://localhost:8001)
Returns:
Layout dictionary with structure:
{
"rooms": [
{
"name": str,
"devices": [
{
"device_id": str,
"title": str,
"icon": str,
"rank": int
}
]
}
]
}
Returns empty layout on error.
"""
url = f"{api_base}/layout"
try:
response = httpx.get(url, timeout=3.0)
response.raise_for_status()
layout = response.json()
# API returns a dict with "rooms" key
if not isinstance(layout, dict) or "rooms" not in layout:
logger.warning(f"Unexpected response format from {url}: expected dict with 'rooms', got {type(layout)}")
return {"rooms": []}
logger.info(f"Fetched layout with {len(layout['rooms'])} rooms from API Gateway")
return layout
except httpx.TimeoutException:
logger.warning(f"Timeout while fetching layout from {url}")
return {"rooms": []}
except httpx.HTTPStatusError as e:
logger.warning(f"HTTP error {e.response.status_code} while fetching layout from {url}")
return {"rooms": []}
except httpx.RequestError as e:
logger.warning(f"Request error while fetching layout from {url}: {e}")
return {"rooms": []}
except Exception as e:
logger.warning(f"Unexpected error while fetching layout from {url}: {e}")
return {"rooms": []}

View File

@@ -1,11 +1,17 @@
"""UI main entry point."""
import logging
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from apps.ui.api_client import fetch_devices, fetch_layout
logger = logging.getLogger(__name__)
# Initialize FastAPI app
app = FastAPI(
title="Home Automation UI",
@@ -17,18 +23,91 @@ app = FastAPI(
templates_dir = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
# Setup static files
static_dir = Path(__file__).parent / "static"
static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
"""Render the main UI page.
"""Redirect to dashboard.
Args:
request: The FastAPI request object
Returns:
HTMLResponse: Rendered HTML template
HTMLResponse: Rendered dashboard
"""
return templates.TemplateResponse("index.html", {"request": request})
return await dashboard(request)
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request) -> HTMLResponse:
"""Render the dashboard with rooms and devices.
Args:
request: The FastAPI request object
Returns:
HTMLResponse: Rendered dashboard template
"""
try:
# Load layout from API
layout_data = fetch_layout()
# Fetch devices from API (now includes features)
api_devices = fetch_devices()
# Create device lookup by device_id
device_map = {d["device_id"]: d for d in api_devices}
# Build rooms with merged device data
rooms = []
for room in layout_data.get("rooms", []):
devices = []
for tile in room.get("devices", []):
# Merge tile data with API device data
device_data = {
"device_id": tile["device_id"],
"title": tile["title"],
"icon": tile["icon"],
"rank": tile["rank"],
}
# Add type, name, and features from API if available
if tile["device_id"] in device_map:
api_device = device_map[tile["device_id"]]
device_data["type"] = api_device.get("type")
device_data["name"] = api_device.get("name")
device_data["features"] = api_device.get("features", {})
else:
device_data["features"] = {}
devices.append(device_data)
# Sort devices by rank (ascending)
devices.sort(key=lambda d: d["rank"])
rooms.append({
"name": room["name"],
"devices": devices
})
logger.info(f"Rendering dashboard with {len(rooms)} rooms")
return templates.TemplateResponse("dashboard.html", {
"request": request,
"rooms": rooms
})
except Exception as e:
logger.error(f"Error rendering dashboard: {e}", exc_info=True)
# Fallback to empty dashboard
return templates.TemplateResponse("dashboard.html", {
"request": request,
"rooms": []
})
def main() -> None:

257
apps/ui/static/style.css Normal file
View File

@@ -0,0 +1,257 @@
/* Home Automation Dashboard Styles */
:root {
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--bg-color: #f8fafc;
--card-bg: #ffffff;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-color: #e2e8f0;
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
/* Header */
header {
background-color: var(--card-bg);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 2rem;
box-shadow: var(--shadow);
}
header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
}
/* Main Content */
main {
margin-bottom: 2rem;
}
/* Room Section */
.room {
background-color: var(--card-bg);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow);
}
.room-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-primary);
border-bottom: 2px solid var(--border-color);
padding-bottom: 0.5rem;
}
/* Device Grid */
.devices-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
/* Tablet: 2 columns */
@media (max-width: 1024px) {
.devices-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Mobile: 1 column */
@media (max-width: 640px) {
.devices-grid {
grid-template-columns: 1fr;
}
}
/* Device Tile */
.device-tile {
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: all 0.2s;
}
.device-tile:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
.device-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.device-icon {
font-size: 2.5rem;
flex-shrink: 0;
line-height: 1;
}
.device-info {
flex: 1;
min-width: 0;
}
.device-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.device-id {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: 'Monaco', 'Courier New', monospace;
}
/* Device State */
.device-state {
padding: 0.5rem;
background-color: var(--card-bg);
border-radius: 0.375rem;
text-align: center;
}
.state-text {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
/* Device Controls */
.device-controls {
display: flex;
gap: 0.5rem;
}
/* Buttons */
.btn {
flex: 1;
padding: 0.625rem 1rem;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.btn-on {
background-color: #10b981;
color: white;
}
.btn-on:hover {
background-color: #059669;
}
.btn-off {
background-color: #ef4444;
color: white;
}
.btn-off:hover {
background-color: #dc2626;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.btn-primary:active {
transform: scale(0.98);
}
/* Empty State */
.empty-state {
background-color: var(--card-bg);
border-radius: 0.5rem;
padding: 3rem 1.5rem;
text-align: center;
box-shadow: var(--shadow);
}
.empty-state p {
font-size: 1.125rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.empty-state .hint {
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Footer */
footer {
text-align: center;
padding: 1rem;
color: var(--text-secondary);
font-size: 0.875rem;
}
/* Responsive */
@media (max-width: 768px) {
header h1 {
font-size: 1.5rem;
}
.room-title {
font-size: 1.25rem;
}
.container {
padding: 0.75rem;
}
.room {
padding: 1rem;
}
}

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home Automation Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>🏠 Home Automation</h1>
</header>
<main>
{% if rooms %}
{% for room in rooms %}
<section class="room">
<h2 class="room-title">{{ room.name }}</h2>
<div class="devices-grid">
{% for device in room.devices %}
<div class="device-tile" data-device-id="{{ device.device_id }}">
<div class="device-header">
<div class="device-icon">{{ device.icon }}</div>
<div class="device-info">
<h3 class="device-title">{{ device.title }}</h3>
<p class="device-id">{{ device.device_id }}</p>
</div>
</div>
<div class="device-state">
<span id="state-{{ device.device_id }}" class="state-text"></span>
</div>
{% if device.type == "light" and device.features.power %}
<div class="device-controls">
<button class="btn btn-on" onclick="setDeviceState('{{ device.device_id }}', 'on')">
AN
</button>
<button class="btn btn-off" onclick="setDeviceState('{{ device.device_id }}', 'off')">
AUS
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Keine Räume oder Geräte konfiguriert.</p>
<p class="hint">Prüfe config/layout.yaml und das API-Gateway.</p>
</div>
{% endif %}
</main>
<footer>
<p>Home Automation System v0.1.0</p>
</footer>
</div>
<script>
// Set device state via API
async function setDeviceState(deviceId, power) {
const stateSpan = document.getElementById(`state-${deviceId}`);
try {
stateSpan.textContent = `${power}...`;
const response = await fetch(`http://localhost:8001/devices/${deviceId}/set`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'light',
payload: {
power: power
}
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
console.log('Device state set:', deviceId, power, result);
stateSpan.textContent = `${power}`;
// Reset to "—" after 2 seconds
setTimeout(() => {
stateSpan.textContent = '—';
}, 2000);
} catch (error) {
console.error('Error setting device state:', error);
stateSpan.textContent = `✗ Error`;
// Reset after 3 seconds
setTimeout(() => {
stateSpan.textContent = '—';
}, 3000);
}
}
</script>
</body>
</html>