hmm
This commit is contained in:
225
packages/system/nebula-ui/templates/base.html
Normal file
225
packages/system/nebula-ui/templates/base.html
Normal file
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nebula UI — {{ network_name }}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--accent: #6c8ef5;
|
||||
--accent2: #4ade80;
|
||||
--text: #e2e4ef;
|
||||
--muted: #7a7f9a;
|
||||
--danger: #f87171;
|
||||
--radius: 8px;
|
||||
--font: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
header .logo {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--accent);
|
||||
letter-spacing: -.3px;
|
||||
}
|
||||
|
||||
header nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius);
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
|
||||
header nav a:hover,
|
||||
header nav a.active {
|
||||
color: var(--text);
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
h1 { font-size: 22px; font-weight: 600; margin-bottom: 24px; }
|
||||
h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .3px;
|
||||
}
|
||||
.badge-green { background: #14532d; color: var(--accent2); }
|
||||
.badge-blue { background: #1e3a5f; color: var(--accent); }
|
||||
.badge-red { background: #450a0a; color: var(--danger); }
|
||||
.badge-gray { background: var(--border); color: var(--muted); }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255,255,255,.02); }
|
||||
|
||||
.mono { font-family: ui-monospace, 'Cascadia Code', monospace; font-size: 12px; }
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-box {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.stat-box .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 6px; }
|
||||
.stat-box .value { font-size: 22px; font-weight: 700; color: var(--accent); }
|
||||
.stat-box .unit { font-size: 11px; color: var(--muted); margin-left: 3px; }
|
||||
|
||||
form .field { margin-bottom: 16px; }
|
||||
form label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; font-weight: 500; }
|
||||
form input[type="text"] {
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 9px 12px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
form input[type="text"]:focus { border-color: var(--accent); }
|
||||
form input[type="text"]::placeholder { color: var(--muted); }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 18px;
|
||||
border-radius: var(--radius);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn:hover { opacity: .85; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.alert-error { background: #450a0a; border: 1px solid #7f1d1d; color: var(--danger); }
|
||||
|
||||
.qr-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
@media (max-width: 640px) { .qr-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.qr-box {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.qr-box .qr-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.qr-box svg { max-width: 100%; height: auto; }
|
||||
|
||||
details { margin-top: 16px; }
|
||||
summary { cursor: pointer; color: var(--muted); font-size: 12px; }
|
||||
pre {
|
||||
margin-top: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.no-data { color: var(--muted); font-size: 13px; padding: 20px 0; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="logo">nebula-ui</span>
|
||||
<nav>
|
||||
<a href="/" {% if request.url.path == "/" %}class="active"{% endif %}>Stats</a>
|
||||
<a href="/sign" {% if request.url.path == "/sign" %}class="active"{% endif %}>Sign Certificate</a>
|
||||
</nav>
|
||||
<span style="margin-left:auto; color:var(--muted); font-size:12px;">{{ network_name }}</span>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
105
packages/system/nebula-ui/templates/index.html
Normal file
105
packages/system/nebula-ui/templates/index.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Network Overview</h1>
|
||||
|
||||
{% if stats is none %}
|
||||
<div class="card">
|
||||
<div class="no-data">
|
||||
Could not reach the Nebula stats endpoint at <code class="mono">{{ request.app.state.stats_url if request.app.state is defined else "configured URL" }}</code>.
|
||||
Make sure <code class="mono">stats.enabled</code> and <code class="mono">stats.listen</code> are set in your Nebula config.
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
{# ── top-level counters ── #}
|
||||
{% set meta = stats.get("meta", {}) %}
|
||||
{% set network = stats.get("network", {}) %}
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-box">
|
||||
<div class="label">Nebula Version</div>
|
||||
<div class="value" style="font-size:15px; padding-top:4px;">{{ meta.get("version", "—") }}</div>
|
||||
</div>
|
||||
{% set peers = hostmap.get("Hosts", {}) if hostmap else {} %}
|
||||
<div class="stat-box">
|
||||
<div class="label">Active Peers</div>
|
||||
<div class="value">{{ peers | length }}</div>
|
||||
</div>
|
||||
{% set counters = stats.get("counters", {}) %}
|
||||
<div class="stat-box">
|
||||
<div class="label">Tx Bytes</div>
|
||||
<div class="value">{{ "{:,}".format(counters.get("send_bytes", 0)) }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="label">Rx Bytes</div>
|
||||
<div class="value">{{ "{:,}".format(counters.get("recv_bytes", 0)) }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="label">Tx Packets</div>
|
||||
<div class="value">{{ "{:,}".format(counters.get("send_packets", 0)) }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="label">Rx Packets</div>
|
||||
<div class="value">{{ "{:,}".format(counters.get("recv_packets", 0)) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── raw stats JSON ── #}
|
||||
<details style="margin-bottom:20px;">
|
||||
<summary>Raw stats JSON</summary>
|
||||
<pre>{{ stats | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{# ── hostmap ── #}
|
||||
<div class="card">
|
||||
<h2>Peer Hostmap</h2>
|
||||
{% if hostmap is none %}
|
||||
<div class="no-data">Hostmap unavailable — stats endpoint not reachable.</div>
|
||||
{% else %}
|
||||
{% set hosts = hostmap.get("Hosts", {}) %}
|
||||
{% if not hosts %}
|
||||
<div class="no-data">No peers connected.</div>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Overlay IP</th>
|
||||
<th>Remote Addrs</th>
|
||||
<th>Index</th>
|
||||
<th>Relay?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for overlay_ip, host in hosts.items() %}
|
||||
<tr>
|
||||
<td class="mono">{{ overlay_ip }}</td>
|
||||
<td>
|
||||
{% for addr in host.get("RemoteAddrs", []) %}
|
||||
<span class="mono badge badge-gray" style="margin-right:4px;">{{ addr }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-gray">none</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="mono">{{ host.get("LocalIndex", "—") }}</td>
|
||||
<td>
|
||||
{% if host.get("Relay") %}
|
||||
<span class="badge badge-blue">relay</span>
|
||||
{% else %}
|
||||
<span class="badge badge-gray">no</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<details style="margin-top:16px;">
|
||||
<summary>Raw hostmap JSON</summary>
|
||||
<pre>{{ hostmap | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
104
packages/system/nebula-ui/templates/sign.html
Normal file
104
packages/system/nebula-ui/templates/sign.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Sign Certificate</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>New Host Certificate</h2>
|
||||
<p style="color:var(--muted); font-size:13px; margin-bottom:20px;">
|
||||
Fill in the details below. The CA on this host will sign a new certificate.
|
||||
Scan the QR codes with the mobile Nebula app or copy the PEM values.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/sign">
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
||||
<div class="field">
|
||||
<label for="name">Host Name *</label>
|
||||
<input type="text" id="name" name="name" placeholder="e.g. laptop" required
|
||||
value="{{ result.name if result else '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ip">Overlay IP / Mask *</label>
|
||||
<input type="text" id="ip" name="ip" placeholder="e.g. 10.1.1.5/24" required
|
||||
value="{{ result.ip if result else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:16px;">
|
||||
<div class="field">
|
||||
<label for="groups">Groups <span style="color:var(--muted)">(comma-separated, optional)</span></label>
|
||||
<input type="text" id="groups" name="groups" placeholder="e.g. laptops,users">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="duration">Duration <span style="color:var(--muted)">(optional, e.g. 8760h0m0s)</span></label>
|
||||
<input type="text" id="duration" name="duration" placeholder="default: CA lifetime">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Sign Certificate</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if result %}
|
||||
<div class="card">
|
||||
<h2>Certificate Issued</h2>
|
||||
|
||||
{# cert info summary #}
|
||||
{% if result.cert_info %}
|
||||
{% set details = result.cert_info.get("details", {}) %}
|
||||
<div class="stat-grid" style="margin-bottom:20px;">
|
||||
<div class="stat-box">
|
||||
<div class="label">Name</div>
|
||||
<div class="value" style="font-size:14px; padding-top:2px;">{{ details.get("name", result.name) }}</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="label">IP</div>
|
||||
<div class="value" style="font-size:14px; padding-top:2px;">{{ (details.get("ips", [result.ip]) | first) }}</div>
|
||||
</div>
|
||||
{% if details.get("groups") %}
|
||||
<div class="stat-box">
|
||||
<div class="label">Groups</div>
|
||||
<div class="value" style="font-size:14px; padding-top:2px;">{{ details.get("groups") | join(", ") }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if details.get("notAfter") %}
|
||||
<div class="stat-box">
|
||||
<div class="label">Expires</div>
|
||||
<div class="value" style="font-size:13px; padding-top:2px; word-break:break-all;">{{ details.get("notAfter") }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p style="color:var(--muted); font-size:12px; margin-bottom:16px;">
|
||||
Scan each QR code separately with the Nebula mobile app, or use the PEM text below.
|
||||
The private key is shown only once — store it securely.
|
||||
</p>
|
||||
|
||||
<div class="qr-grid">
|
||||
<div>
|
||||
<div class="qr-box">
|
||||
<div class="qr-label">Host Certificate</div>
|
||||
{{ result.cert_qr | safe }}
|
||||
</div>
|
||||
<details style="margin-top:10px;">
|
||||
<summary>Certificate PEM</summary>
|
||||
<pre>{{ result.cert_pem }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
<div class="qr-box">
|
||||
<div class="qr-label">Host Key (Private)</div>
|
||||
{{ result.key_qr | safe }}
|
||||
</div>
|
||||
<details style="margin-top:10px;">
|
||||
<summary>Key PEM</summary>
|
||||
<pre>{{ result.key_pem }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user