Add server links management and timeout guard for VM/container discovery
All checks were successful
Build-Publish / build (linux/amd64) (push) Successful in 5s
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:26:07 +13:00
parent 7d7cd25518
commit d9d7662d8c
4 changed files with 200 additions and 6 deletions

View File

@@ -42,6 +42,7 @@ class Server(db.Model):
last_collected = db.Column(db.DateTime, nullable=True) last_collected = db.Column(db.DateTime, nullable=True)
details = db.Column(db.JSON, nullable=True) details = db.Column(db.JSON, nullable=True)
notes = db.Column(db.Text, default='') notes = db.Column(db.Text, default='')
links = db.Column(db.JSON, default=list) # [{"label": "...", "url": "..."}]
parent_hostname = db.Column(db.String(255), default='') parent_hostname = db.Column(db.String(255), default='')
__table_args__ = (db.UniqueConstraint('username', 'hostname', name='uq_user_host'),) __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) 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') @app.route('/api/servers')
def api_servers(): def api_servers():
servers = Server.query.all() servers = Server.query.all()
@@ -334,6 +340,7 @@ def api_servers():
'is_online': s.is_online, 'is_online': s.is_online,
'last_collected': s.last_collected.isoformat() if s.last_collected else None, 'last_collected': s.last_collected.isoformat() if s.last_collected else None,
'notes': s.notes, 'notes': s.notes,
'links': s.links or [],
'parent_hostname': s.parent_hostname, 'parent_hostname': s.parent_hostname,
'details': s.details, 'details': s.details,
}) })
@@ -497,6 +504,16 @@ def api_update_notes(server_id):
return jsonify({'ok': True}) return jsonify({'ok': True})
@app.route('/api/servers/<int:server_id>/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): def _ip_sort_key(ip_str):
if not ip_str: if not ip_str:
return [999, 999, 999, 999] return [999, 999, 999, 999]
@@ -646,6 +663,7 @@ def migrate_db():
'url': "ALTER TABLE servers ADD COLUMN url VARCHAR(1024) DEFAULT ''", 'url': "ALTER TABLE servers ADD COLUMN url VARCHAR(1024) DEFAULT ''",
'notes': "ALTER TABLE servers ADD COLUMN notes TEXT DEFAULT ''", 'notes': "ALTER TABLE servers ADD COLUMN notes TEXT DEFAULT ''",
'parent_hostname': "ALTER TABLE servers ADD COLUMN parent_hostname VARCHAR(255) 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(): for col, sql in migrations.items():
if col not in existing: if col not in existing:

View File

@@ -231,7 +231,7 @@ _sudo() {
# Proxmox LXC (pct) # Proxmox LXC (pct)
if command -v pct &>/dev/null; then 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 [ -z "$line" ] && continue
vmid=$(echo "$line" | awk '{print $1}') vmid=$(echo "$line" | awk '{print $1}')
status=$(echo "$line" | awk '{print $2}') status=$(echo "$line" | awk '{print $2}')
@@ -250,7 +250,7 @@ fi
# Proxmox VMs (qm) # Proxmox VMs (qm)
if command -v qm &>/dev/null; then 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 [ -z "$vmid" ] && continue
echo "[container:qm-${vmid}]" echo "[container:qm-${vmid}]"
echo "type=vm" echo "type=vm"
@@ -271,7 +271,7 @@ fi
# Plain LXC (lxc/lxd) # Plain LXC (lxc/lxd)
if command -v lxc &>/dev/null && ! command -v pct &>/dev/null; then 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 [ -z "$name" ] && continue
echo "[container:lxc-${name}]" echo "[container:lxc-${name}]"
echo "type=lxc" echo "type=lxc"
@@ -288,7 +288,7 @@ fi
# libvirt VMs (virsh) # libvirt VMs (virsh)
if command -v virsh &>/dev/null; then 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 [ -z "$name" ] && continue
state=$(_sudo virsh domstate "$name" 2>/dev/null | head -1) state=$(_sudo virsh domstate "$name" 2>/dev/null | head -1)
echo "[container:virsh-${name}]" echo "[container:virsh-${name}]"
@@ -305,7 +305,7 @@ fi
# Docker containers # Docker containers
if command -v docker &>/dev/null; then 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 [ -z "$name" ] && continue
echo "[container:docker-${name}]" echo "[container:docker-${name}]"
echo "type=docker" 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) docker_ip=$(_sudo docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$name" 2>/dev/null)
[ -n "$docker_ip" ] && echo "ip=$docker_ip" [ -n "$docker_ip" ] && echo "ip=$docker_ip"
# Get memory/cpu stats (one-shot, no stream) # 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 if [ -n "$stats" ]; then
mem_pct=$(echo "$stats" | cut -f2 | tr -d '%') mem_pct=$(echo "$stats" | cut -f2 | tr -d '%')
cpu_pct=$(echo "$stats" | cut -f3 | tr -d '%') cpu_pct=$(echo "$stats" | cut -f3 | tr -d '%')

View File

@@ -194,6 +194,30 @@ main {
white-space: nowrap; 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 { .server-hw {
font-size: 0.7rem; font-size: 0.7rem;
color: #475569; color: #475569;
@@ -371,6 +395,88 @@ main {
color: #475569; 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) --- */ /* --- Container Summary (on card face) --- */
.ct-summary-list { .ct-summary-list {

View File

@@ -57,6 +57,14 @@
</div> </div>
<div class="server-ip">{{ server.primary_ip or 'No IP' }}</div> <div class="server-ip">{{ server.primary_ip or 'No IP' }}</div>
<div class="server-os">{% if sys.get('platform') %}{{ sys.get('platform')|capitalize }} {{ sys.get('platform_version', '') }} / {% endif %}{{ sys.get('os_pretty', '') }}</div> <div class="server-os">{% if sys.get('platform') %}{{ sys.get('platform')|capitalize }} {{ sys.get('platform_version', '') }} / {% endif %}{{ sys.get('os_pretty', '') }}</div>
{% set server_links = server.links or [] %}
{% if server_links %}
<div class="service-links" onclick="event.stopPropagation();">
{% for link in server_links %}
<a href="{{ link.url }}" class="service-link" target="_blank" rel="noopener" title="{{ link.url }}">{{ link.label }}</a>
{% endfor %}
</div>
{% endif %}
{% if server.is_online and (cpu.get('model') or mem.get('total_mb')) %} {% if server.is_online and (cpu.get('model') or mem.get('total_mb')) %}
<div class="server-hw"> <div class="server-hw">
{%- if cpu.get('model') %}{{ cpu.get('model') }}{% endif %} {%- if cpu.get('model') %}{{ cpu.get('model') }}{% endif %}
@@ -139,6 +147,19 @@
onclick="event.stopPropagation();" onclick="event.stopPropagation();"
oninput="autoResizeNotes(this); debounceSaveNotes(this);">{{ server.notes or '' }}</textarea> oninput="autoResizeNotes(this); debounceSaveNotes(this);">{{ server.notes or '' }}</textarea>
</div> </div>
<div class="links-section" onclick="event.stopPropagation();">
<h4>Service Links</h4>
<div class="links-list" data-server-id="{{ server.id }}">
{% for link in server_links %}
<div class="link-row">
<input type="text" class="link-label" value="{{ link.label }}" placeholder="Label" onchange="saveLinks(this)">
<input type="text" class="link-url" value="{{ link.url }}" placeholder="https://..." onchange="saveLinks(this)">
<button class="link-remove" onclick="removeLink(this)" title="Remove">&times;</button>
</div>
{% endfor %}
</div>
<button class="link-add-btn" onclick="addLink(this, {{ server.id }})">+ Add link</button>
</div>
<div class="details-grid"> <div class="details-grid">
<!-- System Info --> <!-- System Info -->
<div class="detail-section"> <div class="detail-section">
@@ -475,6 +496,45 @@
_notesTimers[id] = setTimeout(() => saveNotes(ta), 800); _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 = '<input type="text" class="link-label" placeholder="Label" onchange="saveLinks(this)">' +
'<input type="text" class="link-url" placeholder="https://..." onchange="saveLinks(this)">' +
'<button class="link-remove" onclick="removeLink(this)" title="Remove">&times;</button>';
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) { function saveNotes(ta) {
const id = ta.dataset.serverId; const id = ta.dataset.serverId;
fetch('/api/servers/' + id + '/notes', { fetch('/api/servers/' + id + '/notes', {
@@ -550,6 +610,16 @@
const refreshMs = hasData ? 60000 : 5000; const refreshMs = hasData ? 60000 : 5000;
setInterval(function() { location.reload(); }, refreshMs); 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 // Restore state on load
restoreExpanded(); restoreExpanded();
</script> </script>