Initial commit
This commit is contained in:
commit
70c71105a1
7 changed files with 1817 additions and 0 deletions
23
Dockerfile
Normal file
23
Dockerfile
Normal 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
45
SETUP.md
Normal 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
28
docker-compose.yml
Normal 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
7
requirements.txt
Normal 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
141
src/discord_bridge.py
Normal 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
778
src/main.py
Normal 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
795
templates/dashboard.html
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue