Add display_id conflict detection, editable board display_id, and multi-IP beaconing
This commit is contained in:
parent
3dc3bc75ca
commit
a70f739ef7
28
app.py
28
app.py
@ -50,14 +50,37 @@ def get_local_ip():
|
||||
return ip
|
||||
|
||||
|
||||
def get_local_ips():
|
||||
ips = set()
|
||||
try:
|
||||
_, _, ip_list = socket.gethostbyname_ex(socket.gethostname())
|
||||
ips.update(ip_list)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
addr = info[4][0]
|
||||
if addr != "127.0.0.1":
|
||||
ips.add(addr)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
ips.add(get_local_ip())
|
||||
except:
|
||||
pass
|
||||
return sorted(ips)
|
||||
|
||||
|
||||
def udp_beacon():
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
while True:
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"display_id": config["display_id"],
|
||||
"display_name": config.get("display_name", ""),
|
||||
"ip": get_local_ip(),
|
||||
"ips": get_local_ips(),
|
||||
"port": PORT,
|
||||
})
|
||||
sock.sendto(payload.encode(), ("255.255.255.255", 50744))
|
||||
@ -232,6 +255,11 @@ def api_update_config():
|
||||
if request.method == "OPTIONS":
|
||||
return cors(jsonify({}))
|
||||
data = request.get_json(silent=True) or {}
|
||||
if "display_id" in data:
|
||||
did = data["display_id"]
|
||||
if not isinstance(did, int) or did <= 0:
|
||||
return cors((jsonify({"error": "display_id must be a positive integer"}), 400))
|
||||
config["display_id"] = did
|
||||
if "display_name" in data:
|
||||
config["display_name"] = data["display_name"]
|
||||
if "header" in data:
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
@ -7,6 +9,11 @@ import time
|
||||
import concurrent.futures
|
||||
from collections import OrderedDict
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib
|
||||
|
||||
import requests
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
|
||||
@ -18,27 +25,123 @@ POLL_INTERVAL = 3
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
boards = OrderedDict()
|
||||
boards = OrderedDict() # display_id -> board record
|
||||
boards_lock = threading.Lock()
|
||||
ip_index = {} # ip -> display_id
|
||||
ip_identity = {} # ip -> {"id": identity, "name": display_name}
|
||||
offline_threshold = POLL_INTERVAL * 3
|
||||
|
||||
conflicts = OrderedDict() # display_id -> conflict record
|
||||
conflicts_lock = threading.Lock()
|
||||
|
||||
def get_local_ip():
|
||||
|
||||
def get_local_ips():
|
||||
ips = set()
|
||||
try:
|
||||
_, _, ip_list = socket.gethostbyname_ex(socket.gethostname())
|
||||
ips.update(ip_list)
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
addr = info[4][0]
|
||||
if addr != "127.0.0.1":
|
||||
ips.add(addr)
|
||||
except:
|
||||
pass
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("10.255.255.255", 1))
|
||||
ip = s.getsockname()[0]
|
||||
ips.add(s.getsockname()[0])
|
||||
except:
|
||||
ip = "127.0.0.1"
|
||||
pass
|
||||
finally:
|
||||
s.close()
|
||||
return ip
|
||||
return sorted(ips)
|
||||
|
||||
|
||||
def get_subnet():
|
||||
ip = get_local_ip()
|
||||
parts = ip.split(".")
|
||||
return ".".join(parts[:3])
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG_PATH = os.path.join(BASE_DIR, "config.toml")
|
||||
|
||||
|
||||
def load_scan_subnets():
|
||||
try:
|
||||
with open(CONFIG_PATH, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
val = data.get("scan", {}).get("subnets")
|
||||
if isinstance(val, str):
|
||||
val = [val]
|
||||
return val
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_subnets(patterns):
|
||||
if patterns is None:
|
||||
return None
|
||||
networks = []
|
||||
for pat in patterns:
|
||||
pat = str(pat).strip()
|
||||
if not pat or pat.lower() == "all":
|
||||
return None
|
||||
try:
|
||||
if "*" in pat:
|
||||
prefix = 0
|
||||
cidr_parts = []
|
||||
for part in pat.split("."):
|
||||
if part == "*":
|
||||
break
|
||||
cidr_parts.append(part)
|
||||
prefix += 8
|
||||
if prefix == 0:
|
||||
continue
|
||||
padded = cidr_parts + ["0"] * (4 - len(cidr_parts))
|
||||
networks.append(ipaddress.ip_network(
|
||||
".".join(padded) + "/" + str(prefix), strict=False))
|
||||
elif "/" in pat:
|
||||
networks.append(ipaddress.ip_network(pat, strict=False))
|
||||
else:
|
||||
networks.append(ipaddress.ip_network(pat + "/32", strict=False))
|
||||
except ValueError:
|
||||
print(f" Ignoring invalid subnet pattern: {pat}")
|
||||
return networks
|
||||
|
||||
|
||||
_scan_networks_cache = None
|
||||
|
||||
|
||||
def get_scan_networks():
|
||||
global _scan_networks_cache
|
||||
if _scan_networks_cache is None:
|
||||
_scan_networks_cache = resolve_subnets(load_scan_subnets())
|
||||
return _scan_networks_cache
|
||||
|
||||
|
||||
def ip_allowed(ip):
|
||||
networks = get_scan_networks()
|
||||
if networks is None:
|
||||
return True
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False
|
||||
return any(addr in net for net in networks)
|
||||
|
||||
|
||||
def build_scan_targets(networks):
|
||||
targets = []
|
||||
if networks is None:
|
||||
subnets = set()
|
||||
for ip in get_local_ips():
|
||||
subnets.add(".".join(ip.split(".")[:3]))
|
||||
for subnet in sorted(subnets):
|
||||
for i in range(1, 255):
|
||||
targets.append(f"{subnet}.{i}")
|
||||
else:
|
||||
for net in networks:
|
||||
for ip in net.hosts():
|
||||
targets.append(str(ip))
|
||||
return targets
|
||||
|
||||
|
||||
def check_port(ip, port, timeout=SCAN_TIMEOUT):
|
||||
@ -52,6 +155,114 @@ def check_port(ip, port, timeout=SCAN_TIMEOUT):
|
||||
return None
|
||||
|
||||
|
||||
def fetch_status(ip, port):
|
||||
try:
|
||||
r = requests.get(f"http://{ip}:{port}/api/status", timeout=3)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def board_identity(status):
|
||||
"""Static identity of a board so multi-homed IPs can be told apart
|
||||
from distinct physical boards that share a display_id."""
|
||||
if not status:
|
||||
return None
|
||||
cfg = status.get("config") or {}
|
||||
return (
|
||||
status.get("display_id"),
|
||||
status.get("display_name"),
|
||||
status.get("header"),
|
||||
json.dumps(cfg.get("left"), sort_keys=True, default=str),
|
||||
json.dumps(cfg.get("right"), sort_keys=True, default=str),
|
||||
)
|
||||
|
||||
|
||||
def record_conflict(did, ip, status, other_ips):
|
||||
"""Record that multiple physical boards are using the same display_id."""
|
||||
now = time.time()
|
||||
incoming = board_identity(status)
|
||||
with conflicts_lock:
|
||||
groups = {}
|
||||
for other in set(list(other_ips) + [ip]):
|
||||
info = ip_identity.get(other)
|
||||
ident = info["id"] if info else None
|
||||
name = info["name"] if info else ""
|
||||
if other == ip and incoming is not None:
|
||||
ident = incoming
|
||||
name = (status or {}).get("display_name", "")
|
||||
if ident is None:
|
||||
continue
|
||||
groups.setdefault(ident, []).append({"ip": other, "display_name": name})
|
||||
if len(groups) < 2:
|
||||
conflicts.pop(did, None)
|
||||
return
|
||||
rec = {"display_id": did, "boards": [], "last_seen": now}
|
||||
for entries in groups.values():
|
||||
for e in entries:
|
||||
rec["boards"].append({
|
||||
"ip": e["ip"],
|
||||
"display_name": e["display_name"],
|
||||
"last_seen": now,
|
||||
})
|
||||
conflicts[did] = rec
|
||||
|
||||
|
||||
def register_board(did, ip, port, display_name="", status=None):
|
||||
identity = board_identity(status)
|
||||
conflict_ips = None
|
||||
with boards_lock:
|
||||
now = time.time()
|
||||
prev_did = ip_index.get(ip)
|
||||
if prev_did is not None and prev_did != did and prev_did in boards:
|
||||
old = boards[prev_did]
|
||||
if ip in old["ips"]:
|
||||
old["ips"].remove(ip)
|
||||
if not old["ips"]:
|
||||
boards.pop(prev_did, None)
|
||||
if did in boards:
|
||||
board = boards[did]
|
||||
if identity is not None:
|
||||
for other in list(board["ips"]):
|
||||
other_info = ip_identity.get(other)
|
||||
if other_info and other_info["id"] != identity:
|
||||
conflict_ips = list(board["ips"])
|
||||
break
|
||||
if ip not in board["ips"]:
|
||||
board["ips"].append(ip)
|
||||
print(f" Board {did} also reachable at {ip}:{port}")
|
||||
if display_name and not board["display_name"]:
|
||||
board["display_name"] = display_name
|
||||
board["port"] = port
|
||||
board["last_seen"] = now
|
||||
board["online"] = True
|
||||
board["active_ip"] = ip
|
||||
if status is not None:
|
||||
board["status"] = status
|
||||
if status.get("display_name"):
|
||||
board["display_name"] = status["display_name"]
|
||||
boards.move_to_end(did)
|
||||
else:
|
||||
boards[did] = {
|
||||
"display_id": did,
|
||||
"display_name": display_name or f"Board ({ip})",
|
||||
"ips": [ip],
|
||||
"port": port,
|
||||
"last_seen": now,
|
||||
"online": True,
|
||||
"status": status,
|
||||
"active_ip": ip,
|
||||
}
|
||||
print(f" Discovered board: {boards[did]['display_name']} at {ip}:{port}")
|
||||
if identity is not None:
|
||||
ip_identity[ip] = {"id": identity, "name": display_name or (status or {}).get("display_name", "")}
|
||||
ip_index[ip] = did
|
||||
if conflict_ips is not None:
|
||||
record_conflict(did, ip, status, conflict_ips)
|
||||
|
||||
|
||||
def udp_listener():
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
@ -61,89 +272,145 @@ def udp_listener():
|
||||
try:
|
||||
data, addr = sock.recvfrom(2048)
|
||||
msg = json.loads(data)
|
||||
ip = msg.get("ip", addr[0])
|
||||
port = msg.get("port", BOARD_PORT)
|
||||
with boards_lock:
|
||||
if ip not in boards:
|
||||
print(f" Discovered board: {msg.get('display_name', ip)} at {ip}:{port}")
|
||||
boards[ip] = {
|
||||
"display_name": msg.get("display_name", ""),
|
||||
"ip": ip,
|
||||
"port": port,
|
||||
"last_seen": time.time(),
|
||||
"online": True,
|
||||
"status": boards.get(ip, {}).get("status"),
|
||||
}
|
||||
did = msg.get("display_id")
|
||||
ips = msg.get("ips") or []
|
||||
if not ips:
|
||||
ips = [msg.get("ip")] if msg.get("ip") else [addr[0]]
|
||||
for ip in ips:
|
||||
if not ip_allowed(ip):
|
||||
print(f" Ignoring {ip}: outside scan subnets")
|
||||
continue
|
||||
register_board(did or ip, ip, port, msg.get("display_name", ""))
|
||||
except socket.timeout:
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def scan_subnet():
|
||||
subnet = get_subnet()
|
||||
def scan_subnets():
|
||||
patterns = load_scan_subnets()
|
||||
networks = resolve_subnets(patterns)
|
||||
targets = build_scan_targets(networks)
|
||||
if networks is not None:
|
||||
print(f" Scanning subnets: {', '.join(str(n) for n in networks)}")
|
||||
else:
|
||||
print(" Scanning local subnets (all)")
|
||||
print(f" Scanning {len(targets)} hosts")
|
||||
found = []
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as pool:
|
||||
futures = {pool.submit(check_port, f"{subnet}.{i}", BOARD_PORT): i for i in range(1, 255)}
|
||||
futures = {}
|
||||
for target in targets:
|
||||
futures[pool.submit(check_port, target, BOARD_PORT)] = target
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
ip = future.result()
|
||||
if ip:
|
||||
found.append(ip)
|
||||
with boards_lock:
|
||||
print(f" Scan found IPs: {', '.join(sorted(found))}")
|
||||
for ip in found:
|
||||
if ip not in boards:
|
||||
boards[ip] = {
|
||||
"display_name": f"Board ({ip})",
|
||||
"ip": ip,
|
||||
"port": BOARD_PORT,
|
||||
"last_seen": time.time(),
|
||||
"online": True,
|
||||
"status": None,
|
||||
}
|
||||
print(f" Scanned board: {ip}")
|
||||
status = fetch_status(ip, BOARD_PORT)
|
||||
if status and status.get("display_id"):
|
||||
did = status["display_id"]
|
||||
with boards_lock:
|
||||
known = did in boards
|
||||
if known:
|
||||
print(f" Deduplicated: {ip} -> display_id {did} (already registered)")
|
||||
register_board(did, ip, BOARD_PORT,
|
||||
status.get("display_name", ""), status)
|
||||
else:
|
||||
with boards_lock:
|
||||
known = ip in ip_index
|
||||
if known:
|
||||
print(f" Deduplicated: {ip} (already registered)")
|
||||
register_board(ip, ip, BOARD_PORT, f"Board ({ip})", status)
|
||||
return found
|
||||
|
||||
|
||||
def refresh_board_cache(ip, port):
|
||||
try:
|
||||
r = requests.get(f"http://{ip}:{port}/api/status", timeout=3)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
status = fetch_status(ip, port)
|
||||
if status is None:
|
||||
return
|
||||
did = status.get("display_id") or ip
|
||||
register_board(did, ip, port, status.get("display_name", ""), status)
|
||||
|
||||
|
||||
def refresh_conflicts():
|
||||
"""Rebuild conflict records from current ip -> identity data so
|
||||
conflicts disappear once display_ids are made unique."""
|
||||
now = time.time()
|
||||
with boards_lock:
|
||||
if ip in boards:
|
||||
boards[ip]["status"] = data
|
||||
boards[ip]["online"] = True
|
||||
boards[ip]["last_seen"] = time.time()
|
||||
if data.get("display_name"):
|
||||
boards[ip]["display_name"] = data["display_name"]
|
||||
except:
|
||||
pass
|
||||
by_did = {}
|
||||
for ip, did in list(ip_index.items()):
|
||||
info = ip_identity.get(ip)
|
||||
if info:
|
||||
by_did.setdefault(did, []).append((ip, info["id"], info["name"]))
|
||||
with conflicts_lock:
|
||||
for did in list(conflicts.keys()):
|
||||
if did not in by_did:
|
||||
conflicts.pop(did, None)
|
||||
for did, entries in by_did.items():
|
||||
idents = {ident for _, ident, _ in entries}
|
||||
if len(idents) >= 2:
|
||||
conflicts[did] = {
|
||||
"display_id": did,
|
||||
"boards": [{"ip": ip, "display_name": name, "last_seen": now}
|
||||
for ip, _ident, name in entries],
|
||||
"last_seen": now,
|
||||
}
|
||||
else:
|
||||
conflicts.pop(did, None)
|
||||
|
||||
|
||||
def poll_boards():
|
||||
while True:
|
||||
with boards_lock:
|
||||
snapshot = list(boards.items())
|
||||
for ip, board in snapshot:
|
||||
try:
|
||||
r = requests.get(f"http://{ip}:{board['port']}/api/status", timeout=3)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
snapshot = list(boards.values())
|
||||
for board in snapshot:
|
||||
status = None
|
||||
active_ip = None
|
||||
for ip in list(board["ips"]):
|
||||
status = fetch_status(ip, board["port"])
|
||||
if status is None:
|
||||
continue
|
||||
reported_did = status.get("display_id")
|
||||
if reported_did is not None and reported_did != board["display_id"]:
|
||||
with boards_lock:
|
||||
if ip in boards:
|
||||
boards[ip]["status"] = data
|
||||
boards[ip]["online"] = True
|
||||
boards[ip]["last_seen"] = time.time()
|
||||
if data.get("display_name"):
|
||||
boards[ip]["display_name"] = data["display_name"]
|
||||
rec = boards.get(board["display_id"])
|
||||
if rec and ip in rec["ips"]:
|
||||
rec["ips"].remove(ip)
|
||||
if rec and not rec["ips"]:
|
||||
boards.pop(board["display_id"], None)
|
||||
status = None
|
||||
continue
|
||||
active_ip = ip
|
||||
break
|
||||
with boards_lock:
|
||||
if board["display_id"] not in boards:
|
||||
continue
|
||||
rec = boards[board["display_id"]]
|
||||
if status is not None:
|
||||
rec["status"] = status
|
||||
rec["online"] = True
|
||||
rec["last_seen"] = time.time()
|
||||
rec["active_ip"] = active_ip
|
||||
if status.get("display_name"):
|
||||
rec["display_name"] = status["display_name"]
|
||||
if active_ip in rec["ips"]:
|
||||
rec["ips"].remove(active_ip)
|
||||
rec["ips"].insert(0, active_ip)
|
||||
else:
|
||||
rec["online"] = False
|
||||
with boards_lock:
|
||||
if ip in boards:
|
||||
boards[ip]["online"] = False
|
||||
except:
|
||||
with boards_lock:
|
||||
if ip in boards:
|
||||
boards[ip]["online"] = False
|
||||
now = time.time()
|
||||
dead = [did for did, b in list(boards.items())
|
||||
if not b.get("online") and (now - b["last_seen"]) > 600]
|
||||
for did in dead:
|
||||
boards.pop(did, None)
|
||||
for ip in [ip for ip, d in list(ip_index.items()) if d in dead]:
|
||||
ip_index.pop(ip, None)
|
||||
for ip in [ip for ip in list(ip_identity.keys()) if ip not in ip_index]:
|
||||
ip_identity.pop(ip, None)
|
||||
refresh_conflicts()
|
||||
time.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
@ -161,14 +428,16 @@ def api_boards():
|
||||
now = time.time()
|
||||
with boards_lock:
|
||||
result = []
|
||||
for ip, board in list(boards.items()):
|
||||
for did, board in list(boards.items()):
|
||||
stale = (now - board["last_seen"]) > offline_threshold
|
||||
status = board.get("status") or {}
|
||||
scores = status.get("scores") or {}
|
||||
config_data = status.get("config") or {}
|
||||
result.append({
|
||||
"display_id": did,
|
||||
"display_name": board["display_name"],
|
||||
"ip": ip,
|
||||
"ip": board.get("active_ip") or (board["ips"][0] if board["ips"] else None),
|
||||
"ips": list(board["ips"]),
|
||||
"port": board["port"],
|
||||
"online": board["online"] and not stale,
|
||||
"scores": scores,
|
||||
@ -177,11 +446,17 @@ def api_boards():
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@app.route("/api/conflicts")
|
||||
def api_conflicts():
|
||||
with conflicts_lock:
|
||||
return jsonify(list(conflicts.values()))
|
||||
|
||||
|
||||
@app.route("/api/scan", methods=["POST"])
|
||||
def api_scan():
|
||||
def do_scan():
|
||||
found = scan_subnet()
|
||||
print(f" Scan complete: {len(found)} boards found")
|
||||
found = scan_subnets()
|
||||
print(f" Scan complete: {len(found)} board IPs found")
|
||||
threading.Thread(target=do_scan, daemon=True).start()
|
||||
return jsonify({"status": "scanning"})
|
||||
|
||||
@ -190,8 +465,12 @@ def api_scan():
|
||||
def api_proxy(ip, endpoint):
|
||||
port = BOARD_PORT
|
||||
with boards_lock:
|
||||
if ip in boards:
|
||||
port = boards[ip]["port"]
|
||||
did = ip_index.get(ip)
|
||||
if did is not None and did in boards:
|
||||
board = boards[did]
|
||||
port = board["port"]
|
||||
if board.get("active_ip"):
|
||||
ip = board["active_ip"]
|
||||
url = f"http://{ip}:{port}/{endpoint}"
|
||||
method = request.method
|
||||
headers = {"Content-Type": "application/json"} if request.is_json else {}
|
||||
|
||||
14
controller/config.toml
Normal file
14
controller/config.toml
Normal file
@ -0,0 +1,14 @@
|
||||
# CuteBoard Controller scan configuration.
|
||||
#
|
||||
# subnets controls which subnets the "Scan Network" button searches.
|
||||
#
|
||||
# ["all"] scan every subnet this controller is attached to (default)
|
||||
# ["192.168.*"] scan every 192.168.x.x subnet
|
||||
# ["192.168.1.*"] scan a single 192.168.1.x subnet
|
||||
# ["10.0.1.*"] scan a single 10.0.1.x subnet
|
||||
# ["10.0.0.0/24"] scan a CIDR range
|
||||
# ["192.168.*", "10.0.1.*"] scan multiple ranges
|
||||
#
|
||||
# NOTE: broad patterns like 192.168.* scan 65k+ hosts and take a while.
|
||||
[scan]
|
||||
subnets = ["10.0.1.*"]
|
||||
@ -32,8 +32,14 @@
|
||||
}
|
||||
emptyMsg.style.display = 'none';
|
||||
|
||||
var sorted = boards.slice().sort(function (a, b) {
|
||||
var na = (a.display_name || '').toLowerCase();
|
||||
var nb = (b.display_name || '').toLowerCase();
|
||||
return na.localeCompare(nb);
|
||||
});
|
||||
|
||||
var html = '';
|
||||
boards.forEach(function (board) {
|
||||
sorted.forEach(function (board) {
|
||||
var statusClass = board.online ? 'online' : 'offline';
|
||||
var badgeClass = board.online ? 'badge-online' : 'badge-offline';
|
||||
var left = board.config && board.config.left || {};
|
||||
@ -183,6 +189,7 @@
|
||||
.then(function (data) {
|
||||
var cfg = data.config || {};
|
||||
document.getElementById('edit-display-name').value = data.display_name || '';
|
||||
document.getElementById('edit-display-id').value = data.display_id != null ? data.display_id : '';
|
||||
document.getElementById('edit-header').value = data.header || '';
|
||||
document.getElementById('edit-header-font-size').value = data.header_font_size || '';
|
||||
document.getElementById('edit-content-padding-top').value = data.content_padding_top || '';
|
||||
@ -238,6 +245,9 @@
|
||||
},
|
||||
};
|
||||
|
||||
var did = parseInt(document.getElementById('edit-display-id').value, 10);
|
||||
if (!isNaN(did) && did > 0) payload.display_id = did;
|
||||
|
||||
var saveBtn = configForm.querySelector('.btn-save');
|
||||
saveBtn.textContent = 'Saving...';
|
||||
saveBtn.disabled = true;
|
||||
@ -262,6 +272,55 @@
|
||||
if (e.target === modalOverlay) closeModal();
|
||||
});
|
||||
|
||||
// ---- Conflicts (F9) ----
|
||||
function showConflicts() {
|
||||
fetch('/api/conflicts')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (list) {
|
||||
var summary = document.getElementById('conflicts-summary');
|
||||
var listEl = document.getElementById('conflicts-list');
|
||||
listEl.innerHTML = '';
|
||||
if (!list || list.length === 0) {
|
||||
summary.textContent = 'No display ID conflicts detected.';
|
||||
listEl.innerHTML = '<p class="text-sm text-slate-500">Every discovered board has a unique display_id.</p>';
|
||||
} else {
|
||||
summary.textContent = list.length + ' conflicted display ID' + (list.length > 1 ? 's' : '') +
|
||||
'. Give each board a unique display_id to fix (use Edit).';
|
||||
list.forEach(function (conflict) {
|
||||
var box = document.createElement('div');
|
||||
box.className = 'card p-4 mb-3';
|
||||
var html = '<div class="font-semibold mb-2">display_id ' + esc(String(conflict.display_id)) + '</div>';
|
||||
(conflict.boards || []).forEach(function (b) {
|
||||
html += '<div class="flex items-center justify-between py-1" style="border-top:1px solid #1e1e30;">' +
|
||||
'<div><span class="text-xs text-slate-600">' + esc(b.ip) + '</span>' +
|
||||
'<span class="ml-2">' + esc(b.display_name || '?') + '</span></div>' +
|
||||
'<button class="btn btn-edit" onclick="window.openConfig(\'' + esc(b.ip) + '\')">⚙ Edit</button>' +
|
||||
'</div>';
|
||||
});
|
||||
box.innerHTML = html;
|
||||
listEl.appendChild(box);
|
||||
});
|
||||
}
|
||||
document.getElementById('conflicts-modal').classList.add('open');
|
||||
})
|
||||
.catch(function () { alert('Could not fetch conflicts'); });
|
||||
}
|
||||
|
||||
window.closeConflicts = function () {
|
||||
document.getElementById('conflicts-modal').classList.remove('open');
|
||||
};
|
||||
|
||||
document.getElementById('conflicts-modal').addEventListener('click', function (e) {
|
||||
if (e.target === document.getElementById('conflicts-modal')) window.closeConflicts();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'F9') {
|
||||
e.preventDefault();
|
||||
showConflicts();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Polling ----
|
||||
fetchBoards();
|
||||
setInterval(fetchBoards, 3000);
|
||||
|
||||
@ -90,6 +90,8 @@
|
||||
<summary style="cursor:pointer;font-size:0.8rem;color:#94a3b8;text-transform:uppercase;letter-spacing:0.1em;padding:0.5rem 0;user-select:none;">Technical Settings</summary>
|
||||
<label>Display Name</label>
|
||||
<input type="text" id="edit-display-name" placeholder="e.g. Main Scoreboard">
|
||||
<label>Display ID</label>
|
||||
<input type="number" id="edit-display-id" min="1" step="1" placeholder="Unique integer per board">
|
||||
<label>Header Font Size (rem)</label>
|
||||
<input type="number" id="edit-header-font-size" min="0" step="0.25" placeholder="0 = auto-fit">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
@ -124,6 +126,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conflicts Modal (F9) -->
|
||||
<div class="modal-overlay" id="conflicts-modal">
|
||||
<div class="modal">
|
||||
<h2>Display ID Conflicts</h2>
|
||||
<p class="text-sm text-slate-500 mb-4" id="conflicts-summary"></p>
|
||||
<div id="conflicts-list"></div>
|
||||
<div class="flex gap-3 justify-end mt-6">
|
||||
<button type="button" class="btn" style="background:#374151;color:white;padding:0.6rem 1.5rem;" onclick="window.closeConflicts()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user