Initial homelab infrastructure

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-13 19:41:46 +02:00
co-authored by Cursor
commit 5014d55d62
31 changed files with 1594 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "app.py"]
+116
View File
@@ -0,0 +1,116 @@
from flask import Flask, render_template, jsonify, request, Response
import requests
import os
from functools import wraps
from requests.auth import HTTPBasicAuth
app = Flask(__name__)
WOL_API_URL = os.getenv("WOL_API_URL", "https://wol.zea.lt")
WOL_API_USER = os.getenv("WOL_API_USER")
WOL_API_PASS = os.getenv("WOL_API_PASS")
WEATHER_API_KEY = os.getenv("WEATHER_API_KEY")
WEATHER_CITY = os.getenv("WEATHER_CITY", "")
def _fetch_wol_status():
"""Fetch device list and status from WOL API (single source of truth)."""
try:
auth = (
HTTPBasicAuth(WOL_API_USER, WOL_API_PASS)
if WOL_API_USER and WOL_API_PASS
else None
)
r = requests.get(
f"{WOL_API_URL}/status",
auth=auth,
timeout=5,
)
if r.ok:
return r.json().get("devices", {})
except requests.RequestException:
pass
return {}
def check_auth(username, password):
return username == WOL_API_USER and password == WOL_API_PASS
def authenticate():
"""Sends a 401 response that enables basic auth."""
return Response(
"Could not verify your access level for that URL.\n"
"You have to login with proper credentials",
401,
{"WWW-Authenticate": 'Basic realm="Login Required"'},
)
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or not check_auth(auth.username, auth.password):
return authenticate()
return f(*args, **kwargs)
return decorated
@app.route("/")
def dashboard():
devices_data = _fetch_wol_status()
devices = {k: v.get("mac", "") for k, v in devices_data.items()}
status = {k: v.get("online", False) for k, v in devices_data.items()}
return render_template(
"dashboard.html",
devices=devices,
status=status,
)
@app.route("/wake/<device>", methods=["POST"])
@requires_auth
def wake_device(device):
devices_data = _fetch_wol_status()
if device not in devices_data:
return jsonify({"error": "Device not found"}), 404
try:
auth = HTTPBasicAuth(WOL_API_USER, WOL_API_PASS)
r = requests.post(
f"{WOL_API_URL}/wake/{device}",
auth=auth,
timeout=5,
)
if r.ok:
return jsonify({"message": f"Wake command sent to {device}"})
return jsonify({"error": f"Failed to send wake command: {r.text}"}), r.status_code
except requests.RequestException as e:
return jsonify({"error": str(e)}), 500
@app.route("/weather")
def weather():
"""Proxy for OpenWeatherMap keeps API key server-side."""
if not WEATHER_API_KEY or not WEATHER_CITY:
return jsonify({"error": "Weather not configured"}), 503
try:
r = requests.get(
"https://api.openweathermap.org/data/2.5/weather",
params={
"q": WEATHER_CITY,
"appid": WEATHER_API_KEY,
"units": "metric",
},
timeout=5,
)
if r.ok:
return jsonify(r.json())
except requests.RequestException:
pass
return jsonify({"error": "Weather unavailable"}), 503
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
+21
View File
@@ -0,0 +1,21 @@
services:
dashboard:
build: .
container_name: dashboard
restart: always
environment:
- WOL_API_URL=${WOL_API_URL:-https://wol.zea.lt}
- WOL_API_USER=${WOL_API_USER}
- WOL_API_PASS=${WOL_API_PASS}
- WEATHER_API_KEY=${WEATHER_API_KEY}
- WEATHER_CITY=${WEATHER_CITY}
networks:
- homelab
ports:
- "8080:8080"
labels:
- "traefik.enable=true"
- "traefik.http.routers.dashboard.rule=Host(`zea.lt`)"
- "traefik.http.routers.dashboard.entrypoints=websecure"
- "traefik.http.routers.dashboard.tls.certresolver=leresolver"
- "traefik.http.routers.dashboard.middlewares=auth@file"
+2
View File
@@ -0,0 +1,2 @@
Flask==2.3.2
requests==2.31.0
+197
View File
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>HomeLab Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" rel="stylesheet" />
<style>
html, body {
height: 100%;
margin: 0;
background: linear-gradient(135deg, #0f2027, #203a43, #2c5364);
color: #fff;
font-family: 'Segoe UI', sans-serif;
}
.container {
min-height: 100vh;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.clock {
font-size: 5rem;
font-weight: bold;
text-align: center;
}
.date {
font-size: 1.5rem;
text-align: center;
}
.weather {
text-align: center;
font-size: 1.2rem;
}
input[type="search"] {
width: 100%;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: none;
margin: 1rem auto 2rem;
font-size: 1.1rem;
max-width: 600px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1.5rem;
}
.device-card, .service-card {
background: rgba(255, 255, 255, 0.06);
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
transition: transform 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.device-card:hover, .service-card:hover {
transform: scale(1.03);
background: rgba(255, 255, 255, 0.09);
}
.device-status {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.btn-wake {
padding: 0.5rem 1.25rem;
font-size: 0.95rem;
border-radius: 0.5rem;
margin-top: 0.5rem;
}
a {
color: #0dcaf0;
text-decoration: none;
font-size: 1.1rem;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="clock" id="clock"></div>
<div class="date" id="date"></div>
<div class="weather" id="weather">Loading weather...</div>
<input type="search" id="search" placeholder="Search Google or type a URL..." onkeydown="searchInput(event)" />
<section>
<h2>Services</h2>
<div class="card-grid">
<div class="service-card"><a href="https://git.zea.lt">🗂️ Gitea</a></div>
<div class="service-card"><a href="https://registry.zea.lt">📦 Docker Registry</a></div>
<div class="service-card"><a href="https://traefik.zea.lt">📊 Traefik Dashboard</a></div>
<div class="service-card"><a href="https://wol.zea.lt">🖲️ Wake-on-LAN API</a></div>
<div class="service-card"><a href="https://kuma.zea.lt">📈 Uptime Kuma</a></div>
<div class="service-card"><a href="https://drone.zea.lt">🚀 Drone CI</a></div>
<div class="service-card"><a href="https://print.zea.lt">🖨️ Printer (CUPS)</a></div>
</div>
</section>
<section>
<h2>Devices</h2>
<div class="card-grid">
{% for dev, mac in devices.items() %}
<div class="device-card">
<div class="device-status">
{% if status[dev] %}
🟢
{% else %}
🔴
{% endif %}
</div>
<h5>{{ dev|capitalize }}</h5>
<button
class="btn btn-{{ 'secondary' if status[dev] else 'primary' }} btn-wake"
{% if status[dev] %}disabled{% endif %}
onclick="wake('{{ dev }}')">
Wake
</button>
</div>
{% endfor %}
</div>
</section>
</div>
<script>
// Clock & date
function updateClock() {
const now = new Date();
const clock = now.toLocaleTimeString();
const date = now.toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
document.getElementById('clock').textContent = clock;
document.getElementById('date').textContent = date;
}
setInterval(updateClock, 1000);
updateClock();
// Weather (fetched via server proxy API key stays server-side)
async function fetchWeather() {
const res = await fetch('/weather');
if (!res.ok) return document.getElementById('weather').textContent = 'Weather unavailable';
const data = await res.json();
if (data.error) return document.getElementById('weather').textContent = 'Weather unavailable';
const temp = data.main.temp.toFixed(1);
const desc = data.weather[0].description;
const icon = data.weather[0].icon;
const city = data.name;
document.getElementById('weather').innerHTML =
`<img src="https://openweathermap.org/img/wn/${icon}.png" alt="" /> ${city}: ${temp}°C, ${desc}`;
}
fetchWeather();
// Wake device
async function wake(device) {
const resp = await fetch(`/wake/${device}`, { method: 'POST' });
const data = await resp.json();
alert(data.message || data.error);
location.reload();
}
// Smart search
function searchInput(e) {
if (e.key === 'Enter') {
let q = e.target.value.trim();
if (!q) return;
if (!q.startsWith('http') && q.includes('.') && !q.includes(' ')) {
if (!q.startsWith('http://') && !q.startsWith('https://')) q = 'http://' + q;
location.href = q;
} else {
location.href = 'https://www.google.com/search?q=' + encodeURIComponent(q);
}
}
}
</script>
</body>
</html>