dockerfiles added
This commit is contained in:
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__)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
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 []
|
||||
|
||||
|
||||
def fetch_layout(api_base: str = "http://localhost:8001") -> dict:
|
||||
def fetch_layout(api_base: str) -> dict:
|
||||
"""
|
||||
Fetch UI layout from the API Gateway.
|
||||
|
||||
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:
|
||||
Layout dictionary with structure:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""UI main entry point."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
@@ -12,11 +13,30 @@ from apps.ui.api_client import fetch_devices, fetch_layout
|
||||
|
||||
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(
|
||||
title="Home Automation UI",
|
||||
description="User interface for home automation system",
|
||||
version="0.1.0"
|
||||
version="0.1.0",
|
||||
root_path=BASE_PATH
|
||||
)
|
||||
|
||||
# Setup Jinja2 templates
|
||||
@@ -29,6 +49,21 @@ static_dir.mkdir(exist_ok=True)
|
||||
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)
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
"""Redirect to dashboard.
|
||||
@@ -53,11 +88,11 @@ async def dashboard(request: Request) -> HTMLResponse:
|
||||
HTMLResponse: Rendered dashboard template
|
||||
"""
|
||||
try:
|
||||
# Load layout from API
|
||||
layout_data = fetch_layout()
|
||||
# Load layout from API (use configured API_BASE)
|
||||
layout_data = fetch_layout(API_BASE)
|
||||
|
||||
# Fetch devices from API (now includes features)
|
||||
api_devices = fetch_devices()
|
||||
api_devices = fetch_devices(API_BASE)
|
||||
|
||||
# Create device lookup by device_id
|
||||
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", {
|
||||
"request": request,
|
||||
"rooms": rooms
|
||||
"rooms": rooms,
|
||||
"api_base": API_BASE # Pass API_BASE to template
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -106,7 +142,8 @@ async def dashboard(request: Request) -> HTMLResponse:
|
||||
# Fallback to empty dashboard
|
||||
return templates.TemplateResponse("dashboard.html", {
|
||||
"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>
|
||||
|
||||
<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 currentState = {};
|
||||
let thermostatTargets = {};
|
||||
@@ -542,7 +549,7 @@
|
||||
const newState = currentState[deviceId] === 'on' ? 'off' : 'on';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
||||
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -579,7 +586,7 @@
|
||||
// Set brightness
|
||||
async function setBrightness(deviceId, brightness) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
||||
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -612,7 +619,7 @@
|
||||
const newTarget = Math.max(5.0, Math.min(30.0, currentTarget + delta));
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
||||
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -645,7 +652,7 @@
|
||||
const currentTarget = thermostatTargets[deviceId] || 21.0;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}/set`, {
|
||||
const response = await fetch(api(`/devices/${deviceId}/set`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -777,7 +784,7 @@
|
||||
|
||||
// Connect to SSE
|
||||
function connectSSE() {
|
||||
eventSource = new EventSource(`${API_BASE}/realtime`);
|
||||
eventSource = new EventSource(api('/realtime'));
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('SSE connected');
|
||||
@@ -837,7 +844,7 @@
|
||||
// Optional: Load initial state from API
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/devices`);
|
||||
const response = await fetch(api('/devices'));
|
||||
const devices = await response.json();
|
||||
console.log('Loaded devices:', devices);
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user