Initial commit: Location Management Flask app
This commit is contained in:
76
app/templates/boards/add.html
Normal file
76
app/templates/boards/add.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Add Board – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item active">Add Board</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card border-0 rounded-4" style="max-width:640px">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-plus-circle me-1 text-primary"></i> Add New Board
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Local Name</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="e.g. Server Room Board" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Board Type</label>
|
||||
<select name="board_type" class="form-select" id="board_type_select" onchange="updateDefaults(this.value)">
|
||||
{% for value, label in board_types %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text text-secondary" id="driver_desc"></div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label">IP Address / Hostname</label>
|
||||
<input type="text" name="host" class="form-control font-monospace" placeholder="192.168.1.100" required />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" name="port" class="form-control" value="80" min="1" max="65535" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Relays</label>
|
||||
<input type="number" name="num_relays" id="num_relays" class="form-control" value="4" min="1" max="32" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Inputs</label>
|
||||
<input type="number" name="num_inputs" id="num_inputs" class="form-control" value="4" min="0" max="32" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Add Board</button>
|
||||
<a href="{{ url_for('boards.list_boards') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const driverDefaults = {
|
||||
{% for d in drivers %}
|
||||
"{{ d.DRIVER_ID }}": { relays: {{ d.DEFAULT_NUM_RELAYS }}, inputs: {{ d.DEFAULT_NUM_INPUTS }}, desc: "{{ d.DESCRIPTION }}" },
|
||||
{% endfor %}
|
||||
};
|
||||
function updateDefaults(type) {
|
||||
const d = driverDefaults[type] || { relays: 4, inputs: 4, desc: "" };
|
||||
document.getElementById("num_relays").value = d.relays;
|
||||
document.getElementById("num_inputs").value = d.inputs;
|
||||
document.getElementById("driver_desc").textContent = d.desc;
|
||||
}
|
||||
// init on page load
|
||||
updateDefaults(document.getElementById("board_type_select").value);
|
||||
</script>
|
||||
{% endblock %}
|
||||
205
app/templates/boards/detail.html
Normal file
205
app/templates/boards/detail.html
Normal file
@@ -0,0 +1,205 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ board.name }} – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item active">{{ board.name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-0">{{ board.name }}</h2>
|
||||
<span class="badge text-bg-secondary">{{ board.board_type }}</span>
|
||||
<span class="badge {% if board.is_online %}text-bg-success{% else %}text-bg-secondary{% endif %} ms-1">
|
||||
{% if board.is_online %}Online{% else %}Offline{% endif %}
|
||||
</span>
|
||||
<span class="text-secondary small ms-2 font-monospace">{{ board.host }}:{{ board.port }}</span>
|
||||
</div>
|
||||
{% if current_user.is_admin() %}
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('boards.edit_entities', board_id=board.id) }}" class="btn btn-outline-info">
|
||||
<i class="bi bi-palette me-1"></i> Configure Entities
|
||||
</a>
|
||||
<a href="{{ url_for('boards.edit_board', board_id=board.id) }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil me-1"></i> Edit
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- ── Relay controls ────────────────────────────────────────────────────── -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 rounded-4 h-100">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-lightning-charge me-1 text-warning"></i> Relay Controls
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% 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) %}
|
||||
<div class="col-6">
|
||||
<div id="relay-card-{{ n }}" class="card rounded-3 border-{{ e.on_color if is_on else e.off_color }} h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
|
||||
<i id="relay-icon-{{ n }}" class="bi {{ e.icon }} mb-2 text-{{ e.on_color if is_on else e.off_color }}"
|
||||
style="font-size:2rem"></i>
|
||||
<div class="fs-6 fw-semibold mb-1">{{ e.name }}</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<span id="relay-badge-{{ n }}" class="badge text-bg-{{ e.on_color if is_on else e.off_color }}">
|
||||
{{ e.on_label if is_on else e.off_label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<form method="POST" action="{{ url_for('boards.set_relay_view', board_id=board.id, relay_num=n) }}">
|
||||
<button id="relay-on-btn-{{ n }}" class="btn btn-sm btn-{{ e.on_color }}" {% if is_on %}disabled{% endif %}>{{ e.on_label }}</button>
|
||||
<input type="hidden" name="state" value="on" />
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('boards.toggle_relay_view', board_id=board.id, relay_num=n) }}">
|
||||
<button class="btn btn-sm btn-outline-primary">Toggle</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('boards.set_relay_view', board_id=board.id, relay_num=n) }}">
|
||||
<button id="relay-off-btn-{{ n }}" class="btn btn-sm btn-{{ e.off_color }}" {% if not is_on %}disabled{% endif %}>{{ e.off_label }}</button>
|
||||
<input type="hidden" name="state" value="off" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Input states ──────────────────────────────────────────────────────── -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 rounded-4 h-100">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-activity me-1 text-info"></i> Input States
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{% 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) %}
|
||||
<div class="col-6">
|
||||
<div id="input-card-{{ n }}" class="card rounded-3 border-{{ e.active_color if is_active else e.idle_color }}">
|
||||
<div class="card-body text-center py-3">
|
||||
|
||||
<i id="input-icon-{{ n }}" class="bi {{ e.icon }} mb-2 text-{{ e.active_color if is_active else e.idle_color }}"
|
||||
style="font-size:2rem"></i>
|
||||
<div class="fs-6 fw-semibold mb-1">{{ e.name }}</div>
|
||||
|
||||
<span id="input-badge-{{ n }}" class="badge text-bg-{{ e.active_color if is_active else e.idle_color }}">
|
||||
{{ e.active_label if is_active else e.idle_label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Board info ────────────────────────────────────────────────────────── -->
|
||||
<div class="col-12">
|
||||
<div class="card border-0 rounded-4">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-info-circle me-1"></i> Board Information
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-3">Board ID</dt><dd class="col-sm-9 font-monospace">{{ board.id }}</dd>
|
||||
<dt class="col-sm-3">Type</dt><dd class="col-sm-9">{{ board.board_type }}</dd>
|
||||
<dt class="col-sm-3">Host</dt><dd class="col-sm-9 font-monospace">{{ board.host }}:{{ board.port }}</dd>
|
||||
<dt class="col-sm-3">Firmware</dt><dd class="col-sm-9">{{ board.firmware_version or '—' }}</dd>
|
||||
<dt class="col-sm-3">Added</dt><dd class="col-sm-9">{{ board.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
<dt class="col-sm-3">Last Seen</dt>
|
||||
<dd class="col-sm-9">{% if board.last_seen %}{{ board.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}{% else %}Never{% endif %}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const socket = io();
|
||||
const boardId = {{ board.id }};
|
||||
|
||||
// ── Entity config embedded from server ──────────────────────────────────────
|
||||
const ENTITY_CONFIG = {
|
||||
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 }}",onLabel:"{{ e.on_label }}",offLabel:"{{ e.off_label }}"},
|
||||
{% 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 }}",activeLabel:"{{ e.active_label }}",idleLabel:"{{ e.idle_label }}"},
|
||||
{% endfor %}
|
||||
}
|
||||
};
|
||||
|
||||
function swapBorderColor(el, color) {
|
||||
el.classList.forEach(c => {
|
||||
if (c.startsWith('border-') && c !== 'border-0' && c !== 'border-3') el.classList.remove(c);
|
||||
});
|
||||
el.classList.add('border-' + color);
|
||||
}
|
||||
|
||||
function applyRelayState(n, isOn) {
|
||||
const e = ENTITY_CONFIG.relays[n]; if (!e) return;
|
||||
const color = isOn ? e.onColor : e.offColor;
|
||||
const label = isOn ? e.onLabel : e.offLabel;
|
||||
const card = document.getElementById('relay-card-' + n);
|
||||
const icon = document.getElementById('relay-icon-' + n);
|
||||
const badge = document.getElementById('relay-badge-' + n);
|
||||
const onBtn = document.getElementById('relay-on-btn-' + n);
|
||||
const offBtn= document.getElementById('relay-off-btn-' + n);
|
||||
if (card) swapBorderColor(card, color);
|
||||
if (icon) { icon.className = `bi ${e.icon} mb-2 text-${color}`; icon.style.fontSize = '2rem'; }
|
||||
if (badge) { badge.className = 'badge text-bg-' + color; badge.textContent = label; }
|
||||
if (onBtn) onBtn.disabled = isOn;
|
||||
if (offBtn) offBtn.disabled = !isOn;
|
||||
}
|
||||
|
||||
function applyInputState(n, rawState) {
|
||||
const e = ENTITY_CONFIG.inputs[n]; if (!e) return;
|
||||
const isActive = !rawState; // NC contact: raw true = resting = idle
|
||||
const color = isActive ? e.activeColor : e.idleColor;
|
||||
const label = isActive ? e.activeLabel : e.idleLabel;
|
||||
const card = document.getElementById('input-card-' + n);
|
||||
const icon = document.getElementById('input-icon-' + n);
|
||||
const badge = document.getElementById('input-badge-' + n);
|
||||
if (card) swapBorderColor(card, color);
|
||||
if (icon) { icon.className = `bi ${e.icon} mb-2 text-${color}`; icon.style.fontSize = '2rem'; }
|
||||
if (badge) { badge.className = 'badge text-bg-' + color; badge.textContent = label; }
|
||||
}
|
||||
|
||||
socket.on("board_update", function(data) {
|
||||
if (data.board_id !== boardId) return;
|
||||
if (data.relay_states) {
|
||||
for (const [key, isOn] of Object.entries(data.relay_states)) {
|
||||
applyRelayState(parseInt(key.split('_')[1]), isOn);
|
||||
}
|
||||
}
|
||||
if (data.input_states) {
|
||||
for (const [key, rawState] of Object.entries(data.input_states)) {
|
||||
applyInputState(parseInt(key.split('_')[1]), rawState);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
86
app/templates/boards/edit.html
Normal file
86
app/templates/boards/edit.html
Normal file
@@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit {{ board.name }} – Location Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="card border-0 rounded-4" style="max-width:700px">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-pencil me-1"></i> Edit Board — {{ board.name }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<!-- Connection -->
|
||||
<h6 class="text-secondary mb-3 text-uppercase small fw-semibold">Connection</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Local Name</label>
|
||||
<input type="text" name="name" class="form-control" value="{{ board.name }}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Board Type</label>
|
||||
<select name="board_type" class="form-select">
|
||||
{% for value, label in board_types %}
|
||||
<option value="{{ value }}" {% if board.board_type == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-8">
|
||||
<label class="form-label">IP / Hostname</label>
|
||||
<input type="text" name="host" class="form-control font-monospace" value="{{ board.host }}" required />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" name="port" class="form-control" value="{{ board.port }}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Relays</label>
|
||||
<input type="number" name="num_relays" class="form-control" value="{{ board.num_relays }}" min="1" max="32" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Number of Inputs</label>
|
||||
<input type="number" name="num_inputs" class="form-control" value="{{ board.num_inputs }}" min="0" max="32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Labels -->
|
||||
<h6 class="text-secondary mb-3 text-uppercase small fw-semibold">Relay Labels</h6>
|
||||
<div class="row g-2 mb-4">
|
||||
{% for n in range(1, board.num_relays + 1) %}
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Relay {{ n }}</label>
|
||||
<input type="text" name="relay_{{ n }}_label" class="form-control form-control-sm"
|
||||
placeholder="Relay {{ n }}"
|
||||
value="{{ board.labels.get('relay_' ~ n, '') }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h6 class="text-secondary mb-3 text-uppercase small fw-semibold">Input Labels</h6>
|
||||
<div class="row g-2 mb-4">
|
||||
{% for n in range(1, board.num_inputs + 1) %}
|
||||
<div class="col-6">
|
||||
<label class="form-label small">Input {{ n }}</label>
|
||||
<input type="text" name="input_{{ n }}_label" class="form-control form-control-sm"
|
||||
placeholder="Input {{ n }}"
|
||||
value="{{ board.labels.get('input_' ~ n, '') }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i> Save</button>
|
||||
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
318
app/templates/boards/edit_entities.html
Normal file
318
app/templates/boards/edit_entities.html
Normal file
@@ -0,0 +1,318 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Configure Entities – {{ board.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
|
||||
<li class="breadcrumb-item active">Configure Entities</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h2 class="fw-bold mb-1">Configure Entities</h2>
|
||||
<p class="text-secondary mb-4">
|
||||
Set a type, custom name, and optional icon override for each relay and input.
|
||||
The type determines the icon, status colors, and state labels shown everywhere.
|
||||
</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('boards.edit_entities', board_id=board.id) }}">
|
||||
|
||||
<!-- ── Relay entities ────────────────────────────────────────────────────── -->
|
||||
<h5 class="fw-semibold mb-3"><i class="bi bi-lightning-charge text-warning me-1"></i> Relay Outputs</h5>
|
||||
<div class="row g-3 mb-4">
|
||||
{% for n in range(1, board.num_relays + 1) %}
|
||||
{% set e = board.get_relay_entity(n) %}
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 rounded-4 entity-card" data-entity="relay_{{ n }}" data-kind="relay">
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Header: icon preview + number label -->
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<div class="entity-icon-wrap rounded-3 d-flex align-items-center justify-content-center"
|
||||
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
|
||||
<i class="bi {{ e.icon }} entity-icon-preview" style="font-size:1.8rem" id="icon-preview-relay-{{ n }}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-secondary small mb-1">Relay {{ n }}</div>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
name="relay_{{ n }}_name"
|
||||
value="{{ board.entities.get('relay_' ~ n, {}).get('name', '') }}"
|
||||
maxlength="20" placeholder="{{ e.name }}" />
|
||||
<div class="form-text" style="font-size:.7rem">Leave blank to use type default</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type selector -->
|
||||
<div class="mb-3">
|
||||
<div class="small text-secondary mb-2">Entity type</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for tkey, tdef in relay_types.items() %}
|
||||
<label class="entity-type-pill" title="{{ tdef.label }}"
|
||||
data-icon="{{ tdef.icon }}"
|
||||
data-target="relay_{{ n }}">
|
||||
<input type="radio" name="relay_{{ n }}_type" value="{{ tkey }}" hidden
|
||||
{% if e.type == tkey %}checked{% endif %} />
|
||||
<i class="bi {{ tdef.icon }}"></i>
|
||||
<span>{{ tdef.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom icon override -->
|
||||
<details class="mt-2">
|
||||
<summary class="text-secondary small" style="cursor:pointer">
|
||||
<i class="bi bi-palette me-1"></i> Custom icon override
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text">
|
||||
<i class="bi bi-search"></i>
|
||||
</span>
|
||||
<input type="text" class="form-control icon-text-input"
|
||||
name="relay_{{ n }}_icon"
|
||||
value="{{ board.entities.get('relay_' ~ n, {}).get('icon', '') }}"
|
||||
placeholder="e.g. bi-lightbulb-fill"
|
||||
data-target="relay_{{ n }}" />
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm icon-clear-btn"
|
||||
data-target="relay_{{ n }}">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="icon-palette d-flex flex-wrap gap-1">
|
||||
{% for icon_cls, icon_name in icon_palette %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary icon-pick-btn p-1"
|
||||
title="{{ icon_name }}" data-icon="{{ icon_cls }}" data-target="relay_{{ n }}">
|
||||
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── Input entities ────────────────────────────────────────────────────── -->
|
||||
<h5 class="fw-semibold mb-3"><i class="bi bi-activity text-info me-1"></i> Digital Inputs</h5>
|
||||
<div class="row g-3 mb-4">
|
||||
{% for n in range(1, board.num_inputs + 1) %}
|
||||
{% set e = board.get_input_entity(n) %}
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 rounded-4 entity-card" data-entity="input_{{ n }}" data-kind="input">
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Header: icon preview + number label -->
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<div class="entity-icon-wrap rounded-3 d-flex align-items-center justify-content-center"
|
||||
style="width:56px;height:56px;background:var(--bs-secondary-bg)">
|
||||
<i class="bi {{ e.icon }} entity-icon-preview" style="font-size:1.8rem" id="icon-preview-input-{{ n }}"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-secondary small mb-1">Input {{ n }}</div>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
name="input_{{ n }}_name"
|
||||
value="{{ board.entities.get('input_' ~ n, {}).get('name', '') }}"
|
||||
maxlength="20" placeholder="{{ e.name }}" />
|
||||
<div class="form-text" style="font-size:.7rem">Leave blank to use type default</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type selector -->
|
||||
<div class="mb-3">
|
||||
<div class="small text-secondary mb-2">Entity type</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for tkey, tdef in input_types.items() %}
|
||||
<label class="entity-type-pill" title="{{ tdef.label }}"
|
||||
data-icon="{{ tdef.icon }}"
|
||||
data-target="input_{{ n }}">
|
||||
<input type="radio" name="input_{{ n }}_type" value="{{ tkey }}" hidden
|
||||
{% if e.type == tkey %}checked{% endif %} />
|
||||
<i class="bi {{ tdef.icon }}"></i>
|
||||
<span>{{ tdef.label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom icon override -->
|
||||
<details class="mt-2">
|
||||
<summary class="text-secondary small" style="cursor:pointer">
|
||||
<i class="bi bi-palette me-1"></i> Custom icon override
|
||||
</summary>
|
||||
<div class="mt-2">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text">
|
||||
<i class="bi bi-search"></i>
|
||||
</span>
|
||||
<input type="text" class="form-control icon-text-input"
|
||||
name="input_{{ n }}_icon"
|
||||
value="{{ board.entities.get('input_' ~ n, {}).get('icon', '') }}"
|
||||
placeholder="e.g. bi-door-open-fill"
|
||||
data-target="input_{{ n }}" />
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm icon-clear-btn"
|
||||
data-target="input_{{ n }}">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="icon-palette d-flex flex-wrap gap-1">
|
||||
{% for icon_cls, icon_name in icon_palette %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary icon-pick-btn p-1"
|
||||
title="{{ icon_name }}" data-icon="{{ icon_cls }}" data-target="input_{{ n }}">
|
||||
<i class="bi {{ icon_cls }}" style="font-size:1.1rem"></i>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- ── Actions ────────────────────────────────────────────────────────────── -->
|
||||
<div class="d-flex gap-2 mt-2 mb-5">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-floppy me-1"></i> Save Configuration
|
||||
</button>
|
||||
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<style>
|
||||
/* ── Entity type pills ───────────────────────────────────────────── */
|
||||
.entity-type-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
cursor: pointer;
|
||||
font-size: .8rem;
|
||||
transition: background .15s, border-color .15s, color .15s;
|
||||
user-select: none;
|
||||
}
|
||||
.entity-type-pill:hover {
|
||||
border-color: var(--bs-primary);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
.entity-type-pill:has(input:checked) {
|
||||
border-color: var(--bs-primary);
|
||||
background: var(--bs-primary-bg-subtle, rgba(13,110,253,.15));
|
||||
color: var(--bs-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* ── Icon palette grid ───────────────────────────────────────────── */
|
||||
.icon-palette {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
background: var(--bs-secondary-bg);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
.icon-pick-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.icon-pick-btn.active {
|
||||
background: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/* ── Live icon preview ───────────────────────────────────────────────────── */
|
||||
function resolvePreview(target) {
|
||||
const iconInput = document.querySelector(`.icon-text-input[data-target="${target}"]`);
|
||||
const customIcon = iconInput ? iconInput.value.trim() : "";
|
||||
const preview = document.getElementById(`icon-preview-${target.replace("_", "-")}`);
|
||||
if (!preview) return;
|
||||
|
||||
if (customIcon) {
|
||||
// Remove all bi-* classes, add the custom one
|
||||
preview.className = preview.className.replace(/\bbi-\S+/g, "").trim();
|
||||
const cls = customIcon.startsWith("bi-") ? customIcon : `bi-${customIcon}`;
|
||||
preview.classList.add(cls);
|
||||
} else {
|
||||
// Use the checked type's icon
|
||||
const checkedType = document.querySelector(`input[name="${target}_type"]:checked`);
|
||||
if (checkedType) {
|
||||
const pill = checkedType.closest(".entity-type-pill");
|
||||
const typeIcon = pill ? pill.getAttribute("data-icon") : null;
|
||||
if (typeIcon) {
|
||||
preview.className = preview.className.replace(/\bbi-\S+/g, "").trim();
|
||||
preview.classList.add(typeIcon);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Type pill selection ─────────────────────────────────────────────────── */
|
||||
document.querySelectorAll(".entity-type-pill").forEach(pill => {
|
||||
pill.addEventListener("click", () => {
|
||||
const target = pill.getAttribute("data-target");
|
||||
// Small delay so the radio value has flipped
|
||||
requestAnimationFrame(() => resolvePreview(target));
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Custom icon text input ──────────────────────────────────────────────── */
|
||||
document.querySelectorAll(".icon-text-input").forEach(inp => {
|
||||
inp.addEventListener("input", () => resolvePreview(inp.getAttribute("data-target")));
|
||||
});
|
||||
|
||||
/* ── Icon palette pick ───────────────────────────────────────────────────── */
|
||||
document.querySelectorAll(".icon-pick-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const icon = btn.getAttribute("data-icon");
|
||||
const target = btn.getAttribute("data-target");
|
||||
const inp = document.querySelector(`.icon-text-input[data-target="${target}"]`);
|
||||
if (inp) inp.value = icon;
|
||||
// Highlight selected palette button
|
||||
btn.closest(".icon-palette").querySelectorAll(".icon-pick-btn").forEach(b => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
resolvePreview(target);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Clear custom icon ───────────────────────────────────────────────────── */
|
||||
document.querySelectorAll(".icon-clear-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const target = btn.getAttribute("data-target");
|
||||
const inp = document.querySelector(`.icon-text-input[data-target="${target}"]`);
|
||||
if (inp) inp.value = "";
|
||||
// Deselect all palette buttons for this target
|
||||
const card = btn.closest(".card-body");
|
||||
if (card) card.querySelectorAll(".icon-pick-btn").forEach(b => b.classList.remove("active"));
|
||||
resolvePreview(target);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── Init: highlight pre-selected palette icons, run initial previews ────── */
|
||||
document.querySelectorAll(".icon-text-input").forEach(inp => {
|
||||
const val = inp.value.trim();
|
||||
const target = inp.getAttribute("data-target");
|
||||
if (val) {
|
||||
const match = inp.closest(".card-body").querySelector(`.icon-pick-btn[data-icon="${val}"]`);
|
||||
if (match) match.classList.add("active");
|
||||
}
|
||||
resolvePreview(target);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
77
app/templates/boards/edit_labels.html
Normal file
77
app/templates/boards/edit_labels.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit Labels – {{ board.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.list_boards') }}">Boards</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('boards.board_detail', board_id=board.id) }}">{{ board.name }}</a></li>
|
||||
<li class="breadcrumb-item active">Edit Labels</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h2 class="fw-bold mb-1">Edit Labels</h2>
|
||||
<p class="text-secondary mb-4">Assign custom names to each relay and input (max 20 characters). Leave a field blank to use the default name.</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('boards.edit_labels', board_id=board.id) }}">
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- ── Relay labels ───────────────────────────────────────────────────── -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 rounded-4">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-lightning-charge me-1 text-warning"></i> Relay Names
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for n in range(1, board.num_relays + 1) %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-secondary small mb-1">Relay {{ n }}</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="relay_{{ n }}_label"
|
||||
value="{{ board.labels.get('relay_' ~ n, '') }}"
|
||||
maxlength="20"
|
||||
placeholder="Relay {{ n }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Input labels ───────────────────────────────────────────────────── -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 rounded-4">
|
||||
<div class="card-header bg-transparent fw-semibold pt-3">
|
||||
<i class="bi bi-activity me-1 text-info"></i> Input Names
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for n in range(1, board.num_inputs + 1) %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label text-secondary small mb-1">Input {{ n }}</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
name="input_{{ n }}_label"
|
||||
value="{{ board.labels.get('input_' ~ n, '') }}"
|
||||
maxlength="20"
|
||||
placeholder="Input {{ n }}" />
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Actions ────────────────────────────────────────────────────────── -->
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-floppy me-1"></i> Save Labels
|
||||
</button>
|
||||
<a href="{{ url_for('boards.board_detail', board_id=board.id) }}" class="btn btn-outline-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
{% endblock %}
|
||||
62
app/templates/boards/list.html
Normal file
62
app/templates/boards/list.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% 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>
|
||||
|
||||
{% 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><a href="{{ url_for('boards.board_detail', board_id=b.id) }}" class="fw-semibold text-decoration-none">{{ b.name }}</a></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 %}">
|
||||
{% if b.is_online %}Online{% else %}Offline{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small text-secondary">
|
||||
{% if b.last_seen %}{{ b.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('boards.board_detail', board_id=b.id) }}" 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 %}
|
||||
Reference in New Issue
Block a user