Initial commit of AthonetTools

This commit is contained in:
2025-08-21 12:59:43 +00:00
commit cd932b8fcb
2483 changed files with 433999 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
# 3GPP core identifiers / names
mcc: "{{ mcc }}"
mnc: "{{ mnc }}"
full_network_name: "{{ network_name }}"
short_network_name: "{{ network_name | replace(' ', '-') }}"
# AMF / GUAMI
amf_name: "{{ amf_name | default('amf01.5gc.3gppnetwork.org') }}"
guami:
region: "{{ guami_region | default('02') }}"
set: "{{ guami_set | default('003') }}"
pointer: "{{ guami_pointer | default('000001') }}"
# MME (for 4G interop / S1)
mme_name: "{{ mme_name | default('mme1') }}"
mmegi: "{{ mmegi | default('0001') }}"
mmec: "{{ mmec | default('01') }}"
mme_cname: "{{ mme_cname | default('gw01.nodes') }}"
# DNS info
epc_dns_zone_data:
# Additional PLMNs to handle
plmns:
- { mcc: '999', mnc: '99' }
- { mcc: '001', mnc: '01' }
- { mcc: '{{ mcc }}', mnc: '{{ mnc }}' }
# SBI configuration
sbi:
interface: lo
base_address: 127.0.1.1/24
# Transports configuration
_ngc_ext_aio_transport:
# AIO local transports
- action: set_local_transports
params: {}
# RAN transports (use RAN IP)
- action: override_amf_n2_transport
params: { address: {{ ran.ip }}, vrf: RAN }
- action: override_mme_transport
params: { s1_address: {{ ran.ip }}, s1_vrf: RAN }
# UPF transports (N3 on RAN)
- action: override_upf_transport
params:
n3_interface: eth1
n3_address: {{ ran.ip }}
n3_vrf: RAN
# DN/DNN (N6) with UE pool
- action: add_n6_dnn
params:
n6_dnn: internet
n6_vrf: DN_01
n6_vlan: {{ dn.vlan }}
n6_vrf_table: 511
n6_interface: eth2
n6_ip: {{ dn.cidr }}
n6_gw: {{ dn.gw }}
n6_upf_pools:
- upf_route: {{ dn.ue_pool }}
nssai: false
n6_bgp:
local_as: 65001
peer_as: 65000

View File

@@ -0,0 +1,10 @@
kind: ngcore-AIO
nf_skip_list:
- "aaa"
- "chf"
- "bmsc"
- "dra"
- "eir"
version: '{{ version }}'
ova_file: {{ ova_file }}
report_services: {{ report_services | default(false) | lower }}

View File

@@ -0,0 +1,34 @@
# --- Networking recipe ---
net_recipe: generic_bgp
# --- OAM config ---
oam_network:
add_ansible_host_address: false
addresses:
- {{ mgmt.cidr }}
gateway4: {{ mgmt.gw }}
# --- NTP ---
ntp:
{% for s in ntp %}
- {{ s }}
{% endfor %}
# --- VRF config ---
_ngc_ext_aio_vrf:
- action: net_add_vrf
params: { name: RAN, table: 502 }
- action: net_add_vrf
params: { name: TELCO, table: 535 }
_ngc_ext_aio_net:
# RAN interface
- action: net_set_interface
params:
interface: eth1
vrf: RAN
addresses:
- {{ ran.cidr }} # S1+N2+N3
routes:
- destination: 0.0.0.0/0
gateway: {{ ran.gw }}

View File

@@ -0,0 +1,13 @@
## UDM/UDR testing profile
create_testing_profile:
slices:
- { sst: 1, sd: '000001' }
- { sst: 1, sd: '' }
dnns:
- {{ dn.dnn }}
plmns:
- { mcc: '{{ mcc }}', mnc: '{{ mnc }}' }
# UDR Subscribers provisioning
udr_provisioning:
testing_profile_service_profile: "testing_profile"

View File

@@ -0,0 +1,12 @@
vswitches:
- vSwitchName: GAF_VSWITCH
vSwitchNics: [vmnic4, vmnic5]
vSwitchSecurity:
forged_transmits: true
mac_changes: true
portgroups:
- { vSwitch: GAF_VSWITCH, vlanId: 501, vlanName: GAF_BP_501_OAM }
- { vSwitch: GAF_VSWITCH, vlanId: {{ ran.vlan | default(502) }}, vlanName: GAF_BP_502_RAN }
- { vSwitch: GAF_VSWITCH, vlanId: {{ dn.vlan }}, vlanName: DN_01 }
- { vSwitch: GAF_VSWITCH, vlanId: 4095, vlanName: GAF_BP_T_510_515 }

View File

@@ -0,0 +1,19 @@
all:
hosts:
{{ inventory_host }}:
ansible_host: {{ ansible_host_ip }}
children:
ESXi:
hosts:
{{ esxi_host }}:
VMs:
children:
_5GVMS:
hosts:
{{ inventory_host }}:
_5GAIO:
hosts:
{{ inventory_host }}:
vars:
serialize: 2
esxi_host: {{ esxi_host }}

285
templates/index.html Normal file
View File

