Add SSE streaming for server refresh with live progress log modal
This commit is contained in:
189
app/app.py
189
app/app.py
@@ -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 == ' ':
|
||||||
|
raw_indent += 1
|
||||||
else:
|
else:
|
||||||
is_child = tab_count >= 2
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
db.session.commit()
|
||||||
return jsonify({'ok': True})
|
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'])
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user