Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cabdbc24ad | |||
| 0d25dd74f1 | |||
| c45dd007d1 | |||
| b6cd168542 | |||
| a17b1382bc | |||
| c1c3905179 | |||
| 293edd674e | |||
| bb6393e35b | |||
| e05f1f6c55 | |||
| e8de40250a | |||
| 2c4e8fcfda | |||
| 7cd39bbe9b | |||
| b10eded514 | |||
| 50da3c0a59 | |||
| 3af42505b8 | |||
| 963ceb3e1e | |||
| 28fb5c666c | |||
| 3dfcd5903a | |||
| d576a0fe2d | |||
| 271c3c4373 | |||
| e2b65db2fc | |||
| b80fda7cb2 | |||
| eb8c0ded5e | |||
| b29b70d88b | |||
| 2c67944b4b | |||
| e8314b5ba3 | |||
| 3dce602938 | |||
| 6eb21055ef | |||
| f2541eb45c | |||
| e779b21db4 | |||
| c1fd53f9bd | |||
| 0ca6b1f744 |
@@ -0,0 +1,7 @@
|
|||||||
|
[run]
|
||||||
|
omit =
|
||||||
|
tests/*
|
||||||
|
*/site-packages/*
|
||||||
|
|
||||||
|
[report]
|
||||||
|
show_missing = True
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[flake8]
|
||||||
|
max-line-length = 120
|
||||||
|
# E221: multiple spaces before operator — intentional column alignment
|
||||||
|
# E305: two blank lines after function — relaxed for module-level code
|
||||||
|
extend-ignore = E221, E305
|
||||||
|
exclude = __pycache__, .git, node_modules
|
||||||
|
extend-exclude = node_modules
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
python-lint:
|
||||||
|
name: Python (flake8)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install Python and flake8
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3 python3-pip
|
||||||
|
pip3 install flake8
|
||||||
|
|
||||||
|
- name: Run flake8
|
||||||
|
run: flake8 . --exclude=__pycache__,.git
|
||||||
|
|
||||||
|
js-lint:
|
||||||
|
name: JS (eslint)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install ESLint
|
||||||
|
run: npm install --save-dev eslint@8
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: npx eslint --ext .js static/
|
||||||
|
|
||||||
|
notify-failure:
|
||||||
|
name: Notify on failure
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [python-lint, js-lint]
|
||||||
|
if: failure() && github.event_name == 'push'
|
||||||
|
steps:
|
||||||
|
- name: Send Matrix alert
|
||||||
|
env:
|
||||||
|
MATRIX_WEBHOOK_URL: ${{ secrets.MATRIX_WEBHOOK_URL }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi
|
||||||
|
curl -sf -X POST "$MATRIX_WEBHOOK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [python-lint, js-lint]
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Trigger webhook
|
||||||
|
env:
|
||||||
|
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
||||||
|
GIT_REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
|
||||||
|
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
|
||||||
|
curl -sf --connect-timeout 10 \
|
||||||
|
-X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Gitea-Signature: ${SIG}" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"http://10.10.10.61:9000/hooks/gandalf-deploy"
|
||||||
|
|
||||||
|
- name: Tag deployed commit
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="deploy-$(date -u +%Y.%m.%d)-${{ github.run_number }}"
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token $GITHUB_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"${TAG}\",\"target\":\"${{ github.sha }}\",\"message\":\"Deployed to production\"}" \
|
||||||
|
"https://code.lotusguild.org/api/v1/repos/${{ github.repository }}/tags"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
bandit:
|
||||||
|
name: Python Security (bandit)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install bandit
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3 python3-pip
|
||||||
|
pip3 install bandit
|
||||||
|
|
||||||
|
- name: Run bandit
|
||||||
|
run: bandit -r . --exclude .git,__pycache__,node_modules -ll
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pytest:
|
||||||
|
name: Python Tests (pytest)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install Python and dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq python3 python3-pip
|
||||||
|
pip3 install pytest pytest-cov
|
||||||
|
pip3 install -r requirements.txt --quiet
|
||||||
|
|
||||||
|
- name: Run pytest with coverage
|
||||||
|
run: python3 -m pytest tests/ -v --cov=. --cov-report=term-missing --cov-config=.coveragerc
|
||||||
@@ -2,3 +2,4 @@ log.txt
|
|||||||
config.json
|
config.json
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
node_modules/
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
# GANDALF (Global Advanced Network Detection And Link Facilitator)
|
# GANDALF (Global Advanced Network Detection And Link Facilitator)
|
||||||
|
|
||||||
|
[](https://code.lotusguild.org/LotusGuild/gandalf/actions?workflow=lint.yml)
|
||||||
|
[](https://code.lotusguild.org/LotusGuild/gandalf/actions?workflow=test.yml)
|
||||||
|
[](https://code.lotusguild.org/LotusGuild/gandalf/actions?workflow=security.yml)
|
||||||
|
|
||||||
> Because it shall not let problems pass.
|
> Because it shall not let problems pass.
|
||||||
|
|
||||||
Network monitoring dashboard for the LotusGuild Proxmox cluster.
|
Network monitoring dashboard for the LotusGuild Proxmox cluster.
|
||||||
@@ -14,7 +18,6 @@ GANDALF uses the **LotusGuild Terminal Design System**. For all styling, compone
|
|||||||
- [`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/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.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/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/aesthetic_diff.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/aesthetic_diff.md) — cross-app divergence analysis and convergence guide
|
|
||||||
- [`web_template/python/base.html`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/base.html) — Jinja2 base template
|
- [`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
|
- [`web_template/python/auth.py`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/auth.py) — `@require_auth` decorator pattern
|
||||||
|
|
||||||
@@ -487,3 +490,20 @@ under `unifi_switches.<switch_name>.model`), then add to `SWITCH_LAYOUTS` in
|
|||||||
- SSH collection via Pulse is synchronous — if Pulse is slow, the entire monitor cycle
|
- SSH collection via Pulse is synchronous — if Pulse is slow, the entire monitor cycle
|
||||||
is delayed. The `pulse.timeout` config controls the max wait.
|
is delayed. The `pulse.timeout` config controls the max wait.
|
||||||
- UniFi LLDP data is only as fresh as the last monitor poll (120s default).
|
- UniFi LLDP data is only as fresh as the last monitor poll (120s default).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI / CD
|
||||||
|
|
||||||
|
| Workflow | Purpose | Triggers |
|
||||||
|
|---|---|---|
|
||||||
|
| `lint.yml` (python-lint) | flake8 on all `.py` files | Every push and PR |
|
||||||
|
| `lint.yml` (js-lint) | ESLint on `static/` | Every push and PR |
|
||||||
|
| `test.yml` | pytest — 33 tests for `diagnose.py` static methods | Every push and PR |
|
||||||
|
| `security.yml` | bandit `-ll` (medium+ severity) | Every push, PR, and weekly Monday 6am |
|
||||||
|
| `deploy` job in `lint.yml` | Calls the `gandalf-deploy` webhook on CT157 (10.10.10.61) | Push to `main` only, after both lint jobs pass |
|
||||||
|
|
||||||
|
Branch protection is enabled on `main` — both lint jobs must pass before any PR can merge.
|
||||||
|
|
||||||
|
Tests live in `tests/test_diagnose.py` and cover `DiagnosticsRunner` static methods:
|
||||||
|
`build_ssh_command`, `parse_output`, `parse_sysfs_stats`, `parse_ethtool`, and variants.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Flask web application serving the monitoring dashboard and suppression
|
|||||||
management UI. Authentication via Authelia forward-auth headers.
|
management UI. Authentication via Authelia forward-auth headers.
|
||||||
All monitoring and alerting is handled by the separate monitor.py daemon.
|
All monitoring and alerting is handled by the separate monitor.py daemon.
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -11,9 +12,10 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Flask, jsonify, redirect, render_template, request, url_for
|
from flask import Flask, jsonify, render_template, request
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import diagnose
|
import diagnose
|
||||||
@@ -27,7 +29,15 @@ logger = logging.getLogger('gandalf.web')
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
_AVATAR_COLORS = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', '']
|
||||||
|
|
||||||
|
|
||||||
|
@app.template_filter('avatar_color')
|
||||||
|
def avatar_color_filter(name: str) -> str:
|
||||||
|
return _AVATAR_COLORS[int(hashlib.md5(name.encode()).hexdigest(), 16) % len(_AVATAR_COLORS)] # nosec B324
|
||||||
|
|
||||||
_cfg = None
|
_cfg = None
|
||||||
|
_cfg_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
@@ -42,13 +52,14 @@ def inject_config():
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# In-memory diagnostic job store { job_id: { status, result, created_at } }
|
# In-memory diagnostic job store { job_id: { status, result, created_at } }
|
||||||
_diag_jobs: dict = {}
|
_diag_jobs: dict = {}
|
||||||
_diag_lock = threading.Lock()
|
_diag_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -57,7 +68,7 @@ def _purge_old_jobs_loop():
|
|||||||
stale = [jid for jid, j in _diag_jobs.items() if j.get('created_at', 0) < cutoff]
|
stale = [jid for jid, j in _diag_jobs.items() if j.get('created_at', 0) < cutoff]
|
||||||
for jid in stale:
|
for jid in stale:
|
||||||
del _diag_jobs[jid]
|
del _diag_jobs[jid]
|
||||||
for jid, j in _diag_jobs.items():
|
for jid, j in list(_diag_jobs.items()):
|
||||||
if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff:
|
if j['status'] == 'running' and j.get('created_at', 0) < stuck_cutoff:
|
||||||
j['status'] = 'done'
|
j['status'] = 'done'
|
||||||
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
|
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
|
||||||
@@ -70,12 +81,25 @@ _purge_thread.start()
|
|||||||
|
|
||||||
def _config() -> dict:
|
def _config() -> dict:
|
||||||
global _cfg
|
global _cfg
|
||||||
|
if _cfg is None:
|
||||||
|
with _cfg_lock:
|
||||||
if _cfg is None:
|
if _cfg is None:
|
||||||
with open('config.json') as f:
|
with open('config.json') as f:
|
||||||
_cfg = json.load(f)
|
_cfg = json.load(f)
|
||||||
return _cfg
|
return _cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _daemon_ok(last_check: str) -> bool:
|
||||||
|
"""Return True if monitor last checked within 20 minutes."""
|
||||||
|
if not last_check or last_check == 'Never':
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
|
||||||
|
return (datetime.now(timezone.utc) - ts).total_seconds() < 1200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth helpers
|
# Auth helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -120,24 +144,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 +210,14 @@ 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)
|
||||||
|
last_check = db.get_state('last_check', 'Never')
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'summary': db.get_status_summary(),
|
'summary': db.get_status_summary(),
|
||||||
'last_check': db.get_state('last_check', 'Never'),
|
'last_check': last_check,
|
||||||
'events': db.get_active_events(),
|
'events': active,
|
||||||
|
'total_active': db.count_active_events(),
|
||||||
|
'daemon_ok': _daemon_ok(last_check),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -213,10 +248,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 +290,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,
|
||||||
@@ -407,7 +460,6 @@ def health():
|
|||||||
try:
|
try:
|
||||||
last_check = db.get_state('last_check', '')
|
last_check = db.get_state('last_check', '')
|
||||||
if last_check:
|
if last_check:
|
||||||
from datetime import datetime, timezone
|
|
||||||
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
|
ts = datetime.strptime(last_check, '%Y-%m-%d %H:%M:%S UTC').replace(tzinfo=timezone.utc)
|
||||||
age_s = (datetime.now(timezone.utc) - ts).total_seconds()
|
age_s = (datetime.now(timezone.utc) - ts).total_seconds()
|
||||||
if age_s > 1200:
|
if age_s > 1200:
|
||||||
@@ -426,4 +478,4 @@ def health():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
app.run(debug=True, host='0.0.0.0', port=5000) # nosec B201 B104 — dev runner only; production uses gunicorn
|
||||||
|
|||||||
+13
-9
@@ -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",
|
||||||
|
|||||||
@@ -23,13 +23,8 @@ def _config() -> dict:
|
|||||||
return _config_cache
|
return _config_cache
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
def _new_conn(cfg: dict):
|
||||||
def get_conn():
|
return pymysql.connect(
|
||||||
"""Yield a per-thread cached database connection, reconnecting as needed."""
|
|
||||||
cfg = _config()
|
|
||||||
conn = getattr(_local, 'conn', None)
|
|
||||||
if conn is None:
|
|
||||||
conn = pymysql.connect(
|
|
||||||
host=cfg['host'],
|
host=cfg['host'],
|
||||||
port=cfg.get('port', 3306),
|
port=cfg.get('port', 3306),
|
||||||
user=cfg['user'],
|
user=cfg['user'],
|
||||||
@@ -40,9 +35,27 @@ def get_conn():
|
|||||||
connect_timeout=10,
|
connect_timeout=10,
|
||||||
charset='utf8mb4',
|
charset='utf8mb4',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_conn():
|
||||||
|
"""Yield a per-thread cached database connection, reconnecting as needed."""
|
||||||
|
cfg = _config()
|
||||||
|
conn = getattr(_local, 'conn', None)
|
||||||
|
if conn is None:
|
||||||
|
conn = _new_conn(cfg)
|
||||||
_local.conn = conn
|
_local.conn = conn
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
conn.ping(reconnect=True)
|
conn.ping(reconnect=True)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_local.conn = None
|
||||||
|
conn = _new_conn(cfg)
|
||||||
|
_local.conn = conn
|
||||||
yield conn
|
yield conn
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +166,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 +174,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 +186,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:
|
||||||
|
|||||||
+4
-3
@@ -6,9 +6,8 @@ Executed in a background thread; results stored in _diag_jobs (app.py).
|
|||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import time
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
logger = logging.getLogger('gandalf.diagnose')
|
logger = logging.getLogger('gandalf.diagnose')
|
||||||
|
|
||||||
@@ -77,7 +76,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}\''
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
+57
-19
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -298,6 +325,7 @@ class LinkStatsCollector:
|
|||||||
|
|
||||||
def __init__(self, cfg: dict, prom: 'PrometheusClient',
|
def __init__(self, cfg: dict, prom: 'PrometheusClient',
|
||||||
unifi: Optional['UnifiClient'] = None):
|
unifi: Optional['UnifiClient'] = None):
|
||||||
|
self.cfg = cfg
|
||||||
self.prom = prom
|
self.prom = prom
|
||||||
self.pulse = PulseClient(cfg)
|
self.pulse = PulseClient(cfg)
|
||||||
self.unifi = unifi
|
self.unifi = unifi
|
||||||
@@ -336,7 +364,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 +554,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 +698,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
|
||||||
@@ -840,12 +877,13 @@ class NetworkMonitor:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Snapshot collection (for dashboard)
|
# Snapshot collection (for dashboard)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _collect_snapshot(self) -> dict:
|
def _collect_snapshot(self, iface_states: Dict[str, Dict[str, bool]]) -> dict:
|
||||||
iface_states = self.prom.get_interface_states()
|
|
||||||
unifi_devices = self.unifi.get_devices() or []
|
unifi_devices = self.unifi.get_devices() or []
|
||||||
|
|
||||||
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)
|
||||||
@@ -892,23 +930,23 @@ class NetworkMonitor:
|
|||||||
try:
|
try:
|
||||||
logger.info('Starting network check cycle')
|
logger.info('Starting network check cycle')
|
||||||
|
|
||||||
# 1. Collect and store snapshot for dashboard
|
# 1. Fetch interface states once — shared by snapshot and alert processing
|
||||||
snapshot = self._collect_snapshot()
|
iface_states = self.prom.get_interface_states()
|
||||||
|
|
||||||
|
# 2. Collect and store snapshot for dashboard
|
||||||
|
snapshot = self._collect_snapshot(iface_states)
|
||||||
db.set_state('network_snapshot', snapshot)
|
db.set_state('network_snapshot', snapshot)
|
||||||
db.set_state('last_check', _now_utc())
|
db.set_state('last_check', _now_utc())
|
||||||
|
|
||||||
# 2. Collect link stats (ethtool + traffic metrics)
|
# 3. Collect link stats (ethtool + traffic metrics)
|
||||||
try:
|
try:
|
||||||
link_data = self.link_stats.collect(self._instance_map)
|
link_data = self.link_stats.collect(self._instance_map)
|
||||||
db.set_state('link_stats', link_data)
|
db.set_state('link_stats', link_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Link stats collection failed: {e}', exc_info=True)
|
logger.error(f'Link stats collection failed: {e}', exc_info=True)
|
||||||
|
|
||||||
# 3. Process alerts (separate Prometheus call for fresh data)
|
# 4. Process alerts using already-fetched interface states
|
||||||
# Load suppressions once per cycle to avoid N*M DB queries
|
|
||||||
suppressions = db.get_active_suppressions()
|
suppressions = db.get_active_suppressions()
|
||||||
|
|
||||||
iface_states = self.prom.get_interface_states()
|
|
||||||
self._process_interfaces(iface_states, suppressions)
|
self._process_interfaces(iface_states, suppressions)
|
||||||
|
|
||||||
unifi_devices = self.unifi.get_devices()
|
unifi_devices = self.unifi.get_devices()
|
||||||
|
|||||||
Generated
+1140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^8.57.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"env": { "browser": true, "es2021": true },
|
||||||
|
"extends": "eslint:recommended",
|
||||||
|
"parserOptions": { "ecmaVersion": 2021, "sourceType": "script" },
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-empty": "warn",
|
||||||
|
"no-inner-declarations": "warn",
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"eqeqeq": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
+103
-96
@@ -1,6 +1,21 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// ── Toast notifications — delegates to lt.toast from base.js ─────────
|
// ── Auto-redirect on auth timeout ─────────────────────────────────────
|
||||||
|
// Wraps fetch so a 401 (Authelia session expired) forces a full reload.
|
||||||
|
// lt.api uses fetch internally, so this covers all API calls too.
|
||||||
|
(function () {
|
||||||
|
const _fetch = window.fetch;
|
||||||
|
window.fetch = async function (...args) {
|
||||||
|
const resp = await _fetch(...args);
|
||||||
|
if (resp.status === 401) {
|
||||||
|
window.location.reload();
|
||||||
|
throw new Error('Session expired — reloading');
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Toast notifications — thin wrapper over lt.toast ──────────────────
|
||||||
function showToast(msg, type = 'success') {
|
function showToast(msg, type = 'success') {
|
||||||
if (type === 'error') return lt.toast.error(msg);
|
if (type === 'error') return lt.toast.error(msg);
|
||||||
if (type === 'warning') return lt.toast.warning(msg);
|
if (type === 'warning') return lt.toast.warning(msg);
|
||||||
@@ -8,63 +23,71 @@ function showToast(msg, type = 'success') {
|
|||||||
return lt.toast.success(msg);
|
return lt.toast.success(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Normalise UTC timestamp string for Date() parsing ─────────────────
|
||||||
|
// Server returns "2026-03-14 14:14:21 UTC"; Date() needs ISO 8601.
|
||||||
|
function _toIso(s) {
|
||||||
|
if (!s) return s;
|
||||||
|
return s.replace(' UTC', 'Z').replace(' ', 'T');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dashboard auto-refresh ────────────────────────────────────────────
|
// ── Dashboard auto-refresh ────────────────────────────────────────────
|
||||||
async function refreshAll() {
|
async function refreshAll() {
|
||||||
|
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
||||||
|
if (refreshBtn) refreshBtn.classList.add('is-loading');
|
||||||
try {
|
try {
|
||||||
const [netResp, statusResp] = await Promise.all([
|
const [netResult, statusResult] = await Promise.allSettled([
|
||||||
fetch('/api/network'),
|
lt.api.get('/api/network'),
|
||||||
fetch('/api/status'),
|
lt.api.get('/api/status'),
|
||||||
]);
|
]);
|
||||||
if (!netResp.ok || !statusResp.ok) return;
|
if (netResult.status === 'fulfilled') {
|
||||||
|
const net = netResult.value;
|
||||||
const net = await netResp.json();
|
|
||||||
const status = await statusResp.json();
|
|
||||||
|
|
||||||
updateHostGrid(net.hosts || {});
|
updateHostGrid(net.hosts || {});
|
||||||
updateUnifiTable(net.unifi || []);
|
updateUnifiTable(net.unifi || []);
|
||||||
updateEventsTable(status.events || []);
|
|
||||||
updateStatusBar(status.summary || {}, status.last_check || '');
|
|
||||||
updateTopology(net.hosts || {});
|
updateTopology(net.hosts || {});
|
||||||
|
} else {
|
||||||
} catch (e) {
|
console.warn('Network API failed:', netResult.reason);
|
||||||
console.warn('Refresh failed:', e);
|
}
|
||||||
|
if (statusResult.status === 'fulfilled') {
|
||||||
|
const status = statusResult.value;
|
||||||
|
updateEventsTable(status.events || [], status.total_active);
|
||||||
|
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok);
|
||||||
|
} else {
|
||||||
|
console.warn('Status API failed:', statusResult.reason);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (refreshBtn) refreshBtn.classList.remove('is-loading');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatusBar(summary, lastCheck) {
|
function updateStatusBar(summary, lastCheck, daemonOk) {
|
||||||
const bar = document.querySelector('.status-chips');
|
const bar = document.querySelector('.status-chips');
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
const chips = [];
|
const chips = [];
|
||||||
|
if (daemonOk === false) chips.push('<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>');
|
||||||
if (summary.critical) chips.push(`<span class="chip chip-critical">● ${summary.critical} CRITICAL</span>`);
|
if (summary.critical) chips.push(`<span class="chip chip-critical">● ${summary.critical} CRITICAL</span>`);
|
||||||
if (summary.warning) chips.push(`<span class="chip chip-warning">● ${summary.warning} WARNING</span>`);
|
if (summary.warning) chips.push(`<span class="chip chip-warning">● ${summary.warning} WARNING</span>`);
|
||||||
if (!summary.critical && !summary.warning) chips.push('<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>');
|
if (!summary.critical && !summary.warning && daemonOk !== false) chips.push('<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>');
|
||||||
bar.innerHTML = chips.join('');
|
bar.innerHTML = chips.join('');
|
||||||
|
|
||||||
const lc = document.getElementById('last-check');
|
const lc = document.getElementById('last-check');
|
||||||
if (lc && lastCheck) lc.textContent = lastCheck;
|
if (lc && lastCheck) lc.textContent = lastCheck;
|
||||||
|
|
||||||
// Update browser tab title with alert count
|
|
||||||
const critCount = summary.critical || 0;
|
const critCount = summary.critical || 0;
|
||||||
const warnCount = summary.warning || 0;
|
const warnCount = summary.warning || 0;
|
||||||
if (critCount) {
|
if (critCount) document.title = `(${critCount} CRIT) GANDALF`;
|
||||||
document.title = `(${critCount} CRIT) GANDALF`;
|
else if (warnCount) document.title = `(${warnCount} WARN) GANDALF`;
|
||||||
} else if (warnCount) {
|
else document.title = 'GANDALF';
|
||||||
document.title = `(${warnCount} WARN) GANDALF`;
|
|
||||||
} else {
|
|
||||||
document.title = 'GANDALF';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stale data banner: warn if last_check is older than 15 minutes
|
// Stale data banner: warn if last_check is older than 15 minutes
|
||||||
let staleBanner = document.getElementById('stale-banner');
|
let staleBanner = document.getElementById('stale-banner');
|
||||||
if (lastCheck) {
|
if (lastCheck) {
|
||||||
// last_check format: "2026-03-14 14:14:21 UTC"
|
const checkAge = (Date.now() - new Date(_toIso(lastCheck))) / 1000;
|
||||||
const checkAge = (Date.now() - new Date(lastCheck.replace(' UTC', 'Z').replace(' ', 'T'))) / 1000;
|
if (checkAge > 900) {
|
||||||
if (checkAge > 900) { // 15 minutes
|
|
||||||
if (!staleBanner) {
|
if (!staleBanner) {
|
||||||
staleBanner = document.createElement('div');
|
staleBanner = document.createElement('div');
|
||||||
staleBanner.id = 'stale-banner';
|
staleBanner.id = 'stale-banner';
|
||||||
staleBanner.className = 'stale-banner';
|
staleBanner.className = 'stale-banner';
|
||||||
document.querySelector('.main').prepend(staleBanner);
|
document.querySelector('.lt-main').prepend(staleBanner);
|
||||||
}
|
}
|
||||||
const mins = Math.floor(checkAge / 60);
|
const mins = Math.floor(checkAge / 60);
|
||||||
staleBanner.textContent = `⚠ Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
|
staleBanner.textContent = `⚠ Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
|
||||||
@@ -80,15 +103,12 @@ function updateHostGrid(hosts) {
|
|||||||
const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`);
|
const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`);
|
||||||
if (!card) continue;
|
if (!card) continue;
|
||||||
|
|
||||||
// Update card border class
|
|
||||||
card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, '');
|
card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, '');
|
||||||
card.classList.add(`host-card-${host.status}`);
|
card.classList.add(`host-card-${host.status}`);
|
||||||
|
|
||||||
// Update status dot in header
|
|
||||||
const dot = card.querySelector('.host-status-dot');
|
const dot = card.querySelector('.host-status-dot');
|
||||||
if (dot) dot.className = `host-status-dot dot-${host.status}`;
|
if (dot) dot.className = `host-status-dot dot-${host.status}`;
|
||||||
|
|
||||||
// Update interface rows
|
|
||||||
const ifaceList = card.querySelector('.iface-list');
|
const ifaceList = card.querySelector('.iface-list');
|
||||||
if (ifaceList && host.interfaces && Object.keys(host.interfaces).length > 0) {
|
if (ifaceList && host.interfaces && Object.keys(host.interfaces).length > 0) {
|
||||||
ifaceList.innerHTML = Object.entries(host.interfaces)
|
ifaceList.innerHTML = Object.entries(host.interfaces)
|
||||||
@@ -96,7 +116,7 @@ function updateHostGrid(hosts) {
|
|||||||
.map(([iface, state]) => `
|
.map(([iface, state]) => `
|
||||||
<div class="iface-row">
|
<div class="iface-row">
|
||||||
<span class="iface-dot dot-${state}"></span>
|
<span class="iface-dot dot-${state}"></span>
|
||||||
<span class="iface-name">${escHtml(iface)}</span>
|
<span class="iface-name">${lt.escHtml(iface)}</span>
|
||||||
<span class="iface-state state-${state}">${state}</span>
|
<span class="iface-state state-${state}">${state}</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -109,7 +129,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) {
|
||||||
@@ -128,24 +150,24 @@ function updateUnifiTable(devices) {
|
|||||||
const dotClass = d.connected ? 'dot-up' : 'dot-down';
|
const dotClass = d.connected ? 'dot-up' : 'dot-down';
|
||||||
const statusText = d.connected ? 'Online' : 'Offline';
|
const statusText = d.connected ? 'Online' : 'Offline';
|
||||||
const suppressBtn = !d.connected
|
const suppressBtn = !d.connected
|
||||||
? `<button class="btn-sm btn-suppress"
|
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||||
data-sup-type="unifi_device"
|
data-sup-type="unifi_device"
|
||||||
data-sup-name="${escHtml(d.name)}"
|
data-sup-name="${lt.escHtml(d.name)}"
|
||||||
data-sup-detail="">🔕 Suppress</button>`
|
data-sup-detail="">🔕 Suppress</button>`
|
||||||
: '';
|
: '';
|
||||||
return `
|
return `
|
||||||
<tr class="${statusClass}">
|
<tr class="${statusClass}">
|
||||||
<td><span class="${dotClass}"></span> ${statusText}</td>
|
<td><span class="${dotClass}"></span> ${statusText}</td>
|
||||||
<td><strong>${escHtml(d.name)}</strong></td>
|
<td><strong>${lt.escHtml(d.name)}</strong></td>
|
||||||
<td>${escHtml(d.type)}</td>
|
<td>${lt.escHtml(d.type)}</td>
|
||||||
<td>${escHtml(d.model)}</td>
|
<td>${lt.escHtml(d.model)}</td>
|
||||||
<td>${escHtml(d.ip)}</td>
|
<td>${lt.escHtml(d.ip)}</td>
|
||||||
<td>${suppressBtn}</td>
|
<td>${suppressBtn}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).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;
|
||||||
|
|
||||||
@@ -155,6 +177,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'
|
||||||
@@ -162,32 +189,34 @@ function updateEventsTable(events) {
|
|||||||
const ticketBase = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url)
|
const ticketBase = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url)
|
||||||
? GANDALF_CONFIG.ticket_web_url : 'http://t.lotusguild.org/ticket/';
|
? GANDALF_CONFIG.ticket_web_url : 'http://t.lotusguild.org/ticket/';
|
||||||
const ticket = e.ticket_id
|
const ticket = e.ticket_id
|
||||||
? `<a href="${ticketBase}${e.ticket_id}" target="_blank"
|
? `<a href="${lt.escHtml(ticketBase)}${lt.escHtml(String(e.ticket_id))}" target="_blank"
|
||||||
class="ticket-link">#${e.ticket_id}</a>`
|
class="ticket-link">#${e.ticket_id}</a>`
|
||||||
: '–';
|
: '–';
|
||||||
return `
|
return `
|
||||||
<tr class="row-${e.severity}">
|
<tr class="row-${e.severity}">
|
||||||
<td><span class="badge badge-${e.severity}">${e.severity}</span></td>
|
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span></td>
|
||||||
<td>${escHtml(e.event_type.replace(/_/g,' '))}</td>
|
<td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td>
|
||||||
<td><strong>${escHtml(e.target_name)}</strong></td>
|
<td><strong>${lt.escHtml(e.target_name)}</strong></td>
|
||||||
<td>${escHtml(e.target_detail || '–')}</td>
|
<td>${lt.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="${lt.escHtml(e.description || '')}">${lt.escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
|
||||||
<td class="ts-cell" title="${escHtml(e.first_seen||'')}">${fmtRelTime(e.first_seen)}</td>
|
<td class="ts-cell" title="${lt.escHtml(e.first_seen||'')}">${lt.time.ago(_toIso(e.first_seen))}</td>
|
||||||
<td class="ts-cell" title="${escHtml(e.last_seen||'')}">${fmtRelTime(e.last_seen)}</td>
|
<td class="ts-cell" title="${lt.escHtml(e.last_seen||'')}">${lt.time.ago(_toIso(e.last_seen))}</td>
|
||||||
<td>${e.consecutive_failures}</td>
|
<td>${e.consecutive_failures}</td>
|
||||||
<td>${ticket}</td>
|
<td>${ticket}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-sm btn-suppress"
|
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||||
data-sup-type="${escHtml(supType)}"
|
data-sup-type="${lt.escHtml(supType)}"
|
||||||
data-sup-name="${escHtml(e.target_name)}"
|
data-sup-name="${lt.escHtml(e.target_name)}"
|
||||||
data-sup-detail="${escHtml(e.target_detail||'')}">🔕</button>
|
data-sup-detail="${lt.escHtml(e.target_detail||'')}">🔕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
<div class="table-wrap">
|
${countNotice}
|
||||||
<table class="data-table" id="events-table">
|
<div class="lt-table-wrap">
|
||||||
|
<table class="lt-table" id="events-table">
|
||||||
|
<caption class="lt-sr-only">Active network alerts</caption>
|
||||||
<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>
|
||||||
@@ -199,7 +228,7 @@ function updateEventsTable(events) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Suppression modal (dashboard) ────────────────────────────────────
|
// ── Suppression modal ─────────────────────────────────────────────────
|
||||||
function openSuppressModal(type, name, detail) {
|
function openSuppressModal(type, name, detail) {
|
||||||
const modal = document.getElementById('suppress-modal');
|
const modal = document.getElementById('suppress-modal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -211,7 +240,7 @@ function openSuppressModal(type, name, detail) {
|
|||||||
document.getElementById('sup-expires').value = '';
|
document.getElementById('sup-expires').value = '';
|
||||||
|
|
||||||
updateSuppressForm();
|
updateSuppressForm();
|
||||||
modal.style.display = 'flex';
|
lt.modal.open('suppress-modal');
|
||||||
|
|
||||||
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
|
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
|
||||||
const manualPill = document.querySelector('#suppress-modal .pill-manual');
|
const manualPill = document.querySelector('#suppress-modal .pill-manual');
|
||||||
@@ -221,8 +250,7 @@ function openSuppressModal(type, name, detail) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function closeSuppressModal() {
|
function closeSuppressModal() {
|
||||||
const modal = document.getElementById('suppress-modal');
|
lt.modal.close('suppress-modal');
|
||||||
if (modal) modal.style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSuppressForm() {
|
function updateSuppressForm() {
|
||||||
@@ -260,37 +288,38 @@ async function submitSuppress(e) {
|
|||||||
if (type !== 'all' && !name.trim()) { showToast('Target name is required', 'error'); return; }
|
if (type !== 'all' && !name.trim()) { showToast('Target name is required', 'error'); return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/suppressions', {
|
await lt.api.post('/api/suppressions', {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
target_type: type,
|
target_type: type,
|
||||||
target_name: name,
|
target_name: name,
|
||||||
target_detail: detail,
|
target_detail: detail,
|
||||||
reason: reason,
|
reason,
|
||||||
expires_minutes: expires ? parseInt(expires) : null,
|
expires_minutes: expires ? parseInt(expires) : null,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
closeSuppressModal();
|
closeSuppressModal();
|
||||||
showToast('Suppression applied ✔', 'success');
|
showToast('Suppression applied ✔', 'success');
|
||||||
setTimeout(refreshAll, 500);
|
setTimeout(refreshAll, 500);
|
||||||
} else {
|
|
||||||
showToast(data.error || 'Failed to apply suppression', 'error');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast('Network error', 'error');
|
showToast(err.message || 'Failed to apply suppression', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Global click handler: modal backdrop + suppress button delegation ─
|
// ── Global click delegation ───────────────────────────────────────────
|
||||||
document.addEventListener('click', e => {
|
document.addEventListener('click', e => {
|
||||||
// Close modal when clicking backdrop
|
// Refresh button
|
||||||
const modal = document.getElementById('suppress-modal');
|
if (e.target.closest('[data-action="refresh"]')) {
|
||||||
if (modal && e.target === modal) { closeSuppressModal(); return; }
|
lt.autoRefresh.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Suppress button via data attributes (avoids inline onclick XSS)
|
// Duration pills (data-duration="" = manual/forever)
|
||||||
|
const pill = e.target.closest('.pill[data-duration]');
|
||||||
|
if (pill) {
|
||||||
|
const val = pill.dataset.duration;
|
||||||
|
setDuration(val ? parseInt(val) : null, pill);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress buttons
|
||||||
const btn = e.target.closest('.btn-suppress[data-sup-type]');
|
const btn = e.target.closest('.btn-suppress[data-sup-type]');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
openSuppressModal(
|
openSuppressModal(
|
||||||
@@ -300,25 +329,3 @@ 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 ───────────────────────────────────────────────────────────
|
|
||||||
function escHtml(str) {
|
|
||||||
if (str === null || str === undefined) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/root/code/web_template/base.css
|
|
||||||
+5614
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
|||||||
/root/code/web_template/base.js
|
|
||||||
+2947
File diff suppressed because it is too large
Load Diff
+559
-1145
File diff suppressed because it is too large
Load Diff
+213
-19
@@ -1,57 +1,251 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<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, viewport-fit=cover">
|
||||||
<title>{% block title %}GANDALF{% endblock %}</title>
|
<meta name="theme-color" content="#030508">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>{% block title %}GANDALF{% endblock %} — GANDALF</title>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='base.css') }}">
|
<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') }}">
|
||||||
|
|
||||||
|
<!-- base.js loaded in head so lt.* is available for inline scripts -->
|
||||||
|
<script src="{{ url_for('static', filename='base.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="lt-boot" class="lt-boot-overlay" data-app-name="GANDALF" style="display:none">
|
|
||||||
|
<a class="lt-skip-link" href="#main-content">Skip to main content</a>
|
||||||
|
|
||||||
|
<!-- BOOT OVERLAY -->
|
||||||
|
<div id="lt-boot" class="lt-boot-overlay" data-app-name="GANDALF" style="display:none" aria-hidden="true">
|
||||||
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||||
</div>
|
</div>
|
||||||
<header class="header">
|
|
||||||
<div class="header-left">
|
<!-- MOBILE NAV DRAWER -->
|
||||||
<div class="header-brand">
|
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
|
||||||
<span class="header-title">GANDALF</span>
|
<div class="lt-nav-drawer-header">
|
||||||
<span class="header-sub">Network Monitor // LotusGuild</span>
|
<span class="lt-brand-title">GANDALF</span>
|
||||||
|
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="header-nav">
|
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
|
||||||
<a href="{{ url_for('index') }}"
|
<a href="{{ url_for('index') }}"
|
||||||
class="nav-link {% if request.endpoint == 'index' %}active{% endif %}">
|
class="lt-nav-drawer-link{% if request.endpoint == 'index' %} active{% endif %}"
|
||||||
|
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>Dashboard</a>
|
||||||
|
<a href="{{ url_for('links_page') }}"
|
||||||
|
class="lt-nav-drawer-link{% if request.endpoint == 'links_page' %} active{% endif %}"
|
||||||
|
{% if request.endpoint == 'links_page' %}aria-current="page"{% endif %}>Link Debug</a>
|
||||||
|
<a href="{{ url_for('inspector') }}"
|
||||||
|
class="lt-nav-drawer-link{% if request.endpoint == 'inspector' %} active{% endif %}"
|
||||||
|
{% if request.endpoint == 'inspector' %}aria-current="page"{% endif %}>Inspector</a>
|
||||||
|
{% if user.groups and 'admin' in user.groups %}
|
||||||
|
<a href="{{ url_for('suppressions_page') }}"
|
||||||
|
class="lt-nav-drawer-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
|
||||||
|
{% if request.endpoint == 'suppressions_page' %}aria-current="page"{% endif %}>Suppressions</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div id="lt-nav-overlay" class="lt-nav-drawer-overlay"></div>
|
||||||
|
|
||||||
|
<!-- PRIMARY HEADER -->
|
||||||
|
<header class="lt-header" role="banner">
|
||||||
|
<div class="lt-header-left">
|
||||||
|
|
||||||
|
<!-- Hamburger (mobile) -->
|
||||||
|
<button type="button"
|
||||||
|
class="lt-menu-btn"
|
||||||
|
id="lt-menu-btn"
|
||||||
|
data-action="open-nav-drawer"
|
||||||
|
aria-label="Open navigation menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="lt-nav-drawer">
|
||||||
|
<span class="lt-menu-btn-bar"></span>
|
||||||
|
<span class="lt-menu-btn-bar"></span>
|
||||||
|
<span class="lt-menu-btn-bar"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="lt-brand">
|
||||||
|
<a href="{{ url_for('index') }}"
|
||||||
|
class="lt-brand-title lt-glitch"
|
||||||
|
data-text="GANDALF"
|
||||||
|
style="text-decoration:none"
|
||||||
|
aria-label="GANDALF home">GANDALF</a>
|
||||||
|
<span class="lt-brand-subtitle">Network Monitor // LotusGuild</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop nav -->
|
||||||
|
<nav class="lt-nav" aria-label="Main navigation">
|
||||||
|
<a href="{{ url_for('index') }}"
|
||||||
|
class="lt-nav-link{% if request.endpoint == 'index' %} active{% endif %}"
|
||||||
|
{% if request.endpoint == 'index' %}aria-current="page"{% endif %}>
|
||||||
Dashboard
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('links_page') }}"
|
<a href="{{ url_for('links_page') }}"
|
||||||
class="nav-link {% if request.endpoint == 'links_page' %}active{% endif %}">
|
class="lt-nav-link{% if request.endpoint == 'links_page' %} active{% endif %}"
|
||||||
|
{% if request.endpoint == 'links_page' %}aria-current="page"{% endif %}>
|
||||||
Link Debug
|
Link Debug
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('inspector') }}"
|
<a href="{{ url_for('inspector') }}"
|
||||||
class="nav-link {% if request.endpoint == 'inspector' %}active{% endif %}">
|
class="lt-nav-link{% if request.endpoint == 'inspector' %} active{% endif %}"
|
||||||
|
{% if request.endpoint == 'inspector' %}aria-current="page"{% endif %}>
|
||||||
Inspector
|
Inspector
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('suppressions_page') }}"
|
{% if user.groups and 'admin' in user.groups %}
|
||||||
class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
|
<div class="lt-nav-dropdown" data-action="toggle-nav-dropdown">
|
||||||
|
<a href="#"
|
||||||
|
class="lt-nav-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
|
||||||
|
role="button"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="lt-admin-dropdown-menu">
|
||||||
|
Admin ▾
|
||||||
|
</a>
|
||||||
|
<ul class="lt-nav-dropdown-menu"
|
||||||
|
id="lt-admin-dropdown-menu"
|
||||||
|
role="menu"
|
||||||
|
aria-label="Admin menu">
|
||||||
|
<li role="none">
|
||||||
|
<a href="{{ url_for('suppressions_page') }}" role="menuitem"
|
||||||
|
class="{% if request.endpoint == 'suppressions_page' %}active{% endif %}">
|
||||||
Suppressions
|
Suppressions
|
||||||
</a>
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('suppressions_page') }}"
|
||||||
|
class="lt-nav-link{% if request.endpoint == 'suppressions_page' %} active{% endif %}"
|
||||||
|
{% if request.endpoint == 'suppressions_page' %}aria-current="page"{% endif %}>
|
||||||
|
Suppressions
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
|
||||||
<span class="header-user">{{ user.name or user.username }}</span>
|
<div class="lt-header-right">
|
||||||
|
{% set _uname = user.name or user.username %}
|
||||||
|
{% set _words = _uname.split() %}
|
||||||
|
{% set _initials = (_words[0][0] ~ (_words[1][0] if _words|length > 1 else ''))|upper %}
|
||||||
|
<div class="lt-avatar lt-avatar--sm {{ _uname | avatar_color }}"
|
||||||
|
aria-hidden="true" title="{{ _uname }}">
|
||||||
|
<span class="lt-avatar-initials">{{ _initials }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="lt-header-user">{{ _uname }}</span>
|
||||||
|
{% if user.groups and 'admin' in user.groups %}
|
||||||
|
<span class="lt-badge lt-badge-admin" aria-label="Administrator">ADMIN</span>
|
||||||
|
{% endif %}
|
||||||
|
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
|
||||||
|
aria-label="Toggle theme" title="Toggle light/dark mode">☀</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="main">
|
<!-- COMMAND PALETTE -->
|
||||||
|
<div id="lt-cmd-overlay" class="lt-cmd-overlay" role="dialog" aria-modal="true" aria-label="Command palette" aria-hidden="true">
|
||||||
|
<div class="lt-cmd-palette" id="lt-cmd-palette">
|
||||||
|
<div class="lt-cmd-input-wrap">
|
||||||
|
<span class="lt-cmd-prompt">></span>
|
||||||
|
<input id="lt-cmd-input" class="lt-cmd-input" type="text"
|
||||||
|
placeholder="Search commands…" autocomplete="off"
|
||||||
|
spellcheck="false" aria-label="Search commands">
|
||||||
|
</div>
|
||||||
|
<div class="lt-cmd-results" id="lt-cmd-results">
|
||||||
|
<div class="lt-cmd-empty">Start typing to search…</div>
|
||||||
|
</div>
|
||||||
|
<div class="lt-cmd-footer">
|
||||||
|
<span><kbd>↑</kbd><kbd>↓</kbd> Navigate</span>
|
||||||
|
<span><kbd>Enter</kbd> Select</span>
|
||||||
|
<span><kbd>Esc</kbd> Close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MAIN CONTENT -->
|
||||||
|
<main class="lt-main lt-container" id="main-content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer class="lt-footer" role="contentinfo">
|
||||||
|
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
|
||||||
|
<span class="lt-footer-hint"><span class="lt-footer-key">[ Ctrl+K ]</span> SEARCH</span>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
|
||||||
|
<span class="lt-footer-sep">|</span>
|
||||||
|
<button type="button" class="lt-footer-hint" data-action="show-keyboard-help"><span class="lt-footer-key">[ ? ]</span> HELP</button>
|
||||||
|
</nav>
|
||||||
|
<span>GANDALF — TDS v1.2</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- KEYBOARD SHORTCUTS MODAL -->
|
||||||
|
<div id="lt-keys-help" class="lt-modal-overlay" aria-hidden="true">
|
||||||
|
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="keys-help-title">
|
||||||
|
<div class="lt-modal-header">
|
||||||
|
<span class="lt-modal-title" id="keys-help-title">Keyboard Shortcuts</span>
|
||||||
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-body">
|
||||||
|
<table class="lt-table" style="width:100%">
|
||||||
|
<thead><tr><th>Shortcut</th><th>Action</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Ctrl / ⌘ + K</td><td>Command palette</td></tr>
|
||||||
|
<tr><td>R</td><td>Refresh dashboard data</td></tr>
|
||||||
|
<tr><td>?</td><td>Show this help</td></tr>
|
||||||
|
<tr><td>ESC</td><td>Close modal</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="lt-modal-footer">
|
||||||
|
<button type="button" class="lt-btn" data-modal-close>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const GANDALF_CONFIG = {
|
const GANDALF_CONFIG = {
|
||||||
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 %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
if (window.lt) {
|
||||||
|
lt.init({ bootName: 'GANDALF' });
|
||||||
|
|
||||||
|
// Theme toggle
|
||||||
|
var themeBtn = document.getElementById('lt-theme-btn');
|
||||||
|
if (themeBtn) themeBtn.addEventListener('click', function() { lt.theme.toggle(); });
|
||||||
|
|
||||||
|
// Command palette
|
||||||
|
lt.cmdPalette.init([
|
||||||
|
{ id: 'nav-dashboard', group: 'Navigate', icon: '~', label: 'Dashboard', action: function() { window.location.href = '/'; } },
|
||||||
|
{ id: 'nav-links', group: 'Navigate', icon: '↗', label: 'Link Debug', action: function() { window.location.href = '/links'; } },
|
||||||
|
{ id: 'nav-inspector', group: 'Navigate', icon: '⬡', label: 'Inspector', action: function() { window.location.href = '/inspector'; } },
|
||||||
|
{ id: 'nav-suppressions', group: 'Navigate', icon: '🔕', label: 'Suppressions', action: function() { window.location.href = '/suppressions'; } },
|
||||||
|
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', action: function() { lt.modal.open('lt-keys-help'); } },
|
||||||
|
{ id: 'help-theme', group: 'Help', icon: '*', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
|
||||||
|
{ id: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { if (typeof refreshAll === 'function') refreshAll(); } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer hint actions
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
if (btn.getAttribute('data-action') === 'show-keyboard-help' && window.lt) {
|
||||||
|
lt.modal.open('lt-keys-help');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
lt.keys.on('r', function() {
|
||||||
|
if (typeof refreshAll === 'function') refreshAll();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+275
-98
@@ -18,67 +18,153 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="status-meta">
|
<div class="status-meta">
|
||||||
<span class="last-check" id="last-check">{{ last_check }}</span>
|
<span class="last-check" id="last-check">{{ last_check }}</span>
|
||||||
<button class="btn-refresh" onclick="refreshAll()">↻ REFRESH</button>
|
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="refresh">↻ REFRESH</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Network topology + host grid ───────────────────────────────── -->
|
<!-- ── Network topology + host grid ───────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="g-section">
|
||||||
<div class="section-header">
|
<div class="g-section-header">
|
||||||
<h2 class="section-title">Network Hosts</h2>
|
<h2 class="g-section-title">Network Hosts</h2>
|
||||||
</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-color);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>
|
</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-color); padding:1px 5px; font-size:.56em; color:var(--text-muted);">dashed border = off-rack</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /topo-v2 -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Host cards -->
|
<!-- Host cards -->
|
||||||
@@ -115,7 +201,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="host-actions">
|
<div class="host-actions">
|
||||||
<button class="btn-sm btn-suppress"
|
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||||
data-sup-type="host"
|
data-sup-type="host"
|
||||||
data-sup-name="{{ name }}"
|
data-sup-name="{{ name }}"
|
||||||
data-sup-detail=""
|
data-sup-detail=""
|
||||||
@@ -123,7 +209,7 @@
|
|||||||
🔕 Suppress
|
🔕 Suppress
|
||||||
</button>
|
</button>
|
||||||
<a href="{{ url_for('links_page') }}#{{ name }}"
|
<a href="{{ url_for('links_page') }}#{{ name }}"
|
||||||
class="btn-sm btn-secondary" style="text-decoration:none">
|
class="lt-btn lt-btn-secondary lt-btn-sm" style="text-decoration:none">
|
||||||
↗ Links
|
↗ Links
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,12 +222,13 @@
|
|||||||
|
|
||||||
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
|
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
|
||||||
{% if snapshot.unifi %}
|
{% if snapshot.unifi %}
|
||||||
<section class="section">
|
<section class="g-section">
|
||||||
<div class="section-header">
|
<div class="g-section-header">
|
||||||
<h2 class="section-title">UniFi Devices</h2>
|
<h2 class="g-section-title">UniFi Devices</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="lt-table-wrap">
|
||||||
<table class="data-table" id="unifi-table">
|
<table class="lt-table" id="unifi-table">
|
||||||
|
<caption class="lt-sr-only">UniFi network devices</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
@@ -165,7 +252,7 @@
|
|||||||
<td>{{ d.ip }}</td>
|
<td>{{ d.ip }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if not d.connected %}
|
{% if not d.connected %}
|
||||||
<button class="btn-sm btn-suppress"
|
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||||
data-sup-type="unifi_device"
|
data-sup-type="unifi_device"
|
||||||
data-sup-name="{{ d.name }}"
|
data-sup-name="{{ d.name }}"
|
||||||
data-sup-detail="">
|
data-sup-detail="">
|
||||||
@@ -182,17 +269,32 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- ── Active alerts ───────────────────────────────────────────────── -->
|
<!-- ── Active alerts ───────────────────────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="g-section">
|
||||||
<div class="section-header">
|
<div class="g-section-header">
|
||||||
<h2 class="section-title">Active Alerts</h2>
|
<h2 class="g-section-title">Active Alerts</h2>
|
||||||
{% if summary.critical or summary.warning %}
|
{% if summary.critical or summary.warning %}
|
||||||
<span class="section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
|
<span class="g-section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="g-section-actions">
|
||||||
|
<div class="events-filter-bar">
|
||||||
|
<input type="search" class="lt-input lt-input-sm" id="events-search"
|
||||||
|
placeholder="Filter by target, type, description…" autocomplete="off">
|
||||||
|
<div class="sev-pills">
|
||||||
|
<button type="button" class="pill active" data-sev="">All</button>
|
||||||
|
<button type="button" class="pill" data-sev="critical">Critical</button>
|
||||||
|
<button type="button" class="pill" data-sev="warning">Warning</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="events-table-wrap">
|
<div id="events-table-wrap">
|
||||||
{% if events %}
|
{% if events %}
|
||||||
<div class="table-wrap">
|
{% if total_active is defined and total_active > events|length %}
|
||||||
<table class="data-table" id="events-table">
|
<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="lt-table-wrap">
|
||||||
|
<table class="lt-table" id="events-table">
|
||||||
|
<caption class="lt-sr-only">Active network alerts</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Sev</th>
|
<th>Sev</th>
|
||||||
@@ -211,7 +313,7 @@
|
|||||||
{% for e in events %}
|
{% for e in events %}
|
||||||
{% if e.severity != 'info' %}
|
{% if e.severity != 'info' %}
|
||||||
<tr class="row-{{ e.severity }}">
|
<tr class="row-{{ e.severity }}">
|
||||||
<td><span class="badge badge-{{ e.severity }}">{{ e.severity }}</span></td>
|
<td><span class="lt-badge badge-{{ e.severity }}">{{ e.severity }}</span></td>
|
||||||
<td>{{ e.event_type | replace('_', ' ') }}</td>
|
<td>{{ e.event_type | replace('_', ' ') }}</td>
|
||||||
<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>
|
||||||
@@ -230,7 +332,7 @@
|
|||||||
{% else %}–{% endif %}
|
{% else %}–{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-sm btn-suppress"
|
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
|
||||||
data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}"
|
data-sup-type="{{ 'unifi_device' if e.event_type == 'unifi_device_offline' else 'interface' if e.event_type == 'interface_down' else 'host' }}"
|
||||||
data-sup-name="{{ e.target_name }}"
|
data-sup-name="{{ e.target_name }}"
|
||||||
data-sup-detail="{{ e.target_detail or '' }}"
|
data-sup-detail="{{ e.target_detail or '' }}"
|
||||||
@@ -250,51 +352,93 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ── Recently Resolved (last 24h) ───────────────────────────────── -->
|
||||||
|
{% if recent_resolved %}
|
||||||
|
<section class="g-section">
|
||||||
|
<div class="g-section-header">
|
||||||
|
<h2 class="g-section-title">Recently Resolved</h2>
|
||||||
|
<span class="g-section-badge g-section-badge-resolved">{{ recent_resolved | length }} in last 24h</span>
|
||||||
|
</div>
|
||||||
|
<div class="lt-table-wrap">
|
||||||
|
<table class="lt-table">
|
||||||
|
<caption class="lt-sr-only">Recently resolved alerts</caption>
|
||||||
|
<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="lt-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="lt-modal-overlay"
|
||||||
<div class="modal">
|
role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title" aria-hidden="true">
|
||||||
<div class="modal-header">
|
<div class="lt-modal">
|
||||||
<h3>Suppress Alert</h3>
|
<div class="lt-modal-header">
|
||||||
<button class="modal-close" onclick="closeSuppressModal()">✕</button>
|
<h3 class="lt-modal-title" id="suppress-modal-title">Suppress Alert</h3>
|
||||||
|
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="suppress-form" onsubmit="submitSuppress(event)">
|
<form id="suppress-form" onsubmit="submitSuppress(event)">
|
||||||
<div class="form-group" style="margin-bottom:10px">
|
<div class="lt-modal-body">
|
||||||
<label>Target Type</label>
|
<div class="lt-form-group" style="margin-bottom:12px">
|
||||||
<select id="sup-type" name="target_type" onchange="updateSuppressForm()">
|
<label class="lt-label" for="sup-type">Target Type</label>
|
||||||
|
<select class="lt-select" id="sup-type" name="target_type" onchange="updateSuppressForm()">
|
||||||
<option value="host">Host (all interfaces)</option>
|
<option value="host">Host (all interfaces)</option>
|
||||||
<option value="interface">Specific Interface</option>
|
<option value="interface">Specific Interface</option>
|
||||||
<option value="unifi_device">UniFi Device</option>
|
<option value="unifi_device">UniFi Device</option>
|
||||||
<option value="all">Global Maintenance</option>
|
<option value="all">Global Maintenance</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="sup-name-group" style="margin-bottom:10px">
|
<div class="lt-form-group" id="sup-name-group" style="margin-bottom:12px">
|
||||||
<label>Target Name</label>
|
<label class="lt-label" for="sup-name">Target Name</label>
|
||||||
<input type="text" id="sup-name" name="target_name" placeholder="e.g. large1">
|
<input type="text" class="lt-input" id="sup-name" name="target_name" placeholder="e.g. large1">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="sup-detail-group" style="margin-bottom:10px;display:none">
|
<div class="lt-form-group" id="sup-detail-group" style="margin-bottom:12px;display:none">
|
||||||
<label>Interface <span class="form-hint">(interface type only)</span></label>
|
<label class="lt-label" for="sup-detail">Interface <span class="lt-field-hint">(interface type only)</span></label>
|
||||||
<input type="text" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
|
<input type="text" class="lt-input" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:10px">
|
<div class="lt-form-group" style="margin-bottom:12px">
|
||||||
<label>Reason <span class="required">*</span></label>
|
<label class="lt-label" for="sup-reason">Reason <span class="required">*</span></label>
|
||||||
<input type="text" id="sup-reason" name="reason"
|
<input type="text" class="lt-input" id="sup-reason" name="reason"
|
||||||
placeholder="e.g. Planned switch reboot" required>
|
placeholder="e.g. Planned switch reboot" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-bottom:0">
|
<div class="lt-form-group" style="margin-bottom:0">
|
||||||
<label>Duration</label>
|
<label class="lt-label">Duration</label>
|
||||||
<div class="duration-pills">
|
<div class="duration-pills">
|
||||||
<button type="button" class="pill" onclick="setDuration(30, this)">30 min</button>
|
<button type="button" class="pill" data-duration="30">30 min</button>
|
||||||
<button type="button" class="pill" onclick="setDuration(60, this)">1 hr</button>
|
<button type="button" class="pill" data-duration="60">1 hr</button>
|
||||||
<button type="button" class="pill" onclick="setDuration(240, this)">4 hr</button>
|
<button type="button" class="pill" data-duration="240">4 hr</button>
|
||||||
<button type="button" class="pill" onclick="setDuration(480, this)">8 hr</button>
|
<button type="button" class="pill" data-duration="480">8 hr</button>
|
||||||
<button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button>
|
<button type="button" class="pill pill-manual active" data-duration="">Manual ∞</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
|
<input type="hidden" id="sup-expires" name="expires_minutes" value="">
|
||||||
<div class="form-hint" id="duration-hint">Persists until manually removed.</div>
|
<div class="lt-field-hint" id="duration-hint">Persists until manually removed.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
</div>
|
||||||
<button type="button" class="btn btn-secondary" onclick="closeSuppressModal()">Cancel</button>
|
<div class="lt-modal-footer">
|
||||||
<button type="submit" class="btn btn-primary">Apply</button>
|
<button type="button" class="lt-btn lt-btn-secondary" data-modal-close>Cancel</button>
|
||||||
|
<button type="submit" class="lt-btn lt-btn-primary">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -304,27 +448,60 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
setInterval(refreshAll, 30000);
|
lt.autoRefresh.start(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() {
|
function updateEventAges() {
|
||||||
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
|
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
|
||||||
el.textContent = fmtRelTime(el.dataset.ts);
|
el.textContent = lt.time.ago(_toIso(el.dataset.ts));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEventAges();
|
updateEventAges();
|
||||||
setInterval(updateEventAges, 60000);
|
setInterval(updateEventAges, 60000);
|
||||||
|
|
||||||
|
// ── Event duration (resolved_at - first_seen) ──────────────────
|
||||||
|
function fmtDuration(firstTs, resolvedTs) {
|
||||||
|
if (!firstTs || !resolvedTs) return '–';
|
||||||
|
const secs = Math.floor((new Date(_toIso(resolvedTs)) - new Date(_toIso(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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Events table filter ────────────────────────────────────────
|
||||||
|
let _filterSev = '';
|
||||||
|
|
||||||
|
function applyEventsFilter() {
|
||||||
|
const q = (document.getElementById('events-search')?.value || '').toLowerCase();
|
||||||
|
const tbody = document.querySelector('#events-table tbody');
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.querySelectorAll('tr').forEach(row => {
|
||||||
|
if (row.children.length < 3) { row.style.display = ''; return; }
|
||||||
|
const sevMatch = !_filterSev || row.classList.contains(`row-${_filterSev}`);
|
||||||
|
const textMatch = !q || row.textContent.toLowerCase().includes(q);
|
||||||
|
row.style.display = (sevMatch && textMatch) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('events-search')?.addEventListener('input', applyEventsFilter);
|
||||||
|
|
||||||
|
document.querySelector('.sev-pills')?.addEventListener('click', e => {
|
||||||
|
const pill = e.target.closest('.pill[data-sev]');
|
||||||
|
if (!pill) return;
|
||||||
|
document.querySelectorAll('.sev-pills .pill').forEach(p => p.classList.remove('active'));
|
||||||
|
pill.classList.add('active');
|
||||||
|
_filterSev = pill.dataset.sev;
|
||||||
|
applyEventsFilter();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-apply filter after dynamic table updates
|
||||||
|
new MutationObserver(applyEventsFilter)
|
||||||
|
.observe(document.getElementById('events-table-wrap'), { childList: true, subtree: true });
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+66
-25
@@ -3,9 +3,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="g-page-header">
|
||||||
<h1 class="page-title">Network Inspector</h1>
|
<h1 class="g-page-title">Network Inspector</h1>
|
||||||
<p class="page-sub">
|
<p class="g-page-sub">
|
||||||
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
|
Visual switch chassis diagrams. Click a port to see detailed stats and LLDP path debug.
|
||||||
<span id="inspector-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
|
<span id="inspector-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
|
||||||
</p>
|
</p>
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
const escHtml = s => lt.escHtml(s);
|
||||||
|
|
||||||
// ── Switch layout config ─────────────────────────────────────────────────
|
// ── Switch layout config ─────────────────────────────────────────────────
|
||||||
// keys match the model field returned by the UniFi API
|
// keys match the model field returned by the UniFi API
|
||||||
// rows: array of rows, each row is an array of port_idx values
|
// rows: array of rows, each row is an array of port_idx values
|
||||||
@@ -84,16 +86,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,6 +139,9 @@ 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) {
|
||||||
@@ -116,17 +151,26 @@ function renderChassis(swName, sw) {
|
|||||||
// 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 +202,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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,18 +453,20 @@ function renderInspector(data) {
|
|||||||
// ── Fetch and render ─────────────────────────────────────────────────────
|
// ── Fetch and render ─────────────────────────────────────────────────────
|
||||||
async function loadInspector() {
|
async function loadInspector() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/links');
|
const data = await lt.api.get('/api/links');
|
||||||
if (!resp.ok) throw new Error('API error');
|
|
||||||
const data = await resp.json();
|
|
||||||
renderInspector(data);
|
renderInspector(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('inspector-main').innerHTML =
|
document.getElementById('inspector-main').innerHTML =
|
||||||
'<p class="empty-state">Failed to load inspector data.</p>';
|
'<p class="empty-state">Failed to load inspector data.</p>';
|
||||||
|
lt.toast.error('Failed to load inspector data');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadInspector();
|
loadInspector();
|
||||||
setInterval(loadInspector, 60000);
|
lt.autoRefresh.start(loadInspector, 60000);
|
||||||
|
lt.keys.on('Escape', () => {
|
||||||
|
if (document.getElementById('inspector-panel').classList.contains('open')) closePanel();
|
||||||
|
});
|
||||||
|
|
||||||
// ── Link Diagnostics ─────────────────────────────────────────────────
|
// ── Link Diagnostics ─────────────────────────────────────────────────
|
||||||
let _diagPollTimer = null;
|
let _diagPollTimer = null;
|
||||||
@@ -431,22 +482,13 @@ function runDiagnostic(swName, portIdx) {
|
|||||||
statusEl.textContent = 'Submitting to Pulse...';
|
statusEl.textContent = 'Submitting to Pulse...';
|
||||||
resultsEl.innerHTML = '';
|
resultsEl.innerHTML = '';
|
||||||
|
|
||||||
fetch('/api/diagnose', {
|
lt.api.post('/api/diagnose', {switch_name: swName, port_idx: portIdx})
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({switch_name: swName, port_idx: portIdx}),
|
|
||||||
})
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(resp => {
|
.then(resp => {
|
||||||
if (resp.error) {
|
|
||||||
statusEl.textContent = 'Error: ' + resp.error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
statusEl.textContent = 'Collecting diagnostics via Pulse...';
|
statusEl.textContent = 'Collecting diagnostics via Pulse...';
|
||||||
pollDiagnostic(resp.job_id, statusEl, resultsEl);
|
pollDiagnostic(resp.job_id, statusEl, resultsEl);
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
statusEl.textContent = 'Request failed: ' + e;
|
statusEl.textContent = 'Error: ' + (e.message || 'Request failed');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,8 +501,7 @@ function pollDiagnostic(jobId, statusEl, resultsEl) {
|
|||||||
statusEl.textContent = 'Timed out waiting for results.';
|
statusEl.textContent = 'Timed out waiting for results.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fetch(`/api/diagnose/${jobId}`)
|
lt.api.get(`/api/diagnose/${jobId}`)
|
||||||
.then(r => r.json())
|
|
||||||
.then(resp => {
|
.then(resp => {
|
||||||
if (resp.status === 'done') {
|
if (resp.status === 'done') {
|
||||||
clearInterval(_diagPollTimer);
|
clearInterval(_diagPollTimer);
|
||||||
|
|||||||
+243
-285
@@ -3,9 +3,9 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="g-page-header">
|
||||||
<h1 class="page-title">Link Debug</h1>
|
<h1 class="g-page-title">Link Debug</h1>
|
||||||
<p class="page-sub">
|
<p class="g-page-sub">
|
||||||
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
|
Per-interface stats: speed, duplex, SFP optical levels, TX/RX rates, errors, and carrier changes.
|
||||||
Data collected via Prometheus node_exporter + SSH ethtool (servers) and UniFi API (switches) every poll cycle.
|
Data collected via Prometheus node_exporter + SSH ethtool (servers) and UniFi API (switches) every poll cycle.
|
||||||
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
|
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
const escHtml = s => lt.escHtml(s);
|
||||||
|
|
||||||
// ── Formatting helpers ────────────────────────────────────────────
|
// ── Formatting helpers ────────────────────────────────────────────
|
||||||
function fmtRate(bytesPerSec) {
|
function fmtRate(bytesPerSec) {
|
||||||
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
|
if (bytesPerSec === null || bytesPerSec === undefined) return '–';
|
||||||
@@ -69,44 +71,34 @@ function fmtBias(ma) {
|
|||||||
|
|
||||||
function fmtErrors(rate) {
|
function fmtErrors(rate) {
|
||||||
if (rate === null || rate === undefined) return '–';
|
if (rate === null || rate === undefined) return '–';
|
||||||
if (rate < 0.001) return '<span class="counter-zero">0 /s</span>';
|
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
|
||||||
return `<span class="counter-nonzero">${rate.toFixed(3)} /s</span>`;
|
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtCarrier(n) {
|
function fmtCarrier(n) {
|
||||||
if (n === null || n === undefined) return '–';
|
if (n === null || n === undefined) return '–';
|
||||||
const v = parseInt(n);
|
if (n === 0) return '<span class="counter-zero">0</span>';
|
||||||
if (v <= 2) return `<span class="val-good">${v}</span>`;
|
return `<span class="counter-nonzero">${n}</span>`;
|
||||||
if (v <= 10) return `<span class="val-warn">${v}</span>`;
|
|
||||||
return `<span class="val-crit">${v}</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Power level: returns {cls, pct} for -30..0 dBm scale
|
// ── SFP/DOM value classification ─────────────────────────────────
|
||||||
function rxPowerClass(dbm) {
|
function rxPowerClass(dbm) {
|
||||||
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
|
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||||||
const pct = Math.max(0, Math.min(100, (dbm + 30) / 30 * 100));
|
if (dbm < -15) return 'val-crit';
|
||||||
let cls = 'power-ok';
|
if (dbm < -10) return 'val-warn';
|
||||||
if (dbm < -25 || dbm > 0) cls = 'power-crit';
|
return 'val-good';
|
||||||
else if (dbm < -20) cls = 'power-warn';
|
|
||||||
return {cls, pct};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function txPowerClass(dbm) {
|
function txPowerClass(dbm) {
|
||||||
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
|
if (dbm === null || dbm === undefined) return 'val-neutral';
|
||||||
const pct = Math.max(0, Math.min(100, (dbm + 20) / 20 * 100));
|
if (dbm < -5) return 'val-crit';
|
||||||
let cls = 'power-ok';
|
return 'val-good';
|
||||||
if (dbm < -15 || dbm > 2) cls = 'power-crit';
|
|
||||||
else if (dbm < -10) cls = 'power-warn';
|
|
||||||
return {cls, pct};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tempClass(c) {
|
function tempClass(c) {
|
||||||
if (c === null || c === undefined) return 'val-neutral';
|
if (c === null || c === undefined) return 'val-neutral';
|
||||||
if (c > 80) return 'val-crit';
|
if (c > 80) return 'val-crit';
|
||||||
if (c > 60) return 'val-warn';
|
if (c > 70) return 'val-warn';
|
||||||
return 'val-good';
|
return 'val-good';
|
||||||
}
|
}
|
||||||
|
|
||||||
function voltageClass(v) {
|
function voltageClass(v) {
|
||||||
if (v === null || v === undefined) return 'val-neutral';
|
if (v === null || v === undefined) return 'val-neutral';
|
||||||
if (v < 3.0 || v > 3.6) return 'val-crit';
|
if (v < 3.0 || v > 3.6) return 'val-crit';
|
||||||
@@ -114,319 +106,260 @@ function voltageClass(v) {
|
|||||||
return 'val-good';
|
return 'val-good';
|
||||||
}
|
}
|
||||||
|
|
||||||
function portTypeLabel(pt) {
|
|
||||||
if (!pt) return {label:'–', cls:''};
|
|
||||||
const u = pt.toUpperCase();
|
|
||||||
if (u.includes('FIBRE') || u.includes('FIBER') || u.includes('SFP'))
|
|
||||||
return {label: pt, cls: 'type-fibre'};
|
|
||||||
if (u.includes('DA') || u.includes('DIRECT'))
|
|
||||||
return {label: pt, cls: 'type-da'};
|
|
||||||
return {label: pt, cls: 'type-copper'};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Error alert badge ─────────────────────────────────────────────
|
|
||||||
function errorBadges(d) {
|
function errorBadges(d) {
|
||||||
const badges = [];
|
const badges = [];
|
||||||
if ((d.tx_errs_rate || 0) > 0.01 || (d.rx_errs_rate || 0) > 0.01)
|
if ((d.tx_errs_rate || 0) > 0.001 || (d.rx_errs_rate || 0) > 0.001)
|
||||||
badges.push('<span class="link-alert-badge">ERR</span>');
|
badges.push('<span class="link-alert-badge">ERR</span>');
|
||||||
if ((d.tx_drops_rate || 0) > 0.1 || (d.rx_drops_rate || 0) > 0.1)
|
if ((d.tx_drops_rate || 0) > 0.001 || (d.rx_drops_rate || 0) > 0.001)
|
||||||
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
|
badges.push('<span class="link-alert-badge link-alert-amber">DROP</span>');
|
||||||
if ((d.carrier_changes || 0) > 10)
|
if ((d.carrier_changes || 0) > 3)
|
||||||
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
|
||||||
return badges.join('');
|
return badges.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render a single interface card ────────────────────────────────
|
// ── Render a single server interface card ─────────────────────────
|
||||||
function renderIfaceCard(ifaceName, d) {
|
function renderIfaceCard(ifaceName, d) {
|
||||||
const speed = fmtSpeed(d.speed_mbps);
|
const isDown = d.link_detected === false;
|
||||||
const duplex = fmtDuplex(d.duplex);
|
const pt = (d.port_type || '').toUpperCase();
|
||||||
const ptype = portTypeLabel(d.port_type);
|
const mediaTag = pt === 'FIBRE' || pt === 'SFP' || pt.includes('FIBRE') ? 'type-fibre'
|
||||||
const autoneg= d.auto_neg !== undefined ? (d.auto_neg ? 'On' : 'Off') : '–';
|
: pt === 'DA' ? 'type-da'
|
||||||
const linkDet= d.link_detected !== undefined ? (d.link_detected ? '<span class="val-good">Yes</span>' : '<span class="val-crit">No</span>') : '–';
|
: 'type-copper';
|
||||||
|
const mediaLabel = d.port_type || '–';
|
||||||
|
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '–';
|
||||||
|
const txPct = fmtRateBar(d.tx_bytes_rate, d.speed_mbps);
|
||||||
|
const rxPct = fmtRateBar(d.rx_bytes_rate, d.speed_mbps);
|
||||||
|
|
||||||
// Traffic bars
|
|
||||||
const txRate = d.tx_bytes_rate;
|
|
||||||
const rxRate = d.rx_bytes_rate;
|
|
||||||
const txPct = fmtRateBar(txRate, d.speed_mbps);
|
|
||||||
const rxPct = fmtRateBar(rxRate, d.speed_mbps);
|
|
||||||
|
|
||||||
const txStr = fmtRate(txRate);
|
|
||||||
const rxStr = fmtRate(rxRate);
|
|
||||||
|
|
||||||
// SFP / optical section
|
|
||||||
let sfpHtml = '';
|
let sfpHtml = '';
|
||||||
const sfp = d.sfp;
|
if (d.sfp && Object.keys(d.sfp).length > 0) {
|
||||||
if (sfp && Object.keys(sfp).length > 0) {
|
const s = d.sfp;
|
||||||
const tx = txPowerClass(sfp.tx_power_dbm);
|
const rxClass = rxPowerClass(s.rx_power_dbm);
|
||||||
const rx = rxPowerClass(sfp.rx_power_dbm);
|
const txClass = txPowerClass(s.tx_power_dbm);
|
||||||
const tcls = tempClass(sfp.temp_c);
|
const tmpClass = tempClass(s.temp_c);
|
||||||
const vcls = voltageClass(sfp.voltage_v);
|
const vClass = voltageClass(s.voltage_v);
|
||||||
|
const rxPct2 = s.rx_power_dbm != null ? Math.min(100, Math.max(0, (s.rx_power_dbm + 20) / 15 * 100)) : 0;
|
||||||
const vendorStr = [sfp.vendor, sfp.part_no].filter(Boolean).join(' / ') || '–';
|
const txPct2 = s.tx_power_dbm != null ? Math.min(100, Math.max(0, (s.tx_power_dbm + 10) / 8 * 100)) : 0;
|
||||||
const sfpTypeStr= [sfp.sfp_type, sfp.connector, sfp.wavelength_nm ? sfp.wavelength_nm + 'nm' : ''].filter(Boolean).join(' · ') || '';
|
|
||||||
|
|
||||||
sfpHtml = `
|
sfpHtml = `
|
||||||
<div class="sfp-panel">
|
<div class="sfp-panel">
|
||||||
<div class="sfp-vendor-row">
|
<div class="sfp-vendor-row">
|
||||||
<span>${escHtml(vendorStr)}</span>
|
${s.vendor ? `<span>${escHtml(s.vendor)}</span>` : ''}
|
||||||
${sfpTypeStr ? `<span style="margin-left:8px;color:var(--text-muted)">${escHtml(sfpTypeStr)}</span>` : ''}
|
${s.part_no ? ` / <span>${escHtml(s.part_no)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="sfp-grid">
|
<div class="sfp-grid">
|
||||||
<div class="sfp-stat">
|
<div class="sfp-stat">
|
||||||
<span class="sfp-stat-label">Temp</span>
|
<span class="sfp-stat-label">Temp</span>
|
||||||
<span class="sfp-stat-value ${tcls}">${fmtTemp(sfp.temp_c)}</span>
|
<span class="sfp-stat-value ${tmpClass}">${fmtTemp(s.temp_c)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sfp-stat">
|
<div class="sfp-stat">
|
||||||
<span class="sfp-stat-label">Voltage</span>
|
<span class="sfp-stat-label">Voltage</span>
|
||||||
<span class="sfp-stat-value ${vcls}">${fmtVoltage(sfp.voltage_v)}</span>
|
<span class="sfp-stat-value ${vClass}">${fmtVoltage(s.voltage_v)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sfp-stat">
|
<div class="sfp-stat">
|
||||||
<span class="sfp-stat-label">Bias</span>
|
<span class="sfp-stat-label">Bias</span>
|
||||||
<span class="sfp-stat-value">${fmtBias(sfp.bias_ma)}</span>
|
<span class="sfp-stat-value">${fmtBias(s.bias_ma)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sfp-stat">
|
<div class="sfp-stat">
|
||||||
<span class="sfp-stat-label">TX Power</span>
|
<span class="sfp-stat-label">TX Power</span>
|
||||||
<span class="sfp-stat-value ${tx.cls === 'power-ok' ? 'val-good' : tx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.tx_power_dbm)}</span>
|
<span class="sfp-stat-value ${txClass}">${fmtPower(s.tx_power_dbm)}</span>
|
||||||
<div class="power-row">
|
<div class="power-row">
|
||||||
<div class="power-track"><div class="power-fill ${tx.cls}" style="width:${tx.pct}%"></div></div>
|
<div class="power-track"><div class="power-fill ${txClass === 'val-good' ? 'power-ok' : txClass === 'val-warn' ? 'power-warn' : 'power-crit'}" style="width:${txPct2}%"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sfp-stat">
|
<div class="sfp-stat">
|
||||||
<span class="sfp-stat-label">RX Power</span>
|
<span class="sfp-stat-label">RX Power</span>
|
||||||
<span class="sfp-stat-value ${rx.cls === 'power-ok' ? 'val-good' : rx.cls === 'power-warn' ? 'val-warn' : 'val-crit'}">${fmtPower(sfp.rx_power_dbm)}</span>
|
<span class="sfp-stat-value ${rxClass}">${fmtPower(s.rx_power_dbm)}</span>
|
||||||
<div class="power-row">
|
<div class="power-row">
|
||||||
<div class="power-track"><div class="power-fill ${rx.cls}" style="width:${rx.pct}%"></div></div>
|
<div class="power-track"><div class="power-fill ${rxClass === 'val-good' ? 'power-ok' : rxClass === 'val-warn' ? 'power-warn' : 'power-crit'}" style="width:${rxPct2}%"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${s.rx_power_dbm != null && s.tx_power_dbm != null ? `
|
||||||
<div class="sfp-stat">
|
<div class="sfp-stat">
|
||||||
<span class="sfp-stat-label">RX – TX</span>
|
<span class="sfp-stat-label">RX−TX Δ</span>
|
||||||
<span class="sfp-stat-value ${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined ? (Math.abs(sfp.rx_power_dbm - sfp.tx_power_dbm) > 8 ? 'val-warn' : 'val-neutral') : 'val-neutral'}">
|
<span class="sfp-stat-value">${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm</span>
|
||||||
${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined
|
</div>` : ''}
|
||||||
? (sfp.rx_power_dbm - sfp.tx_power_dbm).toFixed(2) + ' dBm'
|
|
||||||
: '–'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="link-iface-card">
|
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
|
||||||
<div class="link-iface-header">
|
<div class="link-iface-header">
|
||||||
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
<span class="link-iface-name">${escHtml(ifaceName)}</span>
|
||||||
${speed !== '–' ? `<span class="link-iface-speed">${speed}</span>` : ''}
|
<span class="link-iface-speed">${speedStr}</span>
|
||||||
${ptype.label !== '–' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''}
|
<span class="link-iface-type ${mediaTag}">${escHtml(mediaLabel)}</span>
|
||||||
${errorBadges(d)}
|
${errorBadges(d)}
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stats-grid">
|
<div class="link-stats-grid">
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">Link</span>
|
||||||
|
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
|
||||||
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">Duplex</span>
|
<span class="link-stat-label">Duplex</span>
|
||||||
<span class="link-stat-value ${duplex==='Full'?'val-good':duplex==='Half'?'val-crit':'val-neutral'}">${duplex}</span>
|
<span class="link-stat-value">${fmtDuplex(d.duplex)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">Auto-neg</span>
|
<span class="link-stat-label">Auto-neg</span>
|
||||||
<span class="link-stat-value val-neutral">${autoneg}</span>
|
<span class="link-stat-value">${d.auto_neg == null ? '–' : d.auto_neg ? 'On' : 'Off'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">Link Det.</span>
|
<span class="link-stat-label">Carrier Δ</span>
|
||||||
<span class="link-stat-value">${linkDet}</span>
|
|
||||||
</div>
|
|
||||||
<div class="link-stat">
|
|
||||||
<span class="link-stat-label">Carrier Chg</span>
|
|
||||||
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
|
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">TX Errors</span>
|
<span class="link-stat-label">TX Err/s</span>
|
||||||
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">RX Errors</span>
|
<span class="link-stat-label">RX Err/s</span>
|
||||||
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">TX Drops</span>
|
<span class="link-stat-label">TX Drop/s</span>
|
||||||
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">RX Drops</span>
|
<span class="link-stat-label">RX Drop/s</span>
|
||||||
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
|
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${(txRate !== undefined || rxRate !== undefined) ? `
|
|
||||||
<div class="traffic-section">
|
<div class="traffic-section">
|
||||||
<div class="traffic-row">
|
<div class="traffic-row">
|
||||||
<span class="traffic-label">TX</span>
|
<span class="traffic-label">TX</span>
|
||||||
<div class="traffic-bar-track">
|
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
||||||
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div>
|
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
|
||||||
</div>
|
|
||||||
<span class="traffic-value">${txStr}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="traffic-row">
|
<div class="traffic-row">
|
||||||
<span class="traffic-label">RX</span>
|
<span class="traffic-label">RX</span>
|
||||||
<div class="traffic-bar-track">
|
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
||||||
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div>
|
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="traffic-value">${rxStr}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>` : ''}
|
|
||||||
|
|
||||||
${sfpHtml}
|
${sfpHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render a single UniFi switch port card ────────────────────────
|
// ── Render a single UniFi switch port card ────────────────────────
|
||||||
function renderPortCard(portName, d) {
|
function renderPortCard(portName, d) {
|
||||||
const up = d.up;
|
const isDown = !d.up;
|
||||||
const speed = up ? fmtSpeed(d.speed_mbps) : 'DOWN';
|
const speedStr = d.speed_mbps ? fmtSpeed(d.speed_mbps) : '–';
|
||||||
const duplex = d.full_duplex ? 'Full' : (up ? 'Half' : '–');
|
const txPct = fmtRateBar(d.tx_bytes_rate, d.speed_mbps);
|
||||||
const media = d.media || '';
|
const rxPct = fmtRateBar(d.rx_bytes_rate, d.speed_mbps);
|
||||||
|
const numBadge = d.port_idx != null ? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
|
||||||
const uplinkBadge = d.is_uplink
|
const uplinkBadge = d.is_uplink ? `<span class="port-badge port-badge-uplink">UPLINK</span>` : '';
|
||||||
? '<span class="port-badge port-badge-uplink">UPLINK</span>' : '';
|
const poeBadge = d.poe_power ? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : '';
|
||||||
const poeBadge = (d.poe_power != null && d.poe_power > 0)
|
const lldpLine = d.lldp ? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name || '')} (${escHtml(d.lldp.port_id || '')})</div>` : '';
|
||||||
? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : '';
|
const poeLine = d.poe_class ? `<div class="port-poe-info">PoE ${escHtml(d.poe_class)} · max ${d.poe_max_power != null ? d.poe_max_power.toFixed(1)+'W' : '–'}</div>` : '';
|
||||||
const numBadge = d.port_idx
|
|
||||||
? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
|
|
||||||
|
|
||||||
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>` : '';
|
|
||||||
|
|
||||||
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 rxRate = d.rx_bytes_rate;
|
|
||||||
const txPct = fmtRateBar(txRate, d.speed_mbps);
|
|
||||||
const rxPct = fmtRateBar(rxRate, d.speed_mbps);
|
|
||||||
const txStr = fmtRate(txRate);
|
|
||||||
const rxStr = fmtRate(rxRate);
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="link-iface-card${up ? '' : ' port-down'}">
|
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
|
||||||
<div class="link-iface-header">
|
<div class="link-iface-header">
|
||||||
<span class="link-iface-name">${escHtml(portName)}</span>
|
<span class="link-iface-name">${numBadge} ${escHtml(portName)}</span>
|
||||||
<span class="link-iface-speed ${up ? '' : 'val-crit'}">${speed}</span>
|
<span class="link-iface-speed">${speedStr}</span>
|
||||||
${numBadge}${uplinkBadge}${poeBadge}
|
${uplinkBadge}${poeBadge}
|
||||||
${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''}
|
|
||||||
${errorBadges(d)}
|
${errorBadges(d)}
|
||||||
</div>
|
</div>
|
||||||
${lldpHtml}${poeMaxHtml}
|
${lldpLine}${poeLine}
|
||||||
<div class="link-stats-grid">
|
<div class="link-stats-grid">
|
||||||
|
<div class="link-stat">
|
||||||
|
<span class="link-stat-label">Link</span>
|
||||||
|
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
|
||||||
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">Duplex</span>
|
<span class="link-stat-label">Duplex</span>
|
||||||
<span class="link-stat-value ${duplex==='Full'?'val-good':duplex==='Half'?'val-crit':'val-neutral'}">${duplex}</span>
|
<span class="link-stat-value">${d.full_duplex == null ? '–' : d.full_duplex ? 'Full' : 'Half'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">Auto-neg</span>
|
<span class="link-stat-label">Auto-neg</span>
|
||||||
<span class="link-stat-value val-neutral">${d.autoneg ? 'On' : 'Off'}</span>
|
<span class="link-stat-value">${d.autoneg == null ? '–' : d.autoneg ? 'On' : 'Off'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">TX Errors</span>
|
<span class="link-stat-label">TX Err/s</span>
|
||||||
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">RX Errors</span>
|
<span class="link-stat-label">RX Err/s</span>
|
||||||
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
<div class="link-stat">
|
||||||
<span class="link-stat-label">TX Drops</span>
|
<span class="link-stat-label">TX Drop/s</span>
|
||||||
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="link-stat">
|
|
||||||
<span class="link-stat-label">RX Drops</span>
|
|
||||||
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
${(up && (txRate != null || rxRate != null)) ? `
|
|
||||||
<div class="traffic-section">
|
<div class="traffic-section">
|
||||||
<div class="traffic-row">
|
<div class="traffic-row">
|
||||||
<span class="traffic-label">TX</span>
|
<span class="traffic-label">TX</span>
|
||||||
<div class="traffic-bar-track">
|
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div></div>
|
||||||
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div>
|
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
|
||||||
</div>
|
|
||||||
<span class="traffic-value">${txStr}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="traffic-row">
|
<div class="traffic-row">
|
||||||
<span class="traffic-label">RX</span>
|
<span class="traffic-label">RX</span>
|
||||||
<div class="traffic-bar-track">
|
<div class="traffic-bar-track"><div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div></div>
|
||||||
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div>
|
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="traffic-value">${rxStr}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>` : ''}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render UniFi switches section ─────────────────────────────────
|
// ── Render all UniFi switches ─────────────────────────────────────
|
||||||
function renderUnifiSwitches(unifiSwitches) {
|
function renderUnifiSwitches(unifiSwitches, dataUpdated) {
|
||||||
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
|
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
|
||||||
|
const updStr = dataUpdated
|
||||||
const panels = Object.entries(unifiSwitches).map(([swName, sw]) => {
|
? new Date(dataUpdated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString()
|
||||||
|
: '';
|
||||||
|
const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
|
||||||
const ports = sw.ports || {};
|
const ports = sw.ports || {};
|
||||||
const allPorts= Object.entries(ports)
|
const portValues = Object.values(ports);
|
||||||
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0));
|
const portCards = Object.entries(ports)
|
||||||
const upCount = allPorts.filter(([,d]) => d.up).length;
|
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0))
|
||||||
const downCount = allPorts.length - upCount;
|
.map(([pname, d]) => renderPortCard(pname, d)).join('');
|
||||||
|
const poe_total_w = portValues.reduce((s, p) => s + (p.poe_power || 0), 0);
|
||||||
|
const poe_max_w = portValues.reduce((s, p) => s + (p.poe_max_power || 0), 0);
|
||||||
|
const poeLoad = poe_total_w > 0 ? ` · PoE ${poe_total_w.toFixed(1)}W` : '';
|
||||||
|
|
||||||
const portCards = allPorts
|
// PoE utilisation bar
|
||||||
.map(([pname, d]) => renderPortCard(pname, d))
|
let poebar = '';
|
||||||
.join('');
|
if (poe_total_w > 0 && poe_max_w > 0) {
|
||||||
|
const pct = Math.min(100, (poe_total_w / poe_max_w) * 100);
|
||||||
const meta = [
|
const cls = pct > 80 ? 'poe-bar-crit' : pct > 60 ? 'poe-bar-warn' : 'poe-bar-ok';
|
||||||
sw.model,
|
poebar = `<div class="poe-bar-track"><div class="poe-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
|
||||||
`${upCount} up`,
|
}
|
||||||
downCount ? `${downCount} down` : '',
|
|
||||||
].filter(Boolean).join(' · ');
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="link-host-panel" id="unifi-${escHtml(swName)}">
|
<div class="link-host-panel" id="panel-${CSS.escape(swName)}">
|
||||||
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
|
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
|
||||||
<span class="link-host-name">${escHtml(swName)}</span>
|
<span class="link-host-name">${escHtml(swName)}</span>
|
||||||
${sw.ip ? `<span class="link-host-ip">${escHtml(sw.ip)}</span>` : ''}
|
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
|
||||||
<span class="link-host-upd">${escHtml(meta)}</span>
|
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
|
||||||
<span class="panel-toggle" title="Collapse / expand">[–]</span>
|
${poebar}
|
||||||
</div>
|
<span class="panel-toggle">[–]</span>
|
||||||
<div class="link-ifaces-grid">
|
|
||||||
${portCards || '<div class="link-no-data">No port data available.</div>'}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="link-ifaces-grid">${portCards}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`;
|
||||||
<div class="unifi-section-header">UniFi Switches</div>
|
|
||||||
<div class="link-host-list">${panels}</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Collapse / expand panels ───────────────────────────────────────
|
// ── Panel collapse / expand ───────────────────────────────────────
|
||||||
function togglePanel(panel) {
|
function togglePanel(panel) {
|
||||||
panel.classList.toggle('collapsed');
|
panel.classList.toggle('collapsed');
|
||||||
const btn = panel.querySelector('.panel-toggle');
|
const btn = panel.querySelector('.panel-toggle');
|
||||||
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(localStorage.getItem('gandalfCollapsed') || '{}');
|
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
||||||
saved[id] = panel.classList.contains('collapsed');
|
collapsed[id] = panel.classList.contains('collapsed');
|
||||||
localStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
|
sessionStorage.setItem('linksCollapsed', JSON.stringify(collapsed));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreCollapseState() {
|
function restoreCollapseState() {
|
||||||
const saved = JSON.parse(localStorage.getItem('gandalfCollapsed') || '{}');
|
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
|
||||||
for (const [id, collapsed] of Object.entries(saved)) {
|
for (const [id, isCollapsed] of Object.entries(collapsed)) {
|
||||||
if (!collapsed) continue;
|
|
||||||
const panel = document.getElementById(id);
|
const panel = document.getElementById(id);
|
||||||
if (panel) {
|
if (!panel) continue;
|
||||||
|
if (isCollapsed) {
|
||||||
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 = '[+]';
|
||||||
@@ -434,124 +367,149 @@ function restoreCollapseState() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Build summary stats header ────────────────────────────────────
|
||||||
|
function buildLinkSummary(hosts, unifiSwitches) {
|
||||||
|
let totalIfaces = 0, downIfaces = 0, errIfaces = 0, totalPoe = 0;
|
||||||
|
for (const ifaces of Object.values(hosts || {})) {
|
||||||
|
for (const d of Object.values(ifaces)) {
|
||||||
|
totalIfaces++;
|
||||||
|
if (d.link_detected === false) downIfaces++;
|
||||||
|
if ((d.tx_errs_rate || 0) > 0 || (d.rx_errs_rate || 0) > 0) errIfaces++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const sw of Object.values(unifiSwitches || {})) {
|
||||||
|
for (const p of Object.values(sw.ports || {})) {
|
||||||
|
totalPoe += p.poe_power || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hasAlerts = downIfaces > 0 || errIfaces > 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">Total Interfaces</span>
|
||||||
|
<span class="lss-value">${totalIfaces}</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-summary-stat ${downIfaces ? 'lss-alert' : ''}">
|
||||||
|
<span class="lss-label">Interfaces Down</span>
|
||||||
|
<span class="lss-value ${downIfaces ? 'val-crit' : 'val-good'}">${downIfaces}</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-summary-stat ${errIfaces ? 'lss-alert' : ''}">
|
||||||
|
<span class="lss-label">With Errors</span>
|
||||||
|
<span class="lss-value ${errIfaces ? 'val-warn' : 'val-good'}">${errIfaces}</span>
|
||||||
|
</div>
|
||||||
|
${totalPoe > 0 ? `
|
||||||
|
<div class="link-summary-stat">
|
||||||
|
<span class="lss-label">PoE Load</span>
|
||||||
|
<span class="lss-value">${totalPoe.toFixed(1)} <span class="lss-sub">W</span></span>
|
||||||
|
</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main render ───────────────────────────────────────────────────
|
||||||
|
function renderLinks(data) {
|
||||||
|
const hosts = data.hosts || {};
|
||||||
|
const unifiSwitches = data.unifi_switches || {};
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
parts.push(buildLinkSummary(hosts, unifiSwitches));
|
||||||
|
parts.push(`<div class="link-collapse-bar">
|
||||||
|
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="collapseAll()">Collapse All</button>
|
||||||
|
<button class="lt-btn lt-btn-ghost lt-btn-sm" onclick="expandAll()">Expand All</button>
|
||||||
|
</div>`);
|
||||||
|
parts.push('<div class="link-host-list">');
|
||||||
|
|
||||||
|
for (const [hostname, ifaces] of Object.entries(hosts)) {
|
||||||
|
const ifaceCards = Object.entries(ifaces)
|
||||||
|
.sort(([a],[b]) => a.localeCompare(b))
|
||||||
|
.map(([iname, d]) => renderIfaceCard(iname, d)).join('');
|
||||||
|
const sample = Object.values(ifaces)[0] || {};
|
||||||
|
const ip = sample.host_ip || '';
|
||||||
|
const updStr = sample.updated
|
||||||
|
? new Date(sample.updated + (sample.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
parts.push(`
|
||||||
|
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
|
||||||
|
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
|
||||||
|
<span class="link-host-name">${escHtml(hostname)}</span>
|
||||||
|
<span class="link-host-ip">${escHtml(ip)}</span>
|
||||||
|
<span class="link-host-upd">${updStr}</span>
|
||||||
|
<span class="panel-toggle">[–]</span>
|
||||||
|
</div>
|
||||||
|
<div class="link-ifaces-grid">${ifaceCards}</div>
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(renderUnifiSwitches(unifiSwitches, data.updated));
|
||||||
|
parts.push('</div>');
|
||||||
|
document.getElementById('links-container').innerHTML = parts.join('');
|
||||||
|
restoreCollapseState();
|
||||||
|
}
|
||||||
|
|
||||||
function collapseAll() {
|
function collapseAll() {
|
||||||
document.querySelectorAll('.link-host-panel').forEach(panel => {
|
document.querySelectorAll('.link-host-panel').forEach(p => {
|
||||||
panel.classList.add('collapsed');
|
p.classList.add('collapsed');
|
||||||
const btn = panel.querySelector('.panel-toggle');
|
const btn = p.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('linksCollapsed', JSON.stringify(
|
||||||
|
Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true]))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandAll() {
|
function expandAll() {
|
||||||
document.querySelectorAll('.link-host-panel').forEach(panel => {
|
document.querySelectorAll('.link-host-panel').forEach(p => {
|
||||||
panel.classList.remove('collapsed');
|
p.classList.remove('collapsed');
|
||||||
const btn = panel.querySelector('.panel-toggle');
|
const btn = p.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('linksCollapsed', '{}');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render all hosts ──────────────────────────────────────────────
|
// ── Stale data warning ────────────────────────────────────────────
|
||||||
function renderLinks(data) {
|
|
||||||
const hosts = data.hosts || {};
|
|
||||||
const unifi = data.unifi_switches || {};
|
|
||||||
|
|
||||||
if (!Object.keys(hosts).length && !Object.keys(unifi).length) {
|
|
||||||
document.getElementById('links-container').innerHTML =
|
|
||||||
'<p class="empty-state">No link data collected yet. Monitor may still be initialising.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const upd = data.updated ? `Updated: ${data.updated}` : '';
|
|
||||||
const updEl = document.getElementById('links-updated');
|
|
||||||
if (updEl) updEl.textContent = upd;
|
|
||||||
|
|
||||||
const serverHtml = Object.entries(hosts).map(([hostName, ifaces]) => {
|
|
||||||
const ifaceCards = Object.entries(ifaces)
|
|
||||||
.sort(([a],[b]) => a.localeCompare(b))
|
|
||||||
.map(([ifaceName, d]) => renderIfaceCard(ifaceName, d))
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
const hostIp = ifaces[Object.keys(ifaces)[0]]?.host_ip || '';
|
|
||||||
return `
|
|
||||||
<div class="link-host-panel" id="${escHtml(hostName)}">
|
|
||||||
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
|
|
||||||
<span class="link-host-name">${escHtml(hostName)}</span>
|
|
||||||
${hostIp ? `<span class="link-host-ip">${escHtml(hostIp)}</span>` : ''}
|
|
||||||
<span class="panel-toggle" title="Collapse / expand">[–]</span>
|
|
||||||
</div>
|
|
||||||
<div class="link-ifaces-grid">
|
|
||||||
${ifaceCards || '<div class="link-no-data">No interface data available.</div>'}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
document.getElementById('links-container').innerHTML =
|
|
||||||
`<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="expandAll()">Expand all</button>
|
|
||||||
</div>` +
|
|
||||||
`<div class="link-host-list">${serverHtml}</div>` +
|
|
||||||
renderUnifiSwitches(unifi);
|
|
||||||
|
|
||||||
restoreCollapseState();
|
|
||||||
|
|
||||||
// Jump to anchor if URL has #hostname
|
|
||||||
if (location.hash) {
|
|
||||||
const el = document.querySelector(location.hash);
|
|
||||||
if (el) {
|
|
||||||
if (el.classList.contains('collapsed')) togglePanel(el);
|
|
||||||
el.scrollIntoView({behavior:'smooth', block:'start'});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stale data check ─────────────────────────────────────────────
|
|
||||||
function checkLinksStale(updatedStr) {
|
function checkLinksStale(updatedStr) {
|
||||||
let banner = document.getElementById('links-stale-banner');
|
|
||||||
if (!updatedStr) return;
|
if (!updatedStr) return;
|
||||||
const ageMs = Date.now() - new Date(updatedStr.replace(' UTC', 'Z').replace(' ', 'T'));
|
const age = (Date.now() - new Date(updatedStr + (updatedStr.includes('Z') ? '' : 'Z'))) / 1000;
|
||||||
if (ageMs > 120000) { // >2 minutes
|
let banner = document.getElementById('links-stale-banner');
|
||||||
|
if (age > 120) {
|
||||||
if (!banner) {
|
if (!banner) {
|
||||||
banner = document.createElement('div');
|
banner = document.createElement('div');
|
||||||
banner.id = 'links-stale-banner';
|
banner.id = 'links-stale-banner';
|
||||||
banner.className = 'stale-banner';
|
banner.className = 'stale-banner';
|
||||||
document.getElementById('links-container').insertAdjacentElement('beforebegin', banner);
|
document.getElementById('links-container').prepend(banner);
|
||||||
}
|
}
|
||||||
const mins = Math.floor(ageMs / 60000);
|
banner.textContent = `⚠ Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
|
||||||
banner.textContent = `⚠ Link data is stale — last update was ${mins} minute${mins !== 1 ? 's' : ''} ago.`;
|
|
||||||
banner.style.display = '';
|
banner.style.display = '';
|
||||||
} else if (banner) {
|
} else if (banner) {
|
||||||
banner.style.display = 'none';
|
banner.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fetch and render ──────────────────────────────────────────────
|
// ── Fetch + render ────────────────────────────────────────────────
|
||||||
async function loadLinks() {
|
async function loadLinks() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/links');
|
const data = await lt.api.get('/api/links');
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!data.hosts && !data.unifi_switches) {
|
||||||
const data = await resp.json();
|
document.getElementById('links-container').innerHTML =
|
||||||
|
'<div class="link-no-data">No link data yet — monitor has not completed a full cycle.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updEl = document.getElementById('links-updated');
|
||||||
|
if (updEl && data.updated) {
|
||||||
|
updEl.textContent = 'Updated: ' + new Date(data.updated + (data.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString();
|
||||||
|
}
|
||||||
renderLinks(data);
|
renderLinks(data);
|
||||||
checkLinksStale(data.updated);
|
checkLinksStale(data.updated);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById('links-container').innerHTML =
|
document.getElementById('links-container').innerHTML =
|
||||||
`<div class="error-state"><p>Failed to load link data: ${escHtml(e.message)}</p></div>`;
|
'<div class="error-state">Network error loading link statistics.</div>';
|
||||||
|
lt.toast.error('Failed to load link statistics');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadLinks();
|
loadLinks();
|
||||||
setInterval(loadLinks, 60000);
|
lt.autoRefresh.start(loadLinks, 60000);
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+141
-65
@@ -3,79 +3,89 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="g-page-header">
|
||||||
<h1 class="page-title">Alert Suppressions</h1>
|
<h1 class="g-page-title">Alert Suppressions</h1>
|
||||||
<p class="page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
|
<p class="g-page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Create suppression ─────────────────────────────────────────── -->
|
<!-- ── Create suppression ─────────────────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="g-section">
|
||||||
<div class="section-header">
|
<div class="g-section-header">
|
||||||
<h2 class="section-title">Create Suppression</h2>
|
<h2 class="g-section-title">Create Suppression</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-card">
|
<div class="lt-card">
|
||||||
|
<div class="lt-card-body">
|
||||||
<form id="create-suppression-form" onsubmit="createSuppression(event)">
|
<form id="create-suppression-form" onsubmit="createSuppression(event)">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="lt-form-group">
|
||||||
<label for="s-type">Target Type <span class="required">*</span></label>
|
<label class="lt-label" for="s-type">Target Type <span class="required">*</span></label>
|
||||||
<select id="s-type" name="target_type" onchange="onTypeChange()">
|
<select class="lt-select" id="s-type" name="target_type" onchange="onTypeChange()">
|
||||||
<option value="host">Host (all interfaces)</option>
|
<option value="host">Host (all interfaces)</option>
|
||||||
<option value="interface">Specific Interface</option>
|
<option value="interface">Specific Interface</option>
|
||||||
<option value="unifi_device">UniFi Device</option>
|
<option value="unifi_device">UniFi Device</option>
|
||||||
<option value="all">Global (suppress everything)</option>
|
<option value="all">Global (suppress everything)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="name-group">
|
<div class="lt-form-group" id="name-group">
|
||||||
<label for="s-name">Target Name <span class="required">*</span></label>
|
<label class="lt-label" for="s-name">Target Name <span class="required">*</span></label>
|
||||||
<input type="text" id="s-name" name="target_name"
|
<input type="text" class="lt-input" id="s-name" name="target_name"
|
||||||
placeholder="hostname or device name" autocomplete="off">
|
placeholder="hostname or device name" autocomplete="off"
|
||||||
|
list="target-name-list">
|
||||||
|
<datalist id="target-name-list">
|
||||||
|
{% for name in snapshot.hosts.keys() | sort %}
|
||||||
|
<option value="{{ name }}">
|
||||||
|
{% endfor %}
|
||||||
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="detail-group" style="display:none">
|
<div class="lt-form-group" id="detail-group" style="display:none">
|
||||||
<label for="s-detail">Interface Name</label>
|
<label class="lt-label" for="s-detail">Interface Name</label>
|
||||||
<input type="text" id="s-detail" name="target_detail"
|
<input type="text" class="lt-input" id="s-detail" name="target_detail"
|
||||||
placeholder="e.g. enp35s0 or bond0" autocomplete="off">
|
placeholder="e.g. enp35s0 or bond0" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group form-group-wide">
|
<div class="lt-form-group form-group-wide">
|
||||||
<label for="s-reason">Reason <span class="required">*</span></label>
|
<label class="lt-label" for="s-reason">Reason <span class="required">*</span></label>
|
||||||
<input type="text" id="s-reason" name="reason"
|
<input type="text" class="lt-input" id="s-reason" name="reason"
|
||||||
placeholder="e.g. Planned switch maintenance, replacing SFP on large1/enp43s0"
|
placeholder="e.g. Planned switch maintenance, replacing SFP on large1/enp43s0"
|
||||||
required>
|
required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row form-row-align">
|
<div class="form-row form-row-align">
|
||||||
<div class="form-group">
|
<div class="lt-form-group">
|
||||||
<label>Duration</label>
|
<label class="lt-label">Duration</label>
|
||||||
<div class="duration-pills">
|
<div class="duration-pills">
|
||||||
<button type="button" class="pill" onclick="setDur(30, this)">30 min</button>
|
<button type="button" class="pill" data-duration="30">30 min</button>
|
||||||
<button type="button" class="pill" onclick="setDur(60, this)">1 hr</button>
|
<button type="button" class="pill" data-duration="60">1 hr</button>
|
||||||
<button type="button" class="pill" onclick="setDur(240, this)">4 hr</button>
|
<button type="button" class="pill" data-duration="240">4 hr</button>
|
||||||
<button type="button" class="pill" onclick="setDur(480, this)">8 hr</button>
|
<button type="button" class="pill" data-duration="480">8 hr</button>
|
||||||
<button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button>
|
<button type="button" class="pill pill-manual active" data-duration="">Manual ∞</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="s-expires" name="expires_minutes" value="">
|
<input type="hidden" id="s-expires" name="expires_minutes" value="">
|
||||||
<div class="form-hint" id="s-dur-hint">Persists until manually removed.</div>
|
<div class="lt-field-hint" id="s-dur-hint">Persists until manually removed.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group form-group-submit">
|
<div class="lt-form-group form-group-submit">
|
||||||
<button type="submit" class="btn btn-primary btn-lg">🔕 Apply Suppression</button>
|
<button type="submit" class="lt-btn lt-btn-primary lt-btn-lg">🔕 Apply Suppression</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Active suppressions ────────────────────────────────────────── -->
|
<!-- ── Active suppressions ────────────────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="g-section" id="active-sup-section">
|
||||||
<div class="section-header">
|
<div class="g-section-header">
|
||||||
<h2 class="section-title">Active Suppressions</h2>
|
<h2 class="g-section-title">Active Suppressions</h2>
|
||||||
<span class="section-badge">{{ active | length }}</span>
|
<span class="g-section-badge" id="active-sup-badge">{{ active | length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="active-sup-wrap">
|
||||||
{% if active %}
|
{% if active %}
|
||||||
<div class="table-wrap">
|
<div class="lt-table-wrap">
|
||||||
<table class="data-table" id="active-sup-table">
|
<table class="lt-table" id="active-sup-table">
|
||||||
|
<caption class="lt-sr-only">Active suppression rules</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
||||||
@@ -85,7 +95,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for s in active %}
|
{% for s in active %}
|
||||||
<tr id="sup-row-{{ s.id }}">
|
<tr id="sup-row-{{ s.id }}">
|
||||||
<td><span class="badge badge-info">{{ s.target_type }}</span></td>
|
<td><span class="lt-badge badge-info">{{ s.target_type }}</span></td>
|
||||||
<td>{{ s.target_name or 'all' }}</td>
|
<td>{{ s.target_name or 'all' }}</td>
|
||||||
<td>{{ s.target_detail or '–' }}</td>
|
<td>{{ s.target_detail or '–' }}</td>
|
||||||
<td>{{ s.reason }}</td>
|
<td>{{ s.reason }}</td>
|
||||||
@@ -93,7 +103,7 @@
|
|||||||
<td class="ts-cell">{{ s.created_at }}</td>
|
<td class="ts-cell">{{ s.created_at }}</td>
|
||||||
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
|
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn-sm btn-danger" onclick="removeSuppression({{ s.id }})">Remove</button>
|
<button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="{{ s.id }}">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -101,19 +111,21 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-state">No active suppressions.</p>
|
<p class="empty-state" id="no-active-msg">No active suppressions.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Suppression history ────────────────────────────────────────── -->
|
<!-- ── Suppression history ────────────────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="g-section">
|
||||||
<div class="section-header">
|
<div class="g-section-header">
|
||||||
<h2 class="section-title">History</h2>
|
<h2 class="g-section-title">History</h2>
|
||||||
<span class="section-badge">{{ history | length }}</span>
|
<span class="g-section-badge">{{ history | length }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% if history %}
|
{% if history %}
|
||||||
<div class="table-wrap">
|
<div class="lt-table-wrap">
|
||||||
<table class="data-table data-table-sm">
|
<table class="lt-table lt-table-sm">
|
||||||
|
<caption class="lt-sr-only">Suppression history</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
||||||
@@ -132,9 +144,9 @@
|
|||||||
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
|
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if s.active %}
|
{% if s.active %}
|
||||||
<span class="badge badge-ok">Yes</span>
|
<span class="lt-badge badge-ok">Yes</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-neutral">No</span>
|
<span class="lt-badge badge-neutral">No</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -148,9 +160,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Available targets reference ───────────────────────────────── -->
|
<!-- ── Available targets reference ───────────────────────────────── -->
|
||||||
<section class="section">
|
<section class="g-section">
|
||||||
<div class="section-header">
|
<div class="g-section-header">
|
||||||
<h2 class="section-title">Available Targets</h2>
|
<h2 class="g-section-title">Available Targets</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="targets-grid">
|
<div class="targets-grid">
|
||||||
{% for name, host in snapshot.hosts.items() %}
|
{% for name, host in snapshot.hosts.items() %}
|
||||||
@@ -193,9 +205,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderActiveRows(rows) {
|
||||||
|
const wrap = document.getElementById('active-sup-wrap');
|
||||||
|
const badge = document.getElementById('active-sup-badge');
|
||||||
|
if (!rows || !rows.length) {
|
||||||
|
wrap.innerHTML = '<p class="empty-state" id="no-active-msg">No active suppressions.</p>';
|
||||||
|
if (badge) badge.textContent = '0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (badge) badge.textContent = rows.length;
|
||||||
|
const tbody = rows.map(s => `
|
||||||
|
<tr id="sup-row-${s.id}">
|
||||||
|
<td><span class="lt-badge badge-info">${lt.escHtml(s.target_type)}</span></td>
|
||||||
|
<td>${lt.escHtml(s.target_name || 'all')}</td>
|
||||||
|
<td>${lt.escHtml(s.target_detail || '–')}</td>
|
||||||
|
<td>${lt.escHtml(s.reason)}</td>
|
||||||
|
<td>${lt.escHtml(s.suppressed_by)}</td>
|
||||||
|
<td class="ts-cell">${lt.escHtml(s.created_at || '')}</td>
|
||||||
|
<td class="ts-cell">${s.expires_at ? lt.escHtml(s.expires_at) : '<em>manual</em>'}</td>
|
||||||
|
<td><button class="lt-btn lt-btn-danger lt-btn-sm" data-action="remove-sup" data-sup-id="${s.id}">Remove</button></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="lt-table-wrap">
|
||||||
|
<table class="lt-table" id="active-sup-table">
|
||||||
|
<caption class="lt-sr-only">Active suppression rules</caption>
|
||||||
|
<thead><tr>
|
||||||
|
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
|
||||||
|
<th>By</th><th>Created</th><th>Expires</th><th>Actions</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${tbody}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshActive() {
|
||||||
|
try {
|
||||||
|
const rows = await lt.api.get('/api/suppressions');
|
||||||
|
renderActiveRows(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to refresh suppressions:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createSuppression(e) {
|
async function createSuppression(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.target;
|
const form = e.target;
|
||||||
|
const btn = form.querySelector('[type="submit"]');
|
||||||
|
btn.classList.add('is-loading');
|
||||||
const payload = {
|
const payload = {
|
||||||
target_type: form.target_type.value,
|
target_type: form.target_type.value,
|
||||||
target_name: form.target_name ? form.target_name.value : '',
|
target_name: form.target_name ? form.target_name.value : '',
|
||||||
@@ -203,30 +259,50 @@
|
|||||||
reason: form.reason.value,
|
reason: form.reason.value,
|
||||||
expires_minutes: form.expires_minutes.value ? parseInt(form.expires_minutes.value) : null,
|
expires_minutes: form.expires_minutes.value ? parseInt(form.expires_minutes.value) : null,
|
||||||
};
|
};
|
||||||
const resp = await fetch('/api/suppressions', {
|
try {
|
||||||
method: 'POST',
|
await lt.api.post('/api/suppressions', payload);
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
if (data.success) {
|
|
||||||
showToast('Suppression applied', 'success');
|
showToast('Suppression applied', 'success');
|
||||||
setTimeout(() => location.reload(), 800);
|
form.reset();
|
||||||
} else {
|
onTypeChange();
|
||||||
showToast(data.error || 'Error', 'error');
|
document.querySelectorAll('.duration-pills .pill').forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelector('.duration-pills .pill-manual')?.classList.add('active');
|
||||||
|
document.getElementById('s-dur-hint').textContent = 'Persists until manually removed.';
|
||||||
|
await refreshActive();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || 'Error', 'error');
|
||||||
|
} finally {
|
||||||
|
btn.classList.remove('is-loading');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeSuppression(id) {
|
async function removeSuppression(id) {
|
||||||
if (!confirm('Remove this suppression?')) return;
|
if (!confirm('Remove this suppression?')) return;
|
||||||
const resp = await fetch(`/api/suppressions/${id}`, {method:'DELETE'});
|
try {
|
||||||
const data = await resp.json();
|
await lt.api.delete(`/api/suppressions/${id}`);
|
||||||
if (data.success) {
|
|
||||||
document.getElementById(`sup-row-${id}`)?.remove();
|
document.getElementById(`sup-row-${id}`)?.remove();
|
||||||
|
const badge = document.getElementById('active-sup-badge');
|
||||||
|
if (badge) badge.textContent = Math.max(0, parseInt(badge.textContent || '0') - 1);
|
||||||
|
const tbody = document.querySelector('#active-sup-table tbody');
|
||||||
|
if (tbody && !tbody.children.length) {
|
||||||
|
document.getElementById('active-sup-wrap').innerHTML =
|
||||||
|
'<p class="empty-state" id="no-active-msg">No active suppressions.</p>';
|
||||||
|
if (badge) badge.textContent = '0';
|
||||||
|
}
|
||||||
showToast('Suppression removed', 'success');
|
showToast('Suppression removed', 'success');
|
||||||
} else {
|
} catch (err) {
|
||||||
showToast(data.error || 'Failed to remove suppression', 'error');
|
showToast(err.message || 'Failed to remove suppression', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const pill = e.target.closest('#create-suppression-form .pill[data-duration]');
|
||||||
|
if (pill) {
|
||||||
|
const val = pill.dataset.duration;
|
||||||
|
setDur(val ? parseInt(val) : null, pill);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const removeBtn = e.target.closest('[data-action="remove-sup"]');
|
||||||
|
if (removeBtn) removeSuppression(parseInt(removeBtn.dataset.supId));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
"""Tests for gandalf.diagnose — all pure static methods, no external deps."""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from diagnose import DiagnosticsRunner # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# ── build_ssh_command ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestBuildSshCommand:
|
||||||
|
def test_contains_stricthostkeychecking_no(self):
|
||||||
|
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
||||||
|
assert 'StrictHostKeyChecking=no' in cmd
|
||||||
|
|
||||||
|
def test_contains_host_ip(self):
|
||||||
|
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
||||||
|
assert 'root@10.0.0.1' in cmd
|
||||||
|
|
||||||
|
def test_contains_interface_name(self):
|
||||||
|
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
||||||
|
assert 'eth0' in cmd
|
||||||
|
|
||||||
|
def test_shell_quotes_special_chars_in_iface(self):
|
||||||
|
# shlex.quote wraps the whole iface string in single quotes so
|
||||||
|
# shell metacharacters are not interpreted as commands
|
||||||
|
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', "eth0; rm -rf /")
|
||||||
|
# The iface must appear inside single quotes
|
||||||
|
assert "'eth0; rm -rf /'" in cmd
|
||||||
|
|
||||||
|
def test_contains_sysfs_stats_section(self):
|
||||||
|
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
||||||
|
assert 'sysfs_stats' in cmd
|
||||||
|
|
||||||
|
def test_contains_ethtool_section(self):
|
||||||
|
cmd = DiagnosticsRunner.build_ssh_command('10.0.0.1', 'eth0')
|
||||||
|
assert 'ethtool' in cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_output ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SAMPLE_OUTPUT = """\
|
||||||
|
=== carrier ===
|
||||||
|
1
|
||||||
|
=== operstate ===
|
||||||
|
up
|
||||||
|
=== sysfs_stats ===
|
||||||
|
rx_bytes:12345
|
||||||
|
tx_bytes:67890
|
||||||
|
rx_errors:0
|
||||||
|
tx_errors:0
|
||||||
|
rx_dropped:0
|
||||||
|
tx_dropped:0
|
||||||
|
rx_crc_errors:0
|
||||||
|
rx_frame_errors:0
|
||||||
|
rx_fifo_errors:0
|
||||||
|
tx_carrier_errors:0
|
||||||
|
collisions:0
|
||||||
|
rx_missed_errors:0
|
||||||
|
=== carrier_changes ===
|
||||||
|
3
|
||||||
|
=== ethtool ===
|
||||||
|
Speed: 1000Mb/s
|
||||||
|
Duplex: Full
|
||||||
|
Link detected: yes
|
||||||
|
Auto-negotiation: on
|
||||||
|
=== ethtool_driver ===
|
||||||
|
driver: igb
|
||||||
|
version: 5.6.0-k
|
||||||
|
firmware-version: 1.67, 0x80000d38
|
||||||
|
bus-info: 0000:03:00.0
|
||||||
|
=== ethtool_pause ===
|
||||||
|
RX: on
|
||||||
|
TX: off
|
||||||
|
=== ethtool_ring ===
|
||||||
|
Pre-set maximums:
|
||||||
|
RX: 4096
|
||||||
|
TX: 4096
|
||||||
|
Current hardware settings:
|
||||||
|
RX: 256
|
||||||
|
TX: 256
|
||||||
|
=== ethtool_stats ===
|
||||||
|
rx_packets: 1000
|
||||||
|
tx_packets: 2000
|
||||||
|
=== ethtool_dom ===
|
||||||
|
=== ip_link ===
|
||||||
|
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
|
||||||
|
=== ip_addr ===
|
||||||
|
inet 10.0.0.1/24
|
||||||
|
=== ip_route ===
|
||||||
|
default via 10.0.0.254
|
||||||
|
=== dmesg ===
|
||||||
|
eth0: renamed from ens3
|
||||||
|
=== lldpctl ===
|
||||||
|
lldpd not running
|
||||||
|
=== end ===
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOutput:
|
||||||
|
def setup_method(self):
|
||||||
|
self.parsed = DiagnosticsRunner.parse_output(SAMPLE_OUTPUT)
|
||||||
|
|
||||||
|
def test_carrier_parsed(self):
|
||||||
|
assert self.parsed['carrier'] == '1'
|
||||||
|
|
||||||
|
def test_operstate_parsed(self):
|
||||||
|
assert self.parsed['operstate'] == 'up'
|
||||||
|
|
||||||
|
def test_carrier_changes_is_int(self):
|
||||||
|
assert self.parsed['carrier_changes'] == 3
|
||||||
|
|
||||||
|
def test_sysfs_stats_rx_bytes(self):
|
||||||
|
assert self.parsed['sysfs_stats']['rx_bytes'] == 12345
|
||||||
|
|
||||||
|
def test_sysfs_stats_tx_bytes(self):
|
||||||
|
assert self.parsed['sysfs_stats']['tx_bytes'] == 67890
|
||||||
|
|
||||||
|
def test_ethtool_speed(self):
|
||||||
|
assert self.parsed['ethtool']['speed_mbps'] == 1000
|
||||||
|
|
||||||
|
def test_ethtool_link_detected(self):
|
||||||
|
assert self.parsed['ethtool']['link_detected'] is True
|
||||||
|
|
||||||
|
def test_ethtool_duplex(self):
|
||||||
|
assert self.parsed['ethtool']['duplex'] == 'full'
|
||||||
|
|
||||||
|
def test_ethtool_auto_neg(self):
|
||||||
|
assert self.parsed['ethtool']['auto_neg'] is True
|
||||||
|
|
||||||
|
def test_ethtool_driver(self):
|
||||||
|
assert self.parsed['ethtool_driver']['driver'] == 'igb'
|
||||||
|
|
||||||
|
def test_ethtool_pause_rx_on(self):
|
||||||
|
assert self.parsed['ethtool_pause']['rx_pause'] is True
|
||||||
|
|
||||||
|
def test_ethtool_pause_tx_off(self):
|
||||||
|
assert self.parsed['ethtool_pause']['tx_pause'] is False
|
||||||
|
|
||||||
|
def test_ethtool_ring_current(self):
|
||||||
|
assert self.parsed['ethtool_ring']['rx_current'] == 256
|
||||||
|
assert self.parsed['ethtool_ring']['tx_current'] == 256
|
||||||
|
|
||||||
|
def test_ethtool_ring_max(self):
|
||||||
|
assert self.parsed['ethtool_ring']['rx_max'] == 4096
|
||||||
|
|
||||||
|
def test_ip_addr_present(self):
|
||||||
|
assert '10.0.0.1/24' in self.parsed['ip_addr']
|
||||||
|
|
||||||
|
def test_ip_route_present(self):
|
||||||
|
assert 'default via 10.0.0.254' in self.parsed['ip_route']
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseOutputEdgeCases:
|
||||||
|
def test_empty_string_returns_dict(self):
|
||||||
|
result = DiagnosticsRunner.parse_output('')
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert result['carrier'] == '?'
|
||||||
|
assert result['operstate'] == '?'
|
||||||
|
assert result['carrier_changes'] == 0
|
||||||
|
|
||||||
|
def test_no_end_sentinel_still_parses(self):
|
||||||
|
output = "=== carrier ===\n1\n=== operstate ===\nup\n"
|
||||||
|
result = DiagnosticsRunner.parse_output(output)
|
||||||
|
assert result['carrier'] == '1'
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_sysfs_stats ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseSysfsStats:
|
||||||
|
def test_parses_known_keys(self):
|
||||||
|
text = "rx_bytes:100\ntx_bytes:200\nrx_errors:0\n"
|
||||||
|
result = DiagnosticsRunner.parse_sysfs_stats(text)
|
||||||
|
assert result['rx_bytes'] == 100
|
||||||
|
assert result['tx_bytes'] == 200
|
||||||
|
assert result['rx_errors'] == 0
|
||||||
|
|
||||||
|
def test_ignores_unknown_keys(self):
|
||||||
|
text = "unknown_counter:999\nrx_bytes:50\n"
|
||||||
|
result = DiagnosticsRunner.parse_sysfs_stats(text)
|
||||||
|
assert 'unknown_counter' not in result
|
||||||
|
assert result['rx_bytes'] == 50
|
||||||
|
|
||||||
|
def test_non_numeric_value_defaults_to_zero(self):
|
||||||
|
text = "rx_bytes:not_a_number\n"
|
||||||
|
result = DiagnosticsRunner.parse_sysfs_stats(text)
|
||||||
|
assert result['rx_bytes'] == 0
|
||||||
|
|
||||||
|
def test_empty_text_returns_empty_dict(self):
|
||||||
|
assert DiagnosticsRunner.parse_sysfs_stats('') == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_ethtool ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseEthtool:
|
||||||
|
def test_speed_parsed(self):
|
||||||
|
text = "Speed: 10000Mb/s\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool(text)
|
||||||
|
assert result['speed_mbps'] == 10000
|
||||||
|
|
||||||
|
def test_unknown_speed(self):
|
||||||
|
text = "Speed: Unknown! (0)\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool(text)
|
||||||
|
assert result['speed_mbps'] is None
|
||||||
|
|
||||||
|
def test_link_detected_no(self):
|
||||||
|
text = "Link detected: no\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool(text)
|
||||||
|
assert result['link_detected'] is False
|
||||||
|
|
||||||
|
def test_auto_neg_off(self):
|
||||||
|
text = "Auto-negotiation: off\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool(text)
|
||||||
|
assert result['auto_neg'] is False
|
||||||
|
|
||||||
|
def test_empty_returns_empty(self):
|
||||||
|
assert DiagnosticsRunner.parse_ethtool('') == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_ethtool_driver ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseEthtoolDriver:
|
||||||
|
def test_driver_name(self):
|
||||||
|
text = "driver: igb\nversion: 5.6.0-k\nfirmware-version: 1.67\nbus-info: 0000:03:00.0\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool_driver(text)
|
||||||
|
assert result['driver'] == 'igb'
|
||||||
|
|
||||||
|
def test_version(self):
|
||||||
|
text = "driver: igb\nversion: 5.6.0-k\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool_driver(text)
|
||||||
|
assert result['version'] == '5.6.0-k'
|
||||||
|
|
||||||
|
def test_firmware_version(self):
|
||||||
|
text = "firmware-version: 1.67, 0x80000d38\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool_driver(text)
|
||||||
|
assert result['firmware_version'] == '1.67, 0x80000d38'
|
||||||
|
|
||||||
|
def test_bus_info(self):
|
||||||
|
text = "bus-info: 0000:03:00.0\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool_driver(text)
|
||||||
|
assert result['bus_info'] == '0000:03:00.0'
|
||||||
|
|
||||||
|
def test_empty_returns_empty(self):
|
||||||
|
assert DiagnosticsRunner.parse_ethtool_driver('') == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_nic_stats ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseNicStats:
|
||||||
|
def test_parses_integer_values(self):
|
||||||
|
text = "rx_packets: 1234\ntx_packets: 5678\n"
|
||||||
|
result = DiagnosticsRunner.parse_nic_stats(text)
|
||||||
|
assert result['rx_packets'] == 1234
|
||||||
|
assert result['tx_packets'] == 5678
|
||||||
|
|
||||||
|
def test_non_numeric_skipped(self):
|
||||||
|
text = "rx_packets: 100\nbad_stat: N/A\n"
|
||||||
|
result = DiagnosticsRunner.parse_nic_stats(text)
|
||||||
|
assert 'bad_stat' not in result
|
||||||
|
assert result['rx_packets'] == 100
|
||||||
|
|
||||||
|
def test_empty_returns_empty(self):
|
||||||
|
assert DiagnosticsRunner.parse_nic_stats('') == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_ethtool_dom ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseEthtoolDom:
|
||||||
|
def test_unsupported_returns_empty(self):
|
||||||
|
assert DiagnosticsRunner.parse_ethtool_dom('Cannot get module EEPROM information') == {}
|
||||||
|
|
||||||
|
def test_empty_returns_empty(self):
|
||||||
|
assert DiagnosticsRunner.parse_ethtool_dom('') == {}
|
||||||
|
|
||||||
|
def test_vendor_name(self):
|
||||||
|
text = "Vendor name : CISCO-FINISAR\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool_dom(text)
|
||||||
|
assert result['vendor'] == 'CISCO-FINISAR'
|
||||||
|
|
||||||
|
def test_wavelength(self):
|
||||||
|
text = "Laser wavelength : 1310 nm\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool_dom(text)
|
||||||
|
assert result['wavelength_nm'] == 1310
|
||||||
|
|
||||||
|
def test_tx_power_dbm(self):
|
||||||
|
text = "Laser output power : 0.3000 mW / -5.23 dBm\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool_dom(text)
|
||||||
|
assert abs(result['tx_power_dbm'] - (-5.23)) < 0.01
|
||||||
|
|
||||||
|
def test_rx_power_dbm(self):
|
||||||
|
text = "Receiver signal average optical power : 0.1000 mW / -10.00 dBm\n"
|
||||||
|
result = DiagnosticsRunner.parse_ethtool_dom(text)
|
||||||
|
assert abs(result['rx_power_dbm'] - (-10.00)) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_ip_link ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseIpLink:
|
||||||
|
IP_LINK_SAMPLE = """\
|
||||||
|
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP mode DEFAULT group default qlen 1000
|
||||||
|
link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff
|
||||||
|
RX: bytes packets errors dropped missed mcast
|
||||||
|
123456789 1000000 0 5 0 0
|
||||||
|
TX: bytes packets errors dropped carrier collsns
|
||||||
|
98765432 900000 0 0 0 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_mtu_parsed(self):
|
||||||
|
result = DiagnosticsRunner.parse_ip_link(self.IP_LINK_SAMPLE)
|
||||||
|
assert result['mtu'] == 1500
|
||||||
|
|
||||||
|
def test_state_parsed(self):
|
||||||
|
result = DiagnosticsRunner.parse_ip_link(self.IP_LINK_SAMPLE)
|
||||||
|
assert result['state'] == 'up'
|
||||||
|
|
||||||
|
def test_rx_bytes(self):
|
||||||
|
result = DiagnosticsRunner.parse_ip_link(self.IP_LINK_SAMPLE)
|
||||||
|
assert result['ip_rx_bytes'] == 123456789
|
||||||
|
|
||||||
|
def test_tx_bytes(self):
|
||||||
|
result = DiagnosticsRunner.parse_ip_link(self.IP_LINK_SAMPLE)
|
||||||
|
assert result['ip_tx_bytes'] == 98765432
|
||||||
|
|
||||||
|
def test_rx_dropped(self):
|
||||||
|
result = DiagnosticsRunner.parse_ip_link(self.IP_LINK_SAMPLE)
|
||||||
|
assert result['ip_rx_dropped'] == 5
|
||||||
|
|
||||||
|
def test_empty_returns_empty(self):
|
||||||
|
assert DiagnosticsRunner.parse_ip_link('') == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_dmesg ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseDmesg:
|
||||||
|
def test_error_severity(self):
|
||||||
|
text = "[ 1.234567] eth0: Link failure detected\n"
|
||||||
|
events = DiagnosticsRunner.parse_dmesg(text)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0]['severity'] == 'error'
|
||||||
|
|
||||||
|
def test_warn_severity(self):
|
||||||
|
text = "[ 2.000000] eth0: dropped packet\n"
|
||||||
|
events = DiagnosticsRunner.parse_dmesg(text)
|
||||||
|
assert events[0]['severity'] == 'warn'
|
||||||
|
|
||||||
|
def test_info_severity(self):
|
||||||
|
text = "[ 3.000000] eth0: NIC Link is Up\n"
|
||||||
|
events = DiagnosticsRunner.parse_dmesg(text)
|
||||||
|
assert events[0]['severity'] == 'info'
|
||||||
|
|
||||||
|
def test_timestamp_extracted(self):
|
||||||
|
text = "[ 5.678900] eth0: reset\n"
|
||||||
|
events = DiagnosticsRunner.parse_dmesg(text)
|
||||||
|
assert events[0]['timestamp'] == '5.678900'
|
||||||
|
|
||||||
|
def test_no_timestamp(self):
|
||||||
|
text = "eth0: some timeout event\n"
|
||||||
|
events = DiagnosticsRunner.parse_dmesg(text)
|
||||||
|
assert events[0]['timestamp'] == ''
|
||||||
|
assert events[0]['severity'] == 'error'
|
||||||
|
|
||||||
|
def test_empty_returns_empty_list(self):
|
||||||
|
assert DiagnosticsRunner.parse_dmesg('') == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── parse_lldpctl ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestParseLldpctl:
|
||||||
|
def test_unavailable_when_not_running(self):
|
||||||
|
result = DiagnosticsRunner.parse_lldpctl('lldpd not running')
|
||||||
|
assert result == {'available': False}
|
||||||
|
|
||||||
|
def test_unavailable_on_empty(self):
|
||||||
|
result = DiagnosticsRunner.parse_lldpctl('')
|
||||||
|
assert result == {'available': False}
|
||||||
|
|
||||||
|
def test_neighbor_system_parsed(self):
|
||||||
|
text = " SysName: core-sw-01\n PortID: Gi1/0/5\n ChassisID: aa:bb:cc:dd:ee:ff\n"
|
||||||
|
result = DiagnosticsRunner.parse_lldpctl(text)
|
||||||
|
assert result['available'] is True
|
||||||
|
assert result['neighbor_system'] == 'core-sw-01'
|
||||||
|
|
||||||
|
def test_neighbor_port_parsed(self):
|
||||||
|
text = " SysName: core-sw-01\n PortID: Gi1/0/5\n"
|
||||||
|
result = DiagnosticsRunner.parse_lldpctl(text)
|
||||||
|
assert result['neighbor_port'] == 'Gi1/0/5'
|
||||||
|
|
||||||
|
def test_chassis_id_parsed(self):
|
||||||
|
text = " ChassisID: aa:bb:cc:dd:ee:ff\n"
|
||||||
|
result = DiagnosticsRunner.parse_lldpctl(text)
|
||||||
|
assert result['neighbor_chassis_id'] == 'aa:bb:cc:dd:ee:ff'
|
||||||
|
|
||||||
|
|
||||||
|
# ── analyze ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestAnalyze:
|
||||||
|
def _sections(self, **overrides):
|
||||||
|
base = {
|
||||||
|
'carrier': '1',
|
||||||
|
'operstate': 'up',
|
||||||
|
'ethtool': {'link_detected': True, 'duplex': 'full', 'speed_mbps': 1000},
|
||||||
|
'sysfs_stats': {'rx_crc_errors': 0},
|
||||||
|
'ethtool_dom': {},
|
||||||
|
'dmesg': [],
|
||||||
|
'lldpctl': {'available': False},
|
||||||
|
'carrier_changes': 0,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
def test_no_carrier_is_issue(self):
|
||||||
|
result = DiagnosticsRunner.analyze(self._sections(carrier='0'), {})
|
||||||
|
codes = [i['code'] for i in result['issues']]
|
||||||
|
assert 'NO_CARRIER' in codes
|
||||||
|
|
||||||
|
def test_half_duplex_is_issue(self):
|
||||||
|
sections = self._sections(ethtool={'duplex': 'half', 'link_detected': True, 'speed_mbps': 1000})
|
||||||
|
result = DiagnosticsRunner.analyze(sections, {})
|
||||||
|
codes = [i['code'] for i in result['issues']]
|
||||||
|
assert 'HALF_DUPLEX' in codes
|
||||||
|
|
||||||
|
def test_speed_mismatch_is_warning(self):
|
||||||
|
result = DiagnosticsRunner.analyze(
|
||||||
|
self._sections(ethtool={'duplex': 'full', 'link_detected': True, 'speed_mbps': 100}),
|
||||||
|
{'speed_mbps': 1000}
|
||||||
|
)
|
||||||
|
codes = [w['code'] for w in result['warnings']]
|
||||||
|
assert 'SPEED_MISMATCH' in codes
|
||||||
|
|
||||||
|
def test_sfp_rx_critical_low(self):
|
||||||
|
sections = self._sections(ethtool_dom={'rx_power_dbm': -30.0})
|
||||||
|
result = DiagnosticsRunner.analyze(sections, {})
|
||||||
|
codes = [i['code'] for i in result['issues']]
|
||||||
|
assert 'SFP_RX_CRITICAL' in codes
|
||||||
|
|
||||||
|
def test_sfp_rx_low_is_warning(self):
|
||||||
|
sections = self._sections(ethtool_dom={'rx_power_dbm': -20.0})
|
||||||
|
result = DiagnosticsRunner.analyze(sections, {})
|
||||||
|
codes = [w['code'] for w in result['warnings']]
|
||||||
|
assert 'SFP_RX_LOW' in codes
|
||||||
|
|
||||||
|
def test_high_crc_is_issue(self):
|
||||||
|
sections = self._sections(sysfs_stats={'rx_crc_errors': 200})
|
||||||
|
result = DiagnosticsRunner.analyze(sections, {})
|
||||||
|
codes = [i['code'] for i in result['issues']]
|
||||||
|
assert 'CRC_ERRORS_HIGH' in codes
|
||||||
|
|
||||||
|
def test_low_crc_is_warning(self):
|
||||||
|
sections = self._sections(sysfs_stats={'rx_crc_errors': 50})
|
||||||
|
result = DiagnosticsRunner.analyze(sections, {})
|
||||||
|
codes = [w['code'] for w in result['warnings']]
|
||||||
|
assert 'CRC_ERRORS_LOW' in codes
|
||||||
|
|
||||||
|
def test_carrier_flapping_issue(self):
|
||||||
|
result = DiagnosticsRunner.analyze(self._sections(carrier_changes=150), {})
|
||||||
|
codes = [i['code'] for i in result['issues']]
|
||||||
|
assert 'CARRIER_FLAPPING' in codes
|
||||||
|
|
||||||
|
def test_carrier_flaps_warning(self):
|
||||||
|
result = DiagnosticsRunner.analyze(self._sections(carrier_changes=25), {})
|
||||||
|
codes = [w['code'] for w in result['warnings']]
|
||||||
|
assert 'CARRIER_FLAPS' in codes
|
||||||
|
|
||||||
|
def test_lldp_missing_is_info(self):
|
||||||
|
result = DiagnosticsRunner.analyze(self._sections(lldpctl={'available': False}), {})
|
||||||
|
codes = [i['code'] for i in result['info']]
|
||||||
|
assert 'LLDP_MISSING' in codes
|
||||||
|
|
||||||
|
def test_lldp_mismatch_is_warning(self):
|
||||||
|
sections = self._sections(lldpctl={'available': True, 'neighbor_system': 'wrong-switch'})
|
||||||
|
switch_data = {'speed_mbps': 1000, 'lldp': {'system_name': 'core-sw-01'}}
|
||||||
|
result = DiagnosticsRunner.analyze(sections, switch_data)
|
||||||
|
codes = [w['code'] for w in result['warnings']]
|
||||||
|
assert 'LLDP_MISMATCH' in codes
|
||||||
|
|
||||||
|
def test_healthy_link_no_issues(self):
|
||||||
|
result = DiagnosticsRunner.analyze(self._sections(), {})
|
||||||
|
assert result['issues'] == []
|
||||||
|
assert result['warnings'] == []
|
||||||
Reference in New Issue
Block a user