From d9d7662d8cbc5ef252fdd0465187889b920f6a09 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 8 Mar 2026 20:26:07 +1300 Subject: [PATCH] Add server links management and timeout guard for VM/container discovery --- app/app.py | 18 +++++++ app/gather_info.sh | 12 ++--- app/static/style.css | 106 +++++++++++++++++++++++++++++++++++++++ app/templates/index.html | 70 ++++++++++++++++++++++++++ 4 files changed, 200 insertions(+), 6 deletions(-) diff --git a/app/app.py b/app/app.py index 1c2910e..519603c 100644 --- a/app/app.py +++ b/app/app.py @@ -42,6 +42,7 @@ class Server(db.Model): last_collected = db.Column(db.DateTime, nullable=True) details = db.Column(db.JSON, nullable=True) notes = db.Column(db.Text, default='') + links = db.Column(db.JSON, default=list) # [{"label": "...", "url": "..."}] parent_hostname = db.Column(db.String(255), default='') __table_args__ = (db.UniqueConstraint('username', 'hostname', name='uq_user_host'),) @@ -319,6 +320,11 @@ def index(): return render_template('index.html', groups=groups, children_map=children_map, build_date=BUILD_DATE) +@app.route('/api/version') +def api_version(): + return jsonify({'build_date': BUILD_DATE}) + + @app.route('/api/servers') def api_servers(): servers = Server.query.all() @@ -334,6 +340,7 @@ def api_servers(): 'is_online': s.is_online, 'last_collected': s.last_collected.isoformat() if s.last_collected else None, 'notes': s.notes, + 'links': s.links or [], 'parent_hostname': s.parent_hostname, 'details': s.details, }) @@ -497,6 +504,16 @@ def api_update_notes(server_id): return jsonify({'ok': True}) +@app.route('/api/servers//links', methods=['PUT']) +def api_update_links(server_id): + from flask import request + server = Server.query.get_or_404(server_id) + data = request.get_json() + server.links = data.get('links', []) + db.session.commit() + return jsonify({'ok': True}) + + def _ip_sort_key(ip_str): if not ip_str: return [999, 999, 999, 999] @@ -646,6 +663,7 @@ def migrate_db(): 'url': "ALTER TABLE servers ADD COLUMN url VARCHAR(1024) DEFAULT ''", 'notes': "ALTER TABLE servers ADD COLUMN notes TEXT DEFAULT ''", 'parent_hostname': "ALTER TABLE servers ADD COLUMN parent_hostname VARCHAR(255) DEFAULT ''", + 'links': "ALTER TABLE servers ADD COLUMN links JSON DEFAULT '[]'", } for col, sql in migrations.items(): if col not in existing: diff --git a/app/gather_info.sh b/app/gather_info.sh index 486e008..4872df4 100755 --- a/app/gather_info.sh +++ b/app/gather_info.sh @@ -231,7 +231,7 @@ _sudo() { # Proxmox LXC (pct) if command -v pct &>/dev/null; then - _sudo pct list 2>/dev/null | tail -n +2 | while read -r line; do + timeout 10 _sudo pct list 2>/dev/null | tail -n +2 | while read -r line; do [ -z "$line" ] && continue vmid=$(echo "$line" | awk '{print $1}') status=$(echo "$line" | awk '{print $2}') @@ -250,7 +250,7 @@ fi # Proxmox VMs (qm) if command -v qm &>/dev/null; then - _sudo qm list 2>/dev/null | tail -n +2 | while read -r vmid name status _ mem _; do + timeout 10 _sudo qm list 2>/dev/null | tail -n +2 | while read -r vmid name status _ mem _; do [ -z "$vmid" ] && continue echo "[container:qm-${vmid}]" echo "type=vm" @@ -271,7 +271,7 @@ fi # Plain LXC (lxc/lxd) if command -v lxc &>/dev/null && ! command -v pct &>/dev/null; then - _sudo lxc list --format csv -c nsN 2>/dev/null | while IFS=',' read -r name status network; do + timeout 10 _sudo lxc list --format csv -c nsN 2>/dev/null | while IFS=',' read -r name status network; do [ -z "$name" ] && continue echo "[container:lxc-${name}]" echo "type=lxc" @@ -288,7 +288,7 @@ fi # libvirt VMs (virsh) if command -v virsh &>/dev/null; then - _sudo virsh list --all --name 2>/dev/null | while read -r name; do + timeout 10 _sudo virsh list --all --name 2>/dev/null | while read -r name; do [ -z "$name" ] && continue state=$(_sudo virsh domstate "$name" 2>/dev/null | head -1) echo "[container:virsh-${name}]" @@ -305,7 +305,7 @@ fi # Docker containers if command -v docker &>/dev/null; then - _sudo docker ps -a --format '{{.Names}}\t{{.State}}\t{{.Image}}\t{{.Status}}' 2>/dev/null | while IFS=$'\t' read -r name state image status_text; do + timeout 10 _sudo docker ps -a --format '{{.Names}}\t{{.State}}\t{{.Image}}\t{{.Status}}' 2>/dev/null | while IFS=$'\t' read -r name state image status_text; do [ -z "$name" ] && continue echo "[container:docker-${name}]" echo "type=docker" @@ -319,7 +319,7 @@ if command -v docker &>/dev/null; then docker_ip=$(_sudo docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$name" 2>/dev/null) [ -n "$docker_ip" ] && echo "ip=$docker_ip" # Get memory/cpu stats (one-shot, no stream) - stats=$(_sudo docker stats --no-stream --format '{{.MemUsage}}\t{{.MemPerc}}\t{{.CPUPerc}}' "$name" 2>/dev/null) + stats=$(timeout 5 _sudo docker stats --no-stream --format '{{.MemUsage}}\t{{.MemPerc}}\t{{.CPUPerc}}' "$name" 2>/dev/null) if [ -n "$stats" ]; then mem_pct=$(echo "$stats" | cut -f2 | tr -d '%') cpu_pct=$(echo "$stats" | cut -f3 | tr -d '%') diff --git a/app/static/style.css b/app/static/style.css index 7524d3b..6b6f1a3 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -194,6 +194,30 @@ main { white-space: nowrap; } +/* --- Service Links (summary card) --- */ + +.service-links { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 4px; +} + +.service-link { + font-size: 0.65rem; + color: #3b82f6; + background: #1e3a5f; + padding: 1px 6px; + border-radius: 3px; + text-decoration: none; + transition: background 0.2s; +} + +.service-link:hover { + background: #2563eb; + color: #fff; +} + .server-hw { font-size: 0.7rem; color: #475569; @@ -371,6 +395,88 @@ main { color: #475569; } +/* --- Links Editor (details) --- */ + +.links-section { + margin-bottom: 12px; +} + +.links-section h4 { + font-size: 0.75rem; + font-weight: 600; + color: #3b82f6; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 6px; +} + +.links-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.link-row { + display: flex; + gap: 6px; + align-items: center; +} + +.link-label { + width: 120px; + background: #0f172a; + border: 1px solid #1e3a5f; + border-radius: 4px; + color: #cbd5e1; + font-size: 0.8rem; + padding: 4px 8px; +} + +.link-url { + flex: 1; + background: #0f172a; + border: 1px solid #1e3a5f; + border-radius: 4px; + color: #cbd5e1; + font-size: 0.8rem; + padding: 4px 8px; +} + +.link-label:focus, .link-url:focus { + outline: none; + border-color: #3b82f6; +} + +.link-remove { + background: none; + border: none; + color: #64748b; + font-size: 1rem; + cursor: pointer; + padding: 2px 6px; +} + +.link-remove:hover { + color: #ef4444; +} + +.link-add-btn { + margin-top: 6px; + background: none; + border: 1px dashed #334155; + border-radius: 4px; + color: #64748b; + font-size: 0.75rem; + padding: 4px 10px; + cursor: pointer; + transition: all 0.2s; +} + +.link-add-btn:hover { + border-color: #3b82f6; + color: #3b82f6; +} + /* --- Container Summary (on card face) --- */ .ct-summary-list { diff --git a/app/templates/index.html b/app/templates/index.html index e5a558a..1672602 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -57,6 +57,14 @@
{{ server.primary_ip or 'No IP' }}
{% if sys.get('platform') %}{{ sys.get('platform')|capitalize }} {{ sys.get('platform_version', '') }} / {% endif %}{{ sys.get('os_pretty', '') }}
+ {% set server_links = server.links or [] %} + {% if server_links %} + + {% endif %} {% if server.is_online and (cpu.get('model') or mem.get('total_mb')) %}
{%- if cpu.get('model') %}{{ cpu.get('model') }}{% endif %} @@ -139,6 +147,19 @@ onclick="event.stopPropagation();" oninput="autoResizeNotes(this); debounceSaveNotes(this);">{{ server.notes or '' }}
+
@@ -475,6 +496,45 @@ _notesTimers[id] = setTimeout(() => saveNotes(ta), 800); } + function getLinksFromList(listEl) { + const links = []; + listEl.querySelectorAll('.link-row').forEach(row => { + const label = row.querySelector('.link-label').value.trim(); + const url = row.querySelector('.link-url').value.trim(); + if (label && url) links.push({label, url}); + }); + return links; + } + + function saveLinks(el) { + const list = el.closest('.links-list'); + const id = list.dataset.serverId; + const links = getLinksFromList(list); + fetch('/api/servers/' + id + '/links', { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({links}) + }); + } + + function addLink(btn, serverId) { + const list = btn.previousElementSibling; + const row = document.createElement('div'); + row.className = 'link-row'; + row.innerHTML = '' + + '' + + ''; + list.appendChild(row); + row.querySelector('.link-label').focus(); + } + + function removeLink(btn) { + const row = btn.closest('.link-row'); + const list = row.closest('.links-list'); + row.remove(); + saveLinks(list); + } + function saveNotes(ta) { const id = ta.dataset.serverId; fetch('/api/servers/' + id + '/notes', { @@ -550,6 +610,16 @@ const refreshMs = hasData ? 60000 : 5000; setInterval(function() { location.reload(); }, refreshMs); + // Detect new backend version and auto-reload + const knownBuild = '{{ build_date }}'; + setInterval(function() { + fetch('/api/version').then(r => r.json()).then(d => { + if (d.build_date && d.build_date !== knownBuild) { + location.reload(); + } + }).catch(() => {}); + }, 5000); + // Restore state on load restoreExpanded();