Files
location_managemet/app/templates/boards/list.html
ske087 90cbf4e1f0 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)
2026-02-27 13:34:44 +02:00

291 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Boards Location Management{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between mb-4">
<h2 class="fw-bold mb-0"><i class="bi bi-motherboard me-2 text-primary"></i>Boards</h2>
{% if current_user.is_admin() %}
<a href="{{ url_for('boards.add_board') }}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Add Board
</a>
{% endif %}
</div>
<!-- ── Board cards ──────────────────────────────────────────────────────────── -->
{% if boards %}
<div class="row g-3 mb-4" id="board-grid">
{% for board in boards %}
<div class="col-md-6 col-xl-4" id="board-card-{{ board.id }}">
<div class="card board-card border-0 rounded-4 h-100 {% if board.is_online %}border-start border-3 border-success{% else %}border-start border-3 border-secondary{% endif %}">
<div class="card-header bg-transparent d-flex justify-content-between align-items-center pt-3">
<div>
<h5 class="mb-0 fw-semibold">{{ board.name }}</h5>
<span class="badge text-bg-secondary small">{{ board.board_type }}</span>
<span class="badge {% if board.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %} small ms-1" id="online-badge-{{ board.id }}">
{% if board.is_online %}Online{% else %}Offline{% endif %}
</span>
</div>
<a href="{% if board.board_type == 'sonoff_ewelink' %}{{ url_for('sonoff.gateway', board_id=board.id) }}{% else %}{{ url_for('boards.board_detail', board_id=board.id) }}{% endif %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<div class="card-body">
<p class="text-secondary small mb-2"><i class="bi bi-hdd-network me-1"></i>{{ board.host }}:{{ board.port }}</p>
<!-- Quick relay controls -->
<div class="d-flex flex-wrap gap-2">
{% for n in range(1, board.num_relays + 1) %}
{% set relay_key = "relay_" ~ n %}
{% set is_on = board.relay_states.get(relay_key, false) %}
{% set e = board.get_relay_entity(n) %}
<button type="button"
class="btn btn-sm relay-btn {% if is_on %}btn-{{ e.on_color }}{% else %}btn-outline-secondary{% endif %}"
data-relay="{{ n }}" data-board="{{ board.id }}"
data-toggle-url="{{ url_for('boards.toggle_relay_view', board_id=board.id, relay_num=n) }}"
title="{{ e.name }}"
onclick="dashToggleRelay(this)">
<i class="bi {{ e.icon }}"></i>
{{ e.name }}
</button>
{% endfor %}
</div>
<!-- Input states -->
{% if board.num_inputs > 0 %}
<div class="mt-2 d-flex flex-wrap gap-1">
{% for n in range(1, board.num_inputs + 1) %}
{% set input_key = "input_" ~ n %}
{% set raw_state = board.input_states.get(input_key, true) %}
{% set is_active = not raw_state %}
{% set e = board.get_input_entity(n) %}
<span class="badge {% if is_active %}text-bg-{{ e.active_color }}{% else %}text-bg-dark{% endif %} input-badge"
data-input="{{ n }}" data-board="{{ board.id }}">
<i class="bi {{ e.icon }}"></i> {{ e.name }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
<div class="card-footer bg-transparent text-secondary small d-flex justify-content-between align-items-center">
<span id="last-seen-{{ board.id }}">
{% if board.last_seen %}Last seen {{ board.last_seen.strftime('%H:%M:%S') }}{% else %}Never polled{% endif %}
</span>
<button class="btn btn-sm btn-outline-warning ping-btn {% if board.is_online %}d-none{% endif %}"
id="ping-btn-{{ board.id }}"
data-ping-url="{{ url_for('api.ping_board', board_id=board.id) }}"
onclick="pingBoard(this, {{ board.id }})">
<i class="bi bi-arrow-clockwise"></i> Check
</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- ── Board table ────────────────────────────────────────────────────────── -->
{% if boards %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-dark">
<tr>
<th>Name</th><th>Type</th><th>Host</th><th>Relays</th><th>Inputs</th><th>Status</th><th>Last Seen</th><th></th>
</tr>
</thead>
<tbody>
{% for b in boards %}
<tr>
<td>
{% if b.board_type == 'sonoff_ewelink' %}
<a href="{{ url_for('sonoff.gateway', board_id=b.id) }}" class="fw-semibold text-decoration-none">
<i class="bi bi-cloud-lightning-fill text-warning me-1"></i>{{ b.name }}
</a>
{% else %}
<a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="fw-semibold text-decoration-none">{{ b.name }}</a>
{% endif %}
</td>
<td><span class="badge text-bg-secondary">{{ b.board_type }}</span></td>
<td class="font-monospace small">{{ b.host }}:{{ b.port }}</td>
<td>{{ b.num_relays }}</td>
<td>{{ b.num_inputs }}</td>
<td>
<span class="badge {% if b.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %}" id="tbl-badge-{{ b.id }}">
{% if b.is_online %}Online{% else %}Offline{% endif %}
</span>
</td>
<td class="small text-secondary" id="tbl-lastseen-{{ b.id }}">
{% if b.last_seen %}{{ b.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}
</td>
<td>
{% if not b.is_online %}
<button class="btn btn-sm btn-outline-warning me-1 tbl-ping-btn" id="tbl-ping-{{ b.id }}"
data-ping-url="{{ url_for('api.ping_board', board_id=b.id) }}"
onclick="pingBoard(this, {{ b.id }})" title="Check status">
<i class="bi bi-arrow-clockwise"></i>
</button>
{% else %}
<button class="btn btn-sm btn-outline-warning me-1 tbl-ping-btn d-none" id="tbl-ping-{{ b.id }}"
data-ping-url="{{ url_for('api.ping_board', board_id=b.id) }}"
onclick="pingBoard(this, {{ b.id }})" title="Check status">
<i class="bi bi-arrow-clockwise"></i>
</button>
{% endif %}
<a href="{% if b.board_type == 'sonoff_ewelink' %}{{ url_for('sonoff.gateway', board_id=b.id) }}{% else %}{{ url_for('boards.board_detail', board_id=b.id) }}{% endif %}" class="btn btn-sm btn-outline-primary me-1"><i class="bi bi-eye"></i></a>
{% if current_user.is_admin() %}
<a href="{{ url_for('boards.edit_board', board_id=b.id) }}" class="btn btn-sm btn-outline-secondary me-1"><i class="bi bi-pencil"></i></a>
<form method="POST" action="{{ url_for('boards.delete_board', board_id=b.id) }}" class="d-inline"
onsubmit="return confirm('Delete {{ b.name }}?')">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-secondary">
<i class="bi bi-motherboard display-2"></i>
<p class="mt-3">No boards yet.</p>
{% if current_user.is_admin() %}
<a href="{{ url_for('boards.add_board') }}" class="btn btn-primary">Add Board</a>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
// ── Entity config for all boards (embedded from server) ─────────────────────
const BOARD_ENTITIES = {
{% for board in boards %}
{{ board.id }}: {
relays: {
{% for n in range(1, board.num_relays + 1) %}{% set e = board.get_relay_entity(n) %}
{{ n }}: {icon:"{{ e.icon }}",onColor:"{{ e.on_color }}",offColor:"{{ e.off_color }}"},
{% endfor %}
},
inputs: {
{% for n in range(1, board.num_inputs + 1) %}{% set e = board.get_input_entity(n) %}
{{ n }}: {icon:"{{ e.icon }}",activeColor:"{{ e.active_color }}",idleColor:"{{ e.idle_color }}"},
{% endfor %}
}
},
{% endfor %}
};
const socket = io();
socket.on("board_update", function(data) {
const bid = data.board_id;
const ent = BOARD_ENTITIES[bid] || {relays:{}, inputs:{}};
// card online badge
const onlineBadge = document.getElementById("online-badge-" + bid);
if (onlineBadge) {
onlineBadge.textContent = data.is_online ? "Online" : "Offline";
onlineBadge.className = "badge small ms-1 " + (data.is_online ? "text-bg-success" : "text-bg-secondary");
}
// table badge
const tblBadge = document.getElementById("tbl-badge-" + bid);
if (tblBadge) {
tblBadge.textContent = data.is_online ? "Online" : "Offline";
tblBadge.className = "badge " + (data.is_online ? "text-bg-success" : "text-bg-secondary");
}
// show/hide Check buttons (card + table)
const pingBtn = document.getElementById("ping-btn-" + bid);
const tblPingBtn = document.getElementById("tbl-ping-" + bid);
[pingBtn, tblPingBtn].forEach(btn => {
if (!btn) return;
btn.disabled = false;
btn.querySelector("i").className = "bi bi-arrow-clockwise";
if (data.is_online) btn.classList.add("d-none");
else btn.classList.remove("d-none");
});
// last-seen in card footer
if (data.last_seen) {
const ls = document.getElementById("last-seen-" + bid);
if (ls) ls.textContent = "Last seen " + new Date(data.last_seen).toLocaleTimeString();
}
// relay buttons
if (data.relay_states) {
for (const [key, isOn] of Object.entries(data.relay_states)) {
const n = parseInt(key.split("_")[1]);
const e = ent.relays[n] || {};
document.querySelectorAll(`[data-relay="${n}"][data-board="${bid}"]`).forEach(btn => {
btn.className = `btn btn-sm relay-btn ${isOn ? 'btn-' + (e.onColor||'success') : 'btn-outline-secondary'}`;
const icon = btn.querySelector("i");
if (icon && e.icon) icon.className = `bi ${e.icon}`;
});
}
}
// input badges — NC inversion: raw true = resting = idle
if (data.input_states) {
for (const [key, rawState] of Object.entries(data.input_states)) {
const n = parseInt(key.split("_")[1]);
const e = ent.inputs[n] || {};
const isActive = !rawState;
document.querySelectorAll(`[data-input="${n}"][data-board="${bid}"]`).forEach(span => {
span.className = `badge input-badge text-bg-${isActive ? (e.activeColor||'info') : (e.idleColor||'dark')}`;
});
}
}
});
// ── Manual board ping ────────────────────────────────────────────────────────
function pingBoard(btn, boardId) {
const url = btn.getAttribute("data-ping-url");
btn.disabled = true;
btn.querySelector("i").className = "bi bi-arrow-clockwise spin";
fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" },
})
.then(r => r.json())
.then(data => {
// socket.io board_update will handle badge + button visibility;
// also update last_seen in the table row right away
const tblLs = document.getElementById("tbl-lastseen-" + boardId);
if (tblLs) {
tblLs.textContent = data.last_seen
? new Date(data.last_seen).toLocaleString()
: (data.is_online ? "just now" : "—");
}
// re-enable in case socket.io doesn't fire (e.g. still offline)
btn.disabled = false;
btn.querySelector("i").className = "bi bi-arrow-clockwise";
})
.catch(() => {
btn.disabled = false;
btn.querySelector("i").className = "bi bi-arrow-clockwise";
});
}
// ── Relay toggle (AJAX — no page navigation) ─────────────────────────────────
function dashToggleRelay(btn) {
const url = btn.getAttribute("data-toggle-url");
btn.disabled = true;
fetch(url, {
method: "POST",
headers: { "Accept": "application/json", "X-Requested-With": "XMLHttpRequest" },
})
.then(r => r.json())
.then(data => {
btn.disabled = false;
if (!data.hw_ok) {
btn.title = "(board unreachable — local state updated)";
setTimeout(() => btn.title = btn.getAttribute("data-label") || "", 3000);
}
})
.catch(() => { btn.disabled = false; });
}
</script>
{% endblock %}