Add parent-child VM nesting support from infrastructure.conf
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 8s

This commit is contained in:
j
2026-03-08 19:26:09 +13:00
parent f953eef8cf
commit be032e66d7
3 changed files with 120 additions and 4 deletions

View File

@@ -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:

View File

@@ -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;

View File

@@ -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>