Add display_id conflict detection, editable board display_id, and multi-IP beaconing

This commit is contained in:
Nick 2026-08-11 18:28:51 -05:00
parent 3dc3bc75ca
commit a70f739ef7
5 changed files with 469 additions and 75 deletions

28
app.py
View File

@ -50,14 +50,37 @@ def get_local_ip():
return 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(): def udp_beacon():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
while True: while True:
try: try:
payload = json.dumps({ payload = json.dumps({
"display_id": config["display_id"],
"display_name": config.get("display_name", ""), "display_name": config.get("display_name", ""),
"ip": get_local_ip(), "ip": get_local_ip(),
"ips": get_local_ips(),
"port": PORT, "port": PORT,
}) })
sock.sendto(payload.encode(), ("255.255.255.255", 50744)) sock.sendto(payload.encode(), ("255.255.255.255", 50744))
@ -232,6 +255,11 @@ def api_update_config():
if request.method == "OPTIONS": if request.method == "OPTIONS":
return cors(jsonify({})) return cors(jsonify({}))
data = request.get_json(silent=True) or {} 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: if "display_name" in data:
config["display_name"] = data["display_name"] config["display_name"] = data["display_name"]
if "header" in data: if "header" in data:

View File

@ -1,5 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import ipaddress
import json import json
import os
import socket import socket
import struct import struct
import threading import threading
@ -7,6 +9,11 @@ import time
import concurrent.futures import concurrent.futures
from collections import OrderedDict from collections import OrderedDict
try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib
import requests import requests
from flask import Flask, jsonify, render_template, request from flask import Flask, jsonify, render_template, request
@ -18,27 +25,123 @@ POLL_INTERVAL = 3
app = Flask(__name__) app = Flask(__name__)
boards = OrderedDict() boards = OrderedDict() # display_id -> board record
boards_lock = threading.Lock() boards_lock = threading.Lock()
ip_index = {} # ip -> display_id
ip_identity = {} # ip -> {"id": identity, "name": display_name}
offline_threshold = POLL_INTERVAL * 3 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) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try: try:
s.connect(("10.255.255.255", 1)) s.connect(("10.255.255.255", 1))
ip = s.getsockname()[0] ips.add(s.getsockname()[0])
except: except:
ip = "127.0.0.1" pass
finally: finally:
s.close() s.close()
return ip return sorted(ips)
def get_subnet(): BASE_DIR = os.path.dirname(os.path.abspath(__file__))
ip = get_local_ip() CONFIG_PATH = os.path.join(BASE_DIR, "config.toml")
parts = ip.split(".")
return ".".join(parts[:3])
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): def check_port(ip, port, timeout=SCAN_TIMEOUT):
@ -52,6 +155,114 @@ def check_port(ip, port, timeout=SCAN_TIMEOUT):
return None 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(): def udp_listener():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@ -61,89 +272,145 @@ def udp_listener():
try: try:
data, addr = sock.recvfrom(2048) data, addr = sock.recvfrom(2048)
msg = json.loads(data) msg = json.loads(data)
ip = msg.get("ip", addr[0])
port = msg.get("port", BOARD_PORT) port = msg.get("port", BOARD_PORT)
with boards_lock: did = msg.get("display_id")
if ip not in boards: ips = msg.get("ips") or []
print(f" Discovered board: {msg.get('display_name', ip)} at {ip}:{port}") if not ips:
boards[ip] = { ips = [msg.get("ip")] if msg.get("ip") else [addr[0]]
"display_name": msg.get("display_name", ""), for ip in ips:
"ip": ip, if not ip_allowed(ip):
"port": port, print(f" Ignoring {ip}: outside scan subnets")
"last_seen": time.time(), continue
"online": True, register_board(did or ip, ip, port, msg.get("display_name", ""))
"status": boards.get(ip, {}).get("status"),
}
except socket.timeout: except socket.timeout:
pass pass
except: except:
pass pass
def scan_subnet(): def scan_subnets():
subnet = get_subnet() 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 = [] found = []
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as pool: 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): for future in concurrent.futures.as_completed(futures):
ip = future.result() ip = future.result()
if ip: if ip:
found.append(ip) found.append(ip)
with boards_lock: print(f" Scan found IPs: {', '.join(sorted(found))}")
for ip in found: for ip in found:
if ip not in boards: status = fetch_status(ip, BOARD_PORT)
boards[ip] = { if status and status.get("display_id"):
"display_name": f"Board ({ip})", did = status["display_id"]
"ip": ip, with boards_lock:
"port": BOARD_PORT, known = did in boards
"last_seen": time.time(), if known:
"online": True, print(f" Deduplicated: {ip} -> display_id {did} (already registered)")
"status": None, register_board(did, ip, BOARD_PORT,
} status.get("display_name", ""), status)
print(f" Scanned board: {ip}") 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 return found
def refresh_board_cache(ip, port): def refresh_board_cache(ip, port):
try: status = fetch_status(ip, port)
r = requests.get(f"http://{ip}:{port}/api/status", timeout=3) if status is None:
if r.status_code == 200: return
data = r.json() 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: with boards_lock:
if ip in boards: by_did = {}
boards[ip]["status"] = data for ip, did in list(ip_index.items()):
boards[ip]["online"] = True info = ip_identity.get(ip)
boards[ip]["last_seen"] = time.time() if info:
if data.get("display_name"): by_did.setdefault(did, []).append((ip, info["id"], info["name"]))
boards[ip]["display_name"] = data["display_name"] with conflicts_lock:
except: for did in list(conflicts.keys()):
pass 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(): def poll_boards():
while True: while True:
with boards_lock: with boards_lock:
snapshot = list(boards.items()) snapshot = list(boards.values())
for ip, board in snapshot: for board in snapshot:
try: status = None
r = requests.get(f"http://{ip}:{board['port']}/api/status", timeout=3) active_ip = None
if r.status_code == 200: for ip in list(board["ips"]):
data = r.json() 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: with boards_lock:
if ip in boards: rec = boards.get(board["display_id"])
boards[ip]["status"] = data if rec and ip in rec["ips"]:
boards[ip]["online"] = True rec["ips"].remove(ip)
boards[ip]["last_seen"] = time.time() if rec and not rec["ips"]:
if data.get("display_name"): boards.pop(board["display_id"], None)
boards[ip]["display_name"] = data["display_name"] 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: else:
rec["online"] = False
with boards_lock: with boards_lock:
if ip in boards: now = time.time()
boards[ip]["online"] = False dead = [did for did, b in list(boards.items())
except: if not b.get("online") and (now - b["last_seen"]) > 600]
with boards_lock: for did in dead:
if ip in boards: boards.pop(did, None)
boards[ip]["online"] = False 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) time.sleep(POLL_INTERVAL)
@ -161,14 +428,16 @@ def api_boards():
now = time.time() now = time.time()
with boards_lock: with boards_lock:
result = [] result = []
for ip, board in list(boards.items()): for did, board in list(boards.items()):
stale = (now - board["last_seen"]) > offline_threshold stale = (now - board["last_seen"]) > offline_threshold
status = board.get("status") or {} status = board.get("status") or {}
scores = status.get("scores") or {} scores = status.get("scores") or {}
config_data = status.get("config") or {} config_data = status.get("config") or {}
result.append({ result.append({
"display_id": did,
"display_name": board["display_name"], "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"], "port": board["port"],
"online": board["online"] and not stale, "online": board["online"] and not stale,
"scores": scores, "scores": scores,
@ -177,11 +446,17 @@ def api_boards():
return jsonify(result) return jsonify(result)
@app.route("/api/conflicts")
def api_conflicts():
with conflicts_lock:
return jsonify(list(conflicts.values()))
@app.route("/api/scan", methods=["POST"]) @app.route("/api/scan", methods=["POST"])
def api_scan(): def api_scan():
def do_scan(): def do_scan():
found = scan_subnet() found = scan_subnets()
print(f" Scan complete: {len(found)} boards found") print(f" Scan complete: {len(found)} board IPs found")
threading.Thread(target=do_scan, daemon=True).start() threading.Thread(target=do_scan, daemon=True).start()
return jsonify({"status": "scanning"}) return jsonify({"status": "scanning"})
@ -190,8 +465,12 @@ def api_scan():
def api_proxy(ip, endpoint): def api_proxy(ip, endpoint):
port = BOARD_PORT port = BOARD_PORT
with boards_lock: with boards_lock:
if ip in boards: did = ip_index.get(ip)
port = boards[ip]["port"] 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}" url = f"http://{ip}:{port}/{endpoint}"
method = request.method method = request.method
headers = {"Content-Type": "application/json"} if request.is_json else {} headers = {"Content-Type": "application/json"} if request.is_json else {}

14
controller/config.toml Normal file
View 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.*"]

View File

@ -32,8 +32,14 @@
} }
emptyMsg.style.display = 'none'; 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 = ''; var html = '';
boards.forEach(function (board) { sorted.forEach(function (board) {
var statusClass = board.online ? 'online' : 'offline'; var statusClass = board.online ? 'online' : 'offline';
var badgeClass = board.online ? 'badge-online' : 'badge-offline'; var badgeClass = board.online ? 'badge-online' : 'badge-offline';
var left = board.config && board.config.left || {}; var left = board.config && board.config.left || {};
@ -183,6 +189,7 @@
.then(function (data) { .then(function (data) {
var cfg = data.config || {}; var cfg = data.config || {};
document.getElementById('edit-display-name').value = data.display_name || ''; 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').value = data.header || '';
document.getElementById('edit-header-font-size').value = data.header_font_size || ''; document.getElementById('edit-header-font-size').value = data.header_font_size || '';
document.getElementById('edit-content-padding-top').value = data.content_padding_top || ''; 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'); var saveBtn = configForm.querySelector('.btn-save');
saveBtn.textContent = 'Saving...'; saveBtn.textContent = 'Saving...';
saveBtn.disabled = true; saveBtn.disabled = true;
@ -262,6 +272,55 @@
if (e.target === modalOverlay) closeModal(); 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 ---- // ---- Polling ----
fetchBoards(); fetchBoards();
setInterval(fetchBoards, 3000); setInterval(fetchBoards, 3000);

View File

@ -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> <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> <label>Display Name</label>
<input type="text" id="edit-display-name" placeholder="e.g. Main Scoreboard"> <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> <label>Header Font Size (rem)</label>
<input type="number" id="edit-header-font-size" min="0" step="0.25" placeholder="0 = auto-fit"> <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"> <div class="grid grid-cols-2 gap-4">
@ -124,6 +126,18 @@
</div> </div>
</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> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body> </body>
</html> </html>