Compare commits
16 Commits
17d3b7d227
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d576a0fe2d | |||
| 271c3c4373 | |||
| e2b65db2fc | |||
| b80fda7cb2 | |||
| eb8c0ded5e | |||
| b29b70d88b | |||
| 2c67944b4b | |||
| e8314b5ba3 | |||
| 3dce602938 | |||
| 6eb21055ef | |||
| f2541eb45c | |||
| e779b21db4 | |||
| c1fd53f9bd | |||
| 0ca6b1f744 | |||
| 6b6eaa6227 | |||
| 9c9acbb023 |
12
README.md
12
README.md
@@ -5,6 +5,18 @@
|
|||||||
Network monitoring dashboard for the LotusGuild Proxmox cluster.
|
Network monitoring dashboard for the LotusGuild Proxmox cluster.
|
||||||
Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusguild.org`.
|
Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusguild.org`.
|
||||||
|
|
||||||
|
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
|
||||||
|
|
||||||
|
## Styling & Layout
|
||||||
|
|
||||||
|
GANDALF uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
|
||||||
|
|
||||||
|
- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
|
||||||
|
- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
|
||||||
|
- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, auto-refresh, fetch helpers)
|
||||||
|
- [`web_template/python/base.html`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/base.html) — Jinja2 base template
|
||||||
|
- [`web_template/python/auth.py`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/auth.py) — `@require_auth` decorator pattern
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|||||||
53
app.py
53
app.py
@@ -47,8 +47,11 @@ _diag_jobs: dict = {}
|
|||||||
_diag_lock = threading.Lock()
|
_diag_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
_last_event_purge = [0.0] # mutable container so the thread can update it
|
||||||
|
|
||||||
|
|
||||||
def _purge_old_jobs_loop():
|
def _purge_old_jobs_loop():
|
||||||
"""Background thread: remove jobs older than 10 minutes and mark stuck running jobs as errored."""
|
"""Background thread: remove stale diag jobs and run daily event purge."""
|
||||||
while True:
|
while True:
|
||||||
time.sleep(120)
|
time.sleep(120)
|
||||||
cutoff = time.time() - 600
|
cutoff = time.time() - 600
|
||||||
@@ -63,6 +66,15 @@ def _purge_old_jobs_loop():
|
|||||||
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
|
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
|
||||||
logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored')
|
logger.error(f'Diagnostic job {jid} appeared stuck; marked as errored')
|
||||||
|
|
||||||
|
# Purge old resolved events once per day
|
||||||
|
now = time.time()
|
||||||
|
if now - _last_event_purge[0] > 86400:
|
||||||
|
try:
|
||||||
|
db.purge_old_resolved_events(days=90)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Daily event purge failed: {e}')
|
||||||
|
_last_event_purge[0] = now
|
||||||
|
|
||||||
|
|
||||||
_purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True)
|
_purge_thread = threading.Thread(target=_purge_old_jobs_loop, daemon=True)
|
||||||
_purge_thread.start()
|
_purge_thread.start()
|
||||||
@@ -120,24 +132,31 @@ def require_auth(f):
|
|||||||
# Page routes
|
# Page routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PAGE_LIMIT = 200 # max events returned per request
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
@require_auth
|
@require_auth
|
||||||
def index():
|
def index():
|
||||||
user = _get_user()
|
user = _get_user()
|
||||||
events = db.get_active_events()
|
events = db.get_active_events(limit=_PAGE_LIMIT)
|
||||||
|
total_active = db.count_active_events()
|
||||||
summary = db.get_status_summary()
|
summary = db.get_status_summary()
|
||||||
snapshot_raw = db.get_state('network_snapshot')
|
snapshot_raw = db.get_state('network_snapshot')
|
||||||
last_check = db.get_state('last_check', 'Never')
|
last_check = db.get_state('last_check', 'Never')
|
||||||
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
|
||||||
suppressions = db.get_active_suppressions()
|
suppressions = db.get_active_suppressions()
|
||||||
|
recent_resolved = db.get_recent_resolved(hours=24, limit=10)
|
||||||
return render_template(
|
return render_template(
|
||||||
'index.html',
|
'index.html',
|
||||||
user=user,
|
user=user,
|
||||||
events=events,
|
events=events,
|
||||||
|
total_active=total_active,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
snapshot=snapshot,
|
snapshot=snapshot,
|
||||||
last_check=last_check,
|
last_check=last_check,
|
||||||
suppressions=suppressions,
|
suppressions=suppressions,
|
||||||
|
recent_resolved=recent_resolved,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -179,10 +198,12 @@ def suppressions_page():
|
|||||||
@app.route('/api/status')
|
@app.route('/api/status')
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_status():
|
def api_status():
|
||||||
|
active = db.get_active_events(limit=_PAGE_LIMIT)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'summary': db.get_status_summary(),
|
'summary': db.get_status_summary(),
|
||||||
'last_check': db.get_state('last_check', 'Never'),
|
'last_check': db.get_state('last_check', 'Never'),
|
||||||
'events': db.get_active_events(),
|
'events': active,
|
||||||
|
'total_active': db.count_active_events(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -213,10 +234,22 @@ def api_links():
|
|||||||
@app.route('/api/events')
|
@app.route('/api/events')
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_events():
|
def api_events():
|
||||||
return jsonify({
|
try:
|
||||||
'active': db.get_active_events(),
|
limit = min(int(request.args.get('limit', _PAGE_LIMIT)), 1000)
|
||||||
'resolved': db.get_recent_resolved(hours=24, limit=30),
|
offset = max(int(request.args.get('offset', 0)), 0)
|
||||||
})
|
except ValueError:
|
||||||
|
return jsonify({'error': 'limit and offset must be integers'}), 400
|
||||||
|
status_filter = request.args.get('status', 'active')
|
||||||
|
if status_filter not in ('active', 'resolved', 'all'):
|
||||||
|
return jsonify({'error': 'status must be active, resolved, or all'}), 400
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
if status_filter in ('active', 'all'):
|
||||||
|
result['active'] = db.get_active_events(limit=limit, offset=offset)
|
||||||
|
result['total_active'] = db.count_active_events()
|
||||||
|
if status_filter in ('resolved', 'all'):
|
||||||
|
result['resolved'] = db.get_recent_resolved(hours=24, limit=30)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/suppressions', methods=['GET'])
|
@app.route('/api/suppressions', methods=['GET'])
|
||||||
@@ -243,6 +276,12 @@ def api_create_suppression():
|
|||||||
return jsonify({'error': 'target_name required'}), 400
|
return jsonify({'error': 'target_name required'}), 400
|
||||||
if not reason:
|
if not reason:
|
||||||
return jsonify({'error': 'reason required'}), 400
|
return jsonify({'error': 'reason required'}), 400
|
||||||
|
if len(reason) > 500:
|
||||||
|
return jsonify({'error': 'reason must be 500 characters or fewer'}), 400
|
||||||
|
if len(target_name) > 255:
|
||||||
|
return jsonify({'error': 'target_name must be 255 characters or fewer'}), 400
|
||||||
|
if len(target_detail) > 255:
|
||||||
|
return jsonify({'error': 'target_detail must be 255 characters or fewer'}), 400
|
||||||
|
|
||||||
sup_id = db.create_suppression(
|
sup_id = db.create_suppression(
|
||||||
target_type=target_type,
|
target_type=target_type,
|
||||||
|
|||||||
22
config.json
22
config.json
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"ssh": {
|
"pulse": {
|
||||||
"user": "root",
|
"url": "http://10.10.10.65:8080",
|
||||||
"password": "Server#980000Panda",
|
"api_key": "012b303a324152c509bf5ade6f942cfc21404f68662f01a17001cba9e4486049",
|
||||||
"connect_timeout": 5,
|
"worker_id": "1b11d1b5-4ed0-42df-a6af-8d57fffe1343",
|
||||||
"timeout": 20
|
"timeout": 45
|
||||||
},
|
},
|
||||||
"unifi": {
|
"unifi": {
|
||||||
"controller": "https://10.10.10.1",
|
"controller": "https://10.10.10.1",
|
||||||
@@ -28,14 +28,18 @@
|
|||||||
"allowed_groups": ["admin"]
|
"allowed_groups": ["admin"]
|
||||||
},
|
},
|
||||||
"monitor": {
|
"monitor": {
|
||||||
"poll_interval": 120,
|
"poll_interval": 300,
|
||||||
"failure_threshold": 2,
|
"failure_threshold": 2,
|
||||||
"cluster_threshold": 3,
|
"cluster_threshold": 3,
|
||||||
"ping_hosts": [
|
"ping_hosts": [],
|
||||||
{"name": "pbs", "ip": "10.10.10.3"}
|
"links_exclude_ips": ["10.10.10.29", "10.10.10.44", "10.10.10.3"]
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"hosts": [
|
"hosts": [
|
||||||
|
{
|
||||||
|
"name": "pbs",
|
||||||
|
"ip": "10.10.10.3",
|
||||||
|
"prometheus_instance": "10.10.10.3:9100"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "large1",
|
"name": "large1",
|
||||||
"ip": "10.10.10.2",
|
"ip": "10.10.10.2",
|
||||||
|
|||||||
16
db.py
16
db.py
@@ -153,7 +153,7 @@ def set_ticket_id(event_id: int, ticket_id: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_active_events() -> list:
|
def get_active_events(limit: int = 200, offset: int = 0) -> list:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -161,7 +161,9 @@ def get_active_events() -> list:
|
|||||||
WHERE resolved_at IS NULL
|
WHERE resolved_at IS NULL
|
||||||
ORDER BY
|
ORDER BY
|
||||||
FIELD(severity,'critical','warning','info'),
|
FIELD(severity,'critical','warning','info'),
|
||||||
first_seen DESC"""
|
first_seen DESC
|
||||||
|
LIMIT %s OFFSET %s""",
|
||||||
|
(limit, offset),
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -171,6 +173,16 @@ def get_active_events() -> list:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def count_active_events() -> int:
|
||||||
|
"""Return count of all unresolved events (for pagination)."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM network_events WHERE resolved_at IS NULL"
|
||||||
|
)
|
||||||
|
return cur.fetchone()['n']
|
||||||
|
|
||||||
|
|
||||||
def get_recent_resolved(hours: int = 24, limit: int = 50) -> list:
|
def get_recent_resolved(hours: int = 24, limit: int = 50) -> list:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ class DiagnosticsRunner:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
|
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
|
||||||
f'-o LogLevel=ERROR root@{ip_q} \'{remote_cmd}\''
|
f'-o BatchMode=yes -o LogLevel=ERROR '
|
||||||
|
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
|
||||||
|
f'root@{ip_q} \'{remote_cmd}\''
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
58
monitor.py
58
monitor.py
@@ -246,11 +246,16 @@ class PulseClient:
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
})
|
})
|
||||||
|
|
||||||
def run_command(self, command: str) -> Optional[str]:
|
def run_command(self, command: str, _retry: bool = True) -> Optional[str]:
|
||||||
"""Submit *command* to Pulse, poll until done, return stdout or None."""
|
"""Submit *command* to Pulse, poll until done, return stdout or None.
|
||||||
|
|
||||||
|
Retries once automatically on transient submit failures or timeouts.
|
||||||
|
"""
|
||||||
self.last_execution_id = None
|
self.last_execution_id = None
|
||||||
if not self.url or not self.api_key or not self.worker_id:
|
if not self.url or not self.api_key or not self.worker_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Submit
|
||||||
try:
|
try:
|
||||||
resp = self.session.post(
|
resp = self.session.post(
|
||||||
f'{self.url}/api/internal/command',
|
f'{self.url}/api/internal/command',
|
||||||
@@ -262,11 +267,16 @@ class PulseClient:
|
|||||||
self.last_execution_id = execution_id
|
self.last_execution_id = execution_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Pulse command submit failed: {e}')
|
logger.error(f'Pulse command submit failed: {e}')
|
||||||
|
if _retry:
|
||||||
|
logger.info('Retrying Pulse command submit in 5s...')
|
||||||
|
time.sleep(5)
|
||||||
|
return self.run_command(command, _retry=False)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Poll
|
||||||
deadline = time.time() + self.timeout
|
deadline = time.time() + self.timeout
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
time.sleep(1)
|
time.sleep(2)
|
||||||
try:
|
try:
|
||||||
r = self.session.get(
|
r = self.session.get(
|
||||||
f'{self.url}/api/internal/executions/{execution_id}',
|
f'{self.url}/api/internal/executions/{execution_id}',
|
||||||
@@ -281,11 +291,28 @@ class PulseClient:
|
|||||||
if entry.get('action') == 'command_result':
|
if entry.get('action') == 'command_result':
|
||||||
return entry.get('stdout', '')
|
return entry.get('stdout', '')
|
||||||
return ''
|
return ''
|
||||||
if status == 'failed':
|
if status in ('failed', 'timed_out', 'cancelled'):
|
||||||
|
logger.error(
|
||||||
|
f'Pulse execution {execution_id} ended with status={status!r}; '
|
||||||
|
f'view at {self.url}/executions/{execution_id}'
|
||||||
|
)
|
||||||
|
if _retry and status != 'cancelled':
|
||||||
|
logger.info('Retrying failed Pulse command in 5s...')
|
||||||
|
time.sleep(5)
|
||||||
|
return self.run_command(command, _retry=False)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Pulse poll failed: {e}')
|
logger.error(f'Pulse poll failed for {execution_id}: {e}')
|
||||||
logger.warning(f'Pulse command timed out after {self.timeout}s')
|
|
||||||
|
logger.warning(
|
||||||
|
f'Pulse command timed out after {self.timeout}s '
|
||||||
|
f'(execution_id={execution_id}); '
|
||||||
|
f'view at {self.url}/executions/{execution_id}'
|
||||||
|
)
|
||||||
|
if _retry:
|
||||||
|
logger.info('Retrying timed-out Pulse command in 5s...')
|
||||||
|
time.sleep(5)
|
||||||
|
return self.run_command(command, _retry=False)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -336,7 +363,9 @@ class LinkStatsCollector:
|
|||||||
|
|
||||||
ssh_cmd = (
|
ssh_cmd = (
|
||||||
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
|
f'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 '
|
||||||
f'-o LogLevel=ERROR root@{ip} "{shell_cmd}"'
|
f'-o BatchMode=yes -o LogLevel=ERROR '
|
||||||
|
f'-o ServerAliveInterval=10 -o ServerAliveCountMax=2 '
|
||||||
|
f'root@{ip} "{shell_cmd}"'
|
||||||
)
|
)
|
||||||
output = self.pulse.run_command(ssh_cmd)
|
output = self.pulse.run_command(ssh_cmd)
|
||||||
if output is None:
|
if output is None:
|
||||||
@@ -524,15 +553,20 @@ class LinkStatsCollector:
|
|||||||
"""
|
"""
|
||||||
prom_metrics = self._collect_prom_metrics()
|
prom_metrics = self._collect_prom_metrics()
|
||||||
result_hosts: Dict[str, Dict[str, dict]] = {}
|
result_hosts: Dict[str, Dict[str, dict]] = {}
|
||||||
|
exclude_ips = set(self.cfg.get('monitor', {}).get('links_exclude_ips', []))
|
||||||
|
|
||||||
for instance, iface_metrics in prom_metrics.items():
|
for instance, iface_metrics in prom_metrics.items():
|
||||||
host = instance_map.get(instance, instance.split(':')[0])
|
|
||||||
host_ip = instance.split(':')[0]
|
host_ip = instance.split(':')[0]
|
||||||
|
if host_ip in exclude_ips:
|
||||||
|
continue
|
||||||
|
host = instance_map.get(instance, host_ip)
|
||||||
ifaces = list(iface_metrics.keys())
|
ifaces = list(iface_metrics.keys())
|
||||||
|
|
||||||
# SSH ethtool collection via Pulse worker (one connection per host, all ifaces)
|
# SSH ethtool collection via Pulse worker — only for explicitly configured
|
||||||
|
# hosts (instance_map keys). Hosts like postgresql/matrix may report
|
||||||
|
# node_exporter metrics to Prometheus but don't need link diagnostics.
|
||||||
ethtool_data: Dict[str, dict] = {}
|
ethtool_data: Dict[str, dict] = {}
|
||||||
if self.pulse.url and ifaces:
|
if self.pulse.url and ifaces and instance in instance_map:
|
||||||
try:
|
try:
|
||||||
ethtool_data = self._ssh_batch(host_ip, ifaces)
|
ethtool_data = self._ssh_batch(host_ip, ifaces)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -663,6 +697,8 @@ class NetworkMonitor:
|
|||||||
hosts_with_regression: List[str] = []
|
hosts_with_regression: List[str] = []
|
||||||
|
|
||||||
for instance, ifaces in states.items():
|
for instance, ifaces in states.items():
|
||||||
|
if instance not in self._instance_map:
|
||||||
|
continue # skip unconfigured Prometheus instances
|
||||||
host = self._hostname(instance)
|
host = self._hostname(instance)
|
||||||
new_baseline.setdefault(host, {})
|
new_baseline.setdefault(host, {})
|
||||||
host_has_regression = False
|
host_has_regression = False
|
||||||
@@ -846,6 +882,8 @@ class NetworkMonitor:
|
|||||||
|
|
||||||
hosts = {}
|
hosts = {}
|
||||||
for instance, ifaces in iface_states.items():
|
for instance, ifaces in iface_states.items():
|
||||||
|
if instance not in self._instance_map:
|
||||||
|
continue # skip Prometheus instances not in config (e.g. LXC app servers)
|
||||||
host = self._hostname(instance)
|
host = self._hostname(instance)
|
||||||
phys = {k: v for k, v in ifaces.items()}
|
phys = {k: v for k, v in ifaces.items()}
|
||||||
up_count = sum(1 for v in phys.values() if v)
|
up_count = sum(1 for v in phys.values() if v)
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ── Toast notifications ───────────────────────────────────────────────
|
// ── Auto-redirect on auth timeout ─────────────────────────────────────
|
||||||
|
// Intercept all fetch() calls: if the server returns 401 (auth expired),
|
||||||
|
// reload the page so Authelia redirects to the login screen.
|
||||||
|
(function () {
|
||||||
|
const _fetch = window.fetch;
|
||||||
|
window.fetch = async function (...args) {
|
||||||
|
const resp = await _fetch(...args);
|
||||||
|
if (resp.status === 401) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Toast notifications — delegates to lt.toast from base.js ─────────
|
||||||
function showToast(msg, type = 'success') {
|
function showToast(msg, type = 'success') {
|
||||||
let container = document.querySelector('.toast-container');
|
if (type === 'error') return lt.toast.error(msg);
|
||||||
if (!container) {
|
if (type === 'warning') return lt.toast.warning(msg);
|
||||||
container = document.createElement('div');
|
if (type === 'info') return lt.toast.info(msg);
|
||||||
container.className = 'toast-container';
|
return lt.toast.success(msg);
|
||||||
document.body.appendChild(container);
|
|
||||||
}
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `toast toast-${type}`;
|
|
||||||
toast.textContent = msg;
|
|
||||||
container.appendChild(toast);
|
|
||||||
setTimeout(() => toast.remove(), 3500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dashboard auto-refresh ────────────────────────────────────────────
|
// ── Dashboard auto-refresh ────────────────────────────────────────────
|
||||||
@@ -29,7 +36,7 @@ async function refreshAll() {
|
|||||||
|
|
||||||
updateHostGrid(net.hosts || {});
|
updateHostGrid(net.hosts || {});
|
||||||
updateUnifiTable(net.unifi || []);
|
updateUnifiTable(net.unifi || []);
|
||||||
updateEventsTable(status.events || []);
|
updateEventsTable(status.events || [], status.total_active);
|
||||||
updateStatusBar(status.summary || {}, status.last_check || '');
|
updateStatusBar(status.summary || {}, status.last_check || '');
|
||||||
updateTopology(net.hosts || {});
|
updateTopology(net.hosts || {});
|
||||||
|
|
||||||
@@ -116,7 +123,9 @@ function updateTopology(hosts) {
|
|||||||
const name = node.dataset.host;
|
const name = node.dataset.host;
|
||||||
const host = hosts[name];
|
const host = hosts[name];
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, '');
|
||||||
node.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, '');
|
node.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, '');
|
||||||
|
node.classList.add(`topo-v2-status-${host.status}`);
|
||||||
node.classList.add(`topo-status-${host.status}`);
|
node.classList.add(`topo-status-${host.status}`);
|
||||||
const badge = node.querySelector('.topo-badge');
|
const badge = node.querySelector('.topo-badge');
|
||||||
if (badge) {
|
if (badge) {
|
||||||
@@ -152,7 +161,7 @@ function updateUnifiTable(devices) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEventsTable(events) {
|
function updateEventsTable(events, totalActive) {
|
||||||
const wrap = document.getElementById('events-table-wrap');
|
const wrap = document.getElementById('events-table-wrap');
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
|
|
||||||
@@ -162,6 +171,11 @@ function updateEventsTable(events) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const truncated = totalActive != null && totalActive > active.length;
|
||||||
|
const countNotice = truncated
|
||||||
|
? `<div class="pagination-notice">Showing ${active.length} of ${totalActive} active alerts — <a href="/api/events?limit=1000">view all via API</a></div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
const rows = active.map(e => {
|
const rows = active.map(e => {
|
||||||
const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device'
|
const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device'
|
||||||
: e.event_type === 'interface_down' ? 'interface'
|
: e.event_type === 'interface_down' ? 'interface'
|
||||||
@@ -179,7 +193,8 @@ function updateEventsTable(events) {
|
|||||||
<td><strong>${escHtml(e.target_name)}</strong></td>
|
<td><strong>${escHtml(e.target_name)}</strong></td>
|
||||||
<td>${escHtml(e.target_detail || '–')}</td>
|
<td>${escHtml(e.target_detail || '–')}</td>
|
||||||
<td class="desc-cell" title="${escHtml(e.description || '')}">${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
|
<td class="desc-cell" title="${escHtml(e.description || '')}">${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
|
||||||
<td class="ts-cell">${escHtml(e.first_seen||'')}</td>
|
<td class="ts-cell" title="${escHtml(e.first_seen||'')}">${fmtRelTime(e.first_seen)}</td>
|
||||||
|
<td class="ts-cell" title="${escHtml(e.last_seen||'')}">${fmtRelTime(e.last_seen)}</td>
|
||||||
<td>${e.consecutive_failures}</td>
|
<td>${e.consecutive_failures}</td>
|
||||||
<td>${ticket}</td>
|
<td>${ticket}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -192,12 +207,13 @@ function updateEventsTable(events) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
|
${countNotice}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="events-table">
|
<table class="data-table" id="events-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Sev</th><th>Type</th><th>Target</th><th>Detail</th>
|
<th>Sev</th><th>Type</th><th>Target</th><th>Detail</th>
|
||||||
<th>Description</th><th>First Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
|
<th>Description</th><th>First Seen</th><th>Last Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
@@ -307,6 +323,18 @@ document.addEventListener('click', e => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Relative time ─────────────────────────────────────────────────────
|
||||||
|
function fmtRelTime(tsStr) {
|
||||||
|
if (!tsStr) return '–';
|
||||||
|
const d = new Date(tsStr.replace(' UTC', 'Z').replace(' ', 'T'));
|
||||||
|
if (isNaN(d)) return tsStr;
|
||||||
|
const secs = Math.floor((Date.now() - d) / 1000);
|
||||||
|
if (secs < 60) return `${secs}s ago`;
|
||||||
|
if (secs < 3600) return `${Math.floor(secs/60)}m ago`;
|
||||||
|
if (secs < 86400) return `${Math.floor(secs/3600)}h ago`;
|
||||||
|
return `${Math.floor(secs/86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Utility ───────────────────────────────────────────────────────────
|
// ── Utility ───────────────────────────────────────────────────────────
|
||||||
function escHtml(str) {
|
function escHtml(str) {
|
||||||
if (str === null || str === undefined) return '';
|
if (str === null || str === undefined) return '';
|
||||||
|
|||||||
1
static/base.css
Symbolic link
1
static/base.css
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/root/code/web_template/base.css
|
||||||
1
static/base.js
Symbolic link
1
static/base.js
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/root/code/web_template/base.js
|
||||||
827
static/style.css
827
static/style.css
@@ -35,11 +35,27 @@
|
|||||||
|
|
||||||
--font: 'Courier New','Consolas','Monaco','Menlo',monospace;
|
--font: 'Courier New','Consolas','Monaco','Menlo',monospace;
|
||||||
|
|
||||||
--glow: 0 0 5px #00ff41, 0 0 10px rgba(0,255,65,.4);
|
--glow: 0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41;
|
||||||
--glow-xl: 0 0 8px #00ff41, 0 0 20px rgba(0,255,65,.35);
|
--glow-xl: 0 0 8px #00ff41, 0 0 16px #00ff41, 0 0 24px #00ff41, 0 0 32px rgba(0,255,65,.5);
|
||||||
--glow-amber: 0 0 5px #ffb000, 0 0 10px rgba(255,176,0,.4);
|
--glow-amber: 0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000;
|
||||||
--glow-red: 0 0 5px #ff4444, 0 0 10px rgba(255,68,68,.4);
|
--glow-red: 0 0 5px #ff4444, 0 0 10px rgba(255,68,68,.4);
|
||||||
--glow-cyan: 0 0 5px #00ffff, 0 0 10px rgba(0,255,255,.35);
|
--glow-cyan: 0 0 5px #00ffff, 0 0 10px rgba(0,255,255,.35);
|
||||||
|
|
||||||
|
/* Unified naming aliases — matches base.css variable names */
|
||||||
|
--bg-primary: var(--bg);
|
||||||
|
--bg-secondary: var(--bg2);
|
||||||
|
--bg-tertiary: var(--bg3);
|
||||||
|
--terminal-green: var(--green);
|
||||||
|
--terminal-green-dim: var(--green-dim);
|
||||||
|
--terminal-amber: var(--amber);
|
||||||
|
--terminal-amber-dim: var(--amber-dim);
|
||||||
|
--terminal-cyan: var(--cyan);
|
||||||
|
--terminal-red: var(--red);
|
||||||
|
--text-primary: var(--text);
|
||||||
|
--text-secondary: var(--text-dim);
|
||||||
|
--border-color: var(--border);
|
||||||
|
--glow-green: var(--glow);
|
||||||
|
--font-mono: var(--font);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Reset ────────────────────────────────────────────────────────── */
|
/* ── Reset ────────────────────────────────────────────────────────── */
|
||||||
@@ -60,7 +76,7 @@ body {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
animation: flicker .25s ease-in-out 45s infinite;
|
animation: flicker .25s ease-in-out 30s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CRT scanline overlay */
|
/* CRT scanline overlay */
|
||||||
@@ -70,7 +86,7 @@ body::before {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: repeating-linear-gradient(
|
background: repeating-linear-gradient(
|
||||||
0deg,
|
0deg,
|
||||||
rgba(0,0,0,.13) 0px, rgba(0,0,0,.13) 1px,
|
rgba(0,0,0,0.15) 0px, rgba(0,0,0,0.15) 1px,
|
||||||
transparent 1px, transparent 2px
|
transparent 1px, transparent 2px
|
||||||
);
|
);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -157,13 +173,20 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
}
|
}
|
||||||
.nav-link::before { content:'[ '; }
|
.nav-link::before { content:'[ '; }
|
||||||
.nav-link::after { content:' ]'; }
|
.nav-link::after { content:' ]'; }
|
||||||
.nav-link:hover, .nav-link.active {
|
.nav-link:hover {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
background: var(--green-dim);
|
background: var(--green-dim);
|
||||||
text-shadow: var(--glow);
|
text-shadow: var(--glow);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
.nav-link.active {
|
||||||
|
color: var(--amber);
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber-dim);
|
||||||
|
text-shadow: var(--glow-amber);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.header-right { display:flex; align-items:center; gap:10px; }
|
.header-right { display:flex; align-items:center; gap:10px; }
|
||||||
.header-user { font-size: .78em; color: var(--text-muted); }
|
.header-user { font-size: .78em; color: var(--text-muted); }
|
||||||
@@ -193,7 +216,8 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .1em;
|
letter-spacing: .1em;
|
||||||
}
|
}
|
||||||
.section-title::before { content:'╠══ '; color:var(--green); text-shadow:var(--glow); }
|
.section-title::before { content:'╠═══ '; color:var(--green); text-shadow:var(--glow); }
|
||||||
|
.section-title::after { content:' ═══╣'; color:var(--green); text-shadow:var(--glow); }
|
||||||
|
|
||||||
.section-badge {
|
.section-badge {
|
||||||
font-size: .72em;
|
font-size: .72em;
|
||||||
@@ -332,6 +356,103 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
|
|
||||||
.topo-status-dot { width:7px; height:7px; border:1px solid var(--text-muted); background:transparent; position:absolute; top:5px; right:5px; }
|
.topo-status-dot { width:7px; height:7px; border:1px solid var(--text-muted); background:transparent; position:absolute; top:5px; right:5px; }
|
||||||
|
|
||||||
|
/* Topology subtitle text */
|
||||||
|
.topo-node-sub {
|
||||||
|
font-size: .58em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: .02em;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-badge-unknown { color:var(--text-muted); border-color:var(--border); }
|
||||||
|
|
||||||
|
.topo-vlan-tag {
|
||||||
|
color: var(--cyan) !important;
|
||||||
|
opacity: .7;
|
||||||
|
font-size: .54em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch tier: two switches with horizontal connector */
|
||||||
|
.topo-switch-tier {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-h-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-h-link-line {
|
||||||
|
width: 70px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--amber);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-h-link-label {
|
||||||
|
font-size: .52em;
|
||||||
|
color: var(--amber);
|
||||||
|
opacity: .7;
|
||||||
|
margin-top: 3px;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Host tier: two groups side by side */
|
||||||
|
.topo-host-tier {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 40px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-host-group { flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* PoE host group: offset right to sit below PoE switch */
|
||||||
|
.topo-poe-hosts {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Off-rack node (dashed border) */
|
||||||
|
.topo-host-table {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashed 10G line (for off-rack/table host) */
|
||||||
|
.topo-line-dashed {
|
||||||
|
background: none;
|
||||||
|
border-left: 1px dashed var(--green);
|
||||||
|
opacity: .4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1G management band — horizontal amber dashed line with label */
|
||||||
|
.topo-mgmt-band {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-mgmt-label {
|
||||||
|
font-size: .52em;
|
||||||
|
color: var(--amber);
|
||||||
|
opacity: .65;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-mgmt-line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
border-top: 1px dashed var(--amber);
|
||||||
|
opacity: .4;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Host cards ───────────────────────────────────────────────────── */
|
/* ── Host cards ───────────────────────────────────────────────────── */
|
||||||
.host-grid {
|
.host-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -478,8 +599,8 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 6px 14px;
|
padding: 5px 12px;
|
||||||
border: 1px solid;
|
border: 2px solid;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: .8em;
|
font-size: .8em;
|
||||||
@@ -489,10 +610,13 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
transition: all .15s;
|
transition: all .15s;
|
||||||
}
|
}
|
||||||
.btn:hover { transform: translateY(-1px); }
|
.btn::before { content: '[ '; }
|
||||||
|
.btn::after { content: ' ]'; }
|
||||||
|
.btn:hover { transform: translateY(-2px); }
|
||||||
|
|
||||||
.btn-primary { color:var(--green); border-color:var(--green); text-shadow:var(--glow); }
|
.btn-primary { color:var(--green); border-color:var(--green); text-shadow:var(--glow); }
|
||||||
.btn-primary::before { content:'> '; color:var(--amber); }
|
.btn-primary::before { content:'> '; color:var(--amber); }
|
||||||
|
.btn-primary::after { content:''; }
|
||||||
.btn-primary:hover { background:var(--green-dim); box-shadow:var(--glow); }
|
.btn-primary:hover { background:var(--green-dim); box-shadow:var(--glow); }
|
||||||
|
|
||||||
.btn-secondary { color:var(--text-dim); border-color:var(--border); }
|
.btn-secondary { color:var(--text-dim); border-color:var(--border); }
|
||||||
@@ -508,7 +632,7 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: .7em;
|
font-size: .7em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border: 1px solid;
|
border: 2px solid;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
@@ -531,15 +655,15 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
}
|
}
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--bg2);
|
background: var(--bg2);
|
||||||
border: 1px solid var(--green);
|
border: 3px double var(--green);
|
||||||
box-shadow: 0 0 30px rgba(0,255,65,.18);
|
box-shadow: 0 0 30px rgba(0,255,65,.2), 0 8px 40px rgba(0,0,0,.8);
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.modal::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; }
|
.modal::before { content:'╔'; position:absolute; top:-1px; left:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; }
|
||||||
.modal::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; }
|
.modal::after { content:'╗'; position:absolute; top:-1px; right:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; }
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -600,7 +724,7 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group select {
|
.form-group select {
|
||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
border: 1px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: .8em;
|
font-size: .8em;
|
||||||
background: var(--bg3);
|
background: var(--bg3);
|
||||||
@@ -952,9 +1076,9 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SFP port (in rows — slightly narrower to suggest cage) */
|
/* SFP port (in rows) — width overridden to 34px further down */
|
||||||
.switch-port-block.sfp-port {
|
.switch-port-block.sfp-port {
|
||||||
width: 28px;
|
width: 34px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
font-size: .55em;
|
font-size: .55em;
|
||||||
}
|
}
|
||||||
@@ -1410,6 +1534,516 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
text-shadow: var(--glow-cyan);
|
text-shadow: var(--glow-cyan);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Topology v2 – professional network diagram ──────────────────── */
|
||||||
|
|
||||||
|
/* Outer wrapper */
|
||||||
|
.topo-v2 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
min-width: 860px;
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each tier row */
|
||||||
|
.topo-tier {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Vertical connector section between tiers ── */
|
||||||
|
.topo-vc {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Single centered vertical wire */
|
||||||
|
.topo-vc-wire {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to bottom, var(--cyan), var(--green));
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labeled vertical connector */
|
||||||
|
.topo-vc-label {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% + 6px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: .58em;
|
||||||
|
color: var(--amber);
|
||||||
|
text-shadow: var(--glow-amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── WAN tier node (Internet + Router side by side) ── */
|
||||||
|
.topo-tier-wan {
|
||||||
|
gap: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Individual node boxes ── */
|
||||||
|
.topo-v2-node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg3);
|
||||||
|
position: relative;
|
||||||
|
font-size: .75em;
|
||||||
|
font-family: var(--font);
|
||||||
|
min-width: 110px;
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color .2s, box-shadow .2s;
|
||||||
|
}
|
||||||
|
.topo-v2-node::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.85rem; line-height:1; pointer-events:none; }
|
||||||
|
.topo-v2-node::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.85rem; line-height:1; pointer-events:none; }
|
||||||
|
|
||||||
|
.topo-v2-icon { font-size:1.3em; line-height:1; }
|
||||||
|
.topo-v2-label { font-weight:bold; letter-spacing:.04em; }
|
||||||
|
.topo-v2-sub { font-size:.58em; color:var(--text-muted); letter-spacing:.02em; }
|
||||||
|
.topo-v2-vlan { font-size:.54em; color:var(--cyan); opacity:.75; letter-spacing:.02em; }
|
||||||
|
|
||||||
|
/* Node type colours */
|
||||||
|
.topo-v2-internet { border-color:var(--cyan); color:var(--cyan); text-shadow:var(--glow-cyan); }
|
||||||
|
.topo-v2-router { border-color:var(--cyan); color:var(--cyan); text-shadow:var(--glow-cyan); }
|
||||||
|
.topo-v2-switch { border-color:var(--amber); color:var(--amber); text-shadow:var(--glow-amber); }
|
||||||
|
.topo-v2-host { border-color:var(--border); color:var(--text); cursor:default; }
|
||||||
|
|
||||||
|
/* ── CSS fork: UDM-Pro → two switches, no SVG distortion ── */
|
||||||
|
/* The fork sits between the router tier and the switch row.
|
||||||
|
Drops are at left:25% and left:75%, matching each switch's
|
||||||
|
centre (each switch lives in a 50%-wide half). */
|
||||||
|
.topo-fork {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
/* Vertical stem down from router centre */
|
||||||
|
.topo-fork-stem {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--amber);
|
||||||
|
opacity: .65;
|
||||||
|
}
|
||||||
|
/* Horizontal bar at mid-height, spanning between the two drop points */
|
||||||
|
.topo-fork-bar {
|
||||||
|
position: absolute;
|
||||||
|
left: 25%;
|
||||||
|
right: 25%;
|
||||||
|
top: calc(50% - 1px);
|
||||||
|
height: 2px;
|
||||||
|
background: var(--amber);
|
||||||
|
opacity: .55;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.topo-fork-label {
|
||||||
|
position: absolute;
|
||||||
|
top: -13px;
|
||||||
|
font-size: .54em;
|
||||||
|
color: var(--amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
font-family: var(--font);
|
||||||
|
opacity: .85;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 0 4px;
|
||||||
|
text-shadow: var(--glow-amber);
|
||||||
|
}
|
||||||
|
/* Left and right vertical drops from bar down to switch tops */
|
||||||
|
.topo-fork-drop {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 2px;
|
||||||
|
height: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
opacity: .55;
|
||||||
|
}
|
||||||
|
.topo-fork-drop-l { left: 25%; transform: translateX(-50%); }
|
||||||
|
.topo-fork-drop-r { left: 75%; transform: translateX(-50%); }
|
||||||
|
|
||||||
|
/* ── Switch row: two equal 50% halves ── */
|
||||||
|
/* Each switch is centred in its half, so their centres are at
|
||||||
|
exactly 25% and 75% — matching the fork drops above. */
|
||||||
|
.topo-sw-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.topo-sw-half {
|
||||||
|
width: 50%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1; /* sit above the ISL line */
|
||||||
|
}
|
||||||
|
/* ISL line rendered as ::before — switch boxes (bg3) cover it at their edges */
|
||||||
|
.topo-sw-row::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 25%;
|
||||||
|
right: 25%;
|
||||||
|
top: 50%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--amber);
|
||||||
|
opacity: .35;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
/* ISL label centred between the two switches */
|
||||||
|
.topo-sw-row::after {
|
||||||
|
content: '10G ISL';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
top: calc(50% - 14px);
|
||||||
|
font-size: .5em;
|
||||||
|
color: var(--amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font);
|
||||||
|
letter-spacing: .06em;
|
||||||
|
opacity: .65;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 0 5px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dual-home bus section ── */
|
||||||
|
/* This is the complex section linking two switches to N hosts */
|
||||||
|
.topo-bus-section {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bus bar row: the horizontal rail that distributes to hosts */
|
||||||
|
.topo-bus-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The two drop buses: 10G (green) and 1G mgmt (amber dashed) */
|
||||||
|
.topo-bus-10g {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
.topo-bus-10g-line {
|
||||||
|
flex: 1;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--green);
|
||||||
|
opacity: .45;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
.topo-bus-10g-label {
|
||||||
|
font-size: .56em;
|
||||||
|
color: var(--green);
|
||||||
|
text-shadow: var(--glow);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
font-family: var(--font);
|
||||||
|
opacity: .85;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-bus-1g {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.topo-bus-1g-line {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
border-top: 2px dashed var(--amber);
|
||||||
|
opacity: .35;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
.topo-bus-1g-label {
|
||||||
|
font-size: .56em;
|
||||||
|
color: var(--amber);
|
||||||
|
text-shadow: var(--glow-amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
font-family: var(--font);
|
||||||
|
opacity: .8;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Host row ── */
|
||||||
|
.topo-v2-hosts {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Host status colouring */
|
||||||
|
.topo-v2-status-up { border-color:var(--green); box-shadow:0 0 8px rgba(0,255,65,.2); }
|
||||||
|
.topo-v2-status-down { border-color:var(--red); box-shadow:0 0 8px rgba(255,68,68,.35); animation:pulse-glow 2s infinite; }
|
||||||
|
.topo-v2-status-degraded{ border-color:var(--orange); box-shadow:0 0 8px rgba(255,140,0,.2); }
|
||||||
|
.topo-v2-status-unknown { border-color:var(--border); }
|
||||||
|
|
||||||
|
/* Off-rack host: dashed border */
|
||||||
|
.topo-v2-offrack { border-style: dashed !important; }
|
||||||
|
|
||||||
|
/* ── Legend row ── */
|
||||||
|
.topo-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid rgba(0,255,65,.12);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.topo-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: .58em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font);
|
||||||
|
}
|
||||||
|
.topo-legend-line-10g {
|
||||||
|
width: 24px; height: 2px;
|
||||||
|
background: var(--green);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.topo-legend-line-1g {
|
||||||
|
width: 24px; height: 0;
|
||||||
|
border-top: 2px dashed var(--amber);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.topo-legend-line-isl {
|
||||||
|
width: 24px; height: 2px;
|
||||||
|
background: var(--amber);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.topo-legend-line-wan {
|
||||||
|
width: 24px; height: 2px;
|
||||||
|
background: linear-gradient(to right, var(--cyan), var(--green));
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drop-wire stubs for host dual-homing ── */
|
||||||
|
/* Wrapper that sits above each host showing its two connections */
|
||||||
|
.topo-v2-host-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-v2-host-wires {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
height: 28px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.topo-v2-wire-10g {
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--green);
|
||||||
|
opacity: .55;
|
||||||
|
}
|
||||||
|
.topo-v2-wire-1g {
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-left: 2px dashed var(--amber);
|
||||||
|
opacity: .45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* host badge */
|
||||||
|
.topo-v2-badge {
|
||||||
|
font-size: .65em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border: 1px solid;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.topo-v2-badge-up { color:var(--green); border-color:var(--green); text-shadow:var(--glow); }
|
||||||
|
.topo-v2-badge-down { color:var(--red); border-color:var(--red); animation:pulse-glow 1.5s infinite; }
|
||||||
|
.topo-v2-badge-degraded{ color:var(--orange); border-color:var(--orange); }
|
||||||
|
.topo-v2-badge-unknown { color:var(--text-muted); border-color:var(--border); }
|
||||||
|
|
||||||
|
/* (removed: old SVG fork — replaced by .topo-fork CSS above) */
|
||||||
|
|
||||||
|
/* ── Drop wires from each switch down to the bus rails ── */
|
||||||
|
.topo-sw-drops {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.topo-sw-drop {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
.topo-sw-drop-l {
|
||||||
|
left: 25%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
.topo-sw-drop-r {
|
||||||
|
left: 75%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--amber);
|
||||||
|
border-left: 2px dashed var(--amber);
|
||||||
|
width: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Improved chassis legend ── */
|
||||||
|
.chassis-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 16px 8px;
|
||||||
|
border-top: 1px solid rgba(0,255,65,.1);
|
||||||
|
background: var(--bg2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.chassis-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: .58em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font);
|
||||||
|
letter-spacing: .04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.chassis-legend-swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1px solid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.cls-down { background:var(--bg3); border-color:rgba(0,255,65,.15); }
|
||||||
|
.cls-up { background:rgba(0,255,65,.06); border-color:var(--green-muted); }
|
||||||
|
.cls-poe { background:var(--amber-dim); border-color:var(--amber); }
|
||||||
|
.cls-uplink { background:var(--cyan-dim); border-color:var(--cyan); }
|
||||||
|
|
||||||
|
/* ── Port block v2: flex-col with speed sub-label ── */
|
||||||
|
.switch-port-block {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 2px 1px;
|
||||||
|
}
|
||||||
|
.port-num {
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.port-speed {
|
||||||
|
font-size: .72em;
|
||||||
|
opacity: .7;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.port-lldp {
|
||||||
|
font-size: .62em;
|
||||||
|
opacity: .65;
|
||||||
|
line-height: 1;
|
||||||
|
max-width: 32px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: clip;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* US24PRO port group separators (every 2 ports = 1 pair gap) */
|
||||||
|
.chassis-row.us24pro-row .switch-port-block:nth-child(2n+1):not(:first-child) {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SFP block: taller and narrower cage look */
|
||||||
|
.switch-port-block.sfp-block {
|
||||||
|
width: 36px;
|
||||||
|
height: 38px;
|
||||||
|
font-size: .55em;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
border-left-width: 3px;
|
||||||
|
}
|
||||||
|
/* SFP port in rows — same width as copper ports so all-SFP switches
|
||||||
|
(e.g. USW-Agg / USL8A) don't appear narrower than other switches */
|
||||||
|
.switch-port-block.sfp-port {
|
||||||
|
width: 34px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: .55em;
|
||||||
|
border-left-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chassis mounting ears */
|
||||||
|
.chassis-body {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chassis-ear-l,
|
||||||
|
.chassis-ear-r {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 8px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--bg3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.chassis-ear-l { left: -9px; border-right: none; }
|
||||||
|
.chassis-ear-r { right: -9px; border-left: none; }
|
||||||
|
.chassis-ear-l::before,
|
||||||
|
.chassis-ear-r::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Responsive ───────────────────────────────────────────────────── */
|
/* ── Responsive ───────────────────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.host-grid { grid-template-columns:1fr; }
|
.host-grid { grid-template-columns:1fr; }
|
||||||
@@ -1426,13 +2060,158 @@ a:hover { text-decoration: underline; text-shadow: var(--glow-amber); }
|
|||||||
|
|
||||||
/* ── Stale monitoring banner ──────────────────────────────────────── */
|
/* ── Stale monitoring banner ──────────────────────────────────────── */
|
||||||
.stale-banner {
|
.stale-banner {
|
||||||
background: rgba(255, 160, 0, 0.12);
|
background: var(--amber-dim);
|
||||||
border: 1px solid var(--warning);
|
border: 1px solid var(--amber);
|
||||||
border-left: 4px solid var(--warning);
|
border-left: 4px solid var(--amber);
|
||||||
color: var(--warning);
|
color: var(--amber);
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
margin: 12px 16px 0;
|
margin: 12px 0 0;
|
||||||
font-size: 0.88em;
|
font-size: 0.88em;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Link alert badges (error/flap indicators) ────────────────────── */
|
||||||
|
.link-alert-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: .6em;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--red-dim);
|
||||||
|
color: var(--red);
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
margin-left: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-alert-badge.link-alert-amber {
|
||||||
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber);
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PoE utilisation bar ──────────────────────────────────────────── */
|
||||||
|
.poe-bar-track {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--bg3);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poe-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poe-bar-ok { background: var(--green); }
|
||||||
|
.poe-bar-warn { background: var(--amber); }
|
||||||
|
.poe-bar-crit { background: var(--red); }
|
||||||
|
|
||||||
|
/* ── Path mismatch alert ──────────────────────────────────────────── */
|
||||||
|
.path-mismatch-alert {
|
||||||
|
background: var(--amber-dim);
|
||||||
|
border-left: 3px solid var(--amber);
|
||||||
|
color: var(--amber);
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: .72em;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error state for data containers ─────────────────────────────── */
|
||||||
|
.error-state {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-left: 3px solid var(--red);
|
||||||
|
background: var(--red-dim);
|
||||||
|
color: var(--red);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: .88em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Link health summary panel ────────────────────────────────────── */
|
||||||
|
.link-summary-panel {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-summary-panel.link-summary-has-alerts {
|
||||||
|
border-color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-summary-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-summary-stat {
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-summary-stat.lss-alert .lss-label {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lss-label {
|
||||||
|
display: block;
|
||||||
|
font-size: .62em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lss-value {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lss-sub {
|
||||||
|
font-size: .7em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Recently resolved table ──────────────────────────────────────── */
|
||||||
|
.row-resolved td {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-resolved {
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-color: var(--border);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-badge-resolved {
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: .65em;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pagination notice ────────────────────────────────────────────── */
|
||||||
|
.pagination-notice {
|
||||||
|
font-size: .8em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 0 8px;
|
||||||
|
}
|
||||||
|
.pagination-notice a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.pagination-notice a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,9 +4,13 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}GANDALF{% endblock %}</title>
|
<title>{% block title %}GANDALF{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='base.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay" data-app-name="GANDALF" style="display:none">
|
||||||
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
|
</div>
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<div class="header-brand">
|
<div class="header-brand">
|
||||||
@@ -46,6 +50,7 @@
|
|||||||
ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}"
|
ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}"
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{{ url_for('static', filename='base.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -29,56 +29,142 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="topology" id="topology-diagram">
|
<div class="topology" id="topology-diagram">
|
||||||
<div class="topo-row topo-row-internet">
|
<div class="topo-v2">
|
||||||
<div class="topo-node topo-internet">
|
|
||||||
<span class="topo-icon">◈</span>
|
{%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%}
|
||||||
<span class="topo-label">Internet</span>
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
|
TIER 1: Internet (WAN edge)
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="topo-tier">
|
||||||
|
<div class="topo-v2-node topo-v2-internet">
|
||||||
|
<span class="topo-v2-icon">◈</span>
|
||||||
|
<span class="topo-v2-label">INTERNET</span>
|
||||||
|
<span class="topo-v2-sub">WAN uplink</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topo-connectors single">
|
|
||||||
<div class="topo-line"></div>
|
<!-- WAN wire: cyan → green gradient, labeled -->
|
||||||
|
<div class="topo-vc">
|
||||||
|
<div class="topo-vc-wire" style="background:linear-gradient(to bottom,var(--cyan),var(--cyan)); opacity:.55;"></div>
|
||||||
|
<span class="topo-vc-label">WAN · 10G SFP+</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="topo-row">
|
|
||||||
<div class="topo-node topo-unifi" id="topo-gateway">
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
<span class="topo-icon">⬡</span>
|
TIER 2: Router – UDM-Pro
|
||||||
<span class="topo-label">UDM-Pro</span>
|
══════════════════════════════════════════════════════════ -->
|
||||||
<span class="topo-status-dot" data-topo-target="gateway"></span>
|
<div class="topo-tier">
|
||||||
|
<div class="topo-v2-node topo-v2-router">
|
||||||
|
<span class="topo-v2-icon">⬡</span>
|
||||||
|
<span class="topo-v2-label">UDM-Pro</span>
|
||||||
|
<span class="topo-v2-sub">Dream Machine Pro</span>
|
||||||
|
<span class="topo-v2-sub">RU24</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topo-connectors single">
|
|
||||||
<div class="topo-line topo-line-labeled" data-link-label="10G DAC"></div>
|
<!-- UDM-Pro → USW-Agg (10G SFP+) -->
|
||||||
|
<div class="topo-vc">
|
||||||
|
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div>
|
||||||
|
<span class="topo-vc-label">10G SFP+</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="topo-row">
|
|
||||||
<div class="topo-node topo-switch" id="topo-switch-agg">
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
<span class="topo-icon">⬡</span>
|
TIER 3: USW-Aggregation
|
||||||
<span class="topo-label">Agg Switch</span>
|
══════════════════════════════════════════════════════════ -->
|
||||||
<span class="topo-status-dot" data-topo-target="switch-agg"></span>
|
<div class="topo-tier">
|
||||||
|
<div class="topo-v2-node topo-v2-switch" id="topo-switch-agg">
|
||||||
|
<span class="topo-v2-icon">⬡</span>
|
||||||
|
<span class="topo-v2-label">USW-Agg</span>
|
||||||
|
<span class="topo-v2-sub">Aggregation · RU22</span>
|
||||||
|
<span class="topo-v2-sub">8 × 10G SFP+</span>
|
||||||
|
<span class="topo-v2-vlan">VLAN90 · 10.10.90.x/24</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topo-connectors single">
|
|
||||||
<div class="topo-line topo-line-labeled" data-link-label="10G DAC"></div>
|
<!-- USW-Agg → Pro 24 PoE (10G trunk) -->
|
||||||
|
<div class="topo-vc">
|
||||||
|
<div class="topo-vc-wire" style="background:var(--amber);opacity:.6;"></div>
|
||||||
|
<span class="topo-vc-label">10G trunk</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="topo-row">
|
|
||||||
<div class="topo-node topo-switch" id="topo-switch-poe">
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
<span class="topo-icon">⬡</span>
|
TIER 4: Pro 24 PoE
|
||||||
<span class="topo-label">PoE Switch</span>
|
══════════════════════════════════════════════════════════ -->
|
||||||
<span class="topo-status-dot" data-topo-target="switch-poe"></span>
|
<div class="topo-tier">
|
||||||
|
<div class="topo-v2-node topo-v2-switch" id="topo-switch-poe">
|
||||||
|
<span class="topo-v2-icon">⬡</span>
|
||||||
|
<span class="topo-v2-label">Pro 24 PoE</span>
|
||||||
|
<span class="topo-v2-sub">24-Port · RU23</span>
|
||||||
|
<span class="topo-v2-sub">24 × 1G PoE</span>
|
||||||
|
<span class="topo-v2-vlan">DHCP · mgmt</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topo-connectors wide">
|
|
||||||
{% for name in snapshot.hosts %}
|
<!-- Pro 24 PoE → host bus section -->
|
||||||
<div class="topo-line"></div>
|
<div class="topo-vc">
|
||||||
{% endfor %}
|
<div class="topo-vc-wire" style="background:var(--border);opacity:.5;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topo-row topo-hosts-row">
|
|
||||||
{% for name, host in snapshot.hosts.items() %}
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
<div class="topo-node topo-host topo-status-{{ host.status }}" data-host="{{ name }}">
|
TIER 4 connecting bus – two rails (10G green + 1G amber dashed)
|
||||||
<span class="topo-icon">▣</span>
|
showing dual-homing for all 6 servers
|
||||||
<span class="topo-label">{{ name }}</span>
|
══════════════════════════════════════════════════════════ -->
|
||||||
<span class="topo-badge topo-badge-{{ host.status }}">{{ host.status }}</span>
|
<div class="topo-bus-section" style="max-width:860px;">
|
||||||
|
|
||||||
|
<!-- 10G storage bus (Agg → VLAN90) -->
|
||||||
|
<div class="topo-bus-10g">
|
||||||
|
<span class="topo-bus-10g-label">← USW-Agg · 10G SFP+ · VLAN90 →</span>
|
||||||
|
<div class="topo-bus-10g-line"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
|
<!-- 1G management bus (PoE → DHCP) -->
|
||||||
|
<div class="topo-bus-1g">
|
||||||
|
<span class="topo-bus-1g-label">← Pro 24 PoE · 1G · DHCP mgmt →</span>
|
||||||
|
<div class="topo-bus-1g-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Host nodes with drop wires ── -->
|
||||||
|
<div class="topo-v2-hosts">
|
||||||
|
{%- set all_defs = [
|
||||||
|
('compute-storage-gpu-01', 'csg-01', 'RU4–12', 'Ceph · VLAN90', False),
|
||||||
|
('compute-storage-01', 'cs-01', 'RU14–17', 'Ceph · VLAN90', False),
|
||||||
|
('storage-01', 'sto-01', 'rack', 'Ceph · VLAN90', False),
|
||||||
|
('monitor-01', 'mon-01', 'ZimaBoard', 'mgmt', False),
|
||||||
|
('monitor-02', 'mon-02', 'ZimaBoard', 'mgmt', False),
|
||||||
|
('large1', 'large1', 'off-rack', 'table', True),
|
||||||
|
] -%}
|
||||||
|
{%- for hname, hlabel, hsub, hvlan, off_rack in all_defs -%}
|
||||||
|
{%- set st = topo_h[hname].status if hname in topo_h else 'unknown' -%}
|
||||||
|
<div class="topo-v2-host-wrap">
|
||||||
|
<!-- dual-homing wires: 10G solid green + 1G dashed amber -->
|
||||||
|
<div class="topo-v2-host-wires">
|
||||||
|
<div class="topo-v2-wire-10g" title="10G SFP+ → USW-Agg"></div>
|
||||||
|
<div class="topo-v2-wire-1g" title="1G → Pro 24 PoE"></div>
|
||||||
|
</div>
|
||||||
|
<!-- host box -->
|
||||||
|
<div class="topo-v2-node topo-v2-host topo-host topo-v2-status-{{ st }}{{ ' topo-v2-offrack' if off_rack else '' }}"
|
||||||
|
data-host="{{ hname }}" style="min-width:80px; max-width:96px;">
|
||||||
|
<span class="topo-v2-icon">▣</span>
|
||||||
|
<span class="topo-v2-label">{{ hlabel }}</span>
|
||||||
|
<span class="topo-v2-sub">{{ hsub }}</span>
|
||||||
|
<span class="topo-v2-vlan">{{ hvlan }}</span>
|
||||||
|
<span class="topo-badge topo-badge-{{ st }}">{{ st if st != 'unknown' else '–' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{%- endfor -%}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /topo-bus-section -->
|
||||||
|
|
||||||
|
<!-- ── Legend ── -->
|
||||||
|
<div class="topo-legend">
|
||||||
|
<div class="topo-legend-item"><span class="topo-legend-line-wan"></span> WAN / uplink</div>
|
||||||
|
<div class="topo-legend-item"><span class="topo-legend-line-10g"></span> 10G SFP+ (Ceph / VLAN90)</div>
|
||||||
|
<div class="topo-legend-item"><span class="topo-legend-line-1g"></span> 1G DHCP (mgmt)</div>
|
||||||
|
<div class="topo-legend-item" style="border:1px dashed var(--border); padding:1px 5px; font-size:.56em; color:var(--text-muted);">dashed border = off-rack</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /topo-v2 -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Host cards -->
|
<!-- Host cards -->
|
||||||
@@ -191,6 +277,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="events-table-wrap">
|
<div id="events-table-wrap">
|
||||||
{% if events %}
|
{% if events %}
|
||||||
|
{% if total_active is defined and total_active > events|length %}
|
||||||
|
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts — <a href="/api/events?limit=1000">view all via API</a></div>
|
||||||
|
{% endif %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="events-table">
|
<table class="data-table" id="events-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -201,6 +290,7 @@
|
|||||||
<th>Detail</th>
|
<th>Detail</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>First Seen</th>
|
<th>First Seen</th>
|
||||||
|
<th>Last Seen</th>
|
||||||
<th>Failures</th>
|
<th>Failures</th>
|
||||||
<th>Ticket</th>
|
<th>Ticket</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
@@ -215,7 +305,12 @@
|
|||||||
<td><strong>{{ e.target_name }}</strong></td>
|
<td><strong>{{ e.target_name }}</strong></td>
|
||||||
<td>{{ e.target_detail or '–' }}</td>
|
<td>{{ e.target_detail or '–' }}</td>
|
||||||
<td class="desc-cell" title="{{ e.description | e }}">{{ e.description | truncate(60) }}</td>
|
<td class="desc-cell" title="{{ e.description | e }}">{{ e.description | truncate(60) }}</td>
|
||||||
<td class="ts-cell">{{ e.first_seen }}</td>
|
<td class="ts-cell" title="{{ e.first_seen }}">
|
||||||
|
<span class="event-age" data-ts="{{ e.first_seen }}">{{ e.first_seen }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="ts-cell" title="{{ e.last_seen }}">
|
||||||
|
<span class="event-age" data-ts="{{ e.last_seen }}">{{ e.last_seen }}</span>
|
||||||
|
</td>
|
||||||
<td>{{ e.consecutive_failures }}</td>
|
<td>{{ e.consecutive_failures }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if e.ticket_id %}
|
{% if e.ticket_id %}
|
||||||
@@ -233,7 +328,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="9" class="empty-state">No active alerts ✔</td></tr>
|
<tr><td colspan="10" class="empty-state">No active alerts ✔</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -244,6 +339,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
|
||||||
|
{% if recent_resolved %}
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">Recently Resolved</h2>
|
||||||
|
<span class="section-badge section-badge-resolved">{{ recent_resolved | length }} in last 24h</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Sev</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Detail</th>
|
||||||
|
<th>Resolved</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for e in recent_resolved %}
|
||||||
|
<tr class="row-resolved">
|
||||||
|
<td><span class="badge badge-resolved">{{ e.severity }}</span></td>
|
||||||
|
<td>{{ e.event_type | replace('_', ' ') }}</td>
|
||||||
|
<td><strong>{{ e.target_name }}</strong></td>
|
||||||
|
<td>{{ e.target_detail or '–' }}</td>
|
||||||
|
<td class="ts-cell">
|
||||||
|
<span class="event-age" data-ts="{{ e.resolved_at }}">{{ e.resolved_at }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="ts-cell event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}">–</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
|
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
|
||||||
<div id="suppress-modal" class="modal-overlay" style="display:none">
|
<div id="suppress-modal" class="modal-overlay" style="display:none">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
@@ -299,5 +432,42 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
setInterval(refreshAll, 30000);
|
setInterval(refreshAll, 30000);
|
||||||
|
|
||||||
|
// ── Relative time display for event age cells ──────────────────
|
||||||
|
function fmtRelTime(tsStr) {
|
||||||
|
if (!tsStr) return '–';
|
||||||
|
const d = new Date(tsStr.replace(' UTC', 'Z').replace(' ', 'T'));
|
||||||
|
if (isNaN(d)) return tsStr;
|
||||||
|
const secs = Math.floor((Date.now() - d) / 1000);
|
||||||
|
if (secs < 60) return `${secs}s ago`;
|
||||||
|
if (secs < 3600) return `${Math.floor(secs/60)}m ago`;
|
||||||
|
if (secs < 86400) return `${Math.floor(secs/3600)}h ago`;
|
||||||
|
return `${Math.floor(secs/86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEventAges() {
|
||||||
|
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
|
||||||
|
el.textContent = fmtRelTime(el.dataset.ts);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEventAges();
|
||||||
|
setInterval(updateEventAges, 60000);
|
||||||
|
|
||||||
|
// ── Event duration (resolved_at - first_seen) ──────────────────
|
||||||
|
function fmtDuration(firstTs, resolvedTs) {
|
||||||
|
if (!firstTs || !resolvedTs) return '–';
|
||||||
|
const parse = s => new Date(s.replace(' UTC', 'Z').replace(' ', 'T'));
|
||||||
|
const secs = Math.floor((parse(resolvedTs) - parse(firstTs)) / 1000);
|
||||||
|
if (secs < 0) return '–';
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
if (secs < 3600) return `${Math.floor(secs/60)}m`;
|
||||||
|
if (secs < 86400) return `${Math.floor(secs/3600)}h ${Math.floor((secs%3600)/60)}m`;
|
||||||
|
return `${Math.floor(secs/86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.event-duration[data-first][data-resolved]').forEach(el => {
|
||||||
|
el.textContent = fmtDuration(el.dataset.first, el.dataset.resolved);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -84,16 +84,46 @@ function portBlockState(d) {
|
|||||||
return 'up';
|
return 'up';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Speed label helper ───────────────────────────────────────────────────
|
||||||
|
function portSpeedLabel(port) {
|
||||||
|
if (!port || !port.up) return '';
|
||||||
|
const spd = port.speed; // speed in Mbps from UniFi API
|
||||||
|
if (!spd) return '';
|
||||||
|
if (spd >= 10000) return '10G';
|
||||||
|
if (spd >= 1000) return '1G';
|
||||||
|
if (spd >= 100) return '100M';
|
||||||
|
return spd + 'M';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render a single port block element ──────────────────────────────────
|
// ── Render a single port block element ──────────────────────────────────
|
||||||
function portBlockHtml(idx, port, swName, sfpBlock) {
|
function portBlockHtml(idx, port, swName, sfpBlock) {
|
||||||
const state = portBlockState(port);
|
const state = portBlockState(port);
|
||||||
const label = sfpBlock ? 'SFP' : idx;
|
const numLabel = sfpBlock ? 'SFP' : idx;
|
||||||
const title = port ? escHtml(port.name) : `Port ${idx}`;
|
const title = port ? escHtml(port.name) : `Port ${idx}`;
|
||||||
const sfpCls = sfpBlock ? ' sfp-block' : '';
|
const sfpCls = sfpBlock ? ' sfp-block' : '';
|
||||||
|
const speedTxt = portSpeedLabel(port);
|
||||||
|
// LLDP neighbor: first 6 chars of hostname
|
||||||
|
const lldpName = (port && port.lldp_table && port.lldp_table.length)
|
||||||
|
? escHtml((port.lldp_table[0].chassis_id_subtype === 'local'
|
||||||
|
? port.lldp_table[0].chassis_id
|
||||||
|
: port.lldp_table[0].system_name || port.lldp_table[0].chassis_id || '').slice(0, 6))
|
||||||
|
: '';
|
||||||
|
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
|
||||||
|
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
|
||||||
return `<div class="switch-port-block ${state}${sfpCls}"
|
return `<div class="switch-port-block ${state}${sfpCls}"
|
||||||
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
||||||
title="${title}"
|
title="${title}"
|
||||||
onclick="selectPort(this)">${label}</div>`;
|
onclick="selectPort(this)"><span class="port-num">${numLabel}</span>${speedHtml}${lldpHtml}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chassis legend HTML ──────────────────────────────────────────────────
|
||||||
|
function chassisLegendHtml() {
|
||||||
|
return `<div class="chassis-legend">
|
||||||
|
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-down"></span>down</div>
|
||||||
|
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-up"></span>up</div>
|
||||||
|
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-poe"></span>poe active</div>
|
||||||
|
<div class="chassis-legend-item"><span class="chassis-legend-swatch cls-uplink"></span>uplink</div>
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render one switch chassis ────────────────────────────────────────────
|
// ── Render one switch chassis ────────────────────────────────────────────
|
||||||
@@ -107,26 +137,38 @@ function renderChassis(swName, sw) {
|
|||||||
const downCount = totCount - upCount;
|
const downCount = totCount - upCount;
|
||||||
const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · ');
|
const meta = [model, `${upCount}/${totCount} up`, downCount ? `${downCount} down` : ''].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
|
// Is this a US24PRO? Used to add group-separator class
|
||||||
|
const isUs24Pro = (model === 'US24PRO');
|
||||||
|
|
||||||
let chassisHtml = '';
|
let chassisHtml = '';
|
||||||
|
|
||||||
if (layout) {
|
if (layout) {
|
||||||
const sfpPortSet = new Set(layout.sfp_ports || []);
|
const sfpPortSet = new Set(layout.sfp_ports || []);
|
||||||
const sfpSectionSet = new Set(layout.sfp_section || []);
|
const sfpSectionSet = new Set(layout.sfp_section || []);
|
||||||
|
|
||||||
// Main port rows
|
// Main port rows
|
||||||
chassisHtml += '<div class="chassis-rows">';
|
chassisHtml += '<div class="chassis-rows">';
|
||||||
for (const row of layout.rows) {
|
for (const row of layout.rows) {
|
||||||
chassisHtml += '<div class="chassis-row">';
|
const rowCls = isUs24Pro ? ' us24pro-row' : '';
|
||||||
|
chassisHtml += `<div class="chassis-row${rowCls}">`;
|
||||||
for (const idx of row) {
|
for (const idx of row) {
|
||||||
const port = portMap[idx];
|
const port = portMap[idx];
|
||||||
const isSfp = sfpPortSet.has(idx);
|
const isSfp = sfpPortSet.has(idx);
|
||||||
const sfpCls = isSfp ? ' sfp-port' : '';
|
const sfpCls = isSfp ? ' sfp-port' : '';
|
||||||
const state = portBlockState(port);
|
const state = portBlockState(port);
|
||||||
const title = port ? escHtml(port.name) : `Port ${idx}`;
|
const title = port ? escHtml(port.name) : `Port ${idx}`;
|
||||||
|
const speedTxt = portSpeedLabel(port);
|
||||||
|
const lldpName = (port && port.lldp_table && port.lldp_table.length)
|
||||||
|
? escHtml((port.lldp_table[0].chassis_id_subtype === 'local'
|
||||||
|
? port.lldp_table[0].chassis_id
|
||||||
|
: port.lldp_table[0].system_name || port.lldp_table[0].chassis_id || '').slice(0, 6))
|
||||||
|
: '';
|
||||||
|
const speedHtml = speedTxt ? `<span class="port-speed">${speedTxt}</span>` : '';
|
||||||
|
const lldpHtml = lldpName ? `<span class="port-lldp">${lldpName}</span>` : '';
|
||||||
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
|
chassisHtml += `<div class="switch-port-block ${state}${sfpCls}"
|
||||||
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
|
||||||
title="${title}"
|
title="${title}"
|
||||||
onclick="selectPort(this)">${idx}</div>`;
|
onclick="selectPort(this)"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
|
||||||
}
|
}
|
||||||
chassisHtml += '</div>';
|
chassisHtml += '</div>';
|
||||||
}
|
}
|
||||||
@@ -158,7 +200,12 @@ function renderChassis(swName, sw) {
|
|||||||
${sw.ip ? `<span class="chassis-ip">${escHtml(sw.ip)}</span>` : ''}
|
${sw.ip ? `<span class="chassis-ip">${escHtml(sw.ip)}</span>` : ''}
|
||||||
<span class="chassis-meta">${escHtml(meta)}</span>
|
<span class="chassis-meta">${escHtml(meta)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chassis-body">${chassisHtml}</div>
|
<div class="chassis-body">
|
||||||
|
<div class="chassis-ear-l"></div>
|
||||||
|
<div class="chassis-ear-r"></div>
|
||||||
|
${chassisHtml}
|
||||||
|
</div>
|
||||||
|
${chassisLegendHtml()}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,8 +373,25 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
|
|||||||
const swErrTx = (swPort.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
|
const swErrTx = (swPort.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
|
||||||
const swErrRx = (swPort.rx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
|
const swErrRx = (swPort.rx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
|
||||||
|
|
||||||
|
// Detect duplex mismatch (switch full_duplex vs server duplex string)
|
||||||
|
const swFull = swPort.full_duplex;
|
||||||
|
const svrFull = (svrData.duplex || '').toLowerCase().includes('full');
|
||||||
|
const duplexMismatch = swPort.up && svrData.duplex &&
|
||||||
|
((swFull && !svrFull) || (!swFull && svrFull));
|
||||||
|
const duplexWarnHtml = duplexMismatch
|
||||||
|
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH — Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Detect speed mismatch
|
||||||
|
const swSpd = swPort.speed_mbps, svrSpd = svrData.speed_mbps;
|
||||||
|
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd;
|
||||||
|
const speedWarnHtml = speedMismatch
|
||||||
|
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH — Switch: ${fmtSpeed(swSpd)} · Server: ${fmtSpeed(svrSpd)}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="panel-section-title">Path Debug <span class="path-conn-type">${escHtml(connType)}</span></div>
|
<div class="panel-section-title">Path Debug <span class="path-conn-type">${escHtml(connType)}</span></div>
|
||||||
|
${duplexWarnHtml}${speedWarnHtml}
|
||||||
<div class="path-debug-cols">
|
<div class="path-debug-cols">
|
||||||
<div class="path-col">
|
<div class="path-col">
|
||||||
<div class="path-col-header">Switch</div>
|
<div class="path-col-header">Switch</div>
|
||||||
@@ -347,6 +411,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
|
|||||||
<div class="path-row"><span>RX</span><span>${fmtRate(svrData.rx_bytes_rate)}</span></div>
|
<div class="path-row"><span>RX</span><span>${fmtRate(svrData.rx_bytes_rate)}</span></div>
|
||||||
<div class="path-row"><span>TX Err</span><span class="${svrErrTx}">${fmtErrors(svrData.tx_errs_rate)}</span></div>
|
<div class="path-row"><span>TX Err</span><span class="${svrErrTx}">${fmtErrors(svrData.tx_errs_rate)}</span></div>
|
||||||
<div class="path-row"><span>RX Err</span><span class="${svrErrRx}">${fmtErrors(svrData.rx_errs_rate)}</span></div>
|
<div class="path-row"><span>RX Err</span><span class="${svrErrRx}">${fmtErrors(svrData.rx_errs_rate)}</span></div>
|
||||||
|
${svrData.carrier_changes != null ? `<div class="path-row"><span>Carrier Chg</span><span class="${(svrData.carrier_changes||0)>10?'val-crit':(svrData.carrier_changes||0)>2?'val-warn':'val-good'}">${svrData.carrier_changes}</span></div>` : ''}
|
||||||
${sfpDomHtml}
|
${sfpDomHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@@ -124,6 +124,18 @@ function portTypeLabel(pt) {
|
|||||||
return {label: pt, cls: 'type-copper'};
|
return {label: pt, cls: 'type-copper'};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Error alert badge ─────────────────────────────────────────────
|
||||||
|
function errorBadges(d) {
|
||||||
|
const badges = [];
|
||||||
|
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01)
|
||||||
|
badges.push('<span class="link-alert-badge">ERR</span>');
|
||||||
|
if ((d.tx_drops_rate || 0) > 0.1 || (d.rx_drops_rate || 0) > 0.1)
|
||||||
|
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
|
||||||
|
if ((d.carrier_changes || 0) > 10)
|
||||||
|
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
||||||
|
return badges.join('');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render a single interface card ────────────────────────────────
|
// ── Render a single interface card ────────────────────────────────
|
||||||
function renderIfaceCard(ifaceName, d) {
|
function renderIfaceCard(ifaceName, d) {
|
||||||
const speed = fmtSpeed(d.speed_mbps);
|
const speed = fmtSpeed(d.speed_mbps);
|
||||||
@@ -204,6 +216,7 @@ function renderIfaceCard(ifaceName, d) {
|
|||||||
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
||||||
${speed !== '–' ? `<span class="link-iface-speed">${speed}</span>` : ''}
|
${speed !== '–' ? `<span class="link-iface-speed">${speed}</span>` : ''}
|
||||||
${ptype.label !== '–' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''}
|
${ptype.label !== '–' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''}
|
||||||
|
${errorBadges(d)}
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stats-grid">
|
<div class="link-stats-grid">
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
@@ -278,8 +291,19 @@ function renderPortCard(portName, d) {
|
|||||||
|
|
||||||
const lldpHtml = (d.lldp && d.lldp.system_name)
|
const lldpHtml = (d.lldp && d.lldp.system_name)
|
||||||
? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name)}${d.lldp.port_id ? ' (' + escHtml(d.lldp.port_id) + ')' : ''}</div>` : '';
|
? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name)}${d.lldp.port_id ? ' (' + escHtml(d.lldp.port_id) + ')' : ''}</div>` : '';
|
||||||
const poeMaxHtml = (d.poe_class != null)
|
|
||||||
? `<div class="port-poe-info">PoE class ${d.poe_class}${d.poe_max_power ? ' / max ' + d.poe_max_power.toFixed(1) + 'W' : ''}</div>` : '';
|
let poeMaxHtml = '';
|
||||||
|
if (d.poe_class != null) {
|
||||||
|
const poeDraw = d.poe_power || 0;
|
||||||
|
const poeMax = d.poe_max_power || 0;
|
||||||
|
const poePct = poeMax > 0 ? Math.min(100, (poeDraw / poeMax) * 100) : 0;
|
||||||
|
const poeBarCls = poePct >= 80 ? 'poe-bar-crit' : poePct >= 60 ? 'poe-bar-warn' : 'poe-bar-ok';
|
||||||
|
const poeMode = d.poe_mode ? ` · ${escHtml(d.poe_mode)}` : '';
|
||||||
|
poeMaxHtml = `<div class="port-poe-info">
|
||||||
|
PoE class ${d.poe_class}${poeMax > 0 ? ` · ${poeDraw.toFixed(1)}W / ${poeMax.toFixed(1)}W max${poeMode}` : poeMode}
|
||||||
|
${poeMax > 0 ? `<div class="poe-bar-track"><div class="poe-bar-fill ${poeBarCls}" style="width:${poePct.toFixed(1)}%"></div></div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const txRate = d.tx_bytes_rate;
|
const txRate = d.tx_bytes_rate;
|
||||||
const rxRate = d.rx_bytes_rate;
|
const rxRate = d.rx_bytes_rate;
|
||||||
@@ -295,6 +319,7 @@ function renderPortCard(portName, d) {
|
|||||||
<span class="link-iface-speed ${up ? '' : 'val-crit'}">${speed}</span>
|
<span class="link-iface-speed ${up ? '' : 'val-crit'}">${speed}</span>
|
||||||
${numBadge}${uplinkBadge}${poeBadge}
|
${numBadge}${uplinkBadge}${poeBadge}
|
||||||
${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''}
|
${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''}
|
||||||
|
${errorBadges(d)}
|
||||||
</div>
|
</div>
|
||||||
${lldpHtml}${poeMaxHtml}
|
${lldpHtml}${poeMaxHtml}
|
||||||
<div class="link-stats-grid">
|
<div class="link-stats-grid">
|
||||||
@@ -390,14 +415,14 @@ function togglePanel(panel) {
|
|||||||
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[–]';
|
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[–]';
|
||||||
const id = panel.id;
|
const id = panel.id;
|
||||||
if (id) {
|
if (id) {
|
||||||
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
|
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||||
saved[id] = panel.classList.contains('collapsed');
|
saved[id] = panel.classList.contains('collapsed');
|
||||||
sessionStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreCollapseState() {
|
function restoreCollapseState() {
|
||||||
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
|
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||||
for (const [id, collapsed] of Object.entries(saved)) {
|
for (const [id, collapsed] of Object.entries(saved)) {
|
||||||
if (!collapsed) continue;
|
if (!collapsed) continue;
|
||||||
const panel = document.getElementById(id);
|
const panel = document.getElementById(id);
|
||||||
@@ -414,8 +439,13 @@ function collapseAll() {
|
|||||||
panel.classList.add('collapsed');
|
panel.classList.add('collapsed');
|
||||||
const btn = panel.querySelector('.panel-toggle');
|
const btn = panel.querySelector('.panel-toggle');
|
||||||
if (btn) btn.textContent = '[+]';
|
if (btn) btn.textContent = '[+]';
|
||||||
|
const id = panel.id;
|
||||||
|
if (id) {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||||
|
saved[id] = true;
|
||||||
|
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
sessionStorage.setItem('gandalfCollapsed', '{}'); // let restore pick it up next time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandAll() {
|
function expandAll() {
|
||||||
@@ -423,8 +453,81 @@ function expandAll() {
|
|||||||
panel.classList.remove('collapsed');
|
panel.classList.remove('collapsed');
|
||||||
const btn = panel.querySelector('.panel-toggle');
|
const btn = panel.querySelector('.panel-toggle');
|
||||||
if (btn) btn.textContent = '[–]';
|
if (btn) btn.textContent = '[–]';
|
||||||
|
const id = panel.id;
|
||||||
|
if (id) {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
||||||
|
saved[id] = false;
|
||||||
|
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
sessionStorage.setItem('gandalfCollapsed', '{}');
|
}
|
||||||
|
|
||||||
|
// ── Link health summary ───────────────────────────────────────────
|
||||||
|
function buildLinkSummary(hosts, unifiSwitches) {
|
||||||
|
let svrTotal = 0, svrErrors = 0, svrFlap = 0;
|
||||||
|
let swTotal = 0, swUp = 0, swDown = 0, swErrors = 0;
|
||||||
|
let poeDrawW = 0, poeMaxW = 0;
|
||||||
|
|
||||||
|
for (const ifaces of Object.values(hosts)) {
|
||||||
|
for (const d of Object.values(ifaces)) {
|
||||||
|
svrTotal++;
|
||||||
|
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) svrErrors++;
|
||||||
|
if ((d.carrier_changes || 0) > 10) svrFlap++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const sw of Object.values(unifiSwitches || {})) {
|
||||||
|
for (const d of Object.values(sw.ports || {})) {
|
||||||
|
swTotal++;
|
||||||
|
if (d.up) swUp++; else swDown++;
|
||||||
|
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01) swErrors++;
|
||||||
|
if (d.poe_power != null) poeDrawW += d.poe_power;
|
||||||
|
if (d.poe_max_power != null) poeMaxW += d.poe_max_power;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const poePct = poeMaxW > 0 ? (poeDrawW / poeMaxW * 100) : null;
|
||||||
|
const poeBarCls = poePct >= 80 ? 'poe-bar-crit' : poePct >= 60 ? 'poe-bar-warn' : 'poe-bar-ok';
|
||||||
|
const totalErrors = svrErrors + swErrors;
|
||||||
|
const hasAlerts = totalErrors > 0 || svrFlap > 0 || swDown > 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="link-summary-panel${hasAlerts ? ' link-summary-has-alerts' : ''}">
|
||||||
|
<div class="link-summary-grid">
|
||||||
|
<div class="link-summary-stat">
|
||||||
|
<span class="lss-label">Server Ifaces</span>
|
||||||
|
<span class="lss-value">${svrTotal}</span>
|
||||||
|
</div>
|
||||||
|
${svrErrors > 0 ? `<div class="link-summary-stat lss-alert">
|
||||||
|
<span class="lss-label">Iface Errors</span>
|
||||||
|
<span class="lss-value val-crit">${svrErrors}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${svrFlap > 0 ? `<div class="link-summary-stat lss-alert">
|
||||||
|
<span class="lss-label">Flapping</span>
|
||||||
|
<span class="lss-value val-warn">${svrFlap}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${swTotal > 0 ? `<div class="link-summary-stat">
|
||||||
|
<span class="lss-label">Switch Ports</span>
|
||||||
|
<span class="lss-value">${swUp}<span class="lss-sub">/${swTotal}</span></span>
|
||||||
|
</div>` : ''}
|
||||||
|
${swDown > 0 ? `<div class="link-summary-stat lss-alert">
|
||||||
|
<span class="lss-label">Ports Down</span>
|
||||||
|
<span class="lss-value val-crit">${swDown}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${swErrors > 0 ? `<div class="link-summary-stat lss-alert">
|
||||||
|
<span class="lss-label">Port Errors</span>
|
||||||
|
<span class="lss-value val-crit">${swErrors}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
${poePct !== null ? `<div class="link-summary-stat">
|
||||||
|
<span class="lss-label">PoE Load</span>
|
||||||
|
<span class="lss-value ${poeBarCls === 'poe-bar-crit' ? 'val-crit' : poeBarCls === 'poe-bar-warn' ? 'val-warn' : 'val-good'}">${poeDrawW.toFixed(0)}W<span class="lss-sub">/${poeMaxW.toFixed(0)}W</span></span>
|
||||||
|
<div class="poe-bar-track" style="margin-top:3px"><div class="poe-bar-fill ${poeBarCls}" style="width:${poePct.toFixed(1)}%"></div></div>
|
||||||
|
</div>` : ''}
|
||||||
|
${totalErrors === 0 && svrFlap === 0 && swDown === 0 ? `<div class="link-summary-stat">
|
||||||
|
<span class="lss-label">Status</span>
|
||||||
|
<span class="lss-value val-good">All OK ✔</span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render all hosts ──────────────────────────────────────────────
|
// ── Render all hosts ──────────────────────────────────────────────
|
||||||
@@ -463,6 +566,7 @@ function renderLinks(data) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
document.getElementById('links-container').innerHTML =
|
document.getElementById('links-container').innerHTML =
|
||||||
|
buildLinkSummary(hosts, unifi) +
|
||||||
`<div class="link-collapse-bar">
|
`<div class="link-collapse-bar">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="collapseAll()">Collapse all</button>
|
<button class="btn btn-secondary btn-sm" onclick="collapseAll()">Collapse all</button>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="expandAll()">Expand all</button>
|
<button class="btn btn-secondary btn-sm" onclick="expandAll()">Expand all</button>
|
||||||
@@ -482,16 +586,37 @@ function renderLinks(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stale data check ─────────────────────────────────────────────
|
||||||
|
function checkLinksStale(updatedStr) {
|
||||||
|
let banner = document.getElementById('links-stale-banner');
|
||||||
|
if (!updatedStr) return;
|
||||||
|
const ageMs = Date.now() - new Date(updatedStr.replace(' UTC', 'Z').replace(' ', 'T'));
|
||||||
|
if (ageMs > 120000) { // >2 minutes
|
||||||
|
if (!banner) {
|
||||||
|
banner = document.createElement('div');
|
||||||
|
banner.id = 'links-stale-banner';
|
||||||
|
banner.className = 'stale-banner';
|
||||||
|
document.getElementById('links-container').insertAdjacentElement('beforebegin', banner);
|
||||||
|
}
|
||||||
|
const mins = Math.floor(ageMs / 60000);
|
||||||
|
banner.textContent = `⚠ Link data is stale — last update was ${mins} minute${mins !== 1 ? 's' : ''} ago.`;
|
||||||
|
banner.style.display = '';
|
||||||
|
} else if (banner) {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Fetch and render ──────────────────────────────────────────────
|
// ── Fetch and render ──────────────────────────────────────────────
|
||||||
async function loadLinks() {
|
async function loadLinks() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/links');
|
const resp = await fetch('/api/links');
|
||||||
if (!resp.ok) throw new Error('API error');
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
renderLinks(data);
|
renderLinks(data);
|
||||||
|
checkLinksStale(data.updated);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
document.getElementById('links-container').innerHTML =
|
document.getElementById('links-container').innerHTML =
|
||||||
'<p class="empty-state">Failed to load link data.</p>';
|
`<div class="error-state"><p>Failed to load link data: ${escHtml(e.message)}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user