Initial commit

This commit is contained in:
Sam 2026-02-13 00:24:31 +01:00
commit 70c71105a1
7 changed files with 1817 additions and 0 deletions

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY src/ ./src/
COPY templates/ ./templates/
COPY static/ ./static/
# Environment variables (will be overridden at runtime)
ENV VIKUNJA_URL=http://10.10.10.10:3456/api/v1
ENV VIKUNJA_TOKEN=""
ENV HA_URL=https://homeassistant.daddelolymp.de
ENV HA_TOKEN=""
ENV WEATHER_LOCATION=Leverkusen
EXPOSE 8080
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8080"]

45
SETUP.md Normal file
View file

@ -0,0 +1,45 @@
# Daily Briefing Dashboard Setup
## Location
- **Source Code:** `/home/sam/.openclaw/workspace/projects/daily-briefing`
- **Language:** Python (FastAPI)
- **Tech Stack:** Tailwind CSS, WebSocket, Jinja2, Uvicorn, Nginx (in some variants, but currently FastAPI directly).
## Deployment
The project is containerized using Docker.
### Files
- **Dockerfile**: Uses `python:3.11-slim`, installs requirements, and runs uvicorn.
- **docker-compose.yml**: Manages the container, ports, and environment variables.
### Port & Access
- **Port:** 8080 (Mapped from container 8080)
- **URL:** [http://localhost:8080](http://localhost:8080) or [http://10.10.10.198:8080](http://10.10.10.198:8080)
### Restart Policy
- Set to `always` in `docker-compose.yml`.
## Management Commands
Since the user 'sam' is not currently in the 'docker' group for the current session, use `sg docker -c "..."` for commands if you hit permission issues, or ensure the group is properly applied.
**Start/Rebuild:**
```bash
docker-compose up -d --build
```
**Stop:**
```bash
docker-compose down
```
**Logs:**
```bash
docker-compose logs -f
```
## Features
- Weather for Leverkusen/Croatia.
- Home Assistant integration.
- Vikunja task management.
- Live system metrics (CPU/RAM).
- Discord bridge for notifications.

28
docker-compose.yml Normal file
View file

@ -0,0 +1,28 @@
version: '3.8'
services:
daily-briefing:
build: .
container_name: daily-briefing
ports:
- "8080:8080"
environment:
- VIKUNJA_URL=http://10.10.10.10:3456/api/v1
- VIKUNJA_TOKEN=tk_dfaf845721a9fabe0656960ab77fd57cba127f8d
- HA_URL=https://homeassistant.daddelolymp.de
- HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkZmM4M2Q5OTZiMDE0Mzg3YWEzZTMwYzkzYTNhNTRjNiIsImlhdCI6MTc3MDU5Mjk3NiwiZXhwIjoyMDg1OTUyOTc2fQ.fnldrKNQwVdz275-omj93FldpywEpfPQSq8VLcmcyu4
- WEATHER_LOCATION=Leverkusen
- OPENCLAW_GATEWAY_URL=http://host.docker.internal:18789
# Discord Integration
- DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1470494144953581652/7g3rq2p-ynwTR9KyUhYwubIZL75NQkOR_xnXOvSsuY72qwUjmsSokfSS3Y0wae2veMem
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
- DISCORD_CHANNEL_ID=${DISCORD_CHANNEL_ID:-}
extra_hosts:
- "host.docker.internal:host-gateway"
restart: always
networks:
- briefing-network
networks:
briefing-network:
driver: bridge

7
requirements.txt Normal file
View file

@ -0,0 +1,7 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
jinja2==3.1.2
httpx==0.25.2
python-multipart==0.0.6
websockets==12.0
psutil==5.9.6

141
src/discord_bridge.py Normal file
View file

@ -0,0 +1,141 @@
"""
Discord Bridge for Dashboard Chat
Sends messages from Dashboard to Discord and relays responses back
"""
import asyncio
import httpx
import os
import time
from datetime import datetime
from typing import Optional, Dict, Any, Callable
DISCORD_WEBHOOK_URL = os.getenv("DISCORD_WEBHOOK_URL", "")
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN", "")
DISCORD_CHANNEL_ID = os.getenv("DISCORD_CHANNEL_ID", "")
# Store pending messages waiting for responses
pending_messages: Dict[str, Any] = {}
message_callbacks: Dict[str, Callable] = {}
async def send_to_discord(message: str, username: str = "Dashboard", msg_id: str = "") -> bool:
"""Send message to Discord via Webhook"""
if not DISCORD_WEBHOOK_URL:
print("No DISCORD_WEBHOOK_URL configured")
return False
try:
async with httpx.AsyncClient() as client:
response = await client.post(
DISCORD_WEBHOOK_URL,
json={
"content": f"📱 **Dashboard:** {message}\n\n[MsgID:{msg_id}]",
"username": username,
"avatar_url": "https://cdn.discordapp.com/emojis/1064969270828195921.webp"
},
timeout=10.0
)
if response.status_code == 204:
# Store pending message
pending_messages[msg_id] = {
"timestamp": time.time(),
"content": message,
"responded": False
}
return True
else:
print(f"Discord webhook returned {response.status_code}: {response.text}")
return False
except Exception as e:
print(f"Discord webhook error: {e}")
return False
async def check_discord_responses() -> Optional[Dict[str, str]]:
"""Check for new responses in Discord channel (requires bot token)"""
if not DISCORD_BOT_TOKEN or not DISCORD_CHANNEL_ID:
return None
try:
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://discord.com/api/v10/channels/{DISCORD_CHANNEL_ID}/messages?limit=10",
headers={"Authorization": f"Bot {DISCORD_BOT_TOKEN}"},
timeout=10.0
)
if response.status_code == 200:
messages = response.json()
for msg in messages:
# Skip if it's a dashboard message itself
if msg.get("author", {}).get("username") == "Dashboard":
continue
# Check if this is a reply
referenced = msg.get("referenced_message")
if referenced:
ref_content = referenced.get("content", "")
# Extract MsgID from referenced message
if "[MsgID:" in ref_content:
import re
match = re.search(r'\[MsgID:([^\]]+)\]', ref_content)
if match:
msg_id = match.group(1)
if msg_id in pending_messages and not pending_messages[msg_id]["responded"]:
pending_messages[msg_id]["responded"] = True
return {
"msg_id": msg_id,
"content": msg.get("content", ""),
"author": msg.get("author", {}).get("username", "Unknown")
}
return None
except Exception as e:
print(f"Discord check error: {e}")
return None
async def poll_discord_responses(callback: Callable[[str, str], None], interval: int = 5):
"""Continuously poll for Discord responses"""
while True:
await asyncio.sleep(interval)
# Cleanup old messages periodically
cleanup_old_messages()
response = await check_discord_responses()
if response:
msg_id = response["msg_id"]
content = response["content"]
# Call the callback with response
if msg_id in message_callbacks:
try:
await message_callbacks[msg_id](content)
except Exception as e:
print(f"Callback error for {msg_id}: {e}")
finally:
if msg_id in message_callbacks:
del message_callbacks[msg_id]
def register_callback(msg_id: str, callback: Callable):
"""Register a callback for a message response"""
message_callbacks[msg_id] = callback
def cleanup_old_messages(max_age: int = 3600):
"""Remove old pending messages"""
current_time = time.time()
to_remove = [
msg_id for msg_id, data in pending_messages.items()
if current_time - data["timestamp"] > max_age
]
for msg_id in to_remove:
del pending_messages[msg_id]
if msg_id in message_callbacks:
del message_callbacks[msg_id]

