Initial homelab infrastructure
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,2 @@
|
||||
Flask==2.3.2
|
||||
requests==2.31.0
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user