Add parent-child VM nesting support from infrastructure.conf
This commit is contained in:
43
app/app.py
43
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='')
|
||||
parent_hostname = db.Column(db.String(255), default='')
|
||||
__table_args__ = (db.UniqueConstraint('username', 'hostname', name='uq_user_host'),)
|
||||
|
||||
|
||||
@@ -50,6 +51,7 @@ class Server(db.Model):
|
||||
def parse_infrastructure_conf():
|
||||
servers = []
|
||||
current_group = None
|
||||
current_host = None # track the last top-level host for nesting
|
||||
try:
|
||||
with open(INFRA_CONF_PATH) as f:
|
||||
for line in f:
|
||||
@@ -58,7 +60,18 @@ def parse_infrastructure_conf():
|
||||
continue
|
||||
if line[0] not in (' ', '\t'):
|
||||
current_group = line.strip()
|
||||
current_host = None
|
||||
else:
|
||||
# Detect indent level: double indent = child VM
|
||||
stripped = line.lstrip('\t')
|
||||
tab_count = len(line) - len(stripped)
|
||||
if tab_count < 2:
|
||||
# Also check spaces: 8+ spaces = double indent
|
||||
space_indent = len(line) - len(line.lstrip(' '))
|
||||
is_child = space_indent >= 8
|
||||
else:
|
||||
is_child = tab_count >= 2
|
||||
|
||||
parts = line.strip().split(None, 1)
|
||||
entry = parts[0] if parts else ''
|
||||
url = parts[1] if len(parts) > 1 else ''
|
||||
@@ -67,11 +80,17 @@ def parse_infrastructure_conf():
|
||||
else:
|
||||
user, host = 'infmap', entry
|
||||
if host:
|
||||
parent = ''
|
||||
if is_child and current_host:
|
||||
parent = current_host
|
||||
else:
|
||||
current_host = host.strip()
|
||||
servers.append({
|
||||
'group': current_group or 'Default',
|
||||
'username': user.strip(),
|
||||
'hostname': host.strip(),
|
||||
'url': url.strip(),
|
||||
'parent_hostname': parent,
|
||||
})
|
||||
except FileNotFoundError:
|
||||
logger.error("infrastructure.conf not found at %s", INFRA_CONF_PATH)
|
||||
@@ -221,6 +240,7 @@ def collect_all():
|
||||
|
||||
server.group_name = entry['group']
|
||||
server.url = entry.get('url', '')
|
||||
server.parent_hostname = entry.get('parent_hostname', '')
|
||||
server.is_online = result.get('is_online', False)
|
||||
server.last_collected = datetime.now(timezone.utc)
|
||||
server.details = result
|
||||
@@ -269,9 +289,24 @@ def collector_loop():
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
servers = Server.query.order_by(Server.group_name, Server.primary_ip).all()
|
||||
all_servers = Server.query.order_by(Server.group_name, Server.primary_ip).all()
|
||||
|
||||
# Separate parents and children
|
||||
children_map = {} # parent_hostname -> [child_servers]
|
||||
parents = []
|
||||
for s in all_servers:
|
||||
if s.parent_hostname:
|
||||
children_map.setdefault(s.parent_hostname, []).append(s)
|
||||
else:
|
||||
parents.append(s)
|
||||
|
||||
# Sort children by IP
|
||||
for hostname in children_map:
|
||||
children_map[hostname].sort(key=lambda s: _ip_sort_key(s.primary_ip))
|
||||
|
||||
# Group parents
|
||||
groups = {}
|
||||
for s in servers:
|
||||
for s in parents:
|
||||
g = s.group_name or 'Default'
|
||||
if g not in groups:
|
||||
groups[g] = []
|
||||
@@ -281,7 +316,7 @@ def index():
|
||||
for g in groups:
|
||||
groups[g].sort(key=lambda s: _ip_sort_key(s.primary_ip))
|
||||
|
||||
return render_template('index.html', groups=groups, build_date=BUILD_DATE)
|
||||
return render_template('index.html', groups=groups, children_map=children_map, build_date=BUILD_DATE)
|
||||
|
||||
|
||||
@app.route('/api/servers')
|
||||
@@ -299,6 +334,7 @@ def api_servers():
|
||||
'is_online': s.is_online,
|
||||
'last_collected': s.last_collected.isoformat() if s.last_collected else None,
|
||||
'notes': s.notes,
|
||||
'parent_hostname': s.parent_hostname,
|
||||
'details': s.details,
|
||||
})
|
||||
return jsonify(result)
|
||||
@@ -510,6 +546,7 @@ def migrate_db():
|
||||
migrations = {
|
||||
'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 ''",
|
||||
}
|
||||
for col, sql in migrations.items():
|
||||
if col not in existing:
|
||||
|
||||
@@ -419,6 +419,17 @@ main {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ct-summary-type {
|
||||
font-size: 0.55rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
background: #334155;
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ct-summary-ip {
|
||||
color: #64748b;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
|
||||
@@ -100,8 +100,19 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if containers %}
|
||||
{% set child_vms = children_map.get(server.hostname, []) %}
|
||||
{% if containers or child_vms %}
|
||||
<div class="ct-summary-list">
|
||||
{% for vm in child_vms %}
|
||||
<div class="ct-summary-item">
|
||||
<span class="status-dot-sm {% if vm.is_online %}online{% else %}offline{% endif %}"></span>
|
||||
<span class="ct-summary-name">{{ vm.details.get('system', {}).get('hostname', vm.hostname) if vm.details else vm.hostname }}</span>
|
||||
<span class="ct-summary-type">VM</span>
|
||||
{% set vm_os = vm.details.get('system', {}).get('os_pretty', '') if vm.details else '' %}
|
||||
{% if vm_os %}<span class="ct-summary-os">{{ vm_os }}</span>{% endif %}
|
||||
<span class="ct-summary-ip">{{ vm.primary_ip or vm.hostname }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for ct in containers %}
|
||||
{% set ct_up = ct.get('status', '')|lower in ['running', 'started'] %}
|
||||
<div class="ct-summary-item">
|
||||
@@ -341,6 +352,63 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Child VMs (from config) -->
|
||||
{% if child_vms %}
|
||||
<div class="detail-section wide">
|
||||
<h4>Virtual Machines</h4>
|
||||
<div class="container-grid">
|
||||
{% for vm in child_vms %}
|
||||
{% set vd = vm.details or {} %}
|
||||
{% set vs = vd.get('system', {}) if vd.get('system') else {} %}
|
||||
{% set vc = vd.get('cpu', {}) if vd.get('cpu') else {} %}
|
||||
{% set vmem = vd.get('memory', {}) if vd.get('memory') else {} %}
|
||||
<div class="container-card {% if not vm.is_online %}offline{% endif %}">
|
||||
<div class="ct-header">
|
||||
<span class="status-dot {% if vm.is_online %}online{% else %}offline{% endif %}"></span>
|
||||
<span class="ct-name">{{ vs.get('hostname', vm.hostname) }}</span>
|
||||
<span class="ct-type">VM</span>
|
||||
</div>
|
||||
{% if vs.get('os_pretty') %}
|
||||
<div class="ct-image">{{ vs.get('os_pretty') }}</div>
|
||||
{% endif %}
|
||||
{% if vm.is_online %}
|
||||
<div class="ct-details">
|
||||
<span class="ct-ip">{{ vm.primary_ip or vm.hostname }}</span>
|
||||
{% if vs.get('uptime_seconds') %}
|
||||
<span class="ct-uptime">{{ vs.get('uptime_seconds', '')|format_uptime }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% set vm_cpu_pct = vc.get('usage_percent', '0')|float %}
|
||||
{% set vm_mem_pct = vmem.get('usage_percent', '0')|float %}
|
||||
{% if vm_cpu_pct > 0 or vm_mem_pct > 0 %}
|
||||
<div class="usage-bars">
|
||||
<div class="usage-row">
|
||||
<span class="usage-label">CPU</span>
|
||||
<div class="usage-bar-bg">
|
||||
<div class="usage-bar-fill" style="width: {{ vm_cpu_pct }}%; background: {{ vm_cpu_pct|usage_color }};"></div>
|
||||
</div>
|
||||
<span class="usage-pct">{{ '%.0f'|format(vm_cpu_pct) }}%</span>
|
||||
</div>
|
||||
<div class="usage-row">
|
||||
<span class="usage-label">RAM</span>
|
||||
<div class="usage-bar-bg">
|
||||
<div class="usage-bar-fill" style="width: {{ vm_mem_pct }}%; background: {{ vm_mem_pct|usage_color }};"></div>
|
||||
</div>
|
||||
<span class="usage-pct">{{ '%.0f'|format(vm_mem_pct) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="ct-details">
|
||||
<span class="ct-status-label">offline</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if d.get('error') %}
|
||||
<div class="detail-section wide">
|
||||
<h4>Error</h4>
|
||||
|
||||
Reference in New Issue
Block a user