Relocate baseline config to administration subtabs, refine nav row, and add live CPU and RAM reporting
CI Test Suite / Run Component Tests & Pipeline Verification (push) Successful in 2m12s

This commit is contained in:
2026-10-11 11:08:41 +02:00
parent 1bf3f0ed6f
commit 52519e258e
7 changed files with 193 additions and 34 deletions
+1
View File
@@ -118,6 +118,7 @@ def enroll_client_if_needed(
"is_server": inventory.get("is_server", False),
"server_roles": inventory.get("server_roles", []),
"client_info": inventory,
"metrics": collect_system_metrics(),
"enrollment_secret": enrollment_secret,
"version": LOGAR_VERSION
}
+37 -1
View File
@@ -11,6 +11,7 @@ import socket
import shutil
import ctypes
import subprocess
import time
import warnings
from abc import ABC, abstractmethod
from datetime import datetime, timezone, timedelta
@@ -106,7 +107,42 @@ def collect_system_metrics() -> Dict[str, Any]:
# 3. CPU metrics
try:
if hasattr(os, "getloadavg"):
if sys.platform == "win32":
class FILETIME(ctypes.Structure):
_fields_ = [("dwLowDateTime", ctypes.c_uint), ("dwHighDateTime", ctypes.c_uint)]
idle, kernel, user = FILETIME(), FILETIME(), FILETIME()
if ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)):
i1 = (idle.dwHighDateTime << 32) + idle.dwLowDateTime
k1 = (kernel.dwHighDateTime << 32) + kernel.dwLowDateTime
u1 = (user.dwHighDateTime << 32) + user.dwLowDateTime
time.sleep(0.06)
if ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), ctypes.byref(kernel), ctypes.byref(user)):
i2 = (idle.dwHighDateTime << 32) + idle.dwLowDateTime
k2 = (kernel.dwHighDateTime << 32) + kernel.dwLowDateTime
u2 = (user.dwHighDateTime << 32) + user.dwLowDateTime
idle_delta = i2 - i1
total_delta = (k2 - k1) + (u2 - u1)
if total_delta > 0:
pct = 100.0 * (1.0 - (idle_delta / total_delta))
metrics["cpu_percent"] = round(max(0.0, min(100.0, pct)), 1)
elif os.path.exists("/proc/stat"):
def _read_proc_stat():
with open("/proc/stat", "r") as f:
fields = [float(col) for col in f.readline().strip().split()[1:]]
idle = fields[3] + (fields[4] if len(fields) > 4 else 0.0)
total = sum(fields)
return idle, total
i1, t1 = _read_proc_stat()
time.sleep(0.06)
i2, t2 = _read_proc_stat()
total_delta = t2 - t1
idle_delta = i2 - i1
if total_delta > 0:
pct = 100.0 * (1.0 - (idle_delta / total_delta))
metrics["cpu_percent"] = round(max(0.0, min(100.0, pct)), 1)
elif hasattr(os, "getloadavg"):
load1, _, _ = os.getloadavg()
cpu_count = os.cpu_count() or 1
metrics["cpu_percent"] = round(min(100.0, (load1 / cpu_count) * 100), 1)
+11 -2
View File
@@ -436,6 +436,7 @@ def get_full_system_inventory() -> Dict[str, Any]:
# Memory
mem_total_mb = 0
mem_percent = 0.0
try:
if sys.platform == "win32":
import ctypes
@@ -455,12 +456,19 @@ def get_full_system_inventory() -> Dict[str, Any]:
stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
mem_total_mb = int(stat.ullTotalPhys / (1024 * 1024))
mem_percent = float(stat.dwMemoryLoad)
elif os.path.exists("/proc/meminfo"):
tot = 0
avail = 0
with open("/proc/meminfo", "r", encoding="utf-8") as f:
for line in f:
if line.startswith("MemTotal:"):
mem_total_mb = int(line.split(":")[1].strip().split()[0]) // 1024
break
tot = int(line.split(":")[1].strip().split()[0])
elif line.startswith(("MemAvailable:", "MemFree:")) and avail == 0:
avail = int(line.split(":")[1].strip().split()[0])
if tot > 0:
mem_total_mb = tot // 1024
mem_percent = round(((tot - avail) / tot) * 100, 1)
except Exception:
pass
@@ -478,6 +486,7 @@ def get_full_system_inventory() -> Dict[str, Any]:
"architecture": os_info.get("architecture"),
"cpu_count": os.cpu_count() or 1,
"memory_total_mb": mem_total_mb,
"memory_percent": mem_percent,
"disk_total_gb": disk_total_gb,
"disk_free_gb": disk_free_gb,
"disk_percent_free": disk_pct_free,
+37 -12
View File
@@ -62,7 +62,8 @@ try:
WindowsLogCollector,
LinuxLogCollector,
get_log_collector,
is_routine_service_noise
is_routine_service_noise,
collect_system_metrics
)
except ImportError:
from log_collector import (
@@ -70,7 +71,8 @@ except ImportError:
WindowsLogCollector,
LinuxLogCollector,
get_log_collector,
is_routine_service_noise
is_routine_service_noise,
collect_system_metrics
)
# Import specialized modular components
@@ -1042,6 +1044,16 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run
except Exception:
inv = {}
try:
metrics = collect_system_metrics() if "collect_system_metrics" in globals() else {}
except Exception:
metrics = {}
cpu_p = float(metrics.get("cpu_percent", 0.0))
mem_p = float(metrics.get("memory_percent", 0.0))
disk_p = float(metrics.get("disk_percent_free", inv.get("disk_percent_free", 0.0)))
metrics_json = json.dumps(metrics) if metrics else "{}"
machine_id = get_machine_identifier()
os_type = inv.get("os_type", "windows" if sys.platform == "win32" else "linux")
os_name = inv.get("os_name", "")
@@ -1059,9 +1071,11 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = 'LOCAL-HOST-BUILTIN', status = 'active', last_seen = ?,
os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?
os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?,
cpu_percent = ?, memory_percent = ?, disk_percent_free = ?, metrics_json = ?
WHERE client_id = ?
""", (machine_id, os_type, now_sql, os_name, is_server, server_roles, client_info_json, machine_id))
""", (machine_id, os_type, now_sql, os_name, is_server, server_roles, client_info_json,
cpu_p, mem_p, disk_p, metrics_json, machine_id))
except sqlite3.OperationalError:
c.execute("""
UPDATE clients
@@ -1071,9 +1085,10 @@ def run_local_log_collection_cycle(db_path: str, window_hours: int = 12, min_run
else:
try:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, os_name, is_server, server_roles, client_info_json)
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?, ?, ?, ?, ?)
""", (machine_id, machine_id, os_type, now_sql, now_sql, os_name, is_server, server_roles, client_info_json))
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, os_name, is_server, server_roles, client_info_json, cpu_percent, memory_percent, disk_percent_free, metrics_json)
VALUES (?, ?, ?, 'LOCAL-HOST-BUILTIN', 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (machine_id, machine_id, os_type, now_sql, now_sql, os_name, is_server, server_roles, client_info_json,
cpu_p, mem_p, disk_p, metrics_json))
except sqlite3.OperationalError:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen)
@@ -1461,15 +1476,25 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
req_is_server = 1 if req.is_server else (1 if req.os == "windows_server" else 0)
req_server_roles = json.dumps(req.server_roles) if req.server_roles else "[]"
req_client_info = json.dumps(req.client_info) if req.client_info else "{}"
req_metrics = getattr(req, "metrics", None) or {}
cpu_p = float(req_metrics.get("cpu_percent", 0.0))
mem_p = float(req_metrics.get("memory_percent", 0.0))
disk_p = float(req_metrics.get("disk_percent_free", 0.0))
metrics_json = json.dumps(req_metrics) if req_metrics else "{}"
if existing:
try:
c.execute("""
UPDATE clients
SET hostname = ?, os_type = ?, cert_fingerprint = ?, status = 'active', last_seen = ?, client_version = ?,
os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?
os_name = ?, is_server = ?, server_roles = ?, client_info_json = ?,
cpu_percent = CASE WHEN ? > 0.0 THEN ? ELSE cpu_percent END,
memory_percent = CASE WHEN ? > 0.0 THEN ? ELSE memory_percent END,
disk_percent_free = CASE WHEN ? > 0.0 THEN ? ELSE disk_percent_free END,
metrics_json = CASE WHEN ? != '{}' THEN ? ELSE metrics_json END
WHERE client_id = ?
""", (req.hostname, req.os, fp, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info, req.client_id))
""", (req.hostname, req.os, fp, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info,
cpu_p, cpu_p, mem_p, mem_p, disk_p, disk_p, metrics_json, metrics_json, req.client_id))
except sqlite3.OperationalError:
try:
c.execute("""
@@ -1486,9 +1511,9 @@ def enroll_client(req: ClientEnrollRequest, request: Request = None):
else:
try:
c.execute("""
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version, os_name, is_server, server_roles, client_info_json)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info))
INSERT INTO clients (client_id, hostname, os_type, cert_fingerprint, status, first_seen, last_seen, client_version, os_name, is_server, server_roles, client_info_json, cpu_percent, memory_percent, disk_percent_free, metrics_json)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (req.client_id, req.hostname, req.os, fp, now_sql, now_sql, client_ver, req_os_name, req_is_server, req_server_roles, req_client_info, cpu_p, mem_p, disk_p, metrics_json))
except sqlite3.OperationalError:
try:
c.execute("""
+1
View File
@@ -17,6 +17,7 @@ class ClientEnrollRequest(BaseModel):
is_server: Optional[bool] = None
server_roles: Optional[List[str]] = None
client_info: Optional[Dict[str, Any]] = None
metrics: Optional[Dict[str, Any]] = None
class ClientUpdateRequest(BaseModel):
+102 -19
View File
@@ -358,13 +358,29 @@ class WebUIRenderer:
bp_rules_html = "".join(rule_rows)
bp_banner_html = f"""
<div class="card" style="margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; padding: 14px 20px; border: 1px solid rgba(16, 185, 129, 0.35); background: linear-gradient(135deg, rgba(16, 185, 129, 0.05), rgba(15, 23, 42, 0.8));">
<div style="display: flex; align-items: center; gap: 14px;">
<span style="font-size: 26px;">🛡️</span>
<div>
<div style="font-weight: 700; font-size: 14px; color: #fff;">Server Role Baseline &amp; Compliance Status</div>
<div style="font-size: 12px; color: var(--text-muted);">{pass_cnt} passing • {warn_cnt} warnings • {fail_cnt} failures ({total_rules} Rules)</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span class="badge badge-active" style="color:{score_color}; font-size:11px; padding:5px 10px;">{score}% COMPLIANT</span>
<button onclick="switchAdminSubtab('baseline')" class="btn btn-outline" style="font-size: 11px; padding: 6px 12px;">🛡️ Baseline Config</button>
</div>
</div>
"""
baseline_admin_html = f"""
<div class="card" style="border: 1px solid rgba(16, 185, 129, 0.35); background: linear-gradient(135deg, rgba(16, 185, 129, 0.05), rgba(15, 23, 42, 0.8)); margin-bottom: 24px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 16px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; align-items: center; gap: 14px;">
<div style="font-size: 32px; line-height: 1;">🛡️</div>
<div style="font-size: 36px; line-height: 1;">🛡️</div>
<div>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 2px;">
<h3 style="margin: 0; font-size: 16px; color: #34d399;">Best Practice &amp; Server Role Baseline Analyzer</h3>
<h3 style="margin: 0; font-size: 17px; color: #34d399;">Best Practice &amp; Security Baseline Engine</h3>
<span class="badge badge-active" style="font-size: 10px;">AUTHORIZED • ACTIVE</span>
</div>
<div style="font-size: 12px; color: var(--text-muted);">
@@ -374,7 +390,7 @@ class WebUIRenderer:
</div>
<div style="display: flex; align-items: center; gap: 16px;">
<div style="text-align: right;">
<div style="font-size: 26px; font-weight: 800; font-family: 'JetBrains Mono', monospace; color: {score_color};">{score}%</div>
<div style="font-size: 28px; font-weight: 800; font-family: 'JetBrains Mono', monospace; color: {score_color};">{score}%</div>
<div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase;">Fleet Compliance Score</div>
</div>
<div style="display: flex; gap: 8px;">
@@ -384,15 +400,31 @@ class WebUIRenderer:
</div>
</div>
</div>
</div>
<details style="cursor: pointer;">
<summary style="font-size: 12px; font-weight: 700; color: #60a5fa; outline: none; margin-bottom: 12px;">
▶ View {total_rules} Active Baseline Audit Rules &amp; Remediation Standards
</summary>
<div style="margin-top: 10px;">
{bp_rules_html}
<!-- Baseline Rules Configuration Card -->
<div class="card">
<div class="card-header" style="flex-wrap: wrap; gap: 12px;">
<div>
<div class="card-title" style="font-size: 15px;">Configured Baseline Audit Rules &amp; Checks ({total_rules} Rules)</div>
<div class="subtitle" style="margin-top: 3px;">
Modular compliance checks evaluated across all enrolled server nodes. Defined in <code>src/hub/best_practice_rules.py</code>.
</div>
</div>
</details>
<div style="display: flex; gap: 8px; align-items: center;">
<button onclick="switchTab('server-roles')" class="btn btn-outline" style="font-size: 11px; padding: 6px 12px;">🖥️ View Node Results</button>
</div>
</div>
<div style="margin-bottom: 16px; background: rgba(59, 130, 246, 0.08); border: 1px solid rgba(59, 130, 246, 0.25); border-radius: 8px; padding: 12px 16px;">
<div style="font-size: 12px; color: #93c5fd; line-height: 1.5;">
💡 <strong>Rule Customization:</strong> Baseline rules are cleanly decoupled from the evaluation engine and maintained in <code>src/hub/best_practice_rules.py</code>. You can define new role checks, alter warning thresholds, or add compliance baselines without modifying core server logic.
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 10px;">
{bp_rules_html}
</div>
</div>
"""
bp_kpi_value = f"{score}%"
@@ -400,13 +432,29 @@ class WebUIRenderer:
bp_kpi_badge = f'<span class="badge badge-active" style="color:{score_color};">{score}% COMPLIANT</span>'
else:
bp_banner_html = """
<div class="card" style="margin-bottom: 20px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 14px; padding: 14px 20px; border: 1px solid rgba(234, 179, 8, 0.3); background: linear-gradient(135deg, rgba(30, 41, 59, 0.7), rgba(15, 23, 42, 0.9));">
<div style="display: flex; align-items: center; gap: 14px;">
<span style="font-size: 26px;">🛡️</span>
<div>
<div style="font-weight: 700; font-size: 14px; color: #facc15;">Best Practice Analyzer &amp; Role Baseline Engine</div>
<div style="font-size: 12px; color: var(--text-muted);">Requires 'best_practices' enterprise license capability</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<span class="badge" style="background:rgba(234, 179, 8, 0.15); color:#facc15; border:1px solid rgba(234, 179, 8, 0.3);">LOCKED</span>
<button onclick="switchAdminSubtab('baseline')" class="btn btn-outline" style="border-color: #facc15; color: #facc15; font-size: 11px; padding: 6px 12px;">🛡️ Baseline Config</button>
</div>
</div>
"""
baseline_admin_html = """
<div class="card" style="border: 1px solid rgba(234, 179, 8, 0.4); background: linear-gradient(135deg, rgba(30, 41, 59, 0.7), rgba(15, 23, 42, 0.9)); margin-bottom: 24px;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; align-items: flex-start; gap: 16px;">
<div style="font-size: 32px; line-height: 1;">🛡️</div>
<div style="font-size: 36px; line-height: 1;">🛡️</div>
<div>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 4px;">
<h3 style="margin: 0; font-size: 16px; color: #facc15;">Best Practice Analyzer &amp; Role Baseline Engine</h3>
<h3 style="margin: 0; font-size: 17px; color: #facc15;">Best Practice Analyzer &amp; Role Baseline Engine</h3>
<span class="badge" style="background: rgba(234, 179, 8, 0.2); color: #facc15; border: 1px solid rgba(234, 179, 8, 0.4); font-size: 10px;">ENTERPRISE CAPABILITY • INACTIVE</span>
</div>
<p style="margin: 0; font-size: 13px; color: var(--text-muted); max-width: 820px; line-height: 1.5;">
@@ -418,7 +466,30 @@ class WebUIRenderer:
</div>
</div>
<div>
<button onclick="switchTab('pki')" class="btn btn-outline" style="border-color: #facc15; color: #facc15; font-size: 12px;">📜 View Licensing Portal</button>
<button onclick="switchAdminSubtab('pki')" class="btn btn-outline" style="border-color: #facc15; color: #facc15; font-size: 12px;">📜 View Licensing Portal</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<div>
<div class="card-title" style="font-size: 15px;">Baseline Rules Definition File</div>
<div class="subtitle" style="margin-top: 3px;">
Pre-configured baseline rules ready for evaluation once activated. Defined in <code>src/hub/best_practice_rules.py</code>.
</div>
</div>
</div>
<div style="margin-bottom: 16px; background: #0f172a; padding: 16px; border-radius: 8px; border: 1px solid var(--border);">
<div style="font-size: 13px; color: #e2e8f0; margin-bottom: 6px;"><strong>Rules File:</strong> <code>src/hub/best_practice_rules.py</code></div>
<div style="font-size: 12px; color: var(--text-muted); line-height: 1.5;">
Contains modular rule definitions including:
<ul style="margin: 8px 0 0 20px; padding: 0;">
<li><code>BP-SYS-001</code>: Primary Volume Storage Headroom (&gt;= 20% free buffer)</li>
<li><code>BP-SYS-002</code>: Active Memory Saturation (&lt; 85% RAM load)</li>
<li><code>BP-AD-001</code>: Active Directory Domain Controller Isolation (No IIS, RDS, Print)</li>
<li><code>BP-UPD-001</code>: Client Agent Version Baseline Synchronization</li>
</ul>
</div>
</div>
</div>
@@ -634,6 +705,13 @@ class WebUIRenderer:
<div>{os_badge_full}</div>
<div style="font-size:12px; font-weight:600; color:#e2e8f0; margin-top:3px;">{os_display_name}</div>
<div style="font-size:11px; color:#94a3b8;">{specs_sub}</div>
<div style="font-size:11px; margin-top:3px; display:flex; align-items:center; gap:8px;">
<span style="color:{cpu_bar_col}; font-family:'JetBrains Mono',monospace;">⚡ CPU {cpu_p}%</span>
<span style="color:#64748b;">•</span>
<span style="color:{mem_bar_col}; font-family:'JetBrains Mono',monospace;">🧠 RAM {mem_p}%</span>
<span style="color:#64748b;">•</span>
<span style="color:{disk_bar_col}; font-family:'JetBrains Mono',monospace;">💾 Free {disk_p}%</span>
</div>
</td>
<td style="max-width: 340px;">
<div style="display:flex; flex-wrap:wrap; gap:4px;">
@@ -1565,7 +1643,7 @@ class WebUIRenderer:
<!-- Administration Sub-Navigation Tabs Row (Row below, revealed when clicking Administration) -->
<div class="tabs-nav-sub" id="adminSubtabs" style="display: none;">
<span class="subtabs-label">⚙️ Administration:</span>
<button class="subtab-btn" id="tabBtn-baseline" onclick="switchAdminSubtab('baseline')">🛡️ Baseline Config</button>
<button class="subtab-btn" id="tabBtn-hermes" onclick="switchAdminSubtab('hermes')">🤖 Hermes AI &amp; Tokens</button>
<button class="subtab-btn" id="tabBtn-pki" onclick="switchAdminSubtab('pki')">📜 Sub-CA &amp; Licensing</button>
<button class="subtab-btn" id="tabBtn-db" onclick="switchAdminSubtab('db')">💾 DB Engine</button>
@@ -1874,6 +1952,11 @@ class WebUIRenderer:
{db_tab_pane_html}
<!-- Baseline Configuration (Settings Subtab) -->
<div class="tab-pane" id="tabPane-baseline">
{baseline_admin_html}
</div>
<!-- Hermes AI Integration & Tokens (Settings Subtab) -->
<div class="tab-pane" id="tabPane-hermes">
<div class="card">
@@ -2169,7 +2252,7 @@ class WebUIRenderer:
window.addEventListener('click', () => hideUserDropdown());
const PRIMARY_TABS = ['fleet', 'server-roles', 'events', 'admin', 'settings'];
const ADMIN_SUBTABS = ['hermes', 'pki', 'db', 'updates'];
const ADMIN_SUBTABS = ['baseline', 'hermes', 'pki', 'db', 'updates'];
const SETTINGS_SUBTABS = ADMIN_SUBTABS;
function switchTab(tabId) {{
@@ -2184,11 +2267,11 @@ class WebUIRenderer:
const adminBtn = document.getElementById('tabBtn-admin') || document.getElementById('tabBtn-settings');
if (adminBtn) adminBtn.classList.add('active');
let activeSub = 'hermes';
let activeSub = 'baseline';
try {{
activeSub = localStorage.getItem('logar_admin_subtab') || localStorage.getItem('logar_settings_subtab') || 'hermes';
activeSub = localStorage.getItem('logar_admin_subtab') || localStorage.getItem('logar_settings_subtab') || 'baseline';
}} catch(e) {{}}
if (!ADMIN_SUBTABS.includes(activeSub)) activeSub = 'hermes';
if (!ADMIN_SUBTABS.includes(activeSub)) activeSub = 'baseline';
switchAdminSubtab(activeSub);
return;
}}
@@ -193,6 +193,9 @@ class TestServerRolesAndBestPractices(unittest.TestCase):
# Administration sub-navigation tabs (row below)
self.assertIn("id=\"adminSubtabs\"", html)
self.assertNotIn("⚙️ Administration:</span>", html)
self.assertIn("id=\"tabBtn-baseline\"", html)
self.assertIn(">🛡️ Baseline Config</button>", html)
self.assertIn("id=\"tabBtn-hermes\"", html)
self.assertIn(">🤖 Hermes AI &amp; Tokens</button>", html)
self.assertIn("id=\"tabBtn-pki\"", html)
@@ -206,6 +209,7 @@ class TestServerRolesAndBestPractices(unittest.TestCase):
self.assertIn("id=\"tabPane-fleet\"", html)
self.assertIn("id=\"tabPane-server-roles\"", html)
self.assertIn("id=\"tabPane-events\"", html)
self.assertIn("id=\"tabPane-baseline\"", html)
self.assertIn("id=\"tabPane-pki\"", html)
self.assertIn("id=\"tabPane-db\"", html)
self.assertIn("id=\"tabPane-updates\"", html)