dockerfiles added
This commit is contained in:
197
UI_API_CONFIG.md
Normal file
197
UI_API_CONFIG.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# UI API Configuration
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
Die UI-Anwendung verwendet keine hart codierten API-URLs mehr. Stattdessen wird die API-Basis-URL über die Umgebungsvariable `API_BASE` konfiguriert.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Umgebungsvariable
|
||||||
|
- **Name**: `API_BASE`
|
||||||
|
- **Standard**: `http://localhost:8001`
|
||||||
|
- **Beispiele**:
|
||||||
|
- Lokal: `http://localhost:8001`
|
||||||
|
- Docker: `http://api:8001`
|
||||||
|
- Kubernetes: `http://api-service:8001`
|
||||||
|
|
||||||
|
- **Name**: `BASE_PATH`
|
||||||
|
- **Standard**: `""` (leer)
|
||||||
|
- **Beschreibung**: Pfad-Präfix für Reverse Proxy (z.B. `/ui`)
|
||||||
|
- **Beispiele**:
|
||||||
|
- Ohne Proxy: `""` (leer)
|
||||||
|
- Hinter Proxy: `/ui`
|
||||||
|
- Traefik/nginx: `/home-automation`
|
||||||
|
|
||||||
|
### Startup-Ausgabe
|
||||||
|
Beim Start zeigt die UI die verwendete API-URL an:
|
||||||
|
```
|
||||||
|
UI using API_BASE: http://localhost:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
## API-Funktionen
|
||||||
|
|
||||||
|
### `api_url(path: str) -> str`
|
||||||
|
Hilfsfunktion zum Erstellen vollständiger API-URLs:
|
||||||
|
```python
|
||||||
|
from apps.ui.main import api_url
|
||||||
|
|
||||||
|
# Beispiel
|
||||||
|
url = api_url("/devices") # → "http://localhost:8001/devices"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Endpoint
|
||||||
|
Für Kubernetes Liveness/Readiness Probes:
|
||||||
|
```bash
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
Antwort:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "ui",
|
||||||
|
"api_base": "http://localhost:8001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verwendung
|
||||||
|
|
||||||
|
### Lokal (Entwicklung)
|
||||||
|
```bash
|
||||||
|
# Standard (verwendet http://localhost:8001)
|
||||||
|
poetry run uvicorn apps.ui.main:app --host 0.0.0.0 --port 8002
|
||||||
|
|
||||||
|
# Mit anderer API
|
||||||
|
API_BASE=http://192.168.1.100:8001 poetry run uvicorn apps.ui.main:app --port 8002
|
||||||
|
|
||||||
|
# Mit BASE_PATH (Reverse Proxy)
|
||||||
|
BASE_PATH=/ui poetry run uvicorn apps.ui.main:app --port 8002
|
||||||
|
# Zugriff: http://localhost:8002/ui/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
ui:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
environment:
|
||||||
|
- API_BASE=http://api:8001
|
||||||
|
- BASE_PATH="" # Leer für direkten Zugriff
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose mit Reverse Proxy
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
ui:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
- API_BASE=http://api:8001
|
||||||
|
- BASE_PATH=/ui # Pfad-Präfix für nginx
|
||||||
|
expose:
|
||||||
|
- "8002"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ui
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ui
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ui
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ui
|
||||||
|
image: home-automation-ui:latest
|
||||||
|
env:
|
||||||
|
- name: API_BASE
|
||||||
|
value: "http://api-service:8001"
|
||||||
|
- name: BASE_PATH
|
||||||
|
value: "/ui" # Für Ingress
|
||||||
|
ports:
|
||||||
|
- containerPort: 8002
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /ui/health # Mit BASE_PATH!
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /ui/health # Mit BASE_PATH!
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: ui-ingress
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: home.example.com
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /ui
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: ui-service
|
||||||
|
port:
|
||||||
|
number: 8002
|
||||||
|
```
|
||||||
|
|
||||||
|
## Geänderte Dateien
|
||||||
|
|
||||||
|
1. **apps/ui/main.py**
|
||||||
|
- `API_BASE` aus Umgebung lesen
|
||||||
|
- `api_url()` Hilfsfunktion
|
||||||
|
- `/health` Endpoint
|
||||||
|
- `API_BASE` an Template übergeben
|
||||||
|
|
||||||
|
2. **apps/ui/api_client.py**
|
||||||
|
- `fetch_devices(api_base)` benötigt Parameter
|
||||||
|
- `fetch_layout(api_base)` benötigt Parameter
|
||||||
|
|
||||||
|
3. **apps/ui/templates/dashboard.html**
|
||||||
|
- JavaScript verwendet `{{ api_base }}` aus Backend
|
||||||
|
|
||||||
|
## Akzeptanz-Kriterien ✓
|
||||||
|
|
||||||
|
- ✅ `print(API_BASE)` zeigt korrekten Wert beim Start
|
||||||
|
- ✅ UI funktioniert lokal ohne Codeänderung
|
||||||
|
- ✅ Mit `API_BASE=http://api:8001` ruft UI korrekt den API-Service an
|
||||||
|
- ✅ Health-Endpoint für Kubernetes verfügbar
|
||||||
|
- ✅ Keine hart codierten URLs mehr
|
||||||
|
|
||||||
|
## Vorteile
|
||||||
|
|
||||||
|
1. **Flexibilität**: API-URL per ENV konfigurierbar
|
||||||
|
2. **Docker/K8s Ready**: Service Discovery unterstützt
|
||||||
|
3. **Health Checks**: Monitoring-Integration möglich
|
||||||
|
4. **Abwärtskompatibel**: Bestehende Deployments funktionieren weiter
|
||||||
|
5. **Clean Code**: Zentrale Konfiguration statt verteilte Hardcodes
|
||||||
44
apps/abstraction/Dockerfile
Normal file
44
apps/abstraction/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Abstraction Layer Dockerfile
|
||||||
|
# MQTT ↔ Device Protocol Translation Worker
|
||||||
|
|
||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
MQTT_BROKER=172.16.2.16 \
|
||||||
|
MQTT_PORT=1883 \
|
||||||
|
REDIS_HOST=localhost \
|
||||||
|
REDIS_PORT=6379 \
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/abstraction/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
|
COPY apps/abstraction/ /app/apps/abstraction/
|
||||||
|
COPY packages/ /app/packages/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["python", "-m", "apps.abstraction.main"]
|
||||||
5
apps/abstraction/requirements.txt
Normal file
5
apps/abstraction/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pydantic>=2
|
||||||
|
aiomqtt==2.0.1
|
||||||
|
redis==5.0.1
|
||||||
|
pyyaml==6.0.1
|
||||||
|
tenacity==8.2.3
|
||||||
53
apps/api/Dockerfile
Normal file
53
apps/api/Dockerfile
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# API Service Dockerfile
|
||||||
|
# FastAPI + Redis + MQTT Gateway
|
||||||
|
|
||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
MQTT_BROKER=172.16.2.16 \
|
||||||
|
MQTT_PORT=1883 \
|
||||||
|
REDIS_HOST=localhost \
|
||||||
|
REDIS_PORT=6379 \
|
||||||
|
REDIS_DB=0 \
|
||||||
|
REDIS_CHANNEL=ui:updates
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/api/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
|
COPY apps/api/ /app/apps/api/
|
||||||
|
COPY packages/ /app/packages/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8001/health || exit 1
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["python", "-m", "uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
@@ -104,10 +104,12 @@ def load_devices() -> list[dict[str, Any]]:
|
|||||||
def get_mqtt_settings() -> tuple[str, int]:
|
def get_mqtt_settings() -> tuple[str, int]:
|
||||||
"""Get MQTT broker settings from environment.
|
"""Get MQTT broker settings from environment.
|
||||||
|
|
||||||
|
Supports both MQTT_BROKER and MQTT_HOST for compatibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (host, port)
|
tuple: (host, port)
|
||||||
"""
|
"""
|
||||||
host = os.environ.get("MQTT_HOST", "172.16.2.16")
|
host = os.environ.get("MQTT_BROKER") or os.environ.get("MQTT_HOST", "172.16.2.16")
|
||||||
port = int(os.environ.get("MQTT_PORT", "1883"))
|
port = int(os.environ.get("MQTT_PORT", "1883"))
|
||||||
return host, port
|
return host, port
|
||||||
|
|
||||||
@@ -115,9 +117,24 @@ def get_mqtt_settings() -> tuple[str, int]:
|
|||||||
def get_redis_settings() -> tuple[str, str]:
|
def get_redis_settings() -> tuple[str, str]:
|
||||||
"""Get Redis settings from configuration.
|
"""Get Redis settings from configuration.
|
||||||
|
|
||||||
|
Prioritizes environment variables over config file:
|
||||||
|
- REDIS_HOST, REDIS_PORT, REDIS_DB → redis://host:port/db
|
||||||
|
- REDIS_CHANNEL → pub/sub channel name
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (url, channel)
|
tuple: (url, channel)
|
||||||
"""
|
"""
|
||||||
|
# Check environment variables first
|
||||||
|
redis_host = os.getenv("REDIS_HOST")
|
||||||
|
redis_port = os.getenv("REDIS_PORT", "6379")
|
||||||
|
redis_db = os.getenv("REDIS_DB", "0")
|
||||||
|
redis_channel = os.getenv("REDIS_CHANNEL", "ui:updates")
|
||||||
|
|
||||||
|
if redis_host:
|
||||||
|
url = f"redis://{redis_host}:{redis_port}/{redis_db}"
|
||||||
|
return url, redis_channel
|
||||||
|
|
||||||
|
# Fallback to config file
|
||||||
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
|
config_path = Path(__file__).parent.parent.parent / "config" / "devices.yaml"
|
||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
|
|||||||
6
apps/api/requirements.txt
Normal file
6
apps/api/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
pydantic>=2
|
||||||
|
redis==5.0.1
|
||||||
|
aiomqtt==2.0.1
|
||||||
|
pyyaml==6.0.1
|
||||||
49
apps/simulator/Dockerfile
Normal file
49
apps/simulator/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Simulator Service Dockerfile
|
||||||
|
# FastAPI Web UI + MQTT Device Simulator
|
||||||
|
|
||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
MQTT_BROKER=172.16.2.16 \
|
||||||
|
MQTT_PORT=1883 \
|
||||||
|
SIM_PORT=8010
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/simulator/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
|
COPY apps/simulator/ /app/apps/simulator/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8010/health || exit 1
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8010
|
||||||
|
|
||||||
|
# Run the simulator
|
||||||
|
CMD ["python", "-m", "uvicorn", "apps.simulator.main:app", "--host", "0.0.0.0", "--port", "8010"]
|
||||||
4
apps/simulator/requirements.txt
Normal file
4
apps/simulator/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
aiomqtt==2.0.1
|
||||||
|
jinja2==3.1.2
|
||||||
49
apps/ui/Dockerfile
Normal file
49
apps/ui/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# UI Service Dockerfile
|
||||||
|
# FastAPI + Jinja2 + HTMX Dashboard
|
||||||
|
|
||||||
|
FROM python:3.14-alpine
|
||||||
|
|
||||||
|
# Prevent Python from writing .pyc files and enable unbuffered output
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
UI_PORT=8002 \
|
||||||
|
API_BASE=http://api:8001 \
|
||||||
|
BASE_PATH=""
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 10001 -S app && \
|
||||||
|
adduser -u 10001 -S app -G app
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
curl \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY apps/ui/requirements.txt /app/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY apps/__init__.py /app/apps/__init__.py
|
||||||
|
COPY apps/ui/ /app/apps/ui/
|
||||||
|
|
||||||
|
# Change ownership to app user
|
||||||
|
RUN chown -R app:app /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER app
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:${UI_PORT}/health || exit 1
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8002
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["python", "-m", "uvicorn", "apps.ui.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||||
171
apps/ui/README_DOCKER.md
Normal file
171
apps/ui/README_DOCKER.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# UI Service - Docker
|
||||||
|
|
||||||
|
FastAPI + Jinja2 + HTMX Dashboard für Home Automation
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t ui:dev -f apps/ui/Dockerfile .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
### Lokal
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8002:8002 -e API_BASE=http://localhost:8001 ui:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
ui:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/ui/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
environment:
|
||||||
|
- API_BASE=http://api:8001
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kubernetes
|
||||||
|
```yaml
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: ui
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: ui
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: ui
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: ui
|
||||||
|
image: ui:dev
|
||||||
|
ports:
|
||||||
|
- containerPort: 8002
|
||||||
|
env:
|
||||||
|
- name: API_BASE
|
||||||
|
value: "http://api-service:8001"
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8002
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: "128Mi"
|
||||||
|
cpu: "100m"
|
||||||
|
limits:
|
||||||
|
memory: "256Mi"
|
||||||
|
cpu: "500m"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: ui-service
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: ui
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 8002
|
||||||
|
targetPort: 8002
|
||||||
|
type: LoadBalancer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Default | Beschreibung |
|
||||||
|
|----------|---------|--------------|
|
||||||
|
| `API_BASE` | `http://api:8001` | URL des API-Services |
|
||||||
|
| `UI_PORT` | `8002` | Port der UI-Anwendung |
|
||||||
|
| `PYTHONDONTWRITEBYTECODE` | `1` | Keine .pyc Files |
|
||||||
|
| `PYTHONUNBUFFERED` | `1` | Unbuffered Output |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /` - Dashboard
|
||||||
|
- `GET /health` - Health Check
|
||||||
|
- `GET /dashboard` - Dashboard (alias)
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Container läuft als **non-root** User `app` (UID: 10001)
|
||||||
|
- Minimales Python 3.11-slim Base Image
|
||||||
|
- Keine unnötigen System-Pakete
|
||||||
|
- Health Check integriert
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ FastAPI Backend
|
||||||
|
- ✅ Jinja2 Templates
|
||||||
|
- ✅ HTMX für reactive UI
|
||||||
|
- ✅ Server-Sent Events (SSE)
|
||||||
|
- ✅ Responsive Design
|
||||||
|
- ✅ Docker & Kubernetes ready
|
||||||
|
- ✅ Health Check Endpoint
|
||||||
|
- ✅ Non-root Container
|
||||||
|
- ✅ Configurable API Backend
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
### Lokales Testing
|
||||||
|
```bash
|
||||||
|
# Build
|
||||||
|
docker build -t ui:dev -f apps/ui/Dockerfile .
|
||||||
|
|
||||||
|
# Run
|
||||||
|
docker run -d --name ui-test -p 8002:8002 -e API_BASE=http://localhost:8001 ui:dev
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
docker logs -f ui-test
|
||||||
|
|
||||||
|
# Health Check
|
||||||
|
curl http://localhost:8002/health
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
docker stop ui-test && docker rm ui-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
```bash
|
||||||
|
bash /tmp/test_ui_dockerfile.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container startet nicht
|
||||||
|
```bash
|
||||||
|
docker logs ui-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check schlägt fehl
|
||||||
|
```bash
|
||||||
|
docker exec ui-test curl http://localhost:8002/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### API_BASE nicht korrekt
|
||||||
|
```bash
|
||||||
|
docker logs ui-test 2>&1 | grep "UI using API_BASE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Non-root Verifizieren
|
||||||
|
```bash
|
||||||
|
docker exec ui-test id
|
||||||
|
# Sollte zeigen: uid=10001(app) gid=10001(app)
|
||||||
|
```
|
||||||
@@ -8,12 +8,12 @@ import httpx
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def fetch_devices(api_base: str = "http://localhost:8001") -> list[dict]:
|
def fetch_devices(api_base: str) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Fetch devices from the API Gateway.
|
Fetch devices from the API Gateway.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_base: Base URL of the API Gateway (default: http://localhost:8001)
|
api_base: Base URL of the API Gateway (e.g., "http://localhost:8001" or "http://api:8001")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of device dictionaries. Each device contains at least:
|
List of device dictionaries. Each device contains at least:
|
||||||
@@ -56,12 +56,12 @@ def fetch_devices(api_base: str = "http://localhost:8001") -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def fetch_layout(api_base: str = "http://localhost:8001") -> dict:
|
def fetch_layout(api_base: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Fetch UI layout from the API Gateway.
|
Fetch UI layout from the API Gateway.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_base: Base URL of the API Gateway (default: http://localhost:8001)
|
api_base: Base URL of the API Gateway (e.g., "http://localhost:8001" or "http://api:8001")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Layout dictionary with structure:
|
Layout dictionary with structure:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""UI main entry point."""
|
"""UI main entry point."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
@@ -12,11 +13,30 @@ from apps.ui.api_client import fetch_devices, fetch_layout
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Read configuration from environment variables
|
||||||
|
API_BASE = os.getenv("API_BASE", "http://localhost:8001")
|
||||||
|
BASE_PATH = os.getenv("BASE_PATH", "") # e.g., "/ui" for reverse proxy
|
||||||
|
|
||||||
|
print(f"UI using API_BASE: {API_BASE}")
|
||||||
|
print(f"UI using BASE_PATH: {BASE_PATH}")
|
||||||
|
|
||||||
|
def api_url(path: str) -> str:
|
||||||
|
"""Helper function to construct API URLs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: API path (e.g., "/devices")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full API URL
|
||||||
|
"""
|
||||||
|
return f"{API_BASE}{path}"
|
||||||
|
|
||||||
|
# Initialize FastAPI app with optional root_path for reverse proxy
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Home Automation UI",
|
title="Home Automation UI",
|
||||||
description="User interface for home automation system",
|
description="User interface for home automation system",
|
||||||
version="0.1.0"
|
version="0.1.0",
|
||||||
|
root_path=BASE_PATH
|
||||||
)
|
)
|
||||||
|
|
||||||
# Setup Jinja2 templates
|
# Setup Jinja2 templates
|
||||||
@@ -29,6 +49,21 @@ static_dir.mkdir(exist_ok=True)
|
|||||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> JSONResponse:
|
||||||
|
"""Health check endpoint for Kubernetes/Docker.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Health status
|
||||||
|
"""
|
||||||
|
return JSONResponse({
|
||||||
|
"status": "ok",
|
||||||
|
"service": "ui",
|
||||||
|
"api_base": API_BASE,
|
||||||
|
"base_path": BASE_PATH
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def index(request: Request) -> HTMLResponse:
|
async def index(request: Request) -> HTMLResponse:
|
||||||
"""Redirect to dashboard.
|
"""Redirect to dashboard.
|
||||||
@@ -53,11 +88,11 @@ async def dashboard(request: Request) -> HTMLResponse:
|
|||||||
HTMLResponse: Rendered dashboard template
|
HTMLResponse: Rendered dashboard template
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Load layout from API
|
# Load layout from API (use configured API_BASE)
|
||||||
layout_data = fetch_layout()
|
layout_data = fetch_layout(API_BASE)
|
||||||
|
|
||||||
# Fetch devices from API (now includes features)
|
# Fetch devices from API (now includes features)
|
||||||
api_devices = fetch_devices()
|
api_devices = fetch_devices(API_BASE)
|
||||||
|
|
||||||
# Create device lookup by device_id
|
# Create device lookup by device_id
|
||||||
device_map = {d["device_id"]: d for d in api_devices}
|
device_map = {d["device_id"]: d for d in api_devices}
|
||||||
@@ -98,7 +133,8 @@ async def dashboard(request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"rooms": rooms
|
"rooms": rooms,
|
||||||
|
"api_base": API_BASE # Pass API_BASE to template
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -106,7 +142,8 @@ async def dashboard(request: Request) -> HTMLResponse:
|
|||||||
# Fallback to empty dashboard
|
# Fallback to empty dashboard
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"rooms": []
|
"rooms": [],
|
||||||
|
"api_base": API_BASE # Pass API_BASE even on error
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
4
apps/ui/requirements.txt
Normal file
4
apps/ui/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
jinja2==3.1.2
|
||||||
|
httpx==0.25.1
|
||||||
@@ -519,7 +519,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API_BASE = 'http://localhost:8001';
|
// API_BASE injected from backend (supports Docker/K8s environments)
|
||||||
|
window.API_BASE = '{{ api_base }}';
|
||||||
|
|
||||||
|
// Helper function to construct API URLs
|
||||||
|
function api(url) {
|
||||||
|
return `${window.API_BASE}${url}`;
|
||||||
|
}
|
||||||
|
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let currentState = {};
|
let currentState = {};
|
||||||
let thermostatTargets = {};
|
let thermostatTargets = {};
|
||||||
@@ -542,7 +549,7 @@
|
|||||||
const newState = currentState[deviceId] === 'on' ? 'off' : 'on';
|
const newState = currentState[deviceId] === 'on' ? 'off' : 'on';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -579,7 +586,7 @@
|
|||||||
// Set brightness
|
// Set brightness
|
||||||
async function setBrightness(deviceId, brightness) {
|
async function setBrightness(deviceId, brightness) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -612,7 +619,7 @@
|
|||||||
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
|
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -645,7 +652,7 @@
|
|||||||
const currentTarget = thermostatTargets[deviceId] || 21.0;
|
const currentTarget = thermostatTargets[deviceId] || 21.0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -777,7 +784,7 @@
|
|||||||
|
|
||||||
// Connect to SSE
|
// Connect to SSE
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
eventSource = new EventSource(`${API_BASE}/realtime`);
|
eventSource = new EventSource(api('/realtime'));
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
console.log('SSE connected');
|
console.log('SSE connected');
|
||||||
@@ -837,7 +844,7 @@
|
|||||||
// Optional: Load initial state from API
|
// Optional: Load initial state from API
|
||||||
async function loadDevices() {
|
async function loadDevices() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/devices`);
|
const response = await fetch(api('/devices'));
|
||||||
const devices = await response.json();
|
const devices = await response.json();
|
||||||
console.log('Loaded devices:', devices);
|
console.log('Loaded devices:', devices);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user