Add Layouts module with Konva.js builder; smart offline polling; UI improvements

- Move board cards from dashboard to top of boards list page
- Fix Werkzeug duplicate polling (WERKZEUG_RUN_MAIN guard)
- Smart offline polling: fast loop for online boards, slow recheck for offline
- Add manual ping endpoint POST /api/boards/<id>/ping
- Add spin animation CSS for ping button

Layouts module (new):
- app/models/layout.py: Layout model (canvas_json, thumbnail_b64)
- app/routes/layouts.py: 5 routes (list, create, builder, save, delete)
- app/templates/layouts/: list and builder templates
- app/static/js/layout_builder.js: full Konva.js builder engine
- app/static/vendor/konva/: vendored Konva.js 9
- Structure mode: wall, room, door, window, fence, text shapes
- Devices mode: drag relay/input/Sonoff channels onto canvas
- Live view mode: click relays/Sonoff to toggle, socket.io state updates
- Device selection: click to select, remove individual device, Delete key
- Fix door/Arc size persistence across save/reload (outerRadius, scaleX/Y)
- Fix Sonoff devices missing from palette (add makeSonoffChip function)
This commit is contained in:
ske087
2026-02-27 13:34:44 +02:00
parent 30806560a6
commit 90cbf4e1f0
15 changed files with 2006 additions and 177 deletions

View File

@@ -1,10 +1,12 @@
"""REST API board webhook receiver and JSON relay control."""
from datetime import datetime
from flask import Blueprint, request, jsonify, abort
from flask import Blueprint, request, jsonify, abort, current_app
from flask_login import login_required
from app import db, socketio
from app.models.board import Board
from app.services import workflow_engine
from app.services.board_service import poll_board
api_bp = Blueprint("api", __name__)
@@ -46,6 +48,28 @@ def webhook(board_id: int):
return jsonify({"status": "ok"})
# ── Manual ping / status check ───────────────────────────────────────────────
@api_bp.route("/boards/<int:board_id>/ping", methods=["POST"])
@login_required
def ping_board(board_id: int):
"""Trigger an immediate poll for any board (online or offline).
Called by the 'Check Status' button in the UI. The poll result is both
returned as JSON *and* broadcast via socket.io so all open tabs update.
"""
db.get_or_404(Board, board_id) # 404 if unknown
poll_board(current_app._get_current_object(), board_id)
board = db.session.get(Board, board_id)
return jsonify({
"board_id": board.id,
"is_online": board.is_online,
"relay_states": board.relay_states,
"input_states": board.input_states,
"last_seen": board.last_seen.isoformat() if board.last_seen else None,
})
# ── JSON relay status ─────────────────────────────────────────────────────────
@api_bp.route("/boards/<int:board_id>/relays")

143
app/routes/layouts.py Normal file
View File

@@ -0,0 +1,143 @@
"""Layout management routes."""
import json
from datetime import datetime
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, abort
from flask_login import login_required, current_user
from app import db
from app.models.layout import Layout
from app.models.board import Board
from app.models.sonoff_device import SonoffDevice
layouts_bp = Blueprint("layouts", __name__)
# ── list ──────────────────────────────────────────────────────────────────────
@layouts_bp.route("/")
@login_required
def list_layouts():
layouts = Layout.query.order_by(Layout.name).all()
return render_template("layouts/list.html", layouts=layouts)
# ── create ────────────────────────────────────────────────────────────────────
@layouts_bp.route("/create", methods=["POST"])
@login_required
def create_layout():
if not current_user.is_admin():
abort(403)
name = request.form.get("name", "").strip()
description = request.form.get("description", "").strip()
if not name:
flash("Layout name is required.", "danger")
return redirect(url_for("layouts.list_layouts"))
layout = Layout(name=name, description=description or None)
db.session.add(layout)
db.session.commit()
return redirect(url_for("layouts.builder", layout_id=layout.id))
# ── builder ───────────────────────────────────────────────────────────────────
@layouts_bp.route("/<int:layout_id>/builder")
@login_required
def builder(layout_id: int):
layout = db.get_or_404(Layout, layout_id)
boards = Board.query.order_by(Board.name).all()
# Build a boards palette for the JS device sidebar
boards_data = []
for b in boards:
bd = {"id": b.id, "name": b.name, "online": b.is_online,
"relays": [], "inputs": [], "sonoff_channels": [],
"board_type": b.board_type}
# ── Standard relay/input boards ──────────────────────────────────────
for n in range(1, b.num_relays + 1):
e = b.get_relay_entity(n)
bd["relays"].append({
"num": n,
"name": e["name"],
"icon": e["icon"],
"onColor": e["on_color"],
"offColor": e["off_color"],
"isOn": b.relay_states.get(f"relay_{n}", False),
})
for n in range(1, b.num_inputs + 1):
e = b.get_input_entity(n)
bd["inputs"].append({
"num": n,
"name": e["name"],
"icon": e["icon"],
"activeColor": e["active_color"],
"idleColor": e["idle_color"],
"rawState": b.input_states.get(f"input_{n}", True),
})
# ── Sonoff eWeLink sub-devices ────────────────────────────────────────
if b.board_type == "sonoff_ewelink":
KIND_ICON = {
"switch": "bi-toggles",
"light": "bi-lightbulb-fill",
"fan": "bi-fan",
"sensor": "bi-thermometer-half",
"remote": "bi-broadcast",
}
devices = SonoffDevice.query.filter_by(board_id=b.id).order_by(
SonoffDevice.name).all()
for dev in devices:
icon = KIND_ICON.get(dev.kind, "bi-toggles")
num_ch = max(dev.num_channels, 1)
for ch in range(num_ch):
label = dev.name if num_ch == 1 else f"{dev.name} Ch{ch + 1}"
bd["sonoff_channels"].append({
"deviceId": dev.device_id,
"deviceDbId": dev.id,
"channel": ch,
"name": label,
"icon": icon,
"kind": dev.kind,
"isOn": dev.get_channel_state(ch),
"isOnline": dev.is_online,
})
boards_data.append(bd)
return render_template(
"layouts/builder.html",
layout=layout,
boards_data=boards_data,
)
# ── save (AJAX) ───────────────────────────────────────────────────────────────
@layouts_bp.route("/<int:layout_id>/save", methods=["POST"])
@login_required
def save_layout(layout_id: int):
layout = db.get_or_404(Layout, layout_id)
data = request.get_json(silent=True) or {}
if "canvas" in data:
layout.canvas_json = json.dumps(data["canvas"])
if data.get("thumbnail"):
layout.thumbnail_b64 = data["thumbnail"]
layout.updated_at = datetime.utcnow()
db.session.commit()
return jsonify({"status": "ok"})
# ── delete ────────────────────────────────────────────────────────────────────
@layouts_bp.route("/<int:layout_id>/delete", methods=["POST"])
@login_required
def delete_layout(layout_id: int):
if not current_user.is_admin():
abort(403)
layout = db.get_or_404(Layout, layout_id)
db.session.delete(layout)
db.session.commit()
flash(f"Layout '{layout.name}' deleted.", "warning")
return redirect(url_for("layouts.list_layouts"))