Add global notes/links overview modals and fix SSE drain race condition
This commit is contained in:
Binary file not shown.
73
app/app.py
73
app/app.py
@@ -240,12 +240,6 @@ def collect_all():
|
|||||||
|
|
||||||
# Update database (all in main collector thread)
|
# Update database (all in main collector thread)
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
# Remove servers no longer in config
|
|
||||||
config_keys = {(e['username'], e['hostname']) for e in entries}
|
|
||||||
for server in Server.query.all():
|
|
||||||
if (server.username, server.hostname) not in config_keys:
|
|
||||||
db.session.delete(server)
|
|
||||||
|
|
||||||
for key, (entry, result) in results.items():
|
for key, (entry, result) in results.items():
|
||||||
server = Server.query.filter_by(
|
server = Server.query.filter_by(
|
||||||
username=entry['username'],
|
username=entry['username'],
|
||||||
@@ -289,12 +283,23 @@ def collector_loop():
|
|||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
|
# Get group order from config file and filter to configured servers only
|
||||||
|
config_entries = parse_infrastructure_conf()
|
||||||
|
config_keys = {(e['username'], e['hostname']) for e in config_entries}
|
||||||
|
group_order = []
|
||||||
|
for e in config_entries:
|
||||||
|
g = e['group']
|
||||||
|
if g not in group_order:
|
||||||
|
group_order.append(g)
|
||||||
|
|
||||||
all_servers = Server.query.order_by(Server.group_name, Server.primary_ip).all()
|
all_servers = Server.query.order_by(Server.group_name, Server.primary_ip).all()
|
||||||
|
|
||||||
# Separate parents and children
|
# Separate parents and children (only servers in current config)
|
||||||
children_map = {} # parent_hostname -> [child_servers]
|
children_map = {} # parent_hostname -> [child_servers]
|
||||||
parents = []
|
parents = []
|
||||||
for s in all_servers:
|
for s in all_servers:
|
||||||
|
if (s.username, s.hostname) not in config_keys:
|
||||||
|
continue
|
||||||
if s.parent_hostname:
|
if s.parent_hostname:
|
||||||
children_map.setdefault(s.parent_hostname, []).append(s)
|
children_map.setdefault(s.parent_hostname, []).append(s)
|
||||||
else:
|
else:
|
||||||
@@ -304,14 +309,6 @@ def index():
|
|||||||
for hostname in children_map:
|
for hostname in children_map:
|
||||||
children_map[hostname].sort(key=lambda s: _ip_sort_key(s.primary_ip))
|
children_map[hostname].sort(key=lambda s: _ip_sort_key(s.primary_ip))
|
||||||
|
|
||||||
# Get group order from config file
|
|
||||||
config_entries = parse_infrastructure_conf()
|
|
||||||
group_order = []
|
|
||||||
for e in config_entries:
|
|
||||||
g = e['group']
|
|
||||||
if g not in group_order:
|
|
||||||
group_order.append(g)
|
|
||||||
|
|
||||||
# Group parents, preserving config order
|
# Group parents, preserving config order
|
||||||
groups = {}
|
groups = {}
|
||||||
for s in parents:
|
for s in parents:
|
||||||
@@ -430,11 +427,6 @@ def api_refresh_stream():
|
|||||||
|
|
||||||
# Update database
|
# Update database
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
config_keys = {(e['username'], e['hostname']) for e in entries}
|
|
||||||
for server in Server.query.all():
|
|
||||||
if (server.username, server.hostname) not in config_keys:
|
|
||||||
db.session.delete(server)
|
|
||||||
|
|
||||||
for key, (entry, result) in results.items():
|
for key, (entry, result) in results.items():
|
||||||
server = Server.query.filter_by(
|
server = Server.query.filter_by(
|
||||||
username=entry['username'], hostname=entry['hostname'],
|
username=entry['username'], hostname=entry['hostname'],
|
||||||
@@ -530,19 +522,19 @@ def api_refresh_one_stream(server_id):
|
|||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
# Stream progress while collecting
|
# Stream progress while collecting
|
||||||
while not host_done.is_set():
|
result = None
|
||||||
|
while not host_done.is_set() or not progress_q.empty():
|
||||||
try:
|
try:
|
||||||
kind, val = progress_q.get(timeout=0.3)
|
kind, val = progress_q.get(timeout=0.3)
|
||||||
if kind == 'progress':
|
if kind == 'progress':
|
||||||
yield f"data: {val}\n\n"
|
yield f"data: {val}\n\n"
|
||||||
elif kind == 'result':
|
elif kind == 'result':
|
||||||
break
|
result = val
|
||||||
except _queue.Empty:
|
except _queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
t.join()
|
t.join()
|
||||||
# Drain remaining messages
|
# Drain anything remaining
|
||||||
result = None
|
|
||||||
while not progress_q.empty():
|
while not progress_q.empty():
|
||||||
kind, val = progress_q.get_nowait()
|
kind, val = progress_q.get_nowait()
|
||||||
if kind == 'progress':
|
if kind == 'progress':
|
||||||
@@ -592,18 +584,18 @@ def api_refresh_one_stream(server_id):
|
|||||||
ct = threading.Thread(target=_collect_child)
|
ct = threading.Thread(target=_collect_child)
|
||||||
ct.start()
|
ct.start()
|
||||||
|
|
||||||
while not child_done.is_set():
|
child_result = None
|
||||||
|
while not child_done.is_set() or not child_q.empty():
|
||||||
try:
|
try:
|
||||||
kind, val = child_q.get(timeout=0.3)
|
kind, val = child_q.get(timeout=0.3)
|
||||||
if kind == 'progress':
|
if kind == 'progress':
|
||||||
yield f"data: {val}\n\n"
|
yield f"data: {val}\n\n"
|
||||||
elif kind == 'result':
|
elif kind == 'result':
|
||||||
break
|
child_result = val
|
||||||
except _queue.Empty:
|
except _queue.Empty:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ct.join()
|
ct.join()
|
||||||
child_result = None
|
|
||||||
while not child_q.empty():
|
while not child_q.empty():
|
||||||
kind, val = child_q.get_nowait()
|
kind, val = child_q.get_nowait()
|
||||||
if kind == 'progress':
|
if kind == 'progress':
|
||||||
@@ -655,6 +647,33 @@ def api_update_links(server_id):
|
|||||||
return jsonify({'ok': True})
|
return jsonify({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/all-notes')
|
||||||
|
def api_all_notes():
|
||||||
|
servers = Server.query.filter(Server.notes != '', Server.notes != None).all()
|
||||||
|
return jsonify([{
|
||||||
|
'id': s.id,
|
||||||
|
'hostname': s.hostname,
|
||||||
|
'group_name': s.group_name,
|
||||||
|
'notes': s.notes,
|
||||||
|
} for s in servers])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/all-links')
|
||||||
|
def api_all_links():
|
||||||
|
servers = Server.query.filter(Server.links != None, Server.links != '[]').all()
|
||||||
|
result = []
|
||||||
|
for s in servers:
|
||||||
|
links = s.links or []
|
||||||
|
if links:
|
||||||
|
result.append({
|
||||||
|
'id': s.id,
|
||||||
|
'hostname': s.hostname,
|
||||||
|
'group_name': s.group_name,
|
||||||
|
'links': links,
|
||||||
|
})
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
def _ip_sort_key(ip_str):
|
def _ip_sort_key(ip_str):
|
||||||
if not ip_str:
|
if not ip_str:
|
||||||
return [999, 999, 999, 999]
|
return [999, 999, 999, 999]
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ header h1 {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
background: #334155;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover {
|
||||||
|
background: #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
background: #334155;
|
background: #334155;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1>Infrastructure Map</h1>
|
<h1>Infrastructure Map</h1>
|
||||||
<span class="subtitle">Auto-refreshes every 60s | Built: {{ build_date }}</span>
|
<span class="subtitle">Auto-refreshes every 60s | Built: {{ build_date }}</span>
|
||||||
|
<button class="header-btn" onclick="showAllNotes()" title="View all notes">Notes</button>
|
||||||
|
<button class="header-btn" onclick="showAllLinks()" title="View all links">Links</button>
|
||||||
<button class="refresh-btn" onclick="triggerRefresh(this)" title="Force re-collect from all servers">↻ Refresh</button>
|
<button class="refresh-btn" onclick="triggerRefresh(this)" title="Force re-collect from all servers">↻ Refresh</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -502,7 +504,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Overview Modal -->
|
||||||
|
<div id="overviewModal" class="log-modal" style="display:none;" onclick="if(event.target===this)this.style.display='none'">
|
||||||
|
<div class="log-modal-content" style="width:700px;">
|
||||||
|
<div class="log-modal-header">
|
||||||
|
<span id="overviewTitle"></span>
|
||||||
|
<button onclick="document.getElementById('overviewModal').style.display='none'" style="margin-left:auto;background:none;border:none;color:#94a3b8;font-size:1.2rem;cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="overviewBody" class="log-modal-body" style="padding:16px 18px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function showAllNotes() {
|
||||||
|
const modal = document.getElementById('overviewModal');
|
||||||
|
document.getElementById('overviewTitle').textContent = 'All Notes';
|
||||||
|
const body = document.getElementById('overviewBody');
|
||||||
|
body.innerHTML = '<div class="spinner" style="margin:20px auto;"></div>';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
fetch('/api/all-notes').then(r => r.json()).then(data => {
|
||||||
|
if (!data.length) {
|
||||||
|
body.innerHTML = '<div style="color:#64748b;text-align:center;padding:20px;">No notes yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = data.map(s =>
|
||||||
|
'<div style="margin-bottom:16px;">' +
|
||||||
|
'<div style="font-weight:600;color:#3b82f6;font-size:0.8rem;margin-bottom:4px;">' +
|
||||||
|
s.group_name + ' / ' + s.hostname + '</div>' +
|
||||||
|
'<div style="color:#cbd5e1;font-size:0.85rem;white-space:pre-wrap;background:#0f172a;padding:8px 10px;border-radius:6px;border:1px solid #1e3a5f;">' +
|
||||||
|
s.notes.replace(/</g,'<') + '</div></div>'
|
||||||
|
).join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAllLinks() {
|
||||||
|
const modal = document.getElementById('overviewModal');
|
||||||
|
document.getElementById('overviewTitle').textContent = 'All Links';
|
||||||
|
const body = document.getElementById('overviewBody');
|
||||||
|
body.innerHTML = '<div class="spinner" style="margin:20px auto;"></div>';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
fetch('/api/all-links').then(r => r.json()).then(data => {
|
||||||
|
if (!data.length) {
|
||||||
|
body.innerHTML = '<div style="color:#64748b;text-align:center;padding:20px;">No links yet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = data.map(s =>
|
||||||
|
'<div style="margin-bottom:12px;">' +
|
||||||
|
'<div style="font-weight:600;color:#3b82f6;font-size:0.8rem;margin-bottom:4px;">' +
|
||||||
|
s.group_name + ' / ' + s.hostname + '</div>' +
|
||||||
|
'<div style="display:flex;flex-wrap:wrap;gap:6px;">' +
|
||||||
|
s.links.map(l =>
|
||||||
|
'<a href="' + l.url.replace(/"/g,'"') + '" target="_blank" rel="noopener" class="service-link" style="font-size:0.8rem;padding:3px 10px;">' +
|
||||||
|
l.label.replace(/</g,'<') + '</a>'
|
||||||
|
).join('') +
|
||||||
|
'</div></div>'
|
||||||
|
).join('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDetails(card) {
|
function toggleDetails(card) {
|
||||||
const details = card.querySelector('.card-details');
|
const details = card.querySelector('.card-details');
|
||||||
const isOpen = details.style.display !== 'none';
|
const isOpen = details.style.display !== 'none';
|
||||||
|
|||||||
Reference in New Issue
Block a user