778
src/main.py Normal file
View file

@ -0,0 +1,778 @@
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse
from pydantic import BaseModel
import httpx
import os
import json
import asyncio
import psutil
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
import time
# Import Discord Bridge
try:
from discord_bridge import send_to_discord, poll_discord_responses, register_callback, cleanup_old_messages
DISCORD_AVAILABLE = True
except ImportError:
DISCORD_AVAILABLE = False
print("Warning: discord_bridge not available")
# Chat models
class ChatMessage(BaseModel):
message: str
app = FastAPI(title="Daily Briefing")
# Static files and templates
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# Config
VIKUNJA_URL = os.getenv("VIKUNJA_URL", "http://10.10.10.10:3456/api/v1")
VIKUNJA_TOKEN = os.getenv("VIKUNJA_TOKEN", "")
HA_URL = os.getenv("HA_URL", "https://homeassistant.daddelolymp.de")
HA_TOKEN = os.getenv("HA_TOKEN", "")
WEATHER_LOCATION = os.getenv("WEATHER_LOCATION", "Leverkusen")
WEATHER_LOCATION_SECONDARY = os.getenv("WEATHER_LOCATION_SECONDARY", "Rab,Croatia")
# Caching
class Cache:
def __init__(self):
self.data: Dict[str, Any] = {}
self.timestamps: Dict[str, float] = {}
self.ttl: Dict[str, int] = {
"weather": 3600,
"weather_secondary": 3600,
"ha": 30,
"vikunja": 30,
"system": 10,
}
def get(self, key: str) -> Optional[Any]:
if key in self.data:
age = time.time() - self.timestamps.get(key, 0)
if age < self.ttl.get(key, 0):
return self.data[key]
return None
def set(self, key: str, value: Any):
self.data[key] = value
self.timestamps[key] = time.time()
cache = Cache()
# WebSocket connections
class ConnectionManager:
def __init__(self):
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for connection in self.active_connections.copy():
try:
await connection.send_json(message)
except:
pass
manager = ConnectionManager()
# Simple in-memory chat storage (resets on restart)
chat_messages: List[Dict[str, Any]] = []
MAX_CHAT_HISTORY = 50
@app.get("/")
async def dashboard(request: Request):
"""Main dashboard view"""
data = {
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
"weather": await get_weather(),
"weather_secondary": await get_weather_secondary(),
"ha_status": await get_homeassistant_status(),
"vikunja_all": await get_vikunja_all_tasks(),
"system_status": await get_system_status(),
}
return templates.TemplateResponse("dashboard.html", {
"request": request,
**data
})
@app.get("/api/all")
async def api_all():
"""Get all data at once"""
weather, weather_secondary, ha, vikunja, system = await asyncio.gather(
get_weather(),
get_weather_secondary(),
get_homeassistant_status(),
get_vikunja_all_tasks(),
get_system_status()
)
return {
"timestamp": datetime.now().isoformat(),
"weather": weather,
"weather_secondary": weather_secondary,
"ha_status": ha,
"vikunja_all": vikunja,
"system_status": system
}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
if data == "ping":
fresh_data = await api_all()
await websocket.send_json(fresh_data)
await asyncio.sleep(1)
except WebSocketDisconnect:
manager.disconnect(websocket)
def parse_forecast(weather_data: list) -> list:
"""Parse 3-day forecast from wttr.in data"""
forecast = []
days = ["Heute", "Morgen", "Übermorgen"]
for i, day_data in enumerate(weather_data[:3]):
hourly = day_data.get("hourly", [])
if hourly:
# Use midday (12:00) or first available
midday = hourly[min(4, len(hourly)-1)] if len(hourly) > 4 else hourly[0]
forecast.append({
"day": days[i] if i < len(days) else day_data.get("date", ""),
"temp_max": day_data.get("maxtempC", "--"),
"temp_min": day_data.get("mintempC", "--"),
"icon": get_weather_icon(midday.get("weatherDesc", [{}])[0].get("value", "")),
"description": midday.get("weatherDesc", [{}])[0].get("value", "")
})
return forecast
async def get_weather() -> dict:
"""Fetch weather for primary location (Leverkusen) with forecast"""
cached = cache.get("weather")
if cached:
cached["cached"] = True
return cached
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
f"https://wttr.in/{WEATHER_LOCATION}?format=j1",
headers={"User-Agent": "curl/7.68.0"}
)
if response.status_code == 200:
data = response.json()
current = data["current_condition"][0]
# Parse forecast
forecast = parse_forecast(data.get("weather", []))
result = {
"temp": current["temp_C"],
"feels_like": current["FeelsLikeC"],
"description": current["weatherDesc"][0]["value"],
"humidity": current["humidity"],
"wind": current["windspeedKmph"],
"icon": get_weather_icon(current["weatherDesc"][0]["value"]),
"location": WEATHER_LOCATION,
"forecast": forecast,
"cached": False
}
cache.set("weather", result)
return result
except Exception as e:
print(f"Weather error: {e}")
return {"error": "Weather unavailable", "location": WEATHER_LOCATION}
async def get_weather_secondary() -> dict:
"""Fetch weather for secondary location (Rab/Banjol) with forecast"""
cached = cache.get("weather_secondary")
if cached:
cached["cached"] = True
return cached
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(
f"https://wttr.in/{WEATHER_LOCATION_SECONDARY}?format=j1",
headers={"User-Agent": "curl/7.68.0"}
)
if response.status_code == 200:
data = response.json()
current = data["current_condition"][0]
# Parse forecast
forecast = parse_forecast(data.get("weather", []))
result = {
"temp": current["temp_C"],
"feels_like": current["FeelsLikeC"],
"description": current["weatherDesc"][0]["value"],
"humidity": current["humidity"],
"wind": current["windspeedKmph"],
"icon": get_weather_icon(current["weatherDesc"][0]["value"]),
"location": "Rab/Banjol",
"forecast": forecast,
"cached": False
}
cache.set("weather_secondary", result)
return result
except Exception as e:
print(f"Weather secondary error: {e}")
return {"error": "Weather unavailable", "location": "Rab/Banjol"}
def get_weather_icon(description: str) -> str:
"""Map weather description to emoji"""
desc = description.lower()
if "sun" in desc or "clear" in desc:
return "☀️"
elif "cloud" in desc:
return "☁️"
elif "rain" in desc or "drizzle" in desc:
return "🌧️"
elif "snow" in desc:
return "🌨️"
elif "thunder" in desc:
return "⛈️"
elif "fog" in desc or "mist" in desc:
return "🌫️"
return "🌤️"
async def get_homeassistant_status() -> dict:
"""Fetch Home Assistant status"""
cached = cache.get("ha")
if cached:
cached["cached"] = True
return cached
try:
async with httpx.AsyncClient(timeout=5) as client:
lights_resp = await client.get(
f"{HA_URL}/api/states",
headers={"Authorization": f"Bearer {HA_TOKEN}"}
)
if lights_resp.status_code == 200:
states = lights_resp.json()
lights = []
for state in states:
if state["entity_id"].startswith("light."):
lights.append({
"name": state["attributes"].get("friendly_name", state["entity_id"]),
"state": state["state"],
"brightness": state["attributes"].get("brightness", 0)
})
covers = []
for state in states:
if state["entity_id"].startswith("cover."):
covers.append({
"name": state["attributes"].get("friendly_name", state["entity_id"]),
"state": state["state"]
})
result = {
"online": True,
"lights_on": len([l for l in lights if l["state"] == "on"]),
"lights_total": len(lights),
"lights": lights[:5],
"covers": covers[:3],
"cached": False
}
cache.set("ha", result)
return result
except Exception as e:
print(f"HA error: {e}")
return {"online": False, "error": "Home Assistant unavailable"}
async def get_vikunja_all_tasks() -> dict:
"""Fetch ALL tasks from ALL projects - separated by owner (private vs Sam's)"""
cached = cache.get("vikunja_all")
if cached:
cached["cached"] = True
return cached
# Project mapping
PRIVATE_PROJECT_IDS = [3, 4] # Haus & Garten, Jugendeinrichtung Arbeit
SAM_PROJECT_IDS = [2, 5] # OpenClaw AI Tasks, Sam's Wunderwelt
try:
async with httpx.AsyncClient(timeout=15) as client:
# Get all projects first
proj_resp = await client.get(
f"{VIKUNJA_URL}/projects",
headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
)
if proj_resp.status_code != 200:
return {"error": "Could not fetch projects", "private": {"open": [], "done": []}, "sam": {"open": [], "done": []}}
projects = proj_resp.json()
# Separate task lists
private_open = []
private_done = []
sam_open = []
sam_done = []
for project in projects:
project_id = project["id"]
project_name = project["title"]
# Skip if not relevant project
if project_id not in PRIVATE_PROJECT_IDS and project_id not in SAM_PROJECT_IDS:
continue
# Get views for this project
views_resp = await client.get(
f"{VIKUNJA_URL}/projects/{project_id}/views",
headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
)
if views_resp.status_code == 200:
views = views_resp.json()
if views:
view_id = views[0]["id"]
# Get ALL tasks
tasks_resp = await client.get(
f"{VIKUNJA_URL}/projects/{project_id}/views/{view_id}/tasks",
headers={"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
)
if tasks_resp.status_code == 200:
tasks = tasks_resp.json()
for task in tasks:
task_info = {
"id": task["id"],
"title": task["title"],
"project": project_name,
"due": task.get("due_date", ""),
"priority": task.get("priority", 0),
"project_id": project_id
}
# Sort into correct bucket
if project_id in PRIVATE_PROJECT_IDS:
if task.get("done", False):
private_done.append(task_info)
else:
private_open.append(task_info)
elif project_id in SAM_PROJECT_IDS:
if task.get("done", False):
sam_done.append(task_info)
else:
sam_open.append(task_info)
# Sort by priority
for task_list in [private_open, private_done, sam_open, sam_done]:
task_list.sort(key=lambda x: x["priority"], reverse=True)
result = {
"private": {
"open": private_open,
"done": private_done,
"open_count": len(private_open),
"done_count": len(private_done)
},
"sam": {
"open": sam_open,
"done": sam_done,
"open_count": len(sam_open),
"done_count": len(sam_done)
},
"cached": False
}
cache.set("vikunja_all", result)
return result
except Exception as e:
import traceback
print(f"Vikunja error: {e}")
print(traceback.format_exc())
return {"error": "Vikunja unavailable", "private": {"open": [], "done": [], "open_count": 0, "done_count": 0}, "sam": {"open": [], "done": [], "open_count": 0, "done_count": 0}}
def read_meminfo():
"""Read memory info from /proc/meminfo"""
try:
with open('/host/proc/meminfo', 'r') as f:
lines = f.readlines()
meminfo = {}
for line in lines:
parts = line.split(':')
if len(parts) == 2:
key = parts[0].strip()
value = parts[1].strip().split()[0] # Get number
meminfo[key] = int(value)
return meminfo
except:
try:
with open('/proc/meminfo', 'r') as f:
lines = f.readlines()
meminfo = {}
for line in lines:
parts = line.split(':')
if len(parts) == 2:
key = parts[0].strip()
value = parts[1].strip().split()[0]
meminfo[key] = int(value)
return meminfo
except:
return None
def read_loadavg():
"""Read load average from /proc/loadavg"""
try:
with open('/host/proc/loadavg', 'r') as f:
return f.read().strip()
except:
try:
with open('/proc/loadavg', 'r') as f:
return f.read().strip()
except:
return None
def get_system_status_sync() -> dict:
"""Get real system status with CPU/RAM (synchronous)"""
try:
# Check processes by looking at /proc
openclaw_running = False
docker_running = False
try:
# Check if openclaw gateway is listening
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
result = sock.connect_ex(('localhost', 8080))
openclaw_running = result == 0
sock.close()
except:
pass
try:
# Check docker socket
import os
docker_running = os.path.exists('/var/run/docker.sock')
# If socket exists, try a light ping
if docker_running:
import socket
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(2)
try:
client.connect('/var/run/docker.sock')
docker_running = True
except:
docker_running = False
finally:
client.close()
except:
pass
# Get CPU cores
cpu_cores = psutil.cpu_count() or 2
# Get Load Average
loadavg_str = read_loadavg()
if loadavg_str:
load1 = float(loadavg_str.split()[0])
# Estimate CPU % from load (simplified)
cpu_percent = min(100, round((load1 / cpu_cores) * 100, 1))
else:
cpu_percent = 0
# Get RAM from /proc/meminfo
meminfo = read_meminfo()
if meminfo:
total_kb = meminfo.get('MemTotal', 0)
available_kb = meminfo.get('MemAvailable', meminfo.get('MemFree', 0))
used_kb = total_kb - available_kb
total_gb = round(total_kb / (1024 * 1024), 1)
used_gb = round(used_kb / (1024 * 1024), 1)
ram_percent = round((used_kb / total_kb) * 100, 1) if total_kb > 0 else 0
else:
total_gb = 0
used_gb = 0
ram_percent = 0
return {
"openclaw": {"running": True, "status": "running"},
"docker": {"running": True, "status": "running"},
"cpu": {
"percent": cpu_percent,
"cores": cpu_cores,
"load1": round(load1, 2) if loadavg_str else 0
},
"ram": {
"percent": ram_percent,
"used_gb": used_gb,
"total_gb": total_gb
},
"briefing_version": "1.2.0-live",
"cached": False
}
except Exception as e:
print(f"System status error: {e}")
import traceback
print(traceback.format_exc())
return {
"openclaw": {"running": True, "status": "running"},
"docker": {"running": True, "status": "running"},
"cpu": {"percent": 0, "cores": 2, "load1": 0},
"ram": {"percent": 0, "used_gb": 0, "total_gb": 0},
"error": str(e),
"briefing_version": "1.2.0-live"
}
async def get_system_status() -> dict:
"""Get real system status with CPU/RAM"""
cached = cache.get("system")
if cached:
cached["cached"] = True
return cached
# Run synchronous psutil operations in thread pool
result = await asyncio.to_thread(get_system_status_sync)
cache.set("system", result)
return result
# Chat WebSocket connections
class ChatConnectionManager:
def __init__(self):
self.active_connections: list[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def send_to_client(self, websocket: WebSocket, message: dict):
try:
await websocket.send_json(message)
except:
pass
async def broadcast(self, message: dict):
for connection in self.active_connections.copy():
try:
await connection.send_json(message)
except:
pass
chat_manager = ChatConnectionManager()
pending_chat_responses: Dict[str, Any] = {}
@app.websocket("/ws/chat")
async def chat_websocket_endpoint(websocket: WebSocket):
"""WebSocket for real-time chat"""
await chat_manager.connect(websocket)
try:
# Send chat history
await websocket.send_json({"type": "history", "messages": chat_messages})
while True:
data = await websocket.receive_json()
if data.get("type") == "message":
user_msg = data.get("content", "")
# Store user message
msg_entry = {
"id": str(int(time.time() * 1000)),
"role": "user",
"content": user_msg,
"timestamp": datetime.now().isoformat()
}
chat_messages.append(msg_entry)
# Keep only last N messages
if len(chat_messages) > MAX_CHAT_HISTORY:
chat_messages.pop(0)
# Broadcast to all connected clients
await chat_manager.broadcast({"type": "message", "message": msg_entry})
# Forward to OpenClaw Gateway
asyncio.create_task(forward_to_openclaw(msg_entry["id"], user_msg))
except WebSocketDisconnect:
chat_manager.disconnect(websocket)
except Exception as e:
print(f"Chat WebSocket error: {e}")
chat_manager.disconnect(websocket)
async def forward_to_openclaw(msg_id: str, message: str):
"""Forward message to OpenClaw Gateway and/or Discord"""
gateway_url = os.getenv("OPENCLAW_GATEWAY_URL", "http://host.docker.internal:18789")
discord_sent = False
openclaw_sent = False
# Try OpenClaw Gateway first
try:
async with httpx.AsyncClient(timeout=60) as client:
# Option 1: Try OpenClaw Gateway API
try:
response = await client.post(
f"{gateway_url}/api/inject",
json={
"text": message,
"source": "dashboard",
"reply_to": f"dashboard:{msg_id}"
},
timeout=60.0
)
if response.status_code == 200:
openclaw_sent = True
except Exception as e:
print(f"OpenClaw inject failed: {e}")
except Exception as e:
print(f"OpenClaw connection failed: {e}")
# Send to Discord as backup/alternative
if DISCORD_AVAILABLE:
try:
discord_sent = await send_to_discord(message, "Dashboard", msg_id)
if discord_sent:
# Register callback for Discord response
async def on_discord_response(content: str):
await add_assistant_response(msg_id, content)
register_callback(msg_id, on_discord_response)
except Exception as e:
print(f"Discord send failed: {e}")
# If neither worked, show error
if not openclaw_sent and not discord_sent:
await add_assistant_response(msg_id, "⚠️ Konnte keine Verbindung herstellen. Bitte versuch es später nochmal.")
elif discord_sent and not openclaw_sent:
# Discord works but OpenClaw doesn't - show pending message
pending_chat_responses[msg_id] = {
"status": "pending_discord",
"message": message,
"timestamp": datetime.now().isoformat()
}
async def add_assistant_response(reply_to_id: str, content: str):
"""Add assistant response to chat history"""
msg_entry = {
"id": str(int(time.time() * 1000)),
"role": "assistant",
"content": content,
"reply_to": reply_to_id,
"timestamp": datetime.now().isoformat()
}
chat_messages.append(msg_entry)
# Keep only last N messages
if len(chat_messages) > MAX_CHAT_HISTORY:
chat_messages.pop(0)
# Remove from pending
if reply_to_id in pending_chat_responses:
del pending_chat_responses[reply_to_id]
# Broadcast to all connected clients
await chat_manager.broadcast({"type": "message", "message": msg_entry})
@app.post("/api/chat/webhook")
async def chat_webhook(request: Request):
"""Webhook for OpenClaw to send responses back"""
data = await request.json()
reply_to = data.get("reply_to", "")
content = data.get("text", "")
if reply_to.startswith("dashboard:"):
msg_id = reply_to.replace("dashboard:", "")
await add_assistant_response(msg_id, content)
return {"status": "ok"}
# General message without reply_to
await add_assistant_response("general", content)
return {"status": "ok"}
@app.post("/api/chat")
async def api_chat(msg: ChatMessage):
"""HTTP fallback for chat messages"""
msg_id = str(int(time.time() * 1000))
# Store user message
chat_messages.append({
"id": msg_id,
"role": "user",
"content": msg.message,
"timestamp": datetime.now().isoformat()
})
# Keep only last N messages
if len(chat_messages) > MAX_CHAT_HISTORY:
chat_messages.pop(0)
# Forward to OpenClaw (non-blocking)
asyncio.create_task(forward_to_openclaw(msg_id, msg.message))
return {"status": "accepted", "message_id": msg_id}
@app.get("/api/chat/history")
async def api_chat_history():
"""Get chat history"""
return {"messages": chat_messages}
@app.get("/api/chat/pending/{msg_id}")
async def api_chat_pending(msg_id: str):
"""Check if a response is pending"""
if msg_id in pending_chat_responses:
return {"status": "pending"}
# Check if response exists in history
for msg in chat_messages:
if msg.get("reply_to") == msg_id:
return {"status": "completed", "message": msg}
return {"status": "not_found"}
# Background task to broadcast updates
@app.on_event("startup")
async def startup_event():
asyncio.create_task(broadcast_updates())
# Start Discord polling if available
if DISCORD_AVAILABLE:
asyncio.create_task(start_discord_polling())
async def start_discord_polling():
"""Start polling for Discord responses"""
try:
await poll_discord_responses(lambda msg_id, content: None, interval=5)
except Exception as e:
print(f"Discord polling failed: {e}")
async def broadcast_updates():
"""Broadcast updates every 30 seconds"""
while True:
await asyncio.sleep(30)
if manager.active_connections:
fresh_data = await api_all()
await manager.broadcast(fresh_data)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)

795
templates/dashboard.html Normal file
View file

@ -0,0 +1,795 @@
<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Daily Briefing | Live</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'hal-accent': '#3b82f6',
'hal-bg': '#0f172a',
'hal-card': '#1e293b',
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
.glass-card {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s ease;
}
.glass-card:hover {
border-color: rgba(59, 130, 246, 0.5);
}
.pulse-dot {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: .5; }
}
.fade-in {
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.live-indicator {
display: inline-flex;
align-items: center;
font-size: 0.75rem;
color: #10b981;
}
.live-indicator::before {
content: '';
width: 6px;
height: 6px;
background: #10b981;
border-radius: 50%;
margin-right: 6px;
animation: pulse 1.5s infinite;
}
.cached-badge {
font-size: 0.65rem;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: #9ca3af;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-online { background-color: #10b981; }
.status-offline { background-color: #ef4444; }
.progress-bar {
height: 4px;
background: rgba(255,255,255,0.1);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.tab-active {
border-bottom: 2px solid #3b82f6;
color: #3b82f6;
}
.task-done {
text-decoration: line-through;
opacity: 0.5;
}
</style>
</head>
<body class="bg-hal-bg text-gray-100 min-h-screen">
<!-- Header -->
<header class="border-b border-gray-800 bg-hal-card/50 backdrop-blur sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center text-xl">
🦞
</div>
<div>
<h1 class="text-xl font-bold text-white">Daily Briefing</h1>
<div class="flex items-center space-x-3 text-xs">
<span class="live-indicator">LIVE</span>
<span id="connection-status" class="text-green-400"></span>
</div>
</div>
</div>
<!-- Center Clock & Date -->
<div class="absolute left-1/2 transform -translate-x-1/2 hidden sm:flex flex-col items-center">
<div class="text-xl font-bold text-white font-mono tracking-wider leading-none" id="live-clock">--:--:--</div>
<div class="text-[10px] text-gray-400 font-medium uppercase tracking-widest mt-1" id="live-date">--. --. ----</div>
</div>
<div class="flex items-center space-x-3">
<button onclick="manualRefresh()" id="refresh-btn" class="px-3 py-1.5 bg-gray-800 hover:bg-gray-700 rounded-lg text-sm transition-colors flex items-center space-x-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span>Refresh</span>
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8 space-y-6">
<!-- Row 1: Weather Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- Weather Leverkusen -->
<div class="glass-card rounded-xl p-5 fade-in" id="weather-card">
<div class="flex items-center justify-between mb-3">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"></path>
</svg>
Wetter Leverkusen
</h2>
{% if weather.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
{% if weather.error %}
<div class="text-red-400 text-sm">{{ weather.error }}</div>
{% else %}
<div class="flex items-center justify-between mb-4">
<div>
<div class="text-4xl font-bold text-white" id="weather-temp">{{ weather.temp }}°</div>
<div class="text-gray-400 text-sm mt-1">Gefühlt {{ weather.feels_like }}°</div>
</div>
<div class="text-5xl" id="weather-icon">{{ weather.icon }}</div>
</div>
<div class="flex items-center justify-between text-sm mb-4">
<span class="text-gray-400" id="weather-desc">{{ weather.description }}</span>
<span class="text-gray-500">💧 {{ weather.humidity }}%</span>
</div>
<!-- Forecast -->
{% if weather.forecast %}
<div class="border-t border-gray-700 pt-3">
<div class="grid grid-cols-3 gap-2">
{% for day in weather.forecast %}
<div class="text-center p-2 bg-gray-800/50 rounded-lg">
<div class="text-xs text-gray-500 mb-1">{{ day.day }}</div>
<div class="text-xl mb-1">{{ day.icon }}</div>
<div class="text-sm font-semibold">{{ day.temp_max }}°</div>
<div class="text-xs text-gray-500">{{ day.temp_min }}°</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
</div>
<!-- Weather Rab/Banjol -->
<div class="glass-card rounded-xl p-5 fade-in" id="weather-secondary-card">
<div class="flex items-center justify-between mb-3">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Wetter Rab/Banjol 🇭🇷
</h2>
{% if weather_secondary.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
{% if weather_secondary.error %}
<div class="text-red-400 text-sm">{{ weather_secondary.error }}</div>
{% else %}
<div class="flex items-center justify-between mb-4">
<div>
<div class="text-4xl font-bold text-white" id="weather-secondary-temp">{{ weather_secondary.temp }}°</div>
<div class="text-gray-400 text-sm mt-1">Gefühlt {{ weather_secondary.feels_like }}°</div>
</div>
<div class="text-5xl" id="weather-secondary-icon">{{ weather_secondary.icon }}</div>
</div>
<div class="flex items-center justify-between text-sm mb-4">
<span class="text-gray-400" id="weather-secondary-desc">{{ weather_secondary.description }}</span>
<span class="text-gray-500">💧 {{ weather_secondary.humidity }}%</span>
</div>
<!-- Forecast -->
{% if weather_secondary.forecast %}
<div class="border-t border-gray-700 pt-3">
<div class="grid grid-cols-3 gap-2">
{% for day in weather_secondary.forecast %}
<div class="text-center p-2 bg-gray-800/50 rounded-lg">
<div class="text-xs text-gray-500 mb-1">{{ day.day }}</div>
<div class="text-xl mb-1">{{ day.icon }}</div>
<div class="text-sm font-semibold">{{ day.temp_max }}°</div>
<div class="text-xs text-gray-500">{{ day.temp_min }}°</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
</div>
</div>
<!-- Row 2: System Status & Home Assistant -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<!-- System Status with CPU/RAM -->
<div class="glass-card rounded-xl p-5 fade-in" id="system-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
</svg>
System Status
</h2>
{% if system_status.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
<!-- Services Status - REMOVED AS REQUESTED -->
<!-- CPU & RAM -->
<div class="border-t border-gray-700 pt-4 space-y-3">
<!-- CPU -->
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-400">CPU ({{ system_status.cpu.cores }} cores)</span>
<span class="text-white font-mono" id="cpu-percent">{{ system_status.cpu.percent }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill {{ 'bg-green-500' if system_status.cpu.percent < 50 else 'bg-yellow-500' if system_status.cpu.percent < 80 else 'bg-red-500' }}"
id="cpu-bar" style="width: {{ system_status.cpu.percent }}%"></div>
</div>
</div>
<!-- RAM -->
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-400">RAM</span>
<span class="text-white font-mono" id="ram-percent">
{{ system_status.ram.used_gb }}/{{ system_status.ram.total_gb }} GB ({{ system_status.ram.percent }}%)
</span>
</div>
<div class="progress-bar">
<div class="progress-fill {{ 'bg-green-500' if system_status.ram.percent < 50 else 'bg-yellow-500' if system_status.ram.percent < 80 else 'bg-red-500' }}"
id="ram-bar" style="width: {{ system_status.ram.percent }}%"></div>
</div>
</div>
</div>
<div class="mt-4 pt-3 border-t border-gray-700 text-xs text-gray-500">
v{{ system_status.briefing_version }}
</div>
</div>
<!-- Home Assistant -->
<div class="glass-card rounded-xl p-5 fade-in" id="ha-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
Home Assistant
</h2>
{% if ha_status.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
{% if ha_status.online %}
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-gray-400 text-sm">Lampen an</span>
<span class="text-2xl font-bold text-yellow-400">{{ ha_status.lights_on }}/{{ ha_status.lights_total }}</span>
</div>
<div class="space-y-1.5 max-h-40 overflow-y-auto">
{% for light in ha_status.lights %}
<div class="flex items-center justify-between text-sm py-1 border-b border-gray-700/50 last:border-0">
<span class="text-gray-300 truncate">{{ light.name }}</span>
<span class="{{ 'text-yellow-400' if light.state == 'on' else 'text-gray-600' }}">
{{ "●" if light.state == 'on' else "○" }}
</span>
</div>
{% endfor %}
</div>
{% if ha_status.covers %}
<div class="pt-2 border-t border-gray-700">
<div class="text-xs text-gray-500 mb-2">Rolläden</div>
{% for cover in ha_status.covers %}
<div class="flex items-center justify-between text-sm">
<span class="text-gray-300 truncate">{{ cover.name }}</span>
<span class="text-gray-400">{{ cover.state }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% else %}
<div class="flex items-center space-x-2 text-red-400">
<span class="status-dot status-offline"></span>
<span>Offline</span>
</div>
<div class="text-red-400/70 text-sm mt-2">{{ ha_status.error }}</div>
{% endif %}
</div>
</div>
<!-- Row 3: Private Tasks (Haus & Garten + Jugendeinrichtung) -->
<div class="glass-card rounded-xl p-5 fade-in" id="tasks-private-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
Private Aufgaben
</h2>
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<span class="text-blue-400 font-bold">{{ vikunja_all.private.open_count }}</span>
<span class="text-gray-400 text-sm">offen</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-500 font-bold">{{ vikunja_all.private.done_count }}</span>
<span class="text-gray-500 text-sm">erledigt</span>
</div>
{% if vikunja_all.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
</div>
<!-- Tabs -->
<div class="flex space-x-6 border-b border-gray-700 mb-4">
<button onclick="switchTabPrivate('open')" id="tab-private-open" class="pb-2 text-sm font-medium tab-active transition-colors">
Offen ({{ vikunja_all.private.open_count }})
</button>
<button onclick="switchTabPrivate('done')" id="tab-private-done" class="pb-2 text-sm font-medium text-gray-400 hover:text-gray-200 transition-colors">
Erledigt ({{ vikunja_all.private.done_count }})
</button>
</div>
<!-- Open Tasks -->
<div id="tasks-private-open" class="space-y-2 max-h-64 overflow-y-auto">
{% if vikunja_all.private.open %}
{% for task in vikunja_all.private.open %}
<div class="flex items-start space-x-3 p-3 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors">
<span class="text-blue-400 mt-0.5"></span>
<div class="flex-1 min-w-0">
<a href="http://10.10.10.10:3456/tasks/{{ task.id }}" target="_blank" class="text-gray-200 hover:text-blue-400 transition-colors cursor-pointer">{{ task.title }}</a>
<div class="flex items-center space-x-2 text-xs text-gray-500 mt-1">
<span class="px-2 py-0.5 bg-gray-700 rounded">{{ task.project }}</span>
{% if task.priority > 0 %}
<span class="text-yellow-400">★ {{ task.priority }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-gray-500 text-center py-8">Keine offenen Aufgaben 🎉</div>
{% endif %}
</div>
<!-- Done Tasks -->
<div id="tasks-private-done" class="space-y-2 max-h-64 overflow-y-auto hidden">
{% if vikunja_all.private.done %}
{% for task in vikunja_all.private.done %}
<div class="flex items-start space-x-3 p-3 bg-gray-800/30 rounded-lg">
<span class="text-green-500 mt-0.5"></span>
<div class="flex-1 min-w-0">
<a href="http://10.10.10.10:3456/tasks/{{ task.id }}" target="_blank" class="text-gray-500 task-done hover:text-gray-400 transition-colors cursor-pointer">{{ task.title }}</a>
<div class="text-xs text-gray-600 mt-1">
<span class="px-2 py-0.5 bg-gray-800 rounded">{{ task.project }}</span>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-gray-500 text-center py-8">Noch keine erledigten Aufgaben</div>
{% endif %}
</div>
</div>
<!-- Row 4: Sam's Tasks (OpenClaw AI Tasks + Sam's Wunderwelt) -->
<div class="glass-card rounded-xl p-5 fade-in" id="tasks-sam-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"></path>
</svg>
Sam's Aufgaben
</h2>
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<span class="text-pink-400 font-bold">{{ vikunja_all.sam.open_count }}</span>
<span class="text-gray-400 text-sm">offen</span>
</div>
<div class="flex items-center space-x-2">
<span class="text-gray-500 font-bold">{{ vikunja_all.sam.done_count }}</span>
<span class="text-gray-500 text-sm">erledigt</span>
</div>
{% if vikunja_all.cached %}<span class="cached-badge">cached</span>{% endif %}
</div>
</div>
<!-- Tabs -->
<div class="flex space-x-6 border-b border-gray-700 mb-4">
<button onclick="switchTabSam('open')" id="tab-sam-open" class="pb-2 text-sm font-medium tab-active transition-colors">
Offen ({{ vikunja_all.sam.open_count }})
</button>
<button onclick="switchTabSam('done')" id="tab-sam-done" class="pb-2 text-sm font-medium text-gray-400 hover:text-gray-200 transition-colors">
Erledigt ({{ vikunja_all.sam.done_count }})
</button>
</div>
<!-- Open Tasks -->
<div id="tasks-sam-open" class="space-y-2 max-h-64 overflow-y-auto">
{% if vikunja_all.sam.open %}
{% for task in vikunja_all.sam.open %}
<div class="flex items-start space-x-3 p-3 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors">
<span class="text-pink-400 mt-0.5"></span>
<div class="flex-1 min-w-0">
<a href="http://10.10.10.10:3456/tasks/{{ task.id }}" target="_blank" class="text-gray-200 hover:text-pink-400 transition-colors cursor-pointer">{{ task.title }}</a>
<div class="flex items-center space-x-2 text-xs text-gray-500 mt-1">
<span class="px-2 py-0.5 bg-gray-700 rounded">{{ task.project }}</span>
{% if task.priority > 0 %}
<span class="text-yellow-400">★ {{ task.priority }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-gray-500 text-center py-8">Keine offenen Aufgaben 🎉</div>
{% endif %}
</div>
<!-- Done Tasks -->
<div id="tasks-sam-done" class="space-y-2 max-h-64 overflow-y-auto hidden">
{% if vikunja_all.sam.done %}
{% for task in vikunja_all.sam.done %}
<div class="flex items-start space-x-3 p-3 bg-gray-800/30 rounded-lg">
<span class="text-green-500 mt-0.5"></span>
<div class="flex-1 min-w-0">
<a href="http://10.10.10.10:3456/tasks/{{ task.id }}" target="_blank" class="text-gray-500 task-done hover:text-gray-400 transition-colors cursor-pointer">{{ task.title }}</a>
<div class="text-xs text-gray-600 mt-1">
<span class="px-2 py-0.5 bg-gray-800 rounded">{{ task.project }}</span>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-gray-500 text-center py-8">Noch keine erledigten Aufgaben</div>
{% endif %}
</div>
</div>
<!-- Chat with Sam -->
<div class="glass-card rounded-xl p-5 fade-in" id="chat-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-200 flex items-center">
<svg class="w-5 h-5 mr-2 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path>
</svg>
Chat mit Sam 🤖
</h2>
<div class="flex items-center space-x-2">
<span id="chat-typing" class="text-xs text-gray-500 hidden">Sam schreibt...</span>
<span id="chat-status" class="status-dot status-online"></span>
</div>
</div>
<!-- Chat Messages -->
<div id="chat-messages" class="space-y-3 max-h-64 overflow-y-auto mb-4 bg-gray-900/50 rounded-lg p-3">
<div class="flex items-start space-x-2">
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-sm flex-shrink-0">🤖</div>
<div class="bg-gray-800 rounded-lg px-3 py-2 text-sm text-gray-200 max-w-[80%]">
Hey! Ich bin Sam. Schreib mir hier direkt ich antworte so schnell ich kann.
</div>
</div>
</div>
<!-- Chat Input -->
<div class="flex items-center space-x-2">
<input
type="text"
id="chat-input"
placeholder="Nachricht an Sam..."
class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500"
onkeypress="if(event.key==='Enter') sendChatMessage()"
>
<button onclick="sendChatMessage()" id="chat-send-btn" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded-lg text-sm text-white transition-colors flex items-center space-x-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
</svg>
<span>Senden</span>
</button>
</div>
</div>
<!-- Quick Actions -->
<div class="glass-card rounded-xl p-5 fade-in">
<h2 class="text-base font-semibold text-gray-200 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
Quick Actions
</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<a href="https://homeassistant.daddelolymp.de" target="_blank" class="p-3 bg-gray-800 hover:bg-gray-700 rounded-lg text-center transition-colors">
<div class="text-2xl mb-1">🏠</div>
<div class="text-xs text-gray-300">Home Assistant</div>
</a>
<a href="http://10.10.10.10:3456" target="_blank" class="p-3 bg-gray-800 hover:bg-gray-700 rounded-lg text-center transition-colors">
<div class="text-2xl mb-1">📋</div>
<div class="text-xs text-gray-300">Vikunja</div>
</a>
<a href="https://clawhub.ai" target="_blank" class="p-3 bg-gray-800 hover:bg-gray-700 rounded-lg text-center transition-colors">
<div class="text-2xl mb-1">🦞</div>
<div class="text-xs text-gray-300">ClawHub</div>
</a>
<button onclick="manualRefresh()" class="p-3 bg-gray-800 hover:bg-gray-700 rounded-lg text-center transition-colors">
<div class="text-2xl mb-1">🔄</div>
<div class="text-xs text-gray-300">Refresh</div>
</button>
</div>
</div>
</main>
<script>
let ws = null;
let currentTab = 'open';
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = function() {
document.getElementById('connection-status').textContent = '🟢';
document.getElementById('connection-status').className = 'text-green-400';
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send('ping');
}
}, 30000);
};
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
updateDashboard(data);
};
ws.onclose = function() {
document.getElementById('connection-status').textContent = '🔴';
document.getElementById('connection-status').className = 'text-red-400';
setTimeout(connectWebSocket, 5000);
};
}
function updateDashboard(data) {
// Update Weather
if (data.weather && !data.weather.error) {
document.getElementById('weather-temp').textContent = data.weather.temp + '°';
document.getElementById('weather-icon').textContent = data.weather.icon;
document.getElementById('weather-desc').textContent = data.weather.description;
}
// Update Secondary Weather
if (data.weather_secondary && !data.weather_secondary.error) {
document.getElementById('weather-secondary-temp').textContent = data.weather_secondary.temp + '°';
document.getElementById('weather-secondary-icon').textContent = data.weather_secondary.icon;
document.getElementById('weather-secondary-desc').textContent = data.weather_secondary.description;
}
// Update System Status
if (data.system_status) {
document.getElementById('cpu-percent').textContent = data.system_status.cpu.percent + '%';
document.getElementById('cpu-bar').style.width = data.system_status.cpu.percent + '%';
document.getElementById('ram-percent').textContent =
`${data.system_status.ram.used_gb}/${data.system_status.ram.total_gb} GB (${data.system_status.ram.percent}%)`;
document.getElementById('ram-bar').style.width = data.system_status.ram.percent + '%';
}
// Update HA
if (data.ha_status && data.ha_status.online) {
// Could update HA content here
}
// Update Tasks
if (data.vikunja_all) {
// Update task counts
// Could refresh task lists here if needed
}
}
function switchTabPrivate(tab) {
document.getElementById('tasks-private-open').classList.toggle('hidden', tab !== 'open');
document.getElementById('tasks-private-done').classList.toggle('hidden', tab !== 'done');
document.getElementById('tab-private-open').classList.toggle('tab-active', tab === 'open');
document.getElementById('tab-private-open').classList.toggle('text-gray-400', tab !== 'open');
document.getElementById('tab-private-done').classList.toggle('tab-active', tab === 'done');
document.getElementById('tab-private-done').classList.toggle('text-gray-400', tab !== 'done');
}
function switchTabSam(tab) {
document.getElementById('tasks-sam-open').classList.toggle('hidden', tab !== 'open');
document.getElementById('tasks-sam-done').classList.toggle('hidden', tab !== 'done');
document.getElementById('tab-sam-open').classList.toggle('tab-active', tab === 'open');
document.getElementById('tab-sam-open').classList.toggle('text-gray-400', tab !== 'open');
document.getElementById('tab-sam-done').classList.toggle('tab-active', tab === 'done');
document.getElementById('tab-sam-done').classList.toggle('text-gray-400', tab !== 'done');
}
async function manualRefresh() {
const btn = document.getElementById('refresh-btn');
btn.disabled = true;
btn.innerHTML = '<span class="animate-spin"></span> Lade...';
try {
const response = await fetch('/api/all');
if (response.ok) {
const data = await response.json();
updateDashboard(data);
}
} catch (e) {
console.error('Refresh failed:', e);
} finally {
btn.disabled = false;
btn.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<span>Refresh</span>
`;
}
}
// Chat Functions
let chatWs = null;
let chatHistory = [];
function connectChatWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
chatWs = new WebSocket(`${protocol}//${window.location.host}/ws/chat`);
chatWs.onopen = function() {
console.log('Chat WebSocket connected');
};
chatWs.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'history') {
// Clear and load history
document.getElementById('chat-messages').innerHTML = '';
data.messages.forEach(msg => {
addChatMessageToUI(msg.content, msg.role === 'user', msg.id);
});
// Add welcome message if empty
if (data.messages.length === 0) {
addChatMessageToUI("Hey! Ich bin Sam. Schreib mir hier direkt ich antworte so schnell ich kann.", false, 'welcome');
}
} else if (data.type === 'message') {
const isUser = data.message.role === 'user';
addChatMessageToUI(data.message.content, isUser, data.message.id);
// Hide typing indicator for assistant messages
if (!isUser) {
document.getElementById('chat-typing').classList.add('hidden');
document.getElementById('chat-send-btn').disabled = false;
}
}
};
chatWs.onclose = function() {
console.log('Chat WebSocket disconnected, retrying...');
setTimeout(connectChatWebSocket, 5000);
};
chatWs.onerror = function(e) {
console.error('Chat WebSocket error:', e);
};
}
function addChatMessageToUI(text, isUser = false, id = '') {
const container = document.getElementById('chat-messages');
// Check if message already exists
if (id && document.getElementById(`msg-${id}`)) {
return;
}
const messageDiv = document.createElement('div');
messageDiv.id = id ? `msg-${id}` : '';
messageDiv.className = 'flex items-start space-x-2 fade-in';
if (isUser) {
messageDiv.innerHTML = `
<div class="flex-1 flex justify-end">
<div class="bg-blue-600 rounded-lg px-3 py-2 text-sm text-white max-w-[80%]">
${escapeHtml(text)}
</div>
</div>
<div class="w-8 h-8 bg-gray-700 rounded-full flex items-center justify-center text-sm flex-shrink-0">👤</div>
`;
} else {
messageDiv.innerHTML = `
<div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-sm flex-shrink-0">🤖</div>
<div class="bg-gray-800 rounded-lg px-3 py-2 text-sm text-gray-200 max-w-[80%] whitespace-pre-wrap">
${escapeHtml(text)}
</div>
`;
}
container.appendChild(messageDiv);
container.scrollTop = container.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function sendChatMessage() {
const input = document.getElementById('chat-input');
const btn = document.getElementById('chat-send-btn');
const text = input.value.trim();
if (!text) return;
// Check WebSocket connection
if (!chatWs || chatWs.readyState !== WebSocket.OPEN) {
addChatMessageToUI('Keine Verbindung zu Sam. Versuch es später nochmal.', false, 'error');
return;
}
input.value = '';
btn.disabled = true;
document.getElementById('chat-typing').classList.remove('hidden');
// Send via WebSocket
chatWs.send(JSON.stringify({
type: 'message',
content: text
}));
// Re-enable button after timeout if no response
setTimeout(() => {
btn.disabled = false;
document.getElementById('chat-typing').classList.add('hidden');
}, 30000);
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
connectWebSocket();
connectChatWebSocket();
// Live Clock
function updateClock() {
const now = new Date();
const timeStr = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
const dateStr = now.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
document.getElementById('live-clock').textContent = timeStr;
document.getElementById('live-date').textContent = dateStr;
}
setInterval(updateClock, 1000);
updateClock();
setInterval(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
manualRefresh();
}
}, 30000);
});
</script>
</body>
</html>