Add SSE streaming for server refresh with live progress log modal
All checks were successful
Build-Publish / build (linux/amd64) (push) Successful in 4s
Build-Publish / build (linux/arm64) (push) Successful in 13s
Build-Publish / create-manifest (push) Successful in 2s
Build-Publish / publish-template (push) Successful in 15s

This commit is contained in:
j
2026-03-08 20:03:34 +13:00
parent be032e66d7
commit cb946b2259
3 changed files with 262 additions and 65 deletions

View File

@@ -10,7 +10,7 @@ import paramiko
BUILD_DATE = '__BUILD_DATE__' BUILD_DATE = '__BUILD_DATE__'
from flask import Flask, render_template, jsonify from flask import Flask, render_template, jsonify, Response, stream_with_context
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
@@ -63,14 +63,16 @@ def parse_infrastructure_conf():
current_host = None current_host = None
else: else:
# Detect indent level: double indent = child VM # Detect indent level: double indent = child VM
stripped = line.lstrip('\t') # Normalize: count leading whitespace treating tab as 4 spaces
tab_count = len(line) - len(stripped) raw_indent = 0
if tab_count < 2: for ch in line:
# Also check spaces: 8+ spaces = double indent if ch == '\t':
space_indent = len(line) - len(line.lstrip(' ')) raw_indent += 4
is_child = space_indent >= 8 elif ch == ' ':
else: raw_indent += 1
is_child = tab_count >= 2 else:
break
is_child = raw_indent >= 8
parts = line.strip().split(None, 1) parts = line.strip().split(None, 1)
entry = parts[0] if parts else '' entry = parts[0] if parts else ''
@@ -239,30 +241,8 @@ def collect_all():
db.session.add(server) db.session.add(server)
server.group_name = entry['group'] server.group_name = entry['group']
server.url = entry.get('url', '')
server.parent_hostname = entry.get('parent_hostname', '') server.parent_hostname = entry.get('parent_hostname', '')
server.is_online = result.get('is_online', False) _update_server_from_result(server, entry, result)
server.last_collected = datetime.now(timezone.utc)
server.details = result
# Extract primary IP: prefer the interface carrying the default route
default_iface = ''
routing = result.get('routing', {})
if isinstance(routing, dict):
default_iface = routing.get('interface', '')
primary_ip = ''
for iface in result.get('net', []):
ipv4 = iface.get('ipv4', '')
if not ipv4 or ipv4.startswith('127.'):
continue
iface_name = iface.get('name', '') or iface.get('_name', '')
if iface_name == default_iface:
primary_ip = ipv4
break
if not primary_ip:
primary_ip = ipv4
server.primary_ip = primary_ip
db.session.commit() db.session.commit()
logger.info("Collection complete, updated %d servers", len(results)) logger.info("Collection complete, updated %d servers", len(results))
@@ -340,33 +320,13 @@ def api_servers():
return jsonify(result) return jsonify(result)
@app.route('/api/refresh', methods=['POST']) def _update_server_from_result(server, entry, result):
def api_refresh(): """Apply collection result to a server record."""
trigger_collect()
return jsonify({'ok': True, 'message': 'Collection triggered'})
@app.route('/api/servers/<int:server_id>/refresh', methods=['POST'])
def api_refresh_one(server_id):
server = Server.query.get_or_404(server_id)
try:
ssh_key = load_ssh_key()
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
entry = {
'group': server.group_name,
'username': server.username,
'hostname': server.hostname,
'url': server.url,
}
result = collect_one(entry, ssh_key)
server.is_online = result.get('is_online', False) server.is_online = result.get('is_online', False)
server.last_collected = datetime.now(timezone.utc) server.last_collected = datetime.now(timezone.utc)
server.details = result server.details = result
server.url = entry.get('url', server.url)
# Extract primary IP
default_iface = '' default_iface = ''
routing = result.get('routing', {}) routing = result.get('routing', {})
if isinstance(routing, dict): if isinstance(routing, dict):
@@ -384,8 +344,127 @@ def api_refresh_one(server_id):
primary_ip = ipv4 primary_ip = ipv4
server.primary_ip = primary_ip server.primary_ip = primary_ip
db.session.commit()
return jsonify({'ok': True}) @app.route('/api/refresh', methods=['POST'])
def api_refresh():
trigger_collect()
return jsonify({'ok': True, 'message': 'Collection triggered'})
@app.route('/api/refresh/stream')
def api_refresh_stream():
"""SSE endpoint: collect all servers with progress updates."""
def generate():
entries = parse_infrastructure_conf()
if not entries:
yield f"data: No servers configured\n\n"
yield f"data: [DONE]\n\n"
return
try:
ssh_key = load_ssh_key()
except Exception as e:
yield f"data: SSH key error: {e}\n\n"
yield f"data: [DONE]\n\n"
return
yield f"data: Collecting from {len(entries)} servers...\n\n"
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_SSH) as pool:
futures = {pool.submit(collect_one, e, ssh_key): e for e in entries}
results = {}
for future in as_completed(futures):
entry = futures[future]
key = f"{entry['username']}@{entry['hostname']}"
try:
result = future.result(timeout=90)
results[key] = (entry, result)
status = 'online' if result.get('is_online') else 'offline'
yield f"data: {entry['hostname']} - {status}\n\n"
except Exception as e:
results[key] = (entry, {'is_online': False, 'error': str(e)})
yield f"data: {entry['hostname']} - error: {e}\n\n"
# Update database
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():
server = Server.query.filter_by(
username=entry['username'], hostname=entry['hostname'],
).first()
if not server:
server = Server(
group_name=entry['group'],
username=entry['username'],
hostname=entry['hostname'],
)
db.session.add(server)
server.group_name = entry['group']
server.parent_hostname = entry.get('parent_hostname', '')
_update_server_from_result(server, entry, result)
db.session.commit()
yield f"data: Collection complete - {len(results)} servers updated\n\n"
yield f"data: [DONE]\n\n"
return Response(stream_with_context(generate()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@app.route('/api/servers/<int:server_id>/refresh/stream')
def api_refresh_one_stream(server_id):
"""SSE endpoint: collect a single server with progress updates."""
def generate():
with app.app_context():
server = Server.query.get(server_id)
if not server:
yield f"data: Server not found\n\n"
yield f"data: [DONE]\n\n"
return
hostname = server.hostname
entry = {
'group': server.group_name,
'username': server.username,
'hostname': server.hostname,
'url': server.url,
}
try:
ssh_key = load_ssh_key()
except Exception as e:
yield f"data: SSH key error: {e}\n\n"
yield f"data: [DONE]\n\n"
return
yield f"data: Connecting to {hostname}...\n\n"
result = collect_one(entry, ssh_key)
with app.app_context():
server = Server.query.get(server_id)
_update_server_from_result(server, entry, result)
db.session.commit()
if result.get('is_online'):
sys_info = result.get('system', {})
ct_count = len(result.get('container', []))
msg = f"{hostname} - online"
if sys_info.get('os_pretty'):
msg += f" ({sys_info['os_pretty']})"
if ct_count:
msg += f", {ct_count} containers"
yield f"data: {msg}\n\n"
else:
yield f"data: {hostname} - offline: {result.get('error', 'unknown')}\n\n"
yield f"data: [DONE]\n\n"
return Response(stream_with_context(generate()),
mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@app.route('/api/servers/<int:server_id>/notes', methods=['PUT']) @app.route('/api/servers/<int:server_id>/notes', methods=['PUT'])

View File

@@ -512,6 +512,76 @@ main {
font-style: italic; font-style: italic;
} }
/* --- Log Modal --- */
.log-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.log-modal-content {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
width: 500px;
max-width: 90vw;
max-height: 60vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.log-modal-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid #334155;
font-weight: 600;
font-size: 0.9rem;
color: #f1f5f9;
}
.log-modal-body {
padding: 12px 18px;
overflow-y: auto;
flex: 1;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.75rem;
line-height: 1.6;
}
.log-line {
color: #94a3b8;
}
.log-line.log-ok {
color: #22c55e;
}
.log-line.log-err {
color: #ef4444;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid #334155;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* --- Empty State --- */ /* --- Empty State --- */
.empty-state { .empty-state {

View File

@@ -433,6 +433,17 @@
{% endfor %} {% endfor %}
</main> </main>
<!-- Log Modal -->
<div id="logModal" class="log-modal" style="display:none;">
<div class="log-modal-content">
<div class="log-modal-header">
<span id="logTitle">Collecting...</span>
<span id="logSpinner" class="spinner"></span>
</div>
<div id="logBody" class="log-modal-body"></div>
</div>
</div>
<script> <script>
function toggleDetails(card) { function toggleDetails(card) {
const details = card.querySelector('.card-details'); const details = card.querySelector('.card-details');
@@ -481,20 +492,57 @@
if (card) toggleDetails(card); if (card) toggleDetails(card);
} }
function showLogModal(title) {
const modal = document.getElementById('logModal');
const body = document.getElementById('logBody');
const titleEl = document.getElementById('logTitle');
const spinner = document.getElementById('logSpinner');
titleEl.textContent = title;
body.innerHTML = '';
spinner.style.display = 'inline-block';
modal.style.display = 'flex';
}
function addLogLine(text) {
const body = document.getElementById('logBody');
const line = document.createElement('div');
line.className = 'log-line';
// Color based on content
if (text.includes('online')) line.classList.add('log-ok');
else if (text.includes('offline') || text.includes('error')) line.classList.add('log-err');
line.textContent = text;
body.appendChild(line);
body.scrollTop = body.scrollHeight;
}
function streamRefresh(url, title) {
showLogModal(title);
const es = new EventSource(url);
es.onmessage = function(e) {
if (e.data === '[DONE]') {
es.close();
document.getElementById('logSpinner').style.display = 'none';
setTimeout(() => location.reload(), 800);
} else {
addLogLine(e.data);
}
};
es.onerror = function() {
es.close();
addLogLine('Connection lost');
document.getElementById('logSpinner').style.display = 'none';
setTimeout(() => location.reload(), 1500);
};
}
function refreshServer(btn, serverId) { function refreshServer(btn, serverId) {
btn.disabled = true; btn.disabled = true;
btn.textContent = '...'; streamRefresh('/api/servers/' + serverId + '/refresh/stream', 'Refreshing server...');
fetch('/api/servers/' + serverId + '/refresh', {method: 'POST'})
.then(() => location.reload());
} }
function triggerRefresh(btn) { function triggerRefresh(btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Collecting...'; streamRefresh('/api/refresh/stream', 'Refreshing all servers...');
fetch('/api/refresh', {method: 'POST'}).then(() => {
// Wait a few seconds for collection to finish, then reload
setTimeout(() => location.reload(), 8000);
});
} }
// If no servers have data yet, refresh quickly; otherwise every 60s // If no servers have data yet, refresh quickly; otherwise every 60s