@@ -0,0 +1,285 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Core Network Tool</title>
<link rel="icon" href="/static/images/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { min-height: 100vh; }
main { display: flex; flex-wrap: nowrap; height: 100vh; max-height: 100vh; overflow-x: auto; overflow-y: hidden; }
.b-example-vr { flex-shrink: 0; width: 1.5rem; height: 100vh; background-color: rgba(0, 0, 0, .1); border: solid rgba(0, 0, 0, .15); border-width: 1px 0; }
#results-output { background-color: #212529; color: #f8f9fa; border-radius: .25rem; min-height: 300px; font-family: monospace; }
.table-hover tbody tr:hover { cursor: pointer; }
.table-compact td,
.table-compact th {
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
.accordion-button::after {
filter: invert(1) grayscale(100%);
}
.network-card { cursor: pointer; }
.network-card:hover { border-color: #0d6efd !important; }
.nav-pills .nav-link:not(.active) {
color: white;
}
.sidebar-logo {
height: 40px;
width: auto;
transition: transform 0.2s ease-in-out;
}
.sidebar-logo:hover {
transform: scale(1.05);
}
.menu-header {
padding: .5rem 1rem .25rem;
font-size: .875rem;
color: #6c757d;
font-weight: 500;
}
input#host::placeholder {
color: #6c757d;
opacity: 1;
}
.tree-item { cursor: pointer; }
.tree-item:hover { background-color: #495057; }
.tree-item .bi { transition: transform 0.2s; }
.tree-item.open > .bi-chevron-right { transform: rotate(90deg); }
</style>
</head>
<body>
<main>
<div class="d-flex flex-column flex-shrink-0 p-3 text-bg-dark" style="width: 280px;">
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<img src="/static/images/hpe_logo_animated.gif" alt="HPE Logo" class="sidebar-logo">
</a>
<hr>
<ul class="nav nav-pills flex-column mb-auto" id="menu">
<li class="menu-header">Dashboard Functions</li>
<li class="nav-item">
<a href="{{ url_for('system_browser_page') }}" class="nav-link {% if active_page == 'system_browser' %}active{% endif %}" data-page-title="System Browser">
<i class="bi bi-diagram-3-fill me-2"></i> System Browser
</a>
</li>
<li class="nav-item">
<a href="{{ url_for('vpn_status_page') }}" class="nav-link {% if active_page == 'vpn_status' %}active{% endif %}" data-page-title="m2000 Status">
<i class="bi bi-shield-lock-fill me-2"></i> m2000 Status
</a>
</li>
<li>
<a href="{{ url_for('network_config_page') }}" class="nav-link {% if active_page == 'network_config' %}active{% endif %}" data-page-title="Network Configuration">
<i class="bi bi-hdd-network-fill me-2"></i> Network Config
</a>
</li>
<li>
<a href="{{ url_for('tenants_page') }}" class="nav-link {% if active_page == 'tenants' %}active{% endif %}" data-page-title="Dashboard Tenant Management">
<i class="bi bi-building-fill me-2"></i> Dashboard Tenant
</a>
</li>
<li><hr class="my-2"></li>
<li class="menu-header">ComcoCore Functions</li>
<li>
<a href="{{ url_for('hnk_page') }}" class="nav-link {% if active_page == 'hnk' %}active{% endif %}" data-page-title="Home Network Key (HNK) Management">
<i class="bi bi-key-fill me-2"></i> Home Network Keys
</a>
</li>
<li>
<a href="{{ url_for('network_clients_page') }}" class="nav-link {% if active_page == 'network_clients' %}active{% endif %}" data-page-title="Network Clients (SUPI)">
<i class="bi bi-person-fill-gear me-2"></i> Network Clients
</a>
</li>
</ul>
<hr>
<div id="vpn-controls">
<h6 class="text-white">Dashboard VPNs</h6>
<ul class="list-unstyled">
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-triton" data-vpn-name="Triton">
<label class="form-check-label" for="vpn-triton">Triton</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-star" data-vpn-name="Star">
<label class="form-check-label" for="vpn-star">Star</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-bluebonnet" data-vpn-name="Bluebonnet">
<label class="form-check-label" for="vpn-bluebonnet">Bluebonnet</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-lonestar" data-vpn-name="Lonestar">
<label class="form-check-label" for="vpn-lonestar">Lonestar</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-production" data-vpn-name="Production">
<label class="form-check-label" for="vpn-production">Production</label>
</div>
</li>
</ul>
<hr class="my-2">
<h6 class="text-white">HPE P5G Support VPNs</h6>
<ul class="list-unstyled">
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-us-support" data-vpn-name="US-Support">
<label class="form-check-label" for="vpn-us-support">US Support</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-eu-support" data-vpn-name="EU-Support">
<label class="form-check-label" for="vpn-eu-support">EU Support</label>
</div>
</li>
</ul>
</div>
</div>
<div class="b-example-vr"></div>
<div class="flex-grow-1 p-4 overflow-y-auto">
{% block content %}{% endblock %}
</div>
</main>
<div class="modal fade" id="networkDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="networkDetailModalLabel">Network Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre id="modalJsonOutput"></pre>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script defer>
const resultsOutput = document.getElementById('results-output');
const spinner = document.getElementById('spinner');
const networkDetailModal = document.getElementById('networkDetailModal');
document.addEventListener('DOMContentLoaded', () => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
checkVpnStatus();
});
function formatHostIp(ipString) {
if (!ipString) return '';
if (ipString.includes(':') && !ipString.startsWith('[') && !ipString.endsWith(']')) {
return `[${ipString}]`;
}
return ipString;
}
async function apiCall(endpoint, body, clearResults = true) {
spinner.classList.remove('d-none');
if (clearResults) {
resultsOutput.innerHTML = '';
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const result = await response.json();
if (!response.ok) {
if (result.error === 'Network Unreachable') throw new Error(result.message);
throw new Error(result.error || 'Unknown server error');
}
return result;
} catch (error) {
alert(`Error: ${error.message}`);
return null;
} finally {
spinner.classList.add('d-none');
}
}
function getStatusClass(status) {
switch (status) {
case 'DEPLOYED': case 'up': return 'bg-success';
case 'ALL_HW_NOT_ONLINE': return 'bg-danger';
case 'TO_DEPLOY': return 'bg-warning text-dark';
default: return 'bg-secondary';
}
}
function initializeTooltips() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
}
networkDetailModal.addEventListener('show.bs.modal', event => {
const card = event.relatedTarget;
const nodeName = card.getAttribute('data-node-name');
const nodeDetails = JSON.parse(card.getAttribute('data-node-details'));
const modalTitle = networkDetailModal.querySelector('.modal-title');
const modalBody = networkDetailModal.querySelector('#modalJsonOutput');
modalTitle.textContent = `Details for: ${nodeName}`;
modalBody.textContent = JSON.stringify(nodeDetails, null, 2);
});
const vpnToggles = document.querySelectorAll('.vpn-toggle');
function updateVpnTogglesUI(activeVpn) {
vpnToggles.forEach(toggle => {
toggle.checked = (toggle.dataset.vpnName === activeVpn);
toggle.disabled = false;
});
}
async function checkVpnStatus() {
try {
const response = await fetch('/api/vpn/status');
const data = await response.json();
if (response.ok) updateVpnTogglesUI(data.active_vpn);
} catch (error) {
console.error("Failed to fetch VPN status:", error);
}
}
vpnToggles.forEach(toggle => {
toggle.addEventListener('change', async (event) => {
const vpnName = event.target.dataset.vpnName;
const turnOn = event.target.checked;
vpnToggles.forEach(t => t.disabled = true);
try {
const response = await fetch('/api/vpn/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vpn_name: vpnName, state: turnOn })
});
const data = await response.json();
if (!response.ok) {
alert(`Error: ${data.error}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
} finally {
checkVpnStatus();
}
});
});
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

329
templates/layout.html Normal file
View File

@@ -0,0 +1,329 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale-1">
<title>{% block title %}Core Network Tool{% endblock %}</title>
<link rel="icon" href="/static/images/favicon.ico">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { min-height: 100vh; }
main { display: flex; flex-wrap: nowrap; height: 100vh; max-height: 100vh; overflow-x: auto; overflow-y: hidden; }
.b-example-vr { flex-shrink: 0; width: 1.5rem; height: 100vh; background-color: rgba(0, 0, 0, .1); border: solid rgba(0, 0, 0, .15); border-width: 1px 0; }
#results-output { background-color: #212529; color: #f8f9fa; border-radius: .25rem; min-height: 300px; font-family: monospace; }
.table-hover tbody tr:hover {
cursor: pointer;
}
.table-compact td,
.table-compact th {
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
.accordion-button::after {
filter: invert(1) grayscale(100%);
}
.network-card { cursor: pointer; }
.network-card:hover { border-color: #0d6efd !important; }
.nav-pills .nav-link:not(.active) {
color: white;
}
.sidebar-logo {
height: 40px;
width: auto;
transition: transform 0.2s ease-in-out;
}
.sidebar-logo:hover { transform: scale(1.05); }
.menu-header {
padding: .5rem 1rem .25rem;
font-size: .875rem;
color: #6c757d;
font-weight: 500;
}
input#host::placeholder {
color: #6c757d;
opacity: 1;
}
.tree-item {
cursor: pointer;
}
.tree-item:hover { background-color: #495057; }
.tree-item .bi {
transition: transform 0.2s;
}
.tree-item.open > .bi-chevron-right { transform: rotate(90deg); }
/* Scrollable sidebar */
.sidebar-scroll {
max-height: 100vh; /* full viewport height */
overflow-y: auto; /* vertical scroll when needed */
-webkit-overflow-scrolling: touch;
}
@media (min-width: 992px) {
.sidebar-scroll {
position: sticky;
top: 0; /* adjust if navbar is fixed */
}
}
{% block extra_styles %}{% endblock %}
</style>
</head>
<body>
<main>
<div class="d-flex flex-column flex-shrink-0 p-3 text-bg-dark vh-100" style="width: 280px;">
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<img src="/static/images/hpe_logo_animated.gif" alt="HPE Logo" class="sidebar-logo">
</a>
<hr>
{% set active_page = active_page|default('vpn_status') %}
<div class="flex-grow-1 overflow-auto" style="min-height:0">
<ul class="nav nav-pills flex-column mb-auto" id="menu">
<li class="menu-header">Dashboard Functions</li>
<li class="nav-item">
<a href="{{ url_for('vpn_status_page') }}" class="nav-link {% if active_page == 'vpn_status' %}active{% endif %}">
<i class="bi bi-shield-lock-fill me-2"></i> m2000 Status
</a>
</li>
<li>
<a href="{{ url_for('network_config_page') }}" class="nav-link {% if active_page == 'network_config' %}active{% endif %}">
<i class="bi bi-hdd-network-fill me-2"></i> Network Config
</a>
</li>
<li>
<a href="{{ url_for('m2000_password_page') }}" class="nav-link {% if active_page == 'm2000_password' %}active{% endif %}">
<i class="bi bi-asterisk me-2"></i> m2000 Password
</a>
</li>
<li>
<a href="{{ url_for('tenants_page') }}" class="nav-link {% if active_page == 'tenants' %}active{% endif %}">
<i class="bi bi-building-fill me-2"></i> Dashboard Tenant
</a>
</li>
<li>
<a href="{{ url_for('users_page') }}" class="nav-link {% if active_page == 'users' %}active{% endif %}">
<i class="bi bi-people-fill me-2"></i> Dashboard Users
</a>
</li>
<li>
<a href="{{ url_for('m2000_reset_page') }}" class="nav-link {% if active_page == 'm2000_reset' %}active{% endif %}">
<i class="bi bi-arrow-counterclockwise me-2"></i> m2000 Config Reset
</a>
</li>
<li><hr class="my-2"></li>
<li class="menu-header">ComcoCore Functions</li>
<li>
<a href="{{ url_for('system_browser_page') }}" class="nav-link {% if active_page == 'system_browser' %}active{% endif %}">
<i class="bi bi-diagram-3-fill me-2"></i> System Browser
</a>
</li>
<li>
<a href="{{ url_for('vpn_switcher_page') }}" class="nav-link {% if active_page == 'vpn_switcher' %}active{% endif %}">
<i class="bi bi-arrow-repeat me-2"></i> VPN Switcher
</a>
</li>
<li>
<a href="{{ url_for('hnk_page') }}" class="nav-link {% if active_page == 'hnk' %}active{% endif %}">
<i class="bi bi-key-fill me-2"></i> Home Network Keys
</a>
</li>
<li>
<a href="{{ url_for('network_clients_page') }}" class="nav-link {% if active_page == 'network_clients' %}active{% endif %}">
<i class="bi bi-person-fill-gear me-2"></i> Network Clients
</a>
</li>
<li>
<a href="{{ url_for('gaf_desk_page') }}" class="nav-link {% if active_page == 'gaf_desk' %}active{% endif %}">
<i class="bi bi-layout-wtf me-2"></i> GAF Desk
</a>
</li>
</ul>
</div>
<hr>
<div id="vpn-controls">
<h6 class="text-white">Dashboard VPNs</h6>
<ul class="list-unstyled">
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-triton" data-vpn-name="Triton">
<label class="form-check-label" for="vpn-triton">Triton</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-star" data-vpn-name="Star">
<label class="form-check-label" for="vpn-star">Star</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-bluebonnet" data-vpn-name="Bluebonnet">
<label class="form-check-label" for="vpn-bluebonnet">Bluebonnet</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-lonestar" data-vpn-name="Lonestar">
<label class="form-check-label" for="vpn-lonestar">Lonestar</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-production" data-vpn-name="Production">
<label class="form-check-label" for="vpn-production">Production</label>
</div>
</li>
</ul>
<hr class="my-2">
<h6 class="text-white">HPE P5G Support VPNs</h6>
<ul class="list-unstyled">
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-us-support" data-vpn-name="US-Support">
<label class="form-check-label" for="vpn-us-support">US Support</label>
</div>
</li>
<li>
<div class="form-check form-switch text-white">
<input class="form-check-input vpn-toggle" type="checkbox" role="switch" id="vpn-eu-support" data-vpn-name="EU-Support">
<label class="form-check-label" for="vpn-eu-support">EU Support</label>
</div>
</li>
</ul>
</div>
</div>
<div class="b-example-vr"></div>
<div class="flex-grow-1 p-4 overflow-y-auto">
{% block content %}{% endblock %}
</div>
</main>
<div class="modal fade" id="networkDetailModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="networkDetailModalLabel">Network Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre id="modalJsonOutput"></pre>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
<script defer>
const resultsOutput = document.getElementById('results-output');
const spinner = document.getElementById('spinner');
const networkDetailModal = document.getElementById('networkDetailModal');
// ADDED: Reusable function to initialize tooltips
function initializeTooltips() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
}
document.addEventListener('DOMContentLoaded', () => {
initializeTooltips();
checkVpnStatus();
});
function formatHostIp(ipString) {
if (!ipString) return '';
if (ipString.includes(':') && !ipString.startsWith('[') && !ipString.endsWith(']')) {
return `[${ipString}]`;
}
return ipString;
}
async function apiCall(endpoint, body, clearResults = true) {
spinner.classList.remove('d-none');
if (clearResults) {
resultsOutput.innerHTML = '';
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const result = await response.json();
if (!response.ok) {
if (result.error === 'Network Unreachable') throw new Error(result.message);
throw new Error(result.error || 'Unknown server error');
}
return result;
} catch (error) {
alert(`Error: ${error.message}`);
return null;
} finally {
spinner.classList.add('d-none');
}
}
function getStatusClass(status) {
switch (status) {
case 'DEPLOYED': case 'up': return 'bg-success';
case 'ALL_HW_NOT_ONLINE': return 'bg-danger';
case 'TO_DEPLOY': return 'bg-warning text-dark';
default: return 'bg-secondary';
}
}
networkDetailModal.addEventListener('show.bs.modal', event => {
const card = event.relatedTarget;
const nodeName = card.getAttribute('data-node-name');
const nodeDetails = JSON.parse(card.getAttribute('data-node-details'));
const modalTitle = networkDetailModal.querySelector('.modal-title');
const modalBody = networkDetailModal.querySelector('#modalJsonOutput');
modalTitle.textContent = `Details for: ${nodeName}`;
modalBody.textContent = JSON.stringify(nodeDetails, null, 2);
});
const vpnToggles = document.querySelectorAll('.vpn-toggle');
function updateVpnTogglesUI(activeVpn) {
vpnToggles.forEach(toggle => {
toggle.checked = (toggle.dataset.vpnName === activeVpn);
toggle.disabled = false;
});
}
async function checkVpnStatus() {
try {
const response = await fetch('/api/vpn/status');
const data = await response.json();
if (response.ok) updateVpnTogglesUI(data.active_vpn);
} catch (error) {
console.error("Failed to fetch VPN status:", error);
}
}
vpnToggles.forEach(toggle => {
toggle.addEventListener('change', async (event) => {
const vpnName = event.target.dataset.vpnName;
const turnOn = event.target.checked;
vpnToggles.forEach(t => t.disabled = true);
try {
const response = await fetch('/api/vpn/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vpn_name: vpnName, state: turnOn })
});
const data = await response.json();
if (!response.ok) {
alert(`Error: ${data.error}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
} finally {
checkVpnStatus();
}
});
});
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,431 @@
{% extends "layout.html" %}
{% set active_page = 'gaf_desk' %}
{% block title %}GAF Desk - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">GAF Desk: Configuration Generator</h2>
</div>
<hr>
<div class="row">
<div class="col-md-4">
<label for="blueprint-select" class="form-label"><h5>1. Select a Blueprint</h5></label>
<select class="form-select" id="blueprint-select">
<option selected value="">Choose a blueprint...</option>
<option value="00_simple_5G_only">00_simple_5G_only</option>
<option value="01_single_site">01_single_site</option>
<option value="02_high_availability">02_high_availability</option>
<option value="03_distributed">03_distributed</option>
<option value="04_high_availability_ipv6">04_high_availability_ipv6</option>
<option value="05_high_availability_ospf">05_high_availability_ospf</option>
<option value="06_upf_active_standby">06_upf_active_standby</option>
<option value="07_high_availability_proxmox">07_high_availability_proxmox</option>
<option value="08_all_in_one">08_all_in_one</option>
<option value="10_4G_MVNO">10_4G_MVNO</option>
</select>
</div>
</div>
<div class="card my-4">
<div class="card-header">
<h5>2. Global Configuration</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label for="network-name-input" class="form-label">Network Name</label>
<input type="text" class="form-control" id="network-name-input" value="JohnWayne" readonly
data-bs-toggle="tooltip" title="This is the hardcoded network name.">
</div>
<div class="col-md-3">
<label for="plmn-input" class="form-label">PLMN</label>
<input type="text" class="form-control" id="plmn-input" value="315-010" readonly
data-bs-toggle="tooltip" title="The hardcoded PLMN ID for this configuration.">
</div>
<div class="col-md-3">
<label for="dns-input" class="form-label">DNS</label>
<input type="text" class="form-control" id="dns-input" value="8.8.8.8" readonly
data-bs-toggle="tooltip" title="Primary DNS server. This value is fixed.">
</div>
<div class="col-md-3">
<label for="ntp-input" class="form-label">NTP</label>
<input type="text" class="form-control" id="ntp-input" value="0.pool.ntp.org, 1.pool.ntp.org" readonly
data-bs-toggle="tooltip" title="NTP servers for time synchronization.">
</div>
<div class="row g-3 mt-2">
<!-- RAN -->
<div class="col-md-4">
<div class="mb-1"><span class="form-label d-block fw-semibold">RAN Network</span></div>
<input type="text" class="form-control" id="ip-core-ran"
placeholder="Core IP addr in format 172.28.20.25/24"
data-bs-toggle="tooltip"
title="Enter the IP range for the connection between the Cell Site and the Core.">
<input type="text" class="form-control mt-2" id="ip-core-ran-gw"
placeholder="RAN Gateway IP e.g. 172.28.20.1">
</div>
<div class="col-md-4">
<div class="mb-1"><span class="form-label d-block fw-semibold">Management Network</span></div>
<input type="text" class="form-control" id="ip-core-mgmt"
value="via DHCP" readonly
placeholder="Will be set in Step 1"
data-bs-toggle="tooltip"
title="This IP will be set automatically based on the current DHCP address during prechecks.">
<input type="text" class="form-control mt-2" id="ip-core-mgmt-gw"
value="via DHCP" readonly
placeholder="Gateway will be set in Step 1"
data-bs-toggle="tooltip"
title="This gateway will be set automatically based on the current DHCP address during prechecks.">
</div>
<div class="col-md-4">
<div class="mb-1"><span class="form-label d-block fw-semibold">Data Network</span></div>
<input type="text" class="form-control" id="ip-core-dn"
placeholder="Core DN/APN IP addr in format 172.28.10.25/24"
data-bs-toggle="tooltip"
title="Enter the IP range for the connection between the Core and the Data Network.">
<input type="text" class="form-control mt-2" id="ip-core-dn-gw"
placeholder="DN/APN Gateway IP e.g. 172.28.20.1">
<label for="vlan-core-dn" class="visually-hidden">Data Network VLAN</label>
<input type="number" class="form-control mt-2" id="ip-core-dn-vlan"
placeholder="VLAN (e.g. 200)" min="1" max="4094">
<label for="ue-ip-pool" class="visually-hidden">UE IP Pool (CIDR)</label>
<input type="text" class="form-control mt-2" id="ip-core-dn-uepool"
placeholder="UE IP Pool (e.g. 10.20.0.0/16)">
</div>
</div>
</div>
</div>
<h5>3. Blueprint Details</h5>
<div id="blueprint-diagram-area" class="mt-3">
<div id="cy-5g" class="d-none" style="height:520px; border:1px solid #e5e7eb; border-radius:8px;"></div>
<div id="diagram-empty" class="text-muted text-center py-4" aria-live="polite">
Please select a blueprint to see its diagram.
</div>
</div>
<hr>
<div id="deployment-wizard">
<h5 class="mb-3">4. Deployment Wizard</h5>
<div class="progress mb-4" style="height: 25px;">
<div class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
</div>
<div class="accordion" id="deploymentAccordion">
<div class="accordion-item">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne">
Stage 1: Prechecks
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse show" data-bs-parent="#deploymentAccordion">
<div class="accordion-body">
<ul class="list-group">
<li class="list-group-item" id="target-host-row">
<div class="d-flex flex-wrap align-items-center gap-2">
<strong class="me-2">Target host (VPN IP):</strong>
<div class="input-group" style="max-width: 260px;">
<span class="input-group-text">IPv4</span>
<input id="target-ip-input" type="text" class="form-control" placeholder="10.x.x.x" inputmode="numeric" autocomplete="off">
</div>
<button id="btn-enable-access" class="btn btn-sm btn-primary">Enable Access</button>
<span id="access-badge" class="badge bg-secondary ms-2">Pending</span>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<strong class="me-2">Retrieve eth0 network info:</strong>
<button class="btn btn-sm btn-primary" id="btn-capture-oam">Run</button>
<span class="badge bg-secondary ms-2" id="oam-badge">Pending</span>
</div>
<div class="small text-muted mt-2">
Enables SSH &amp; Webconsole (enable → enable-autostart → start).<br>
Retrieves eth0 network info and configures static address.
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingTwo">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo">
Stage 2: Preparation
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse" data-bs-parent="#deploymentAccordion">
<div class="accordion-body">
<ul class="list-group">
<li class="list-group-item" id="create-yaml-row">
<div class="d-flex flex-wrap align-items-center gap-2">
<strong class="me-2">Create YAML files:</strong>
<button class="btn btn-sm btn-primary" id="btn-create-yaml">Create</button>
<span class="badge bg-secondary ms-2" id="yaml-badge">Pending</span>
</div>
<div class="small text-muted mt-2">
Generates YAML files for deployment based on the configuration above.
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingThree">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseThree">
Stage 3: Execution
</button>
</h2>
<div id="collapseThree" class="accordion-collapse collapse" data-bs-parent="#deploymentAccordion">
<div class="accordion-body">
<ul class="list-group">
<li class="list-group-item" id="run-gaf-row">
<div class="d-flex flex-wrap align-items-center gap-2">
<strong class="me-2">Run GAF:</strong>
<button class="btn btn-sm btn-primary" id="btn-run-gaf">Run</button>
</div>
<div class="small text-muted mt-2">
Executes the GAF deployment process.
</div>
<div class="mt-3">
<pre id="gaf-output" style="background:#f8f9fa; border-radius:6px; padding:12px; max-height:300px; overflow:auto;"></pre>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingFour">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseFour">
Stage 4: Postchecks
</button>
</h2>
<div id="collapseFour" class="accordion-collapse collapse" data-bs-parent="#deploymentAccordion">
<div class="accordion-body">
<ul class="list-group">
<li class="list-group-item" id="post-backup-row">
<div class="d-flex flex-wrap align-items-center gap-2">
<strong class="me-2">Post-backup:</strong>
<button class="btn btn-sm btn-primary" id="btn-post-backup" disabled>Run</button>
</div>
<div class="small text-muted mt-2">
Performs post-deployment backup operations.
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="https://unpkg.com/cytoscape@3.28.0/dist/cytoscape.min.js"></script>
<script src="https://unpkg.com/dagre@0.8.5/dist/dagre.min.js"></script>
<script src="https://unpkg.com/cytoscape-dagre@2.5.0/cytoscape-dagre.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
[...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
});
</script>
<script type="module">
import { mountCy } from '/static/js/graph/initGraph.js';
import { mountWizard } from '/static/js/wizard/steps.js';
import { mountStep3Deploy } from '/static/js/wizard/step3_deploy.js';
mountWizard();
mountStep3Deploy();
// === Recapture OAM (eth0) on demand ===
// Stage 3 deployment logic is now handled by static/js/wizard/step3_deploy.js
async function recaptureOAM(host) {
const oamBadge = document.getElementById('oam-badge');
const mgmtInput = document.getElementById('ip-core-mgmt'); // the read-only field in "Management Network"
if (!host) {
alert('Enter the Target host first (Stage 1).');
return null;
}
try {
oamBadge.textContent = 'Running…';
oamBadge.className = 'badge bg-warning';
const res = await fetch('/api/local/eth0/capture', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host })
});
const j = await res.json();
if (!res.ok || !j.ok) throw new Error(j.error || `HTTP ${res.status}`);
// Update UI with fresh values
oamBadge.textContent = 'Captured';
oamBadge.className = 'badge bg-success';
if (mgmtInput) mgmtInput.value = j.cidr; // e.g. 192.168.86.55/24
const mgmtGwInput = document.getElementById('ip-core-mgmt-gw');
if (mgmtGwInput) mgmtGwInput.value = j.gw || '';
return j; // { cidr, gw, ok:true }
} catch (e) {
console.error('OAM capture failed:', e);
oamBadge.textContent = 'Failed';
oamBadge.className = 'badge bg-danger';
alert(`OAM capture failed: ${e.message}`);
return null;
}
}
// Wire the Stage 1 "Run" button to always re-capture
document.getElementById('btn-capture-oam')?.addEventListener('click', async () => {
const targetIp = document.querySelector('#target-host-row input')?.value?.trim();
await recaptureOAM(targetIp);
});
const blueprintSelect = document.getElementById('blueprint-select');
const cyContainer = document.getElementById('cy-5g');
const emptyMsg = document.getElementById('diagram-empty');
let cy = null;
const cache = new Map();
function showEmpty() {
if (cy) { cy.destroy(); cy = null; }
cyContainer.classList.add('d-none');
emptyMsg.textContent = 'Please select a blueprint to see its diagram.'; // reset
emptyMsg.classList.remove('d-none');
}
let dagreRegistered = false;
async function loadBlueprint(name) {
if (!name) return showEmpty();
const url = `/static/blueprints/${name}.json`;
try {
const data = cache.has(url) ? cache.get(url) : await (await fetch(url, { cache: 'no-cache' })).json();
cache.set(url, data);
emptyMsg.classList.add('d-none');
cyContainer.classList.remove('d-none');
if (cy) { cy.destroy(); cy = null; }
if (!dagreRegistered && window.cytoscapeDagre) {
cytoscape.use(cytoscapeDagre);
dagreRegistered = true;
}
cy = mountCy(cyContainer, data);
} catch (err) {
console.error('Failed to load blueprint:', err);
showEmpty();
emptyMsg.textContent = 'Failed to load the selected blueprint.';
}
}
blueprintSelect.addEventListener('change', () => {
const selectedValue = blueprintSelect.value;
loadBlueprint(selectedValue);
});
// Handle Create YAML button click (calls /api/ansible/render)
document.getElementById('btn-create-yaml').addEventListener('click', async () => {
const val = id => (document.getElementById(id)?.value || '').trim();
// Arrays
const dns = (val('dns-input') || '8.8.8.8')
.split(',')
.map(s => s.trim())
.filter(Boolean);
const ntp = (val('ntp-input') || '0.pool.ntp.org, 1.pool.ntp.org')
.split(',')
.map(s => s.trim())
.filter(Boolean);
// DN VLAN as number (or undefined)
const vlanStr = val('ip-core-dn-vlan');
const vlanNum = vlanStr ? Number(vlanStr) : undefined;
// Target IP from Stage 1
const targetIp = document.querySelector('#target-host-row input')?.value?.trim() || '';
// Always recapture just before render so we never use stale cache
const cap = await recaptureOAM(targetIp); // <- requires the helper added earlier
if (!cap) return; // capture failed; stop here
// Update the Management field from fresh capture (nice UX)
const mgmtField = document.getElementById('ip-core-mgmt');
if (mgmtField && cap.cidr) mgmtField.value = cap.cidr;
// Build payload; mgmt is determined server-side from the fresh capture
const payload = {
hostname: val('network-name-input') || 'AIO-1',
network_name: val('network-name-input') || 'Network',
plmn: val('plmn-input') || '315-010',
dns,
ntp,
ran: { cidr: val('ip-core-ran'), gw: val('ip-core-ran-gw') },
dn: {
cidr: val('ip-core-dn'),
gw: val('ip-core-dn-gw'),
vlan: vlanNum,
ue_pool: val('ip-core-dn-uepool'),
dnn: 'internet'
},
inventory_host: 'GBP08-AIO-1',
esxi_host: 'ESXI-1',
version: '25.1',
ova_file: '/home/mjensen/OVA/HPE_ANW_P5G_Core-1.25.1.1-qemux86-64.ova',
// Make sure backend knows which host to use; it will read the fresh snapshot
ansible_host_ip: targetIp,
force_oam_refresh: true // harmless hint; backend may ignore if not implemented
};
try {
const res = await fetch('/api/ansible/render', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const j = await res.json();
if (!res.ok || !j.ok) throw new Error(j.error || `HTTP ${res.status}`);
document.getElementById('yaml-badge').textContent = 'Created';
document.getElementById('yaml-badge').className = 'badge bg-success';
alert(`YAML files created in: ${j.staging}`);
} catch (err) {
console.error('Error creating YAML:', err);
document.getElementById('yaml-badge').textContent = 'Failed';
document.getElementById('yaml-badge').className = 'badge bg-danger';
}
});
showEmpty();
</script>
{% endblock %}

45
templates/pages/hnk.html Normal file
View File

@@ -0,0 +1,45 @@
{% extends "layout.html" %}
{% set active_page = 'hnk' %}
{% block title %}HNK Management - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">Home Network Key (HNK) Management</h2>
</div>
<div class="row align-items-end">
<div class="col-md-5" id="host-ip-wrapper">
<label for="host" class="form-label">5GC Host IP</label>
<input type="text" class="form-control" id="host" placeholder="IPv4 or IPv6 Address">
</div>
<div class="col-auto">
<i class="bi bi-info-circle" data-bs-toggle="tooltip" data-bs-placement="right" title="Enter the IPv4 or IPv6 address. IPv6 addresses are automatically enclosed in [ ] if needed."></i>
</div>
</div>
<hr>
<div id="hnk-view">
<p>Retrieve a list of all Home Network Keys from the specified 5GC Host.</p>
<button class="btn btn-primary" id="listHnkBtn">List HNKs</button>
</div>
<div class="mt-4">
<h4>Results</h4>
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
document.getElementById('listHnkBtn').addEventListener('click', async () => {
const rawHost = document.getElementById('host').value;
if (!rawHost) {
alert('Please enter a 5GC Host IP address.');
return;
}
const host = formatHostIp(rawHost);
const hnkData = await apiCall('/api/hnk/list', { host });
if (hnkData) {
resultsOutput.textContent = JSON.stringify(hnkData, null, 2);
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,138 @@
{% extends "layout.html" %}
{% set active_page = 'system_browser' %}
{% block title %}Host Details - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">Details for {{ details.system.hostname }}</h2>
<a href="{{ url_for('system_browser_page') }}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Back to System Browser
</a>
</div>
<hr>
{% if details and not details.error %}
<div class="row g-4">
<div class="col-md-4">
<h5>System Information</h5>
<dl class="row">
<dt class="col-sm-5">Hostname</dt><dd class="col-sm-7">{{ details.system.hostname }}</dd>
<dt class="col-sm-5">Customer</dt><dd class="col-sm-7">{{ details.browser_info.customer_name }}</dd>
<dt class="col-sm-5">Common Name</dt><dd class="col-sm-7">{{ details.browser_info.common_name }}</dd>
<dt class="col-sm-5">Virtual IP</dt><dd class="col-sm-7">{{ request.view_args.host_ip }}</dd>
<dt class="col-sm-5">Public IP</dt><dd class="col-sm-7">{{ details.browser_info.public_ip }}</dd>
<dt class="col-sm-5">Connected Since</dt><dd class="col-sm-7">{{ details.browser_info.connected_since }}</dd>
</dl>
</div>
<div class="col-md-4">
<h5>Site Information</h5>
<dl class="row">
<dt class="col-sm-4">Node Name</dt><dd class="col-sm-8">{{ details.site.current_node.name }}</dd>
<dt class="col-sm-4">API Address</dt><dd class="col-sm-8">{{ details.site.current_node.api_address }}</dd>
<dt class="col-sm-4">Site ID</dt><dd class="col-sm-8"><code>{{ details.licensed_host.siteid.value }}</code></dd>
</dl>
</div>
<div class="col-md-4">
<h5>License Information</h5>
{% if details.license and details.license.license and details.license.license.license_params %}
<dl class="row">
<dt class="col-sm-5">Status</dt><dd class="col-sm-7">{{ details.license.state.str }}</dd>
<dt class="col-sm-5">Start Date</dt><dd class="col-sm-7">{{ details.license.license.license_params.start_date_str }}</dd>
<dt class="col-sm-5">Expire Date</dt><dd class="col-sm-7">{{ details.license.license.license_params.expire_date_str }}</dd>
</dl>
{% else %}
<div class="alert alert-warning">No license information found.</div>
{% endif %}
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<h5>Services Status</h5>
<div class="table-responsive">
<table class="table table-sm table-dark table-hover table-compact">
<thead id="services-table-head">
<tr>
<th data-sort="name">Service <i class="bi bi-arrow-down-up"></i></th>
<th data-sort="version">Version <i class="bi bi-arrow-down-up"></i></th>
<th data-sort="state">Status <i class="bi bi-arrow-down-up"></i></th>
<th data-sort="license_id">License ID <i class="bi bi-arrow-down-up"></i></th>
<th data-sort="start_date">Start Date <i class="bi bi-arrow-down-up"></i></th>
<th data-sort="expire_date">Expire Date <i class="bi bi-arrow-down-up"></i></th>
</tr>
</thead>
<tbody id="services-table-body">
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="alert alert-danger">Could not load details for this host. Please check the server logs.</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}
<script defer>
const allServices = {{ details.services|tojson }};
let currentSort = { column: 'name', direction: 'asc' };
function renderServiceTable(services) {
const tableBody = document.getElementById('services-table-body');
tableBody.innerHTML = '';
services.forEach(service => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${service.name}</td>
<td>${service.version}</td>
<td><span class="badge ${service.state === 'started' ? 'bg-success' : 'bg-danger'}">${service.state}</span></td>
<td><code>${service.license ? service.license.license_id : 'N/A'}</code></td>
<td>${service.license ? service.license.license.license_params.start_date_str : 'N/A'}</td>
<td>${service.license ? service.license.license.license_params.expire_date_str : 'N/A'}</td>
`;
tableBody.appendChild(row);
});
}
function sortAndRender() {
allServices.sort((a, b) => {
let valA, valB;
if (currentSort.column === 'license_id') {
valA = a.license ? a.license.license_id : '';
valB = b.license ? b.license.license_id : '';
} else if (currentSort.column === 'start_date' || currentSort.column === 'expire_date') {
valA = a.license ? a.license.license.license_params[currentSort.column + '_str'] : '';
valB = b.license ? b.license.license.license_params[currentSort.column + '_str'] : '';
} else {
valA = a[currentSort.column] || '';
valB = b[currentSort.column] || '';
}
if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
renderServiceTable(allServices);
}
document.addEventListener('DOMContentLoaded', () => {
sortAndRender();
});
document.getElementById('services-table-head').addEventListener('click', (event) => {
const header = event.target.closest('th[data-sort]');
if (header) {
const sortColumn = header.dataset.sort;
if (currentSort.column === sortColumn) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = sortColumn;
currentSort.direction = 'asc';
}
sortAndRender();
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,70 @@
{% extends "layout.html" %}
{% set active_page = 'm2000_reset' %}
{% block title %}m2000 Config Reset - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">m2000 Configuration Reset</h2>
</div>
<hr>
<div id="reset-view">
<p>Enter a base IPv6 address to reset the configuration for AMF, UPF, SMF, SGWC, MME, and PCF services on the derived hosts (ending in ':a' and ':b').</p>
<div class="row align-items-end g-3">
<div class="col-md-6">
<label for="base-ip-input" class="form-label">Base IPv6 Address</label>
<input type="text" class="form-control" id="base-ip-input" placeholder="e.g., fd14:6666::1a:0">
</div>
<div class="col-auto">
<button class="btn btn-danger" id="resetConfigBtn">Reset Configuration</button>
</div>
</div>
</div>
<div class="mt-4">
<h4>Result</h4>
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
document.getElementById('resetConfigBtn').addEventListener('click', async () => {
const baseIp = document.getElementById('base-ip-input').value;
if (!baseIp) {
alert('Please enter a base IPv6 address.');
return;
}
if (!confirm('Are you sure you want to factory reset the configuration for 6 services on 2 hosts? This action cannot be undone.')) {
return;
}
const results = await apiCall('/api/m2000/reset-config', { base_ip: baseIp });
if (results) {
let tableHtml = `<table class="table table-dark table-sm table-compact">
<thead>
<tr>
<th>Host</th>
<th>Service</th>
<th>Status</th>
<th>Message</th>
</tr>
</thead>
<tbody>`;
results.forEach(result => {
const statusClass = result.status === 'Success' ? 'bg-success' : 'bg-danger';
tableHtml += `<tr>
<td>${result.host}</td>
<td>${result.service}</td>
<td><span class="badge ${statusClass}">${result.status}</span></td>
<td>${result.message}</td>
</tr>`;
});
tableHtml += '</tbody></table>';
resultsOutput.innerHTML = tableHtml;
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,67 @@
{% extends "layout.html" %}
{% set active_page = 'm2000_password' %}
{% block title %}m2000 Password Generator - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">m2000 Password Generator</h2>
</div>
<hr>
<div id="password-view">
<p>Enter an m2000 serial number to generate its password.</p>
<div class="row align-items-end g-3">
<div class="col-md-4">
<label for="serial-input" class="form-label">Serial Number</label>
<input type="text" class="form-control" id="serial-input">
</div>
<div class="col-auto">
<button class="btn btn-primary" id="getPasswordBtn">Generate Password</button>
</div>
</div>
</div>
<div class="mt-4">
<h4>Result</h4>
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
const serialInput = document.getElementById('serial-input');
const getPasswordBtn = document.getElementById('getPasswordBtn');
async function generatePassword() {
const serial = serialInput.value;
if (!serial) {
alert('Please enter a serial number.');
return;
}
// CHANGED: Update the URL in the browser's history
history.pushState({}, '', `/m2000psw?serial=${serial}`);
const result = await apiCall('/api/m2000/get-password', { serial });
if (result) {
resultsOutput.innerHTML = `
<dl class="row">
<dt class="col-sm-3">Serial Number:</dt>
<dd class="col-sm-9">${result.serial}</dd>
<dt class="col-sm-3">Derived Password:</dt>
<dd class="col-sm-9"><code>${result.password}</code></dd>
</dl>
`;
}
}
getPasswordBtn.addEventListener('click', generatePassword);
document.addEventListener('DOMContentLoaded', () => {
const serialFromUrl = '{{ serial_from_url|default("", True) }}';
if (serialFromUrl) {
serialInput.value = serialFromUrl;
getPasswordBtn.click();
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "layout.html" %}
{% set active_page = 'network_clients' %}
{% block title %}Network Clients - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">Network Clients (SUPI)</h2>
</div>
<div class="row align-items-end">
<div class="col-md-5" id="host-ip-wrapper">
<label for="host" class="form-label">5GC Host IP</label>
<input type="text" class="form-control" id="host" placeholder="IPv4 or IPv6 Address">
</div>
<div class="col-auto">
<i class="bi bi-info-circle" data-bs-toggle="tooltip" data-bs-placement="right" title="Enter the IPv4 or IPv6 address. IPv6 addresses are automatically enclosed in [ ] if needed."></i>
</div>
</div>
<hr>
<div id="subscriber-view">
<p>Retrieve a list of all Network Clients (SUPIs) from the specified 5GC Host.</p>
<button class="btn btn-primary" id="listSupiBtn">List Network Clients</button>
</div>
<div class="mt-4">
<h4>Results</h4>
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
document.getElementById('listSupiBtn').addEventListener('click', async () => {
const rawHost = document.getElementById('host').value;
if (!rawHost) {
alert('Please enter a 5GC Host IP address.');
return;
}
const host = formatHostIp(rawHost);
const supiData = await apiCall('/api/supis/list', { host });
if (supiData) {
resultsOutput.textContent = JSON.stringify(supiData, null, 2);
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,189 @@
{% extends "layout.html" %}
{% set active_page = 'network_config' %}
{% block title %}Network Config - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">Network Configuration</h2>
</div>
<div class="row align-items-end">
<div class="col-md-4" id="dashboard-select-wrapper">
<label for="dashboard-select" class="form-label">HPE P5G Dashboard</label>
<select class="form-select" id="dashboard-select">
<option selected>Triton</option>
<option>Star</option>
<option>Bluebonnet</option>
<option>Lonestar</option>
<option>Production</option>
<option>Test (future)</option>
</select>
</div>
</div>
<hr>
<div id="netconfig-view">
<p>Retrieve a summary of network nodes from the selected dashboard. Click on any node to view its full configuration.</p>
<button class="btn btn-primary" id="getConfigBtn">Get Network Config</button>
</div>
<div class="mt-4">
<h4>Results</h4>
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
<div id="details-pane" class="d-none">
<hr>
<h4>Selected Network Details</h4>
<div class="row g-4">
<div class="col-md-6">
<h5>General Info</h5>
<dl class="row">
<dt class="col-sm-4">Name</dt>
<dd class="col-sm-8" id="detail-name"></dd>
<dt class="col-sm-4">Tenant</dt>
<dd class="col-sm-8" id="detail-tenant"></dd>
<dt class="col-sm-4">Status</dt>
<dd class="col-sm-8" id="detail-status"></dd>
<dt class="col-sm-4">Reachable</dt>
<dd class="col-sm-8" id="detail-reachable"></dd>
<dt class="col-sm-4">Serials</dt>
<dd class="col-sm-8" id="detail-serials"></dd>
<dt class="col-sm-4">Radios</dt>
<dd class="col-sm-8">
<div class="input-group">
<input type="number" class="form-control" id="detail-radios-input" min="0">
<button class="btn btn-success" id="updateRadiosBtn">Update</button>
</div>
</dd>
</dl>
<h5>RAN Addressing</h5>
<dl class="row">
<dt class="col-sm-4">Gateway</dt>
<dd class="col-sm-8" id="detail-ran-gateway"></dd>
<dt class="col-sm-4">Subnet</dt>
<dd class="col-sm-8" id="detail-ran-subnet"></dd>
<dt class="col-sm-4">Radio Subnets</dt>
<dd class="col-sm-8" id="detail-ran-radiosubnets"></dd>
<dt class="col-sm-4">VLAN</dt>
<dd class="col-sm-8" id="detail-ran-vlan"></dd>
</dl>
</div>
<div class="col-md-6">
<h5>Data Network (DNN) List</h5>
<div id="detail-dnn-list"></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
const detailsPane = document.getElementById('details-pane');
let fullNodeConfig = []; // Store the full config data
function populateDetailsPane(node) {
detailsPane.dataset.networkId = node.id;
document.getElementById('detail-name').textContent = node.name || 'N/A';
document.getElementById('detail-tenant').textContent = node.tenant?.name || 'N/A';
document.getElementById('detail-status').innerHTML = `<span class="badge ${getStatusClass(node.status)}">${node.status}</span>`;
document.getElementById('detail-reachable').innerHTML = `<span class="badge ${node.network_reachable ? 'bg-success' : 'bg-danger'}">${node.network_reachable ? 'Reachable' : 'Unreachable'}</span>`;
document.getElementById('detail-serials').textContent = node.info?.hardware?.map(hw => hw.serial).join(', ') || 'N/A';
document.getElementById('detail-radios-input').value = node.info?.radio_pool?.[0]?.num_of_radios ?? 0;
const ran = node.addressing?.ran;
document.getElementById('detail-ran-gateway').textContent = ran?.gateway || 'N/A';
document.getElementById('detail-ran-subnet').textContent = ran?.subnet || 'N/A';
document.getElementById('detail-ran-radiosubnets').textContent = ran?.radio_subnets?.join(', ') || 'N/A';
document.getElementById('detail-ran-vlan').textContent = ran?.vlan || 'N/A';
const dnnListContainer = document.getElementById('detail-dnn-list');
dnnListContainer.innerHTML = '';
const dnnList = node.addressing?.dn_list || [];
if (dnnList.length > 0) {
dnnList.forEach((dnn, index) => {
const dnnHtml = `
<div class="mb-3">
<h6>DNN #${index + 1}: ${dnn.dnn}</h6>
<dl class="row">
<dt class="col-sm-4">Gateway</dt><dd class="col-sm-8">${dnn.gateway || 'N/A'}</dd>
<dt class="col-sm-4">Subnet</dt><dd class="col-sm-8">${dnn.subnet || 'N/A'}</dd>
<dt class="col-sm-4">IP Pools</dt><dd class="col-sm-8">${dnn.ip_pools?.join(', ') || 'N/A'}</dd>
<dt class="col-sm-4">DNS</dt><dd class="col-sm-8">${dnn.dns?.join(', ') || 'N/A'}</dd>
<dt class="col-sm-4">VLAN</dt><dd class="col-sm-8">${dnn.vlan || 'N/A'}</dd>
</dl>
</div>
`;
dnnListContainer.innerHTML += dnnHtml;
});
} else {
dnnListContainer.textContent = 'No Data Networks configured.';
}
detailsPane.classList.remove('d-none');
}
document.getElementById('getConfigBtn').addEventListener('click', async () => {
detailsPane.classList.add('d-none');
const dashboard = document.getElementById('dashboard-select').value;
const configData = await apiCall('/api/network/get-config', { dashboard });
if (configData && Array.isArray(configData.items)) {
fullNodeConfig = configData.items; // Store the full data
let cardsHtml = '<div class="row g-3">';
fullNodeConfig.forEach((node, index) => {
const name = node.name || 'Unnamed Node';
cardsHtml += `
<div class="col-md-6 col-lg-4">
<div class="card h-100 network-card" data-node-index="${index}">
<div class="card-header"><strong>${name}</strong></div>
<div class="card-body">
<p class="card-text mb-1"><small>Tenant: ${node.tenant?.name || 'N/A'}</small></p>
<p class="card-text mb-1"><small>Serials: ${node.info?.hardware?.map(hw => hw.serial).join(', ') || 'N/A'}</small></p>
<p class="card-text"><small>Radios: ${node.info?.radio_pool?.[0]?.num_of_radios ?? 'N/A'}</small></p>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<span class="badge ${getStatusClass(node.status)}">${node.status}</span>
<span class="badge ${node.network_reachable ? 'bg-success' : 'bg-danger'}">
${node.network_reachable ? 'Reachable' : 'Unreachable'}
</span>
</div>
</div>
</div>
`;
});
cardsHtml += '</div>';
resultsOutput.innerHTML = cardsHtml;
} else {
resultsOutput.innerHTML = `<div class="alert alert-info">Could not find network items in the response.</div>`;
}
});
resultsOutput.addEventListener('click', (event) => {
const card = event.target.closest('.network-card');
if (card) {
document.querySelectorAll('.network-card.active').forEach(c => c.classList.remove('active'));
card.classList.add('active');
const nodeIndex = card.dataset.nodeIndex;
const selectedNode = fullNodeConfig[nodeIndex];
if (selectedNode) {
populateDetailsPane(selectedNode);
}
}
});
document.getElementById('updateRadiosBtn').addEventListener('click', async () => {
const networkId = detailsPane.dataset.networkId;
const newCount = document.getElementById('detail-radios-input').value;
const dashboard = document.getElementById('dashboard-select').value;
if (!networkId) {
alert('No network selected.');
return;
}
const node = fullNodeConfig.find(n => n.id === networkId);
const operation = (node && node.info && node.info.radio_pool && node.info.radio_pool.length > 0) ? 'replace' : 'add';
const result = await apiCall('/api/network/update-radios', { dashboard, network_id: networkId, new_count: newCount, operation });
if (result) {
document.getElementById('getConfigBtn').click();
detailsPane.classList.add('d-none');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends "layout.html" %}
{% set active_page = 'system_browser' %}
{% block title %}System Browser - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">System Browser</h2>
</div>
<hr>
<div id="browser-view">
<p>Retrieve and display live status information for all connected customer networks. You can search, sort, and filter the results.</p>
<button class="btn btn-primary" id="getBrowserDataBtn">Get System Status</button>
</div>
<div class="row g-3 my-3 align-items-end">
<div class="col-md-6">
<label for="customer-filter" class="form-label">Filter by Customer</label>
<select id="customer-filter" class="form-select"></select>
</div>
<div class="col-md-6">
<label for="search-input" class="form-label">Search Results</label>
<input type="text" id="search-input" class="form-control" placeholder="Search any column...">
</div>
</div>
<div class="mt-4">
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
let fullBrowserData = [];
let currentSort = { column: 'customer_id', direction: 'asc' };
const searchInput = document.getElementById('search-input');
const customerFilter = document.getElementById('customer-filter');
function renderTable(data) {
let tableHtml = `<table class="table table-dark table-hover table-sm table-compact">
<thead>
<tr>
<th class="tree-item" data-sort="customer_id">Customer ID <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="customer_name">Customer Name <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="common_name">Common Name <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="virtual_ip">Virtual IP <i class="bi bi-arrow-down-up"></i></th>
</tr>
</thead>
<tbody>`;
data.forEach(client => {
// ADDED: The necessary attributes to make the row clickable
tableHtml += `
<tr class="clickable-row"
data-host-ip="${client.virtual_ip}"
data-public-ip="${client.public_ip}"
data-connected-since="${client.connected_since}"
data-customer-name="${client.customer_name}"
data-common-name="${client.common_name}"
style="cursor: pointer;">
<td>${client.customer_id}</td>
<td>${client.customer_name}</td>
<td>${client.common_name}</td>
<td><a href="http://${client.virtual_ip}" target="_blank">${client.virtual_ip}</a></td>
</tr>`;
});
tableHtml += '</tbody></table>';
resultsOutput.innerHTML = tableHtml;
}
function populateCustomerFilter(data) {
const customers = [...new Set(data.map(item => item.customer_name))].sort();
customerFilter.innerHTML = '<option value="">All Customers</option>';
customers.forEach(customer => {
const option = document.createElement('option');
option.value = customer;
option.textContent = customer;
customerFilter.appendChild(option);
});
}
function applyFiltersAndSort() {
let filteredData = [...fullBrowserData];
const searchTerm = searchInput.value.toLowerCase();
const selectedCustomer = customerFilter.value;
if (selectedCustomer) {
filteredData = filteredData.filter(item => item.customer_name === selectedCustomer);
}
if (searchTerm) {
filteredData = filteredData.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
filteredData.sort((a, b) => {
const valA = a[currentSort.column] || '';
const valB = b[currentSort.column] || '';
if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
renderTable(filteredData);
}
document.getElementById('getBrowserDataBtn').addEventListener('click', async () => {
const browserData = await apiCall('/api/system-browser/data', {});
if (browserData) {
fullBrowserData = browserData;
populateCustomerFilter(fullBrowserData);
applyFiltersAndSort();
}
});
searchInput.addEventListener('input', applyFiltersAndSort);
customerFilter.addEventListener('change', applyFiltersAndSort);
// RESTORED: The full click handler for the results area
resultsOutput.addEventListener('click', (event) => {
const header = event.target.closest('th[data-sort]');
if (header) {
const sortColumn = header.dataset.sort;
if (currentSort.column === sortColumn) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = sortColumn;
currentSort.direction = 'asc';
}
applyFiltersAndSort();
return;
}
const row = event.target.closest('.clickable-row');
if (row) {
const hostIp = row.dataset.hostIp;
if (hostIp && hostIp !== 'N/A') {
const customerName = encodeURIComponent(row.dataset.customerName);
const commonName = encodeURIComponent(row.dataset.commonName);
const publicIp = encodeURIComponent(row.dataset.publicIp);
const connectedSince = encodeURIComponent(row.dataset.connectedSince);
window.location.href = `/host/${hostIp}?customer_name=${customerName}&common_name=${commonName}&public_ip=${publicIp}&connected_since=${connectedSince}`;
}
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,126 @@
{% extends "layout.html" %}
{% set active_page = 'tenants' %}
{% block title %}Dashboard Tenants - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">Dashboard Tenant Management</h2>
</div>
<div class="row align-items-end">
<div class="col-md-4" id="dashboard-select-wrapper">
<label for="dashboard-select" class="form-label">HPE P5G Dashboard</label>
<select class="form-select" id="dashboard-select">
<option selected>Triton</option>
<option>Star</option>
<option>Bluebonnet</option>
<option>Lonestar</option>
<option>Production</option>
<option>Test (future)</option>
</select>
</div>
</div>
<hr>
<div id="tenant-view">
<p>Retrieve the tenant hierarchy from the selected dashboard. Click on any item to expand it.</p>
<button class="btn btn-primary" id="getTenantsBtn">Get Tenants</button>
</div>
<div class="mt-4">
<h4>Results</h4>
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
document.getElementById('getTenantsBtn').addEventListener('click', async () => {
const dashboard = document.getElementById('dashboard-select').value;
const tenants = await apiCall('/api/tenants/list', { dashboard });
if (tenants) {
resultsOutput.innerHTML = '<ul class="list-group"></ul>';
const listContainer = resultsOutput.querySelector('.list-group');
tenants.forEach(tenant => {
const li = document.createElement('li');
li.className = 'list-group-item list-group-item-dark tree-item';
li.dataset.level = 'tenant';
li.dataset.id = tenant.id;
li.dataset.loaded = 'false';
li.innerHTML = `<i class="bi bi-chevron-right me-2"></i> <strong>${tenant.name}</strong> <small class="text-muted">(${tenant.type})</small>`;
listContainer.appendChild(li);
});
}
});
resultsOutput.addEventListener('click', async (event) => {
const item = event.target.closest('.tree-item');
if (item) {
const level = item.dataset.level;
const id = item.dataset.id;
const isLoaded = item.dataset.loaded === 'true';
const isOpen = item.classList.contains('open');
const dashboard = document.getElementById('dashboard-select').value;
if (isLoaded) {
const childrenContainer = item.nextElementSibling;
if (childrenContainer && childrenContainer.classList.contains('nested-group')) {
childrenContainer.classList.toggle('d-none');
item.classList.toggle('open', !isOpen);
item.querySelector('.bi-chevron-right')?.classList.toggle('open');
}
return;
}
item.dataset.loaded = 'true';
item.classList.add('open');
item.querySelector('.bi-chevron-right')?.classList.add('open');
const spinnerSpan = document.createElement('span');
spinnerSpan.className = 'spinner-border spinner-border-sm ms-2';
item.appendChild(spinnerSpan);
let childrenData = [];
if (level === 'tenant') {
// CHANGED: Added 'false' to prevent clearing the results
childrenData = await apiCall('/api/plmns/list', { dashboard, tenant_id: id }, false);
renderChildren(item, childrenData, 'plmn');
} else if (level === 'plmn') {
const tenantId = item.dataset.tenantId;
// CHANGED: Added 'false' to prevent clearing the results
childrenData = await apiCall('/api/hnks/list/by-plmn', { dashboard, tenant_id: tenantId, plmn_id: id }, false);
renderChildren(item, childrenData, 'hnk');
}
spinnerSpan.remove();
}
});
function renderChildren(parentItem, children, childLevel) {
if (!children || children.length === 0) {
const icon = parentItem.querySelector('.bi');
if(icon) icon.classList.replace('bi-chevron-right', 'bi-dot');
return;
}
const nestedGroup = document.createElement('ul');
nestedGroup.className = 'list-group mt-2 mb-2 ms-4 nested-group';
children.forEach(child => {
const li = document.createElement('li');
li.className = 'list-group-item list-group-item-dark tree-item';
li.dataset.level = childLevel;
li.dataset.id = child.id;
li.dataset.loaded = 'false';
let content = '';
if (childLevel === 'plmn') {
li.dataset.tenantId = child.tenant_id;
content = `<i class="bi bi-chevron-right me-2"></i> <strong>${child.name}</strong> <small class="text-muted">(MCC: ${child.mcc}, MNC: ${child.mnc})</small>`;
} else if (childLevel === 'hnk') {
content = `<i class="bi bi-key-fill me-2"></i> <strong>Key ID: ${child.key_id}</strong>`;
}
li.innerHTML = content;
nestedGroup.appendChild(li);
});
parentItem.after(nestedGroup);
}
</script>
{% endblock %}

155
templates/pages/users.html Normal file
View File

@@ -0,0 +1,155 @@
{% extends "layout.html" %}
{% set active_page = 'users' %}
{% block title %}Dashboard Users - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">Dashboard Users</h2>
</div>
<div class="row align-items-end">
<div class="col-md-4" id="dashboard-select-wrapper">
<label for="dashboard-select" class="form-label">HPE P5G Dashboard</label>
<select class="form-select" id="dashboard-select">
<option selected>Triton</option>
<option>Star</option>
<option>Bluebonnet</option>
<option>Lonestar</option>
<option>Production</option>
<option>Test (future)</option>
</select>
</div>
</div>
<hr>
<div id="users-view">
<p>Retrieve a list of all users from the selected dashboard.</p>
<button class="btn btn-primary" id="listUsersBtn">List Users</button>
</div>
<div class="row g-3 my-3 align-items-end">
<div class="col-md-6">
<label for="tenant-filter" class="form-label">Filter by Tenant</label>
<select id="tenant-filter" class="form-select"></select>
</div>
<div class="col-md-6">
<label for="search-input" class="form-label">Search Results</label>
<input type="text" id="search-input" class="form-control" placeholder="Search any column...">
</div>
</div>
<div class="mt-4">
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
let fullUsersData = [];
let currentSort = { column: 'fullname', direction: 'asc' };
const searchInput = document.getElementById('search-input');
const tenantFilter = document.getElementById('tenant-filter');
function getRoleClass(role) {
switch (role) {
case 'OWNER': return 'bg-primary';
case 'ADMIN': return 'bg-danger';
case 'EDITOR': return 'bg-info text-dark';
case 'VIEWER': return 'bg-success';
default: return 'bg-light text-dark';
}
}
function renderTable(data) {
let tableHtml = `<table class="table table-dark table-hover table-sm table-compact">
<thead>
<tr>
<th class="tree-item" data-sort="fullname">Full Name <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="email">Email <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="role">Role <i class="bi bi-arrow-down-up"></i></th>
<th class="tree-item" data-sort="tenant">Tenant <i class="bi bi-arrow-down-up"></i></th>
</tr>
</thead>
<tbody>`;
data.forEach(user => {
const roleClass = getRoleClass(user.role);
tableHtml += `<tr>
<td>${user.fullname || 'N/A'}</td>
<td>${user.email || 'N/A'}</td>
<td><span class="badge ${roleClass}">${user.role || 'N/A'}</span></td>
<td>${user.tenant || 'N/A'}</td>
</tr>`;
});
tableHtml += '</tbody></table>';
resultsOutput.innerHTML = tableHtml;
}
function populateTenantFilter(data) {
const tenants = [...new Set(data.map(item => item.tenant))].sort();
tenantFilter.innerHTML = '<option value="">All Tenants</option>';
tenants.forEach(tenant => {
const option = document.createElement('option');
option.value = tenant;
option.textContent = tenant;
tenantFilter.appendChild(option);
});
}
function applyFiltersAndSort() {
let filteredData = [...fullUsersData];
const searchTerm = searchInput.value.toLowerCase();
const selectedTenant = tenantFilter.value;
if (selectedTenant) {
filteredData = filteredData.filter(item => item.tenant === selectedTenant);
}
if (searchTerm) {
filteredData = filteredData.filter(item =>
Object.values(item).some(value =>
String(value).toLowerCase().includes(searchTerm)
)
);
}
filteredData.sort((a, b) => {
const valA = a[currentSort.column] || '';
const valB = b[currentSort.column] || '';
if (valA < valB) return currentSort.direction === 'asc' ? -1 : 1;
if (valA > valB) return currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
renderTable(filteredData);
}
document.getElementById('listUsersBtn').addEventListener('click', async () => {
const dashboard = document.getElementById('dashboard-select').value;
const users = await apiCall('/api/users/list', { dashboard });
if (users) {
fullUsersData = users;
populateTenantFilter(fullUsersData);
applyFiltersAndSort();
}
});
searchInput.addEventListener('input', applyFiltersAndSort);
tenantFilter.addEventListener('change', applyFiltersAndSort);
resultsOutput.addEventListener('click', (event) => {
const header = event.target.closest('th[data-sort]');
if (header) {
const sortColumn = header.dataset.sort;
if (currentSort.column === sortColumn) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = sortColumn;
currentSort.direction = 'asc';
}
applyFiltersAndSort();
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,80 @@
{% extends "layout.html" %}
{% set active_page = 'vpn_status' %}
{% block title %}m2000 Status - {{ super() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">m2000 Status</h2>
</div>
<div class="row align-items-end">
<div class="col-md-4" id="dashboard-select-wrapper">
<label for="dashboard-select" class="form-label">HPE P5G Dashboard</label>
<select class="form-select" id="dashboard-select">
<option selected>Triton</option>
<option>Star</option>
<option>Bluebonnet</option>
<option>Lonestar</option>
<option>Production</option>
<option>Test (future)</option>
</select>
</div>
</div>
<hr>
<div id="vpn-view">
<p>List the status of all network links from the selected dashboard. From the results table, you can restart the OpenVPN service on a specific device.</p>
<p>Note that after a VPN connection is restarted it will take some time for the connection to re-establish. Click List m2000 status to refresh the list.</p>
<button class="btn btn-primary" id="listVpnsBtn">List m2000 Status</button>
</div>
<div class="mt-4">
<h4>Results</h4>
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
document.getElementById('listVpnsBtn').addEventListener('click', async () => {
const dashboard = document.getElementById('dashboard-select').value;
const devices = await apiCall('/api/m2000/list', { dashboard });
if (devices) {
let tableHtml = `<table class="table table-dark table-hover table-sm align-middle table-compact">
<thead><tr><th>Serial</th><th>Name</th><th>Subnet</th><th>Status</th><th class="text-center">Action</th></tr></thead>
<tbody>`;
devices.forEach(d => {
const statusClass = getStatusClass(d.status);
let actionHtml = '';
if (d.status === 'DEPLOYED') {
actionHtml = `<a href="#" class="badge bg-danger text-decoration-none restart-btn" data-serial="${d.serial}" data-subnet="${d.subnet}" data-bs-toggle="tooltip" title="Restart VPN"><i class="bi bi-arrow-clockwise"></i> Restart</a>`;
}
tableHtml += `<tr><td>${d.serial}</td><td>${d.name}</td><td>${d.subnet}</td><td><span class="badge ${statusClass}">${d.status}</span></td><td><div class="d-flex justify-content-center">${actionHtml}</div></td></tr>`;
});
tableHtml += '</tbody></table>';
resultsOutput.innerHTML = tableHtml;
initializeTooltips();
}
});
resultsOutput.addEventListener('click', async (event) => {
const actionLink = event.target.closest('.restart-btn');
if (actionLink) {
event.preventDefault();
const serial = actionLink.dataset.serial;
const subnet = actionLink.dataset.subnet;
const dashboard = document.getElementById('dashboard-select').value;
actionLink.disabled = true;
actionLink.innerHTML = `<span class="spinner-border spinner-border-sm"></span> Sending...`;
const result = await apiCall('/api/m2000/restart', { dashboard, serial, subnet }, false);
if (result) {
alert(result.message);
}
actionLink.disabled = false;
actionLink.innerHTML = `<i class="bi bi-arrow-clockwise"></i> Restart`;
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,131 @@
{% extends "layout.html" %}
{% set active_page = 'vpn_switcher' %}
{% block title %}VPN Switcher - {{ super() }}{% endblock %}
{% block extra_styles %}
<style>
.config-output {
background-color: #343a40;
border-radius: .25rem;
padding: 1rem;
font-size: 1.1em;
white-space: pre-wrap;
font-family: 'Courier New', Courier, monospace;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 id="page-title" class="mb-0">VPN Switcher</h2>
</div>
<hr>
<div id="switcher-view">
<p>Enter a host IP to view and change its current VPN endpoint and system details.</p>
<div class="row align-items-end g-3">
<div class="col-md-6">
<label for="host-ip-input" class="form-label">Host IP Address</label>
<input type="text" class="form-control" id="host-ip-input" placeholder="IPv4 Addresses only">
</div>
<div class="col-auto">
<button class="btn btn-primary" id="getInfoBtn">Get Info</button>
</div>
</div>
</div>
<div class="mt-4">
<h4>Result</h4>
<div id="spinner" class="d-none spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>
<div id="results-output" class="p-3"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script defer>
const hostIpInput = document.getElementById('host-ip-input');
const getInfoBtn = document.getElementById('getInfoBtn');
async function getInfo() {
const hostIp = hostIpInput.value;
if (!hostIp) {
alert('Please enter a host IP address.');
return;
}
history.pushState({}, '', `/vpn-switcher?ip=${hostIp}`);
const result = await apiCall('/api/vpn/get-config', { host_ip: hostIp });
if (result) {
const details = result.details;
const endpointInfo = result.vpn_endpoint;
const isUS = endpointInfo.region === 'US';
const isEU = endpointInfo.region === 'EU';
const detailHtml = `
<div class="card mb-4">
<div class="card-header">
<strong>Current VPN Endpoint</strong>
</div>
<div class="card-body">
<p>Connected to: <strong>${endpointInfo.region} VPN</strong> (${endpointInfo.ip})</p>
<pre class="config-output"><code>${result.vpn_config}</code></pre>
<hr>
<p>Select a new endpoint:</p>
<button class="btn btn-primary me-2 switch-btn" data-region="US" ${isUS ? 'disabled' : ''}>Switch to US-VPN</button>
<button class="btn btn-primary switch-btn" data-region="EU" ${isEU ? 'disabled' : ''}>Switch to EU-VPN</button>
</div>
</div>
<hr>
<div class="row g-4">
<div class="col-md-6">
<h5>System Information</h5>
<dl class="row">
<dt class="col-sm-5">Hostname</dt><dd class="col-sm-7">${details.system.hostname}</dd>
<dt class="col-sm-5">Product</dt><dd class="col-sm-7">${details.system.product_name}</dd>
<dt class="col-sm-5">Version</dt><dd class="col-sm-7">${details.system.version}</dd>
</dl>
</div>
<div class="col-md-6">
<h5>Site Information</h5>
<dl class="row">
<dt class="col-sm-4">Node Name</dt><dd class="col-sm-8">${details.site.current_node.name}</dd>
<dt class="col-sm-4">API Address</dt><dd class="col-sm-8">${details.site.current_node.api_address}</dd>
</dl>
</div>
</div>
`;
resultsOutput.innerHTML = detailHtml;
}
}
getInfoBtn.addEventListener('click', getInfo);
resultsOutput.addEventListener('click', async (event) => {
const switchButton = event.target.closest('.switch-btn');
if (switchButton) {
const hostIp = hostIpInput.value;
const region = switchButton.dataset.region;
switchButton.disabled = true;
switchButton.innerHTML = `<span class="spinner-border spinner-border-sm"></span> Switching...`;
const result = await apiCall('/api/vpn/set-endpoint', { host_ip: hostIp, region: region }, false);
if (result) {
alert(result.message);
getInfo(); // Refresh the info after a successful switch
} else {
getInfo(); // Also refresh on failure to restore the button state
}
}
});
document.addEventListener('DOMContentLoaded', () => {
const ipFromUrl = '{{ ip_from_url|default("", True) }}';
if (ipFromUrl) {
hostIpInput.value = ipFromUrl;
getInfoBtn.click();
}
});
</script>
{% endblock %}