From be032e66d786798e1abdbb88342a9d328b35dcef Mon Sep 17 00:00:00 2001 From: j Date: Sun, 8 Mar 2026 19:26:09 +1300 Subject: [PATCH] Add parent-child VM nesting support from infrastructure.conf --- app/app.py | 43 ++++++++++++++++++++++-- app/static/style.css | 11 +++++++ app/templates/index.html | 70 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/app/app.py b/app/app.py index 5cfb695..0759788 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='') + 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: diff --git a/app/static/style.css b/app/static/style.css index 6155285..5ad4610 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -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; diff --git a/app/templates/index.html b/app/templates/index.html index 1f928ce..83dcf2b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -100,8 +100,19 @@ {% endif %} - {% if containers %} + {% set child_vms = children_map.get(server.hostname, []) %} + {% if containers or child_vms %}
+ {% for vm in child_vms %} +
+ + {{ vm.details.get('system', {}).get('hostname', vm.hostname) if vm.details else vm.hostname }} + VM + {% set vm_os = vm.details.get('system', {}).get('os_pretty', '') if vm.details else '' %} + {% if vm_os %}{{ vm_os }}{% endif %} + {{ vm.primary_ip or vm.hostname }} +
+ {% endfor %} {% for ct in containers %} {% set ct_up = ct.get('status', '')|lower in ['running', 'started'] %}
@@ -341,6 +352,63 @@
{% endif %} + + {% if child_vms %} +
+

Virtual Machines

+
+ {% 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 {} %} +
+
+ + {{ vs.get('hostname', vm.hostname) }} + VM +
+ {% if vs.get('os_pretty') %} +
{{ vs.get('os_pretty') }}
+ {% endif %} + {% if vm.is_online %} +
+ {{ vm.primary_ip or vm.hostname }} + {% if vs.get('uptime_seconds') %} + {{ vs.get('uptime_seconds', '')|format_uptime }} + {% endif %} +
+ {% 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 %} +
+
+ CPU +
+
+
+ {{ '%.0f'|format(vm_cpu_pct) }}% +
+
+ RAM +
+
+
+ {{ '%.0f'|format(vm_mem_pct) }}% +
+
+ {% endif %} + {% else %} +
+ offline +
+ {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + {% if d.get('error') %}

Error