Add server links management and timeout guard for VM/container discovery
This commit is contained in:
18
app/app.py
18
app/app.py
@@ -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:
|
||||||
|
|||||||
@@ -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 '%')
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">×</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">×</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user