44 Commits

Author SHA1 Message Date
jared 760e45bb68 TDS polish: lt-frame tables, links search toolbar, dead CSS cleanup
Lint / Python (flake8) (push) Successful in 56s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Failing after 40s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- index.html: wrap UniFi devices table in lt-frame with section header
- links.html: add static lt-toolbar with lt-search filter and collapse
  controls above the dynamic container; remove collapse bar from
  renderLinks() since it's now static; add applyLinksSearch() to
  filter host/switch panels by name as user types
- suppressions.html: wrap Available Targets section in lt-frame
- style.css: remove unused .link-summary-panel and related rules
  (replaced by lt-stats-grid in previous commit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:39:11 -04:00
jared c3aa3bea6f TDS polish: lt-frame tables, lt-stats-grid link summary, settings-aware refresh
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Failing after 42s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- links.html: replace custom link-summary-panel with lt-stats-grid/lt-stat-card
  showing total interfaces, ports down, errors, and PoE load
- suppressions.html: wrap active suppressions and history tables in lt-frame
  with lt-section-header labels
- inspector.html: wire auto-refresh to gandalfSettings (respects interval pill),
  fix updated timestamp to use locale time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 17:15:48 -04:00
jared b393d94e81 Upgrade page headers to lt-page-header/lt-page-title across all pages
Lint / Python (flake8) (push) Successful in 1m7s
Lint / JS (eslint) (push) Successful in 10s
Security / Python Security (bandit) (push) Failing after 1m17s
Test / Python Tests (pytest) (push) Successful in 1m23s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 01:09:30 -04:00
jared 4cb36a47a9 Add stat cards, lt-frame alert queue, and timeline for resolved alerts
Lint / Python (flake8) (push) Successful in 54s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 40s
Test / Python Tests (pytest) (push) Successful in 50s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
- Four lt-stat-card widgets (Critical, Warning, Hosts, Resolved 24h)
  below the status bar; Critical card pulses red when count > 0
- Clicking Critical or Warning card filters the events table by severity
- Events table wrapped in lt-frame with ASCII corner ornaments and
  lt-section-header; filter bar moved to lt-toolbar with lt-search icon
- Recently Resolved table replaced with lt-timeline component
- updateStatusBar() and updateHostGrid() keep stat card values live

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 22:19:50 -04:00
jared 7922d4bc79 Add notification bell, settings modal, and context-sensitive footer
Lint / Python (flake8) (push) Successful in 40s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Failing after 1m11s
Test / Python Tests (pytest) (push) Successful in 1m3s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
- Notification bell in header polls /api/status and shows active alerts
  with severity-colored dots; badge counts unread items via localStorage
- Settings modal ([ * ] CFG) controls auto-refresh interval (15s/30s/1m/5m/off)
  persisted to localStorage and wired into lt.autoRefresh on all pages
- Context-sensitive footer hints: Dashboard shows REFRESH + SUPPRESS,
  Link Debug shows REFRESH, all pages show CFG + HELP
- Added S key (quick suppress) and * key (settings) shortcuts
- ⌘K affordance button added to header-right
- R key now uses lt.autoRefresh.now() so it works on any page
- refreshAll() pushes fresh events to notification bell on each poll

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:33:02 -04:00
jared 1f8a99bbd4 Switch LDAP bind to dedicated gandalf service account
Lint / Python (flake8) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Failing after 1m5s
Test / Python Tests (pytest) (push) Successful in 49s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:21:04 -04:00
jared 9d6583a08a Add LDAP avatar photos, UX polish, and TDS component upgrades
Lint / Python (flake8) (push) Successful in 1m13s
Lint / JS (eslint) (push) Successful in 9s
Security / Python Security (bandit) (push) Failing after 45s
Test / Python Tests (pytest) (push) Successful in 57s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 5s
- Add /api/avatar endpoint querying lldap for user jpegPhoto; disk cache
  with sentinel pattern avoids repeat LDAP hits for users without photos
- Add ldap3 dependency and ldap config block to config.json
- Wire lt-avatar img overlay in base.html with capture-phase error
  fallback (lt-avatar-img-err) to reveal initials when image is absent
- Fix lt-avatar CSS shim: position:relative + absolute inset on img
  (local base.css was missing these; added to style.css)
- Replace all empty-state paragraphs with proper lt-empty-state markup
  (icon + title + body) across index, suppressions, inspector, app.js
- Add lt-spinner--cyan next to refresh button; shows during refreshAll()
- Replace inspector panel-section-title with lt-divider throughout
- Add data-tooltip attributes to SFP DOM metrics, TX/RX/Carrier/Duplex/
  Auto-neg/Error labels in links.html and inspector panel
- Add tooltips to events table column headers (Sev, First Seen, Failures)
- Fix links.html host panel timestamp (was reading sample.updated which
  is always undefined; now uses data.updated)
- Fix UniFi status text casing (Online→ONLINE to match server render)
- Remove dead topo-status-* class manipulation from updateTopology()
- Always render alert-count-badge; toggle display:none when count is 0
- Fix double UniFi get_devices() call in monitor.py run loop
- Fix chip-critical animation (was using green pulse-glow; now red)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:09:56 -04:00
jared 29267c9933 Integrate test code improvements using web_template components
Lint / Python (flake8) (push) Successful in 45s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Successful in 41s
Test / Python Tests (pytest) (push) Successful in 52s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 3s
lt-alert:
- Replace custom .stale-banner with lt-alert lt-alert--warning in app.js
  and links.html; remove stale-banner CSS, reuse lt-alert margin rule

lt-progress:
- Replace custom .traffic-bar-track/.traffic-bar-fill in links.html with
  lt-progress from base.css; TX uses default (orange), RX uses --cyan,
  both flip to --red when utilisation >85% (trafficBarClass helper)
- Keep traffic layout classes (.traffic-section/.traffic-row etc.) for structure

Suppression type badges:
- Map target_type to distinct badge colors: host→badge-warning (orange),
  interface→badge-info (cyan), unifi_device→badge-purple (new alias using
  --accent-purple from base.css), all→badge-critical (red)
- Applied in both server-rendered table (Jinja2 dict lookup) and
  renderActiveRows() JS

Topology animated down-wire:
- Add data-host attribute to .topo-v2-wire-10g/.topo-v2-wire-1g elements
- updateTopology() toggles .wire-down class on the 10G drop-wire when
  host.status === 'down'
- .wire-down CSS: animated repeating-linear-gradient dashed red line
  via wire-dash-anim @keyframes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 23:37:47 -04:00
jared 03375ef22f Remove all inline event handlers; replace with data-action delegation
Lint / Python (flake8) (push) Successful in 39s
Lint / JS (eslint) (push) Successful in 6s
Security / Python Security (bandit) (push) Successful in 50s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
- inspector.html: onclick on port blocks, close button, run-diagnostic button,
  and diag-toggle sections all converted to data-action attributes; single
  delegated click listener handles all cases + Escape key closes panel
- links.html: onclick on panel title headers, Collapse All, Expand All
  converted to data-action with delegated listener
- suppressions.html: onsubmit/onchange wired via addEventListener at init
- index.html: onsubmit/onchange on suppress modal form wired at init

No behavioural changes — pure event-handling refactor for TDS compliance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:53:48 -04:00
jared c025da85c1 Audit quick wins: null guard, API error toasts, aria-labels on suppress buttons
- app.js: guard events array with || [] before .filter() to prevent crash on null
- app.js: show warning toast when /api/network or /api/status fail (was silent)
- app.js: add aria-label to all dynamically-generated suppress buttons
- index.html: add aria-label to server-rendered suppress buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 17:50:00 -04:00
jared cabdbc24ad fix: resolve bandit B324/B104 and flake8 E302/E303/E501 in app.py
Lint / Python (flake8) (push) Successful in 42s
Lint / JS (eslint) (push) Successful in 6s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Has been skipped
Lint / Deploy (push) Successful in 2s
Security / Python Security (bandit) (push) Successful in 1m10s
- Add nosec B324 to md5 avatar-colour call (non-security deterministic hash)
- Extend nosec on host='0.0.0.0' to cover B104 alongside existing B201
- Fix E302 (missing blank line before template_filter decorator)
- Fix E303 (4 blank lines → 2 before _purge_old_jobs_loop)
- Add extend-exclude = node_modules to .flake8 so CI --exclude flag
  doesn't override config and third-party JS Python helpers stay ignored

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 20:51:41 -04:00
jared 0d25dd74f1 test: expand diagnose test coverage — parsers, dmesg, lldp, and analyze
Lint / Python (flake8) (push) Failing after 40s
Lint / JS (eslint) (push) Successful in 8s
Security / Python Security (bandit) (push) Failing after 1m0s
Test / Python Tests (pytest) (push) Successful in 56s
Lint / Notify on failure (push) Successful in 7s
Lint / Deploy (push) Has been skipped
Add 47 new tests covering parse_ethtool_driver, parse_nic_stats,
parse_ethtool_dom (SFP DOM), parse_ip_link, parse_dmesg, parse_lldpctl,
and the analyze() health-analysis method with all issue/warning/info
code paths (NO_CARRIER, HALF_DUPLEX, SPEED_MISMATCH, SFP thresholds,
CRC errors, carrier flapping, LLDP mismatch/missing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 19:53:35 -04:00
jared c45dd007d1 Fix field name mismatches, add events filter, in-place suppression refresh
Lint / Python (flake8) (push) Failing after 50s
Lint / JS (eslint) (push) Successful in 7s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
Security / Python Security (bandit) (push) Failing after 59s
- links.html: fix all field name bugs (auto_negotiation→autoneg, full_duplex,
  tx/rx_errors/drops_per_sec→_rate, tx/rx_bytes_per_sec→_rate, poe_total_w/poe_max_w
  computed from ports, renderUnifiSwitches uses top-level updated timestamp)
- suppressions.html: in-place DOM refresh after create/remove (no page reload),
  datalist autocomplete for target names, form reset after submit
- inspector.html: ESC key closes detail panel via lt.keys.on
- index.html: events filter bar with search input + severity pills (All/Critical/Warning),
  MutationObserver re-applies filter after dynamic updates
- style.css: g-section-actions, events-filter-bar, sev-pills layout
- app.js/db.py/monitor.py: carry forward prior session fixes (Promise.allSettled,
  daemon_ok, stale connection handling, double Prometheus call, self.cfg fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 23:35:02 -04:00
jared b6cd168542 Clean up suppressions.html: standardise pill attribute and remove inline onclick
Lint / Python (flake8) (push) Failing after 1m26s
Lint / JS (eslint) (push) Successful in 12s
Security / Python Security (bandit) (push) Failing after 1m59s
Test / Python Tests (pytest) (push) Successful in 53s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
- Rename data-dur → data-duration to match index.html/app.js convention
- Replace onclick="removeSuppression({{ s.id }})" with data-action="remove-sup" data-sup-id delegation
- Scope pill delegation to #create-suppression-form to avoid cross-page conflicts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 00:01:52 -04:00
jared a17b1382bc Migrate inspector and links pages to TDS lt.* APIs
Lint / Python (flake8) (push) Failing after 1m22s
Lint / JS (eslint) (push) Successful in 10s
Security / Python Security (bandit) (push) Failing after 49s
Lint / Notify on failure (push) Has been cancelled
Lint / Deploy (push) Has been cancelled
Test / Python Tests (pytest) (push) Has been cancelled
- Add escHtml alias (lt.escHtml) to both pages so existing template strings work without touching 40+ call sites
- Replace raw fetch() with lt.api.get/post in loadInspector, loadLinks, runDiagnostic, pollDiagnostic
- Replace setInterval(load*, 60000) with lt.autoRefresh.start() for intelligent polling
- Add lt.toast.error() to catch blocks for user-visible error feedback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:59:19 -04:00
jared c1c3905179 Add avatar color, initials structure, and admin nav dropdown
Lint / Python (flake8) (push) Failing after 1m7s
Lint / JS (eslint) (push) Successful in 10s
Security / Python Security (bandit) (push) Failing after 1m31s
Test / Python Tests (pytest) (push) Successful in 1m8s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
- app.py: avatar_color Jinja filter using deterministic hash → lt-avatar--orange/green/purple
- base.html: proper lt-avatar--sm with lt-avatar-initials span and color class; multi-word initials support
- base.html: admin users get lt-nav-dropdown for Suppressions; non-admins see flat link; mobile drawer hides Suppressions for non-admins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:54:01 -04:00
jared 293edd674e Integrate TDS v1.2 lt.* APIs throughout app
Lint / Python (flake8) (push) Failing after 58s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 44s
Test / Python Tests (pytest) (push) Successful in 1m24s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
- app.js: replace raw fetch/escHtml/fmtRelTime with lt.api, lt.escHtml, lt.time.ago; modal open/close via lt.modal; add _toIso() for timestamp normalisation
- index.html: data-action="refresh", data-duration pills, lt.autoRefresh.start, remove local fmtRelTime
- suppressions.html: lt.api.post/delete, data-dur pill delegation
- base.html: user avatar with initials, admin badge, lt.keys.on('r') replaces manual keydown handler
- base.css: add dot-*, chip, row-state aliases so apps can use unprefixed class names

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:46:44 -04:00
jared bb6393e35b Replace dangling symlinks with real base.css and base.js files
Lint / JS (eslint) (push) Successful in 21s
Lint / Python (flake8) (push) Failing after 11m41s
Security / Python Security (bandit) (push) Failing after 40m42s
Test / Python Tests (pytest) (push) Successful in 2m46s
Lint / Notify on failure (push) Has been cancelled
Lint / Deploy (push) Has been cancelled
The symlinks pointed to /root/code/web_template/ which does not exist on
the production LXC (10.10.10.61). Flask returned 404 for both assets.
Replaced with actual file copies from web_template v1.2.

To update when web_template changes:
  cp /root/code/web_template/base.css static/base.css
  cp /root/code/web_template/base.js  static/base.js

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 22:11:44 -04:00
jared e05f1f6c55 Align base.html and modal with tinker_tickets reference implementation
Lint / Notify on failure (push) Blocked by required conditions
Lint / Python (flake8) (push) Failing after 1m12s
Lint / JS (eslint) (push) Successful in 13s
Security / Python Security (bandit) (push) Failing after 1m14s
Test / Python Tests (pytest) (push) Failing after 24m3s
Lint / Deploy (push) Failing after 12m10s
- Add lt-glitch + data-text to brand title (signature glitch effect)
- Add mobile nav drawer (lt-nav-drawer) and hamburger button (lt-menu-btn)
- Add VT323 font, theme toggle button (lt-theme-btn), footer with hints
- Add command palette overlay + lt.cmdPalette.init() with nav commands
- Add keyboard shortcuts help modal and skip link
- Move base.js to <head> so lt.* is available for inline scripts
- Fix suppress modal: lt-modal-backdrop → lt-modal-overlay (base.css class)
- Fix modal open/close: use .is-open / aria-hidden instead of inline style

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 21:42:04 -04:00
jared e8de40250a Restructure app to use LotusGuild Terminal Design System v1.2
Lint / Python (flake8) (push) Failing after 45s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 1m22s
Test / Python Tests (pytest) (push) Successful in 51s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
Replace custom phosphor-green terminal aesthetic with the lt-* component
system from base.css/base.js. All templates now inherit the LotusGuild
multi-accent Anduril palette via variable aliases in style.css, and use
lt-header, lt-nav, lt-card, lt-table, lt-btn, lt-modal, lt-badge etc.
Custom components (topology, inspector chassis, link debug, SFP panels)
are preserved with color values updated to base.css palette variables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 21:01:20 -04:00
jared 2c4e8fcfda ci: add notify-failure, deploy tagging, and coverage reporting
Lint / Python (flake8) (push) Failing after 20s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 22s
Test / Python Tests (pytest) (push) Successful in 30s
Lint / Notify on failure (push) Successful in 2s
Lint / Deploy (push) Has been skipped
- lint.yml: add notify-failure Matrix alert job; add Tag deployed commit
  step to deploy job with deploy-YYYY.MM.DD-N tagging via Gitea API
- test.yml: add pytest-cov for coverage reporting
- .coveragerc: omit tests and site-packages from coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:16:02 -04:00
jared 7cd39bbe9b Add CI badges and CI/CD section to README
Lint / Python (flake8) (push) Failing after 21s
Lint / JS (eslint) (push) Successful in 6s
Security / Python Security (bandit) (push) Failing after 21s
Test / Python Tests (pytest) (push) Successful in 27s
Lint / Deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:53:47 -04:00
jared b10eded514 Suppress bandit B201 false positive in dev runner
Lint / Python (flake8) (push) Failing after 20s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 22s
Test / Python Tests (pytest) (push) Successful in 29s
Lint / Deploy (push) Has been skipped
app.run(debug=True) is only reached via __main__ (dev mode).
Production runs gunicorn, never this block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:40:10 -04:00
jared 50da3c0a59 Add pytest test suite and security scanning
Lint / Python (flake8) (push) Failing after 20s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 22s
Test / Python Tests (pytest) (push) Successful in 32s
Lint / Deploy (push) Has been skipped
- Add 33 tests for DiagnosticsRunner static methods (build_ssh_command,
  parse_output, parse_sysfs_stats, parse_ethtool and variants)
- Add test.yml CI workflow running pytest on every push/PR
- Add security.yml CI workflow running bandit on every push/PR (weekly)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 12:22:57 -04:00
jared 3af42505b8 Add bandit security scanning workflow
Lint / Python (flake8) (push) Successful in 28s
Lint / JS (eslint) (push) Successful in 7s
Security / Python Security (bandit) (push) Failing after 21s
Lint / Deploy (push) Successful in 3s
Scans Python code weekly and on every push/PR for medium+ severity issues.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:04:39 -04:00
jared 963ceb3e1e Add JS linting and deploy gating to CI pipeline
Lint / Python (flake8) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / Deploy (push) Successful in 2s
- Add js-lint job (ESLint 8) for static/ directory
- Add deploy job gated on both python-lint and js-lint passing
- Deploy triggers gandalf-deploy webhook on main branch only
- Add static/.eslintrc.json with browser env config
- Add node_modules/ to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:14:33 -04:00
jared 28fb5c666c ci: install Python3 via apt before pip — runner is node:20-bullseye
Lint / Python (flake8) (push) Successful in 21s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:04:42 -04:00
jared 3dfcd5903a ci: add flake8 lint workflow; fix unused imports
Lint / Python (flake8) (push) Failing after 4s
Adds .gitea/workflows/lint.yml running flake8 with .flake8 config.
Removes unused imports (flask.redirect, flask.url_for, time, typing.Tuple).
Config ignores E221/E305 (intentional column alignment and function spacing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:23:26 -04:00
jared d576a0fe2d Auto-reload on auth timeout (401 response)
Wrap window.fetch so any 401 triggers window.location.reload(),
sending the browser back through the Authelia proxy to the login page.
Covers all pages since app.js is loaded by base.html.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:43:08 -04:00
jared 271c3c4373 Exclude LXC IPs from link stats collection
Add links_exclude_ips to monitor config; collect() skips any Prometheus
instance whose IP is in that list, preventing LXC containers from
appearing on the links/inspector pages as phantom hosts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:39:47 -04:00
jared e2b65db2fc Add pagination to event queries, input validation, daily event purge
- get_active_events() now takes limit/offset (default 200) to cap unbounded queries
- count_active_events() added to return total for pagination display
- /api/events supports ?limit=, ?offset=, ?status= query params (max 1000)
- /api/status includes total_active count alongside paginated events list
- index() route passes total_active to template for server-side truncation notice
- Show "Showing X of Y" notice in dashboard when events are truncated
- Suppression POST validates: reason ≤500 chars, target_name/detail ≤255 chars
- _purge_old_jobs_loop runs purge_old_resolved_events(90d) once per day

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:32:32 -04:00
jared b80fda7cb2 Fix host filtering: only show/monitor configured hosts; add PBS
- _collect_snapshot() and _process_interfaces() now skip any Prometheus
  instance not explicitly listed in config.json hosts[]. LXC app servers
  (postgresql, matrix, etc.) report node_exporter metrics but are not
  infrastructure hosts Gandalf should display or alert on.
- Add PBS (10.10.10.3) to config hosts[] with prometheus_instance;
  remove from ping_hosts (node_exporter already running on PBS, now
  added to Prometheus scrape config as job pbs-node).
- The _instance_map membership check is now consistent across snapshot,
  alerting, and ethtool SSH collection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:17:40 -04:00
jared eb8c0ded5e Fix: only SSH into explicitly configured hosts for ethtool collection
LinkStatsCollector.collect() was SSHing into every host reporting
node_network_* metrics to Prometheus, including unrelated app servers
like postgresql and matrix. Add instance_map membership check so ethtool
collection via Pulse only runs on hosts defined in config.json.

Prometheus metrics (traffic rates, errors) are still collected for all
instances — only the SSH/ethtool step is gated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:35:21 -04:00
jared b29b70d88b Improve Pulse execution reliability: retry logic, better logging, SSH hardening
monitor.py / diagnose.py PulseClient.run_command:
- Add automatic single retry on submit failure, explicit Pulse failure
  (status=failed/timed_out), and poll timeout — handles transient SSH
  or Pulse hiccups without dropping the whole collection cycle
- Log execution_id and full Pulse URL on every failure so failed runs
  can be found in the Pulse UI immediately
- Handle 'timed_out' and 'cancelled' Pulse statuses explicitly (previously
  only 'failed' was caught; others would spin until local deadline)
- Poll every 2s instead of 1s to reduce Pulse API chatter

SSH command options (_ssh_batch + diagnose.py):
- Add BatchMode=yes: aborts immediately instead of hanging on a
  password prompt if key auth fails
- Add ServerAliveInterval=10 ServerAliveCountMax=2: SSH detects a
  hung remote command within ~20s instead of sitting silent until the
  45s Pulse timeout expires

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 09:19:07 -04:00
jared 2c67944b4b Fix topology chain order and inspector SFP port width
Topology:
- Correct series layout: UDM-Pro → USW-Agg → Pro 24 PoE (not a fork)
- Remove CSS fork divs, replace with straight vertical connectors
- Labels: WAN · 10G SFP+ (UDM→Agg), 10G trunk (Agg→PoE)
- Remove ISL from legend (no parallel switch pair)

Inspector:
- Fix USW-Agg port blocks appearing narrower than other switches
- SFP ports in rows now use same width (34px) as copper ports;
  all-SFP switches like USL8A no longer look undersized

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:42:38 -04:00
jared e8314b5ba3 Fix topology diagram: replace SVG fork with CSS, fix line alignment
- Remove SVG fork with preserveAspectRatio="none" (caused line width
  distortion and stretched 10G DAC label like a tube TV)
- Replace with pure CSS .topo-fork: stem + horizontal bar + left/right
  drops, all absolutely positioned at consistent 2px width
- Use .topo-sw-row with two 50% halves so switch centres land at
  exactly 25% and 75% — matching fork drop positions mathematically
- ISL rendered via ::before/::after on .topo-sw-row (switch boxes
  with solid bg cover the line at their edges, leaving only the gap)
- Add .topo-sw-drops: two vertical stubs from switch centres to bus rails
- All lines are now exactly 2px, no distortion, no misalignment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:35:02 -04:00
jared 3dce602938 Redesign topology diagram with dual-homed bus layout and improve inspector chassis
- Replace flat topology with tiered bus-bar layout: Internet → UDM-Pro → SVG fork → USW-Agg + Pro 24 PoE → dual-homed servers
- Show 10G VLAN90 (Ceph) bus from USW-Agg and 1G DHCP management bus from Pro 24 PoE per host
- Add per-host drop wires (solid 10G + dashed 1G) with correct rack positions
- Mark large1 as off-rack (dashed border), ZimaBoards as off-rack mon-01/mon-02
- Add topology legend, inter-switch 10G ISL indicator
- Add recently resolved events section (last 24h) to dashboard
- Add last_seen column and relative timestamps to events table
- Add stale data banner when monitoring data >15 min old
- Improve inspector chassis with port speed labels, LLDP neighbor info, mounting ears, chassis legend
- Add duplex/speed mismatch warnings and carrier changes to path debug panel
- Bump updateTopology() to handle both topo-v2-status-* and topo-status-* classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:22:19 -04:00
jared 6eb21055ef fix: topology — reflect VLAN90 Ceph network and DHCP management separation
10G SFP+ ports on USW-Agg are VLAN90 (10.10.90.x/24, static IPs, Ceph storage).
1G ports on Pro 24 PoE are DHCP management. Update topology to show this:
- USW-Agg sublabel shows VLAN90 · 10.10.90.x (cyan)
- Pro 24 PoE sublabel shows DHCP mgmt (cyan)
- Host sublabels changed from "10G+1G" to "VLAN90" for the 10G Agg connection
- 1G management band label updated to "← 1G DHCP mgmt (Pro 24 PoE) →"
- Add .topo-vlan-tag CSS for cyan VLAN annotation on switch nodes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:10:17 -04:00
jared f2541eb45c fix: topology — all servers dual-homed 10G+1G, show mgmt band
All rack servers (and large1 on table) have both a 10G link to USW-Agg
and a 1G management link to Pro 24 PoE. Update topology:
- Move all 6 hosts into single row (including large1)
- Update sublabels to "10G+1G" for all nodes
- large1 dashed-border (off-rack) with "table · 10G+1G"
- Add dashed amber "1G mgmt (PoE)" horizontal band above hosts
  to represent the PoE switch management connections
- 10G primary fan-out lines still drop from Agg switch above
- large1 primary line rendered as dashed green (off-rack run)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:08:48 -04:00
jared e779b21db4 feat: redesign network topology diagram with accurate rack layout
Replace linear Internet→UDM→Agg→PoE→all-hosts chain with accurate topology:
- USW-Aggregation and Pro 24 PoE switch shown side-by-side with horizontal
  10G SFP+ link between them (not in series)
- 5 compute/storage/monitor nodes fanned out under Agg Switch with 10G labels
  and rack unit positions (RU4–12, RU14–17) as sublabels
- large1 shown separately under PoE switch, dashed border = off-rack (table)
- Add device specs as subtitles on all nodes (Dream Machine Pro · RU24, etc.)
- Shorter display names: csg-01 / cs-01 instead of full hostnames
- Live status badges still updated by JS via data-host attributes
- New CSS: .topo-node-sub, .topo-switch-tier, .topo-h-link, .topo-host-tier,
  .topo-host-table (dashed), .topo-badge-unknown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 22:06:03 -04:00
jared c1fd53f9bd Remove aesthetic_diff.md reference from README — convergence complete
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:50:02 -04:00
jared 0ca6b1f744 feat: link health summary, recently resolved panel, event duration
- dashboard: pass recent_resolved (last 24h, limit 10) to index template;
  render "Recently Resolved" section showing type, target, resolved time,
  and calculated duration (first_seen → resolved_at)
- dashboard: event-age spans now also update via setInterval; duration
  shown for resolved events (e.g. "2h 15m")
- links page: link health summary panel shows server iface count,
  error/flap counts, switch port up/down, PoE total draw/capacity bar;
  only shows problematic stats if non-zero; shows "All OK ✔" when clean
- style.css: new classes for summary panel, resolved row/badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:48:40 -04:00
jared 6b6eaa6227 feat: UI improvements — event ages, error badges, PoE bars, mismatch detection
- events table: add Last Seen column; show relative times ("3h ago") with
  absolute timestamp on hover; update updateEventsTable() in app.js to match
- links.html: add error/drop/flap alert badges to interface and port card headers
- links.html: PoE power bar (draw/max ratio with colour-coded fill) and poe_mode
- links.html: stale data warning banner when link_stats are >2 minutes old
- links.html: improved error handler shows HTTP status instead of generic message
- links.html: fix collapse state persisted to localStorage (was sessionStorage,
  lost on browser restart); fix collapseAll/expandAll to also persist state
- inspector.html: duplex mismatch and speed mismatch warnings in path debug panel
- inspector.html: carrier changes added to server column of path debug
- style.css: new classes — .link-alert-badge, .poe-bar-*, .path-mismatch-alert,
  .error-state; fix .stale-banner to use CSS variables

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:46:11 -04:00
jared 9c9acbb023 Apply LotusGuild design system convergence (aesthetic_diff.md)
CSS (style.css):
- §1: Add unified naming aliases (--terminal-green, --bg-primary, etc.)
- §2: Upgrade borders: modal 1px→3px double, btn/btn-sm/inputs 1px→2px
- §3: Add [ ] bracket decorations to .btn classes; primary keeps > prefix;
  hover lift -1px→-2px; padding 6px 14px→5px 12px
- §4: Fix glow definitions from 2-layer rgba to 3-layer solid stack
- §5: Section headers now symmetric ╠═══ TITLE ═══╣ (was one-sided)
- §6+§7: Modal border 3px double, corners ┌┐→╔╗, add glow shadow
- §11: Nav active state now amber tint (was green); hover remains green
- §15: Scanline opacity 0.13→0.15; flicker delay 45s→30s

JS (app.js):
- §18: Replace custom showToast() with lt.toast.* delegate wrapper

Templates (base.html):
- Load base.css and base.js (symlinked from web_template)
- Add lt-boot overlay for boot sequence animation (§13)

README: Remove completed pending convergence items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 21:40:20 -04:00
26 changed files with 13144 additions and 1946 deletions
+7
View File
@@ -0,0 +1,7 @@
[run]
omit =
tests/*
*/site-packages/*
[report]
show_missing = True
+7
View File
@@ -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
+86
View File
@@ -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"
+25
View File
@@ -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
+24
View File
@@ -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
+1
View File
@@ -2,3 +2,4 @@ log.txt
config.json
__pycache__/
*.pyc
node_modules/
+33
View File
@@ -1,10 +1,26 @@
# GANDALF (Global Advanced Network Detection And Link Facilitator)
[![Lint](https://code.lotusguild.org/LotusGuild/gandalf/actions/workflows/lint.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/gandalf/actions?workflow=lint.yml)
[![Test](https://code.lotusguild.org/LotusGuild/gandalf/actions/workflows/test.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/gandalf/actions?workflow=test.yml)
[![Security](https://code.lotusguild.org/LotusGuild/gandalf/actions/workflows/security.yml/badge.svg)](https://code.lotusguild.org/LotusGuild/gandalf/actions?workflow=security.yml)
> Because it shall not let problems pass.
Network monitoring dashboard for the LotusGuild Proxmox cluster.
Deployed on **LXC 157** (monitor-02 / 10.10.10.9), reachable at `gandalf.lotusguild.org`.
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
## Styling & Layout
GANDALF uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
- [`web_template/README.md`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/README.md) — full component reference, CSS variables, JS API
- [`web_template/base.css`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.css) — unified CSS (`.lt-*` classes)
- [`web_template/base.js`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/base.js) — `window.lt` utilities (toast, modal, auto-refresh, fetch helpers)
- [`web_template/python/base.html`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/base.html) — Jinja2 base template
- [`web_template/python/auth.py`](https://code.lotusguild.org/LotusGuild/web_template/src/branch/main/python/auth.py) — `@require_auth` decorator pattern
---
## Architecture
@@ -474,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
is delayed. The `pulse.timeout` config controls the max wait.
- 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.
+142 -12
View File
@@ -4,16 +4,19 @@ Flask web application serving the monitoring dashboard and suppression
management UI. Authentication via Authelia forward-auth headers.
All monitoring and alerting is handled by the separate monitor.py daemon.
"""
import hashlib
import ipaddress
import json
import logging
import os
import re
import threading
import time
import uuid
from datetime import datetime, timezone
from functools import wraps
from flask import Flask, jsonify, redirect, render_template, request, url_for
from flask import Flask, jsonify, make_response, render_template, request, send_file
import db
import diagnose
@@ -27,7 +30,15 @@ logger = logging.getLogger('gandalf.web')
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_lock = threading.Lock()
@app.context_processor
@@ -42,13 +53,14 @@ def inject_config():
}
}
# In-memory diagnostic job store { job_id: { status, result, created_at } }
_diag_jobs: dict = {}
_diag_lock = threading.Lock()
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:
time.sleep(120)
cutoff = time.time() - 600
@@ -57,7 +69,7 @@ def _purge_old_jobs_loop():
stale = [jid for jid, j in _diag_jobs.items() if j.get('created_at', 0) < cutoff]
for jid in stale:
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:
j['status'] = 'done'
j['result'] = {'status': 'error', 'error': 'Diagnostic timed out (thread crash)'}
@@ -70,12 +82,25 @@ _purge_thread.start()
def _config() -> dict:
global _cfg
if _cfg is None:
with _cfg_lock:
if _cfg is None:
with open('config.json') as f:
_cfg = json.load(f)
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
# ---------------------------------------------------------------------------
@@ -120,24 +145,32 @@ def require_auth(f):
# Page routes
# ---------------------------------------------------------------------------
_PAGE_LIMIT = 200 # max events returned per request
@app.route('/')
@require_auth
def index():
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()
snapshot_raw = db.get_state('network_snapshot')
last_check = db.get_state('last_check', 'Never')
snapshot = json.loads(snapshot_raw) if snapshot_raw else {}
suppressions = db.get_active_suppressions()
recent_resolved = db.get_recent_resolved(hours=24, limit=10)
return render_template(
'index.html',
user=user,
events=events,
total_active=total_active,
summary=summary,
snapshot=snapshot,
last_check=last_check,
suppressions=suppressions,
recent_resolved=recent_resolved,
daemon_ok=_daemon_ok(last_check),
)
@@ -179,10 +212,14 @@ def suppressions_page():
@app.route('/api/status')
@require_auth
def api_status():
active = db.get_active_events(limit=_PAGE_LIMIT)
last_check = db.get_state('last_check', 'Never')
return jsonify({
'summary': db.get_status_summary(),
'last_check': db.get_state('last_check', 'Never'),
'events': db.get_active_events(),
'last_check': last_check,
'events': active,
'total_active': db.count_active_events(),
'daemon_ok': _daemon_ok(last_check),
})
@@ -213,10 +250,22 @@ def api_links():
@app.route('/api/events')
@require_auth
def api_events():
return jsonify({
'active': db.get_active_events(),
'resolved': db.get_recent_resolved(hours=24, limit=30),
})
try:
limit = min(int(request.args.get('limit', _PAGE_LIMIT)), 1000)
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'])
@@ -243,6 +292,12 @@ def api_create_suppression():
return jsonify({'error': 'target_name required'}), 400
if not reason:
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(
target_type=target_type,
@@ -389,6 +444,82 @@ def api_diagnose_poll(job_id: str):
return jsonify({'status': job['status'], 'result': job.get('result')})
@app.route('/api/avatar')
@require_auth
def api_avatar():
"""Serve the current user's LDAP avatar photo (JPEG), cached to disk."""
username = request.headers.get('Remote-User', '').strip()
if not username:
return '', 404
ldap_cfg = _config().get('ldap', {})
if not ldap_cfg.get('host') or not ldap_cfg.get('bind_dn'):
return '', 404
# Build a safe cache filename from the username (alphanumeric + - _ .)
safe_name = re.sub(r'[^a-zA-Z0-9._-]', '_', username)
cache_dir = ldap_cfg.get('cache_dir', '/tmp/gandalf_avatars')
os.makedirs(cache_dir, exist_ok=True)
cache_file = os.path.join(cache_dir, f'user_{safe_name}.jpg')
sentinel = os.path.join(cache_dir, f'user_{safe_name}.none')
cache_ttl = int(ldap_cfg.get('cache_ttl', 3600))
now = time.time()
# Serve cached image if fresh
if os.path.exists(cache_file) and now - os.path.getmtime(cache_file) < cache_ttl:
return send_file(cache_file, mimetype='image/jpeg',
max_age=cache_ttl, conditional=True)
# Skip LDAP if we already know this user has no avatar
if os.path.exists(sentinel) and now - os.path.getmtime(sentinel) < cache_ttl:
return '', 404
# Query lldap
avatar_data = None
try:
import ldap3
server = ldap3.Server(ldap_cfg['host'], port=int(ldap_cfg.get('port', 3890)))
conn = ldap3.Connection(server,
user=ldap_cfg['bind_dn'],
password=ldap_cfg.get('bind_pw', ''),
auto_bind=True, receive_timeout=5)
safe_uid = ldap3.utils.conv.escape_filter_chars(username)
conn.search(ldap_cfg.get('user_base', 'ou=people,dc=example,dc=com'),
f'(uid={safe_uid})', attributes=['avatar'])
if conn.entries and conn.entries[0]['avatar'].value:
avatar_data = conn.entries[0]['avatar'].value
conn.unbind()
except ImportError:
logger.error('ldap3 not installed — run: pip install ldap3')
return '', 404
except Exception as e:
logger.error(f'LDAP avatar lookup failed for {username}: {e}')
return '', 404
if not avatar_data or len(avatar_data) < 100:
open(sentinel, 'w').close()
return '', 404
# Validate JPEG magic bytes (FF D8 FF)
if isinstance(avatar_data, str):
avatar_data = avatar_data.encode('latin-1')
if avatar_data[:3] != b'\xFF\xD8\xFF':
logger.warning(f'Non-JPEG avatar data for {username}')
open(sentinel, 'w').close()
return '', 404
with open(cache_file, 'wb') as f:
f.write(avatar_data)
if os.path.exists(sentinel):
os.unlink(sentinel)
resp = make_response(avatar_data)
resp.headers['Content-Type'] = 'image/jpeg'
resp.headers['Cache-Control'] = f'private, max-age={cache_ttl}'
return resp
@app.route('/health')
def health():
"""Health check endpoint (no auth). Checks DB and monitor freshness."""
@@ -407,7 +538,6 @@ def health():
try:
last_check = db.get_state('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)
age_s = (datetime.now(timezone.utc) - ts).total_seconds()
if age_s > 1200:
@@ -426,4 +556,4 @@ def health():
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
+22 -9
View File
@@ -1,9 +1,9 @@
{
"ssh": {
"user": "root",
"password": "Server#980000Panda",
"connect_timeout": 5,
"timeout": 20
"pulse": {
"url": "http://10.10.10.65:8080",
"api_key": "012b303a324152c509bf5ade6f942cfc21404f68662f01a17001cba9e4486049",
"worker_id": "1b11d1b5-4ed0-42df-a6af-8d57fffe1343",
"timeout": 45
},
"unifi": {
"controller": "https://10.10.10.1",
@@ -24,18 +24,31 @@
"url": "http://10.10.10.45/create_ticket_api.php",
"api_key": "5acc5d3c647b84f7c6f59082ce4450ee772e2d1633238b960136f653d20c93af"
},
"ldap": {
"host": "10.10.10.39",
"port": 3890,
"bind_dn": "uid=gandalf,ou=people,dc=example,dc=com",
"bind_pw": "AZ-eUFRxamnlLEELuDy5-Mh6z-2cJbL_YJZnu64Tf0A",
"user_base": "ou=people,dc=example,dc=com",
"cache_dir": "/tmp/gandalf_avatars",
"cache_ttl": 3600
},
"auth": {
"allowed_groups": ["admin"]
},
"monitor": {
"poll_interval": 120,
"poll_interval": 300,
"failure_threshold": 2,
"cluster_threshold": 3,
"ping_hosts": [
{"name": "pbs", "ip": "10.10.10.3"}
]
"ping_hosts": [],
"links_exclude_ips": ["10.10.10.29", "10.10.10.44", "10.10.10.3"]
},
"hosts": [
{
"name": "pbs",
"ip": "10.10.10.3",
"prometheus_instance": "10.10.10.3:9100"
},
{
"name": "large1",
"ip": "10.10.10.2",
+34 -9
View File
@@ -23,13 +23,8 @@ def _config() -> dict:
return _config_cache
@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 = pymysql.connect(
def _new_conn(cfg: dict):
return pymysql.connect(
host=cfg['host'],
port=cfg.get('port', 3306),
user=cfg['user'],
@@ -40,9 +35,27 @@ def get_conn():
connect_timeout=10,
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
else:
try:
conn.ping(reconnect=True)
except Exception:
try:
conn.close()
except Exception:
pass
_local.conn = None
conn = _new_conn(cfg)
_local.conn = 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 conn.cursor() as cur:
cur.execute(
@@ -161,7 +174,9 @@ def get_active_events() -> list:
WHERE resolved_at IS NULL
ORDER BY
FIELD(severity,'critical','warning','info'),
first_seen DESC"""
first_seen DESC
LIMIT %s OFFSET %s""",
(limit, offset),
)
rows = cur.fetchall()
for r in rows:
@@ -171,6 +186,16 @@ def get_active_events() -> list:
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:
with get_conn() as conn:
with conn.cursor() as cur:
+4 -3
View File
@@ -6,9 +6,8 @@ Executed in a background thread; results stored in _diag_jobs (app.py).
"""
import re
import shlex
import time
import logging
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Optional
logger = logging.getLogger('gandalf.diagnose')
@@ -77,7 +76,9 @@ class DiagnosticsRunner:
return (
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}\''
)
# ------------------------------------------------------------------
+66 -23
View File
@@ -246,11 +246,16 @@ class PulseClient:
'Content-Type': 'application/json',
})
def run_command(self, command: str) -> Optional[str]:
"""Submit *command* to Pulse, poll until done, return stdout or None."""
def run_command(self, command: str, _retry: bool = True) -> Optional[str]:
"""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
if not self.url or not self.api_key or not self.worker_id:
return None
# Submit
try:
resp = self.session.post(
f'{self.url}/api/internal/command',
@@ -262,11 +267,16 @@ class PulseClient:
self.last_execution_id = execution_id
except Exception as 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
# Poll
deadline = time.time() + self.timeout
while time.time() < deadline:
time.sleep(1)
time.sleep(2)
try:
r = self.session.get(
f'{self.url}/api/internal/executions/{execution_id}',
@@ -281,11 +291,28 @@ class PulseClient:
if entry.get('action') == 'command_result':
return entry.get('stdout', '')
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
except Exception as e:
logger.error(f'Pulse poll failed: {e}')
logger.warning(f'Pulse command timed out after {self.timeout}s')
logger.error(f'Pulse poll failed for {execution_id}: {e}')
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
@@ -298,6 +325,7 @@ class LinkStatsCollector:
def __init__(self, cfg: dict, prom: 'PrometheusClient',
unifi: Optional['UnifiClient'] = None):
self.cfg = cfg
self.prom = prom
self.pulse = PulseClient(cfg)
self.unifi = unifi
@@ -336,7 +364,9 @@ class LinkStatsCollector:
ssh_cmd = (
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)
if output is None:
@@ -524,15 +554,20 @@ class LinkStatsCollector:
"""
prom_metrics = self._collect_prom_metrics()
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():
host = instance_map.get(instance, 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())
# 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] = {}
if self.pulse.url and ifaces:
if self.pulse.url and ifaces and instance in instance_map:
try:
ethtool_data = self._ssh_batch(host_ip, ifaces)
except Exception as e:
@@ -663,6 +698,8 @@ class NetworkMonitor:
hosts_with_regression: List[str] = []
for instance, ifaces in states.items():
if instance not in self._instance_map:
continue # skip unconfigured Prometheus instances
host = self._hostname(instance)
new_baseline.setdefault(host, {})
host_has_regression = False
@@ -840,12 +877,17 @@ class NetworkMonitor:
# ------------------------------------------------------------------
# Snapshot collection (for dashboard)
# ------------------------------------------------------------------
def _collect_snapshot(self) -> dict:
iface_states = self.prom.get_interface_states()
unifi_devices = self.unifi.get_devices() or []
def _collect_snapshot(
self, iface_states: Dict[str, Dict[str, bool]],
unifi_devices: Optional[List[dict]] = None,
) -> dict:
# Accept pre-fetched devices; fall back to empty list if unavailable
display_unifi = unifi_devices if unifi_devices is not None else []
hosts = {}
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)
phys = {k: v for k, v in ifaces.items()}
up_count = sum(1 for v in phys.values() if v)
@@ -876,7 +918,7 @@ class NetworkMonitor:
return {
'hosts': hosts,
'unifi': unifi_devices,
'unifi': display_unifi,
'updated': datetime.utcnow().isoformat(),
}
@@ -892,26 +934,27 @@ class NetworkMonitor:
try:
logger.info('Starting network check cycle')
# 1. Collect and store snapshot for dashboard
snapshot = self._collect_snapshot()
# 1. Fetch interface states once — shared by snapshot and alert processing
iface_states = self.prom.get_interface_states()
# 2. Fetch UniFi devices once — used by both snapshot and alert processing
unifi_devices = self.unifi.get_devices()
# 3. Collect and store snapshot for dashboard
snapshot = self._collect_snapshot(iface_states, unifi_devices)
db.set_state('network_snapshot', snapshot)
db.set_state('last_check', _now_utc())
# 2. Collect link stats (ethtool + traffic metrics)
# 4. Collect link stats (ethtool + traffic metrics)
try:
link_data = self.link_stats.collect(self._instance_map)
db.set_state('link_stats', link_data)
except Exception as e:
logger.error(f'Link stats collection failed: {e}', exc_info=True)
# 3. Process alerts (separate Prometheus call for fresh data)
# Load suppressions once per cycle to avoid N*M DB queries
# 5. Process alerts using already-fetched data
suppressions = db.get_active_suppressions()
iface_states = self.prom.get_interface_states()
self._process_interfaces(iface_states, suppressions)
unifi_devices = self.unifi.get_devices()
self._process_unifi(unifi_devices, suppressions)
self._process_ping_hosts(suppressions)
+1140
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"eslint": "^8.57.1"
}
}
+1
View File
@@ -1,5 +1,6 @@
flask>=2.2.0
gunicorn>=20.1.0
ldap3>=2.9
pymysql>=1.1.0
requests>=2.31.0
urllib3>=2.0.0
+13
View File
@@ -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"
}
}
+144 -102
View File
@@ -1,80 +1,117 @@
'use strict';
// ── Toast notifications ───────────────────────────────────────────────
function showToast(msg, type = 'success') {
let container = document.querySelector('.toast-container');
if (!container) {
container = document.createElement('div');
container.className = 'toast-container';
document.body.appendChild(container);
// ── 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');
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = msg;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3500);
return resp;
};
})();
// ── Toast notifications — thin wrapper over lt.toast ──────────────────
function showToast(msg, type = 'success') {
if (type === 'error') return lt.toast.error(msg);
if (type === 'warning') return lt.toast.warning(msg);
if (type === 'info') return lt.toast.info(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 ────────────────────────────────────────────
async function refreshAll() {
const refreshBtn = document.querySelector('[data-action="refresh"]');
const spinner = document.getElementById('refresh-spinner');
if (refreshBtn) refreshBtn.classList.add('is-loading');
if (spinner) spinner.style.display = '';
try {
const [netResp, statusResp] = await Promise.all([
fetch('/api/network'),
fetch('/api/status'),
const [netResult, statusResult] = await Promise.allSettled([
lt.api.get('/api/network'),
lt.api.get('/api/status'),
]);
if (!netResp.ok || !statusResp.ok) return;
const net = await netResp.json();
const status = await statusResp.json();
if (netResult.status === 'fulfilled') {
const net = netResult.value;
updateHostGrid(net.hosts || {});
updateUnifiTable(net.unifi || []);
updateEventsTable(status.events || []);
updateStatusBar(status.summary || {}, status.last_check || '');
updateTopology(net.hosts || {});
} catch (e) {
console.warn('Refresh failed:', e);
} else {
showToast('Network data unavailable', 'warning');
}
if (statusResult.status === 'fulfilled') {
const status = statusResult.value;
updateEventsTable(status.events || [], status.total_active);
updateStatusBar(status.summary || {}, status.last_check || '', status.daemon_ok);
if (typeof window.gandalfNotifUpdate === 'function') window.gandalfNotifUpdate(status.events || []);
} else {
showToast('Status data unavailable', 'warning');
}
} finally {
if (refreshBtn) refreshBtn.classList.remove('is-loading');
if (spinner) spinner.style.display = 'none';
}
}
function updateStatusBar(summary, lastCheck) {
function updateStatusBar(summary, lastCheck, daemonOk) {
const bar = document.querySelector('.status-chips');
if (!bar) return;
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.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('');
const lc = document.getElementById('last-check');
if (lc && lastCheck) lc.textContent = lastCheck;
// Update browser tab title with alert count
const critCount = summary.critical || 0;
const warnCount = summary.warning || 0;
if (critCount) {
document.title = `(${critCount} CRIT) GANDALF`;
} else if (warnCount) {
document.title = `(${warnCount} WARN) GANDALF`;
} else {
document.title = 'GANDALF';
if (critCount) document.title = `(${critCount} CRIT) GANDALF`;
else if (warnCount) document.title = `(${warnCount} WARN) GANDALF`;
else document.title = 'GANDALF';
const alertBadge = document.getElementById('alert-count-badge');
if (alertBadge) {
const total = critCount + warnCount;
alertBadge.textContent = total;
alertBadge.style.display = total ? '' : 'none';
}
// Update stat cards
const scCrit = document.getElementById('stat-critical-val');
const scWarn = document.getElementById('stat-warning-val');
if (scCrit) scCrit.textContent = critCount;
if (scWarn) scWarn.textContent = warnCount;
const statCritCard = document.getElementById('stat-critical');
if (statCritCard) statCritCard.classList.toggle('lt-stat-card--alert', critCount > 0);
// Stale data banner: warn if last_check is older than 15 minutes
let staleBanner = document.getElementById('stale-banner');
if (lastCheck) {
// last_check format: "2026-03-14 14:14:21 UTC"
const checkAge = (Date.now() - new Date(lastCheck.replace(' UTC', 'Z').replace(' ', 'T'))) / 1000;
if (checkAge > 900) { // 15 minutes
const checkAge = (Date.now() - new Date(_toIso(lastCheck))) / 1000;
if (checkAge > 900) {
if (!staleBanner) {
staleBanner = document.createElement('div');
staleBanner.id = 'stale-banner';
staleBanner.className = 'stale-banner';
document.querySelector('.main').prepend(staleBanner);
staleBanner.className = 'lt-alert lt-alert--warning';
staleBanner.innerHTML = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>';
document.querySelector('.lt-main').prepend(staleBanner);
}
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.querySelector('.lt-alert-msg').textContent =
`Monitoring data is stale — last check was ${mins} minute${mins !== 1 ? 's' : ''} ago. The monitor daemon may be down.`;
staleBanner.style.display = '';
} else if (staleBanner) {
staleBanner.style.display = 'none';
@@ -83,19 +120,19 @@ function updateStatusBar(summary, lastCheck) {
}
function updateHostGrid(hosts) {
const scHosts = document.getElementById('stat-hosts-val');
if (scHosts) scHosts.textContent = Object.keys(hosts).length;
for (const [name, host] of Object.entries(hosts)) {
const card = document.querySelector(`.host-card[data-host="${CSS.escape(name)}"]`);
if (!card) continue;
// Update card border class
card.className = card.className.replace(/host-card-(up|down|degraded|unknown)/g, '');
card.classList.add(`host-card-${host.status}`);
// Update status dot in header
const dot = card.querySelector('.host-status-dot');
if (dot) dot.className = `host-status-dot dot-${host.status}`;
// Update interface rows
const ifaceList = card.querySelector('.iface-list');
if (ifaceList && host.interfaces && Object.keys(host.interfaces).length > 0) {
ifaceList.innerHTML = Object.entries(host.interfaces)
@@ -103,7 +140,7 @@ function updateHostGrid(hosts) {
.map(([iface, state]) => `
<div class="iface-row">
<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>
</div>
`).join('');
@@ -116,13 +153,18 @@ function updateTopology(hosts) {
const name = node.dataset.host;
const host = hosts[name];
if (!host) return;
node.className = node.className.replace(/topo-status-(up|down|degraded|unknown)/g, '');
node.classList.add(`topo-status-${host.status}`);
node.className = node.className.replace(/topo-v2-status-(up|down|degraded|unknown)/g, '');
node.classList.add(`topo-v2-status-${host.status}`);
const badge = node.querySelector('.topo-badge');
if (badge) {
badge.className = `topo-badge topo-badge-${host.status}`;
badge.textContent = host.status;
}
// Animate the 10G drop-wire red+dashed when host is down
document.querySelectorAll(`.topo-v2-wire-10g[data-host="${CSS.escape(name)}"]`).forEach(wire => {
wire.classList.toggle('wire-down', host.status === 'down');
});
});
}
@@ -133,35 +175,41 @@ function updateUnifiTable(devices) {
tbody.innerHTML = devices.map(d => {
const statusClass = d.connected ? '' : 'row-critical';
const dotClass = d.connected ? 'dot-up' : 'dot-down';
const statusText = d.connected ? 'Online' : 'Offline';
const statusText = d.connected ? 'ONLINE' : 'OFFLINE';
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-name="${escHtml(d.name)}"
data-sup-detail="">🔕 Suppress</button>`
data-sup-name="${lt.escHtml(d.name)}"
data-sup-detail=""
aria-label="Suppress alerts for ${lt.escHtml(d.name)}">🔕 Suppress</button>`
: '';
return `
<tr class="${statusClass}">
<td><span class="${dotClass}"></span> ${statusText}</td>
<td><strong>${escHtml(d.name)}</strong></td>
<td>${escHtml(d.type)}</td>
<td>${escHtml(d.model)}</td>
<td>${escHtml(d.ip)}</td>
<td><strong>${lt.escHtml(d.name)}</strong></td>
<td>${lt.escHtml(d.type)}</td>
<td>${lt.escHtml(d.model)}</td>
<td>${lt.escHtml(d.ip)}</td>
<td>${suppressBtn}</td>
</tr>`;
}).join('');
}
function updateEventsTable(events) {
function updateEventsTable(events, totalActive) {
const wrap = document.getElementById('events-table-wrap');
if (!wrap) return;
const active = events.filter(e => e.severity !== 'info');
const active = (events || []).filter(e => e.severity !== 'info');
if (!active.length) {
wrap.innerHTML = '<p class="empty-state">No active alerts ✔</p>';
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✔</div><div class="lt-empty-state-title">No active alerts</div></div>';
return;
}
const truncated = totalActive != null && totalActive > active.length;
const countNotice = truncated
? `<div class="pagination-notice">Showing ${active.length} of ${totalActive} active alerts &mdash; <a href="/api/events?limit=1000">view all via API</a></div>`
: '';
const rows = active.map(e => {
const supType = e.event_type === 'unifi_device_offline' ? 'unifi_device'
: e.event_type === 'interface_down' ? 'interface'
@@ -169,35 +217,39 @@ function updateEventsTable(events) {
const ticketBase = (typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG.ticket_web_url)
? GANDALF_CONFIG.ticket_web_url : 'http://t.lotusguild.org/ticket/';
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>`
: '';
return `
<tr class="row-${e.severity}">
<td><span class="badge badge-${e.severity}">${e.severity}</span></td>
<td>${escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${escHtml(e.target_name)}</strong></td>
<td>${escHtml(e.target_detail || '')}</td>
<td class="desc-cell" title="${escHtml(e.description || '')}">${escHtml((e.description||'').substring(0,60))}${(e.description||'').length>60?'…':''}</td>
<td class="ts-cell">${escHtml(e.first_seen||'')}</td>
<td><span class="lt-badge badge-${e.severity}">${e.severity}</span></td>
<td>${lt.escHtml(e.event_type.replace(/_/g,' '))}</td>
<td><strong>${lt.escHtml(e.target_name)}</strong></td>
<td>${lt.escHtml(e.target_detail || '')}</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="${lt.escHtml(e.first_seen||'')}">${lt.time.ago(_toIso(e.first_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>${ticket}</td>
<td>
<button class="btn-sm btn-suppress"
data-sup-type="${escHtml(supType)}"
data-sup-name="${escHtml(e.target_name)}"
data-sup-detail="${escHtml(e.target_detail||'')}">🔕</button>
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
data-sup-type="${lt.escHtml(supType)}"
data-sup-name="${lt.escHtml(e.target_name)}"
data-sup-detail="${lt.escHtml(e.target_detail||'')}"
aria-label="Suppress alert for ${lt.escHtml(e.target_name)}">🔕</button>
</td>
</tr>`;
}).join('');
wrap.innerHTML = `
<div class="table-wrap">
<table class="data-table" id="events-table">
${countNotice}
<div class="lt-table-wrap">
<table class="lt-table" id="events-table">
<caption class="lt-sr-only">Active network alerts</caption>
<thead>
<tr>
<th>Sev</th><th>Type</th><th>Target</th><th>Detail</th>
<th>Description</th><th>First Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
<th>Description</th><th>First Seen</th><th>Last Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
@@ -205,7 +257,7 @@ function updateEventsTable(events) {
</div>`;
}
// ── Suppression modal (dashboard) ────────────────────────────────────
// ── Suppression modal ─────────────────────────────────────────────────
function openSuppressModal(type, name, detail) {
const modal = document.getElementById('suppress-modal');
if (!modal) return;
@@ -217,7 +269,7 @@ function openSuppressModal(type, name, detail) {
document.getElementById('sup-expires').value = '';
updateSuppressForm();
modal.style.display = 'flex';
lt.modal.open('suppress-modal');
document.querySelectorAll('#suppress-modal .pill').forEach(p => p.classList.remove('active'));
const manualPill = document.querySelector('#suppress-modal .pill-manual');
@@ -227,8 +279,7 @@ function openSuppressModal(type, name, detail) {
}
function closeSuppressModal() {
const modal = document.getElementById('suppress-modal');
if (modal) modal.style.display = 'none';
lt.modal.close('suppress-modal');
}
function updateSuppressForm() {
@@ -266,37 +317,38 @@ async function submitSuppress(e) {
if (type !== 'all' && !name.trim()) { showToast('Target name is required', 'error'); return; }
try {
const resp = await fetch('/api/suppressions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
await lt.api.post('/api/suppressions', {
target_type: type,
target_name: name,
target_detail: detail,
reason: reason,
reason,
expires_minutes: expires ? parseInt(expires) : null,
}),
});
const data = await resp.json();
if (data.success) {
closeSuppressModal();
showToast('Suppression applied ✔', 'success');
setTimeout(refreshAll, 500);
} else {
showToast(data.error || 'Failed to apply suppression', 'error');
}
} 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 => {
// Close modal when clicking backdrop
const modal = document.getElementById('suppress-modal');
if (modal && e.target === modal) { closeSuppressModal(); return; }
// Refresh button
if (e.target.closest('[data-action="refresh"]')) {
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]');
if (btn) {
openSuppressModal(
@@ -306,13 +358,3 @@ document.addEventListener('click', e => {
);
}
});
// ── Utility ───────────────────────────────────────────────────────────
function escHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+5614
View File
File diff suppressed because it is too large Load Diff
+2947
View File
File diff suppressed because it is too large Load Diff
+634 -1066
View File
File diff suppressed because it is too large Load Diff
+458 -17
View File
@@ -1,46 +1,291 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}GANDALF{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<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='style.css') }}">
<!-- base.js loaded in head so lt.* is available for inline scripts -->
<script src="{{ url_for('static', filename='base.js') }}"></script>
</head>
<body>
<header class="header">
<div class="header-left">
<div class="header-brand">
<span class="header-title">GANDALF</span>
<span class="header-sub">Network Monitor // LotusGuild</span>
<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>
</div>
<nav class="header-nav">
<!-- MOBILE NAV DRAWER -->
<div id="lt-nav-drawer" class="lt-nav-drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu">
<div class="lt-nav-drawer-header">
<span class="lt-brand-title">GANDALF</span>
<button type="button" class="lt-nav-drawer-close" id="lt-nav-drawer-close" aria-label="Close navigation">&#x2715;</button>
</div>
<nav class="lt-nav-drawer-links" aria-label="Mobile navigation">
<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
</a>
<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
</a>
<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
</a>
<a href="{{ url_for('suppressions_page') }}"
class="nav-link {% if request.endpoint == 'suppressions_page' %}active{% endif %}">
{% if user.groups and 'admin' in user.groups %}
<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 &#x25BE;
</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
</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>
</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 }}">
<img src="{{ url_for('api_avatar') }}" alt="" class="lt-avatar-img">
<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 %}
<!-- Notification bell — shows active monitoring alerts -->
<div class="lt-notif-dropdown-wrap" id="lt-notif-wrap">
<button type="button"
class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-bell-btn"
id="lt-notif-bell"
aria-label="Active alerts"
aria-expanded="false"
aria-controls="lt-notif-panel"
title="Active alerts">&#x1F514;</button>
<div class="lt-notif-panel" id="lt-notif-panel" aria-hidden="true" role="dialog" aria-label="Active alerts">
<div class="lt-notif-panel-header">
<span>Active Alerts</span>
<button type="button" class="lt-notif-panel-clear" id="lt-notif-clear-btn">Mark all read</button>
</div>
<div class="lt-notif-panel-list" id="lt-notif-list">
<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Loading&hellip;</div>
</div>
<div class="lt-notif-panel-footer">
<a href="{{ url_for('index') }}" class="lt-btn lt-btn-ghost lt-btn-sm" style="width:100%;text-align:center;display:block;font-size:0.72rem">View dashboard</a>
</div>
</div>
</div>
<!-- ⌘K affordance -->
<button type="button"
class="lt-btn lt-btn-ghost lt-btn-sm"
title="Command palette (Ctrl+K)"
aria-label="Open command palette"
onclick="if(window.lt&&lt.cmdPalette)lt.cmdPalette.open()"
style="font-size:0.65rem;opacity:0.55;letter-spacing:0.03em;padding:0.2rem 0.45rem">&#x2315;&nbsp;K</button>
<button type="button" class="lt-theme-btn" id="lt-theme-btn"
aria-label="Toggle theme" title="Toggle light/dark mode">&#x2600;</button>
</div>
</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">&gt;</span>
<input id="lt-cmd-input" class="lt-cmd-input" type="text"
placeholder="Search commands&hellip;" 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&hellip;</div>
</div>
<div class="lt-cmd-footer">
<span><kbd>&#x2191;</kbd><kbd>&#x2193;</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 %}
</main>
<!-- FOOTER — context-sensitive per page -->
<footer class="lt-footer" role="contentinfo">
<nav class="lt-footer-hints" aria-label="Keyboard shortcuts">
{% if request.endpoint == 'index' %}
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
<span class="lt-footer-sep">|</span>
<span class="lt-footer-hint"><span class="lt-footer-key">[ S ]</span> SUPPRESS</span>
<span class="lt-footer-sep">|</span>
{% elif request.endpoint == 'links_page' %}
<span class="lt-footer-hint"><span class="lt-footer-key">[ R ]</span> REFRESH</span>
<span class="lt-footer-sep">|</span>
{% endif %}
<button type="button" class="lt-footer-hint" data-action="open-settings"><span class="lt-footer-key">[ * ]</span> CFG</button>
<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 &mdash; 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">&#x2715;</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 / &#x2318; + K</td><td>Command palette</td></tr>
<tr><td>R</td><td>Refresh data (Dashboard / Link Debug)</td></tr>
<tr><td>S</td><td>Quick-suppress alert (Dashboard)</td></tr>
<tr><td>*</td><td>Open settings</td></tr>
<tr><td>?</td><td>Show this help</td></tr>
<tr><td>ESC</td><td>Close modal / panel</td></tr>
</tbody>
</table>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn" data-modal-close>Close</button>
</div>
</div>
</div>
<!-- SETTINGS MODAL -->
<div id="lt-settings-modal" class="lt-modal-overlay" aria-hidden="true">
<div class="lt-modal" role="dialog" aria-modal="true" aria-labelledby="settings-modal-title">
<div class="lt-modal-header">
<span class="lt-modal-title" id="settings-modal-title">Settings</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</button>
</div>
<div class="lt-modal-body">
<div class="lt-form-group">
<label class="lt-label">Auto-refresh interval</label>
<div class="duration-pills" id="settings-refresh-pills">
<button type="button" class="pill" data-refresh-interval="15">15 s</button>
<button type="button" class="pill" data-refresh-interval="30">30 s</button>
<button type="button" class="pill" data-refresh-interval="60">1 min</button>
<button type="button" class="pill" data-refresh-interval="300">5 min</button>
<button type="button" class="pill" data-refresh-interval="0">Off</button>
</div>
<div class="lt-field-hint" id="settings-refresh-hint"></div>
</div>
<div class="lt-divider" style="margin:1rem 0 0.75rem"></div>
<div class="lt-kv-grid">
<div class="lt-kv-row">
<span class="lt-kv-label">User</span>
<span class="lt-kv-value lt-text-cyan">{{ user.name or user.username }}</span>
</div>
{% if user.groups %}
<div class="lt-kv-row">
<span class="lt-kv-label">Groups</span>
<span class="lt-kv-value">{{ user.groups | join(', ') }}</span>
</div>
{% endif %}
</div>
</div>
<div class="lt-modal-footer">
<button type="button" class="lt-btn" data-modal-close>Close</button>
</div>
</div>
</div>
<script>
const GANDALF_CONFIG = {
ticket_web_url: "{{ config.get('ticket_api', {}).get('web_url', 'http://t.lotusguild.org/ticket/') }}"
@@ -48,5 +293,201 @@
</script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
{% 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: 'action-refresh', group: 'Actions', icon: '↻', label: 'Refresh Data', kbd: 'R', action: function() { lt.autoRefresh.now(); } },
{ id: 'action-suppress', group: 'Actions', icon: '🔕', label: 'New Suppression', kbd: 'S', action: function() { if (typeof openSuppressModal === 'function') openSuppressModal('host','',''); else window.location.href='/suppressions'; } },
{ id: 'help-shortcuts', group: 'Help', icon: '?', label: 'Keyboard Shortcuts', kbd: '?', action: function() { lt.modal.open('lt-keys-help'); } },
{ id: 'help-settings', group: 'Help', icon: '*', label: 'Settings', kbd: '*', action: function() { lt.modal.open('lt-settings-modal'); } },
{ id: 'help-theme', group: 'Help', icon: '☀', label: 'Toggle Theme', action: function() { lt.theme.toggle(); } },
]);
}
// ── Global footer + key actions ───────────────────────────────────────
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
if (action === 'show-keyboard-help' && window.lt) lt.modal.open('lt-keys-help');
if (action === 'open-settings' && window.lt) lt.modal.open('lt-settings-modal');
});
lt.keys.on('r', function() { lt.autoRefresh.now(); });
lt.keys.on('?', function() { if (window.lt) lt.modal.open('lt-keys-help'); });
lt.keys.on('*', function() { if (window.lt) lt.modal.open('lt-settings-modal'); });
lt.keys.on('s', function() {
if (typeof openSuppressModal === 'function') openSuppressModal('host', '', '');
});
// ── Avatar image error fallback ───────────────────────────────────────
document.addEventListener('error', function(e) {
if (e.target.tagName === 'IMG' && e.target.closest('.lt-avatar')) {
e.target.classList.add('lt-avatar-img-err');
}
}, true);
// ── Settings modal ────────────────────────────────────────────────────
(function() {
var LS_KEY = 'gandalf_settings';
var DEFAULT = { refreshInterval: 30 };
function loadSettings() {
try { return Object.assign({}, DEFAULT, JSON.parse(localStorage.getItem(LS_KEY) || '{}')); }
catch(_) { return Object.assign({}, DEFAULT); }
}
function saveSettings(s) {
try { localStorage.setItem(LS_KEY, JSON.stringify(s)); } catch(_) {}
if (typeof window.onGandalfSettingsChanged === 'function') window.onGandalfSettingsChanged(s);
}
function applyRefreshPillUI(interval) {
document.querySelectorAll('#settings-refresh-pills .pill').forEach(function(p) {
p.classList.toggle('active', parseInt(p.dataset.refreshInterval) === interval);
});
var hint = document.getElementById('settings-refresh-hint');
if (hint) {
if (interval === 0) hint.textContent = 'Auto-refresh disabled.';
else if (interval < 60) hint.textContent = 'Refreshes every ' + interval + ' seconds.';
else hint.textContent = 'Refreshes every ' + Math.floor(interval/60) + ' minute' + (interval > 60 ? 's' : '') + '.';
}
}
// Init pill UI from saved settings
var _settings = loadSettings();
applyRefreshPillUI(_settings.refreshInterval);
// Expose for pages that need to read it (e.g. index.html for autoRefresh)
window.gandalfSettings = _settings;
document.addEventListener('click', function(e) {
var pill = e.target.closest('#settings-refresh-pills .pill[data-refresh-interval]');
if (!pill) return;
var interval = parseInt(pill.dataset.refreshInterval);
_settings.refreshInterval = interval;
saveSettings(_settings);
applyRefreshPillUI(interval);
});
})();
// ── Notification Bell — shows active monitoring alerts ────────────────
(function() {
var bell = document.getElementById('lt-notif-bell');
var panel = document.getElementById('lt-notif-panel');
var list = document.getElementById('lt-notif-list');
var clearBtn = document.getElementById('lt-notif-clear-btn');
var wrapEl = document.getElementById('lt-notif-wrap');
if (!bell || !panel) return;
var _open = false;
var _lastEvents = [];
var LS_READ_KEY = 'gandalf_notif_read_before';
function getReadBefore() {
try { return parseInt(localStorage.getItem(LS_READ_KEY) || '0'); } catch(_) { return 0; }
}
function setReadBefore(ts) {
try { localStorage.setItem(LS_READ_KEY, String(ts)); } catch(_) {}
}
function esc(s) {
return (window.lt && lt.escHtml) ? lt.escHtml(String(s)) : String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function toMs(dateStr) {
if (!dateStr) return 0;
return new Date(dateStr.replace(' UTC','Z').replace(' ','T')).getTime();
}
function fmtAgo(dateStr) {
var diff = Math.floor((Date.now() - toMs(dateStr)) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
return Math.floor(diff/86400) + 'd ago';
}
var SEV_DOT = { critical: 'var(--red)', warning: 'var(--amber)' };
function renderAlerts(events) {
_lastEvents = events || [];
var readBefore = getReadBefore();
var active = _lastEvents.filter(function(e) { return e.severity !== 'info'; });
var unreadCount = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
lt.notif.set(bell, unreadCount);
if (!active.length) {
list.innerHTML = '<div style="padding:1rem;font-size:0.75rem;color:var(--text-muted);text-align:center">&#x2714; No active alerts</div>';
return;
}
list.innerHTML = active.slice(0, 25).map(function(e) {
var isUnread = toMs(e.last_seen) > readBefore;
var dotColor = SEV_DOT[e.severity] || 'var(--text-muted)';
return '<div class="lt-notif-item' + (isUnread ? ' lt-notif-item--unread' : '') + '">' +
'<div class="lt-notif-dot' + (isUnread ? '' : ' lt-notif-dot--read') + '" style="background:' + dotColor + ';border-radius:50%;margin-top:4px"></div>' +
'<div class="lt-notif-item-body">' +
'<div class="lt-notif-item-title">' + esc(e.target_name) + (e.target_detail ? ' &middot; ' + esc(e.target_detail) : '') + '</div>' +
'<div class="lt-notif-item-time">' + esc(e.event_type.replace(/_/g,' ')) + ' &middot; ' + fmtAgo(e.last_seen) + '</div>' +
'</div></div>';
}).join('');
}
function fetchAlerts(andRender) {
fetch('/api/status', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
var events = data.events || [];
if (andRender) {
renderAlerts(events);
} else {
_lastEvents = events;
var readBefore = getReadBefore();
var active = events.filter(function(e) { return e.severity !== 'info'; });
var unread = active.filter(function(e) { return toMs(e.last_seen) > readBefore; }).length;
lt.notif.set(bell, unread);
}
})
.catch(function() {
if (andRender) list.innerHTML = '<div style="padding:0.75rem;font-size:0.75rem;color:var(--text-muted);text-align:center">Could not load</div>';
});
}
function openPanel() { _open = true; panel.removeAttribute('aria-hidden'); bell.setAttribute('aria-expanded','true'); fetchAlerts(true); }
function closePanel() { _open = false; panel.setAttribute('aria-hidden','true'); bell.setAttribute('aria-expanded','false'); }
bell.addEventListener('click', function(e) { e.stopPropagation(); _open ? closePanel() : openPanel(); });
if (clearBtn) {
clearBtn.addEventListener('click', function() {
setReadBefore(Date.now());
renderAlerts(_lastEvents);
});
}
document.addEventListener('click', function(e) { if (_open && wrapEl && !wrapEl.contains(e.target)) closePanel(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && _open) closePanel(); });
// Initial badge load + poll every 60 s
fetchAlerts(false);
setInterval(function() { fetchAlerts(_open); }, 60000);
// Allow refreshAll() to also push fresh events to the bell
window.gandalfNotifUpdate = function(events) { renderAlerts(events); };
})();
</script>
</body>
</html>
+382 -100
View File
@@ -6,79 +6,205 @@
<!-- ── Status bar ──────────────────────────────────────────────────── -->
<div class="status-bar">
<div class="status-chips">
{% if not daemon_ok %}
<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>
{% endif %}
{% if summary.critical %}
<span class="chip chip-critical">● {{ summary.critical }} CRITICAL</span>
{% endif %}
{% if summary.warning %}
<span class="chip chip-warning">● {{ summary.warning }} WARNING</span>
{% endif %}
{% if not summary.critical and not summary.warning %}
{% if daemon_ok and not summary.critical and not summary.warning %}
<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>
{% endif %}
</div>
<div class="status-meta">
<span class="last-check" id="last-check">{{ last_check }}</span>
<button class="btn-refresh" onclick="refreshAll()">↻ REFRESH</button>
<span class="last-check" id="last-check" data-tooltip="Last monitor poll timestamp" data-tooltip-pos="bottom">{{ last_check }}</span>
<span class="lt-spinner lt-spinner--sm lt-spinner--cyan" id="refresh-spinner" style="display:none"></span>
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="refresh" data-tooltip="Refresh all data now" data-tooltip-pos="bottom">↻ REFRESH</button>
</div>
</div>
<!-- ── Stats summary cards ──────────────────────────────────────────── -->
<div class="lt-stats-grid">
<div class="lt-stat-card{% if summary.critical %} lt-stat-card--alert{% endif %}"
id="stat-critical" role="button" tabindex="0"
data-stat-filter="critical" aria-label="{{ summary.critical or 0 }} critical alerts">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--red);text-shadow:var(--glow-red)"></span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="stat-critical-val" style="color:var(--red)">{{ summary.critical or 0 }}</span>
<span class="lt-stat-label">Critical</span>
</div>
</div>
<div class="lt-stat-card"
id="stat-warning" role="button" tabindex="0"
data-stat-filter="warning" aria-label="{{ summary.warning or 0 }} warning alerts">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--amber)"></span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="stat-warning-val" style="color:var(--amber)">{{ summary.warning or 0 }}</span>
<span class="lt-stat-label">Warning</span>
</div>
</div>
<div class="lt-stat-card" id="stat-hosts" aria-label="Monitored hosts">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--cyan)"></span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="stat-hosts-val" style="color:var(--cyan)">{{ snapshot.hosts | length }}</span>
<span class="lt-stat-label">Hosts</span>
</div>
</div>
<div class="lt-stat-card" id="stat-resolved" aria-label="{{ recent_resolved | length }} alerts resolved in last 24 hours">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--green);text-shadow:var(--glow)"></span>
<div class="lt-stat-info">
<span class="lt-stat-value" id="stat-resolved-val" style="color:var(--green)">{{ recent_resolved | length }}</span>
<span class="lt-stat-label">Resolved 24h</span>
</div>
</div>
</div>
<!-- ── Network topology + host grid ───────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Network Hosts</h2>
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Network Hosts</h2>
</div>
<div class="topology" id="topology-diagram">
<div class="topo-row topo-row-internet">
<div class="topo-node topo-internet">
<span class="topo-icon"></span>
<span class="topo-label">Internet</span>
<div class="topo-v2">
{%- set topo_h = snapshot.hosts if snapshot.hosts else {} -%}
<!-- ══════════════════════════════════════════════════════════════
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 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),rgba(0,212,255,.3)); opacity:.7;"></div>
<span class="topo-vc-label">WAN · 10G SFP+</span>
</div>
<div class="topo-row">
<div class="topo-node topo-unifi" id="topo-gateway">
<span class="topo-icon"></span>
<span class="topo-label">UDM-Pro</span>
<span class="topo-status-dot" data-topo-target="gateway"></span>
<!-- ══════════════════════════════════════════════════════════════
TIER 2: Router UDM-Pro
══════════════════════════════════════════════════════════ -->
<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 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 class="topo-row">
<div class="topo-node topo-switch" id="topo-switch-agg">
<span class="topo-icon"></span>
<span class="topo-label">Agg Switch</span>
<span class="topo-status-dot" data-topo-target="switch-agg"></span>
<!-- ══════════════════════════════════════════════════════════════
TIER 3: USW-Aggregation
══════════════════════════════════════════════════════════ -->
<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 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 class="topo-row">
<div class="topo-node topo-switch" id="topo-switch-poe">
<span class="topo-icon"></span>
<span class="topo-label">PoE Switch</span>
<span class="topo-status-dot" data-topo-target="switch-poe"></span>
<!-- ══════════════════════════════════════════════════════════════
TIER 4: Pro 24 PoE
══════════════════════════════════════════════════════════ -->
<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 class="topo-connectors wide">
{% for name in snapshot.hosts %}
<div class="topo-line"></div>
{% endfor %}
<!-- Pro 24 PoE → host bus section -->
<div class="topo-vc">
<div class="topo-vc-wire" style="background:var(--border-color);opacity:.5;"></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 }}">
<span class="topo-icon"></span>
<span class="topo-label">{{ name }}</span>
<span class="topo-badge topo-badge-{{ host.status }}">{{ host.status }}</span>
<!-- ══════════════════════════════════════════════════════════════
TIER 4 connecting bus two rails (10G green + 1G amber dashed)
showing dual-homing for all 6 servers
══════════════════════════════════════════════════════════ -->
<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>
{% endfor %}
<!-- 1G management bus (PoE → DHCP) -->
<div class="topo-bus-1g">
<span class="topo-bus-1g-label">← Pro 24 PoE · 1G · DHCP mgmt →</span>
<div class="topo-bus-1g-line"></div>
</div>
<!-- ── Host nodes with drop wires ── -->
<div class="topo-v2-hosts">
{%- set all_defs = [
('compute-storage-gpu-01', 'csg-01', 'RU412', 'Ceph · VLAN90', False),
('compute-storage-01', 'cs-01', 'RU1417', '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" data-host="{{ hname }}" title="10G SFP+ → USW-Agg"></div>
<div class="topo-v2-wire-1g" data-host="{{ hname }}" 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>
<!-- Host cards -->
@@ -115,7 +241,7 @@
{% endif %}
<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-name="{{ name }}"
data-sup-detail=""
@@ -123,25 +249,34 @@
🔕 Suppress
</button>
<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
</a>
</div>
</div>
{% else %}
<p class="empty-state">No host data yet monitor is initializing.</p>
<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-icon"></div>
<div class="lt-empty-state-title">No host data yet</div>
<div class="lt-empty-state-body">The monitor daemon may still be starting up.</div>
</div>
{% endfor %}
</div>
</section>
<!-- ── UniFi devices ────────────────────────────────────────────────── -->
{% if snapshot.unifi %}
<section class="section">
<div class="section-header">
<h2 class="section-title">UniFi Devices</h2>
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">UniFi Devices</h2>
</div>
<div class="table-wrap">
<table class="data-table" id="unifi-table">
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Device Inventory</div>
<div class="lt-table-wrap">
<table class="lt-table" id="unifi-table">
<caption class="lt-sr-only">UniFi network devices</caption>
<thead>
<tr>
<th>Status</th>
@@ -165,7 +300,7 @@
<td>{{ d.ip }}</td>
<td>
{% 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-name="{{ d.name }}"
data-sup-detail="">
@@ -178,30 +313,52 @@
</tbody>
</table>
</div>
</div>
</section>
{% endif %}
<!-- ── Active alerts ───────────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Active Alerts</h2>
{% if summary.critical or summary.warning %}
<span class="section-badge">{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
{% endif %}
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Active Alerts</h2>
<span class="g-section-badge" id="alert-count-badge"
{% if not summary.critical and not summary.warning %}style="display:none"{% endif %}>{{ (summary.critical or 0) + (summary.warning or 0) }}</span>
</div>
<div class="lt-toolbar">
<div class="lt-toolbar-left">
<div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="events-search"
placeholder="Filter by target, type, description…" autocomplete="off">
</div>
<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 class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Alert Queue</div>
<div id="events-table-wrap">
{% if events %}
<div class="table-wrap">
<table class="data-table" id="events-table">
{% if total_active is defined and total_active > events|length %}
<div class="pagination-notice">Showing {{ events|length }} of {{ total_active }} active alerts &mdash; <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>
<tr>
<th>Sev</th>
<th data-tooltip="Alert severity level" data-tooltip-pos="bottom">Sev</th>
<th>Type</th>
<th>Target</th>
<th>Detail</th>
<th>Description</th>
<th>First Seen</th>
<th>Failures</th>
<th data-tooltip="When this alert was first raised" data-tooltip-pos="bottom">First Seen</th>
<th data-tooltip="Most recent check failure" data-tooltip-pos="bottom">Last Seen</th>
<th data-tooltip="Consecutive check failures since first seen" data-tooltip-pos="bottom">Failures</th>
<th>Ticket</th>
<th>Actions</th>
</tr>
@@ -210,12 +367,17 @@
{% for e in events %}
{% if e.severity != 'info' %}
<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><strong>{{ e.target_name }}</strong></td>
<td>{{ e.target_detail or '' }}</td>
<td class="desc-cell" title="{{ e.description | e }}">{{ e.description | truncate(60) }}</td>
<td class="ts-cell">{{ e.first_seen }}</td>
<td class="ts-cell" title="{{ e.first_seen }}">
<span class="event-age" data-ts="{{ e.first_seen }}">{{ e.first_seen }}</span>
</td>
<td class="ts-cell" title="{{ e.last_seen }}">
<span class="event-age" data-ts="{{ e.last_seen }}">{{ e.last_seen }}</span>
</td>
<td>{{ e.consecutive_failures }}</td>
<td>
{% if e.ticket_id %}
@@ -224,71 +386,112 @@
{% else %}{% endif %}
</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-name="{{ e.target_name }}"
data-sup-detail="{{ e.target_detail or '' }}"
title="Suppress">🔕</button>
title="Suppress" aria-label="Suppress alert for {{ e.target_name }}">🔕</button>
</td>
</tr>
{% endif %}
{% else %}
<tr><td colspan="9" class="empty-state">No active alerts ✔</td></tr>
<tr><td colspan="10">
<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-icon"></div>
<div class="lt-empty-state-title">No active alerts</div>
</div>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty-state">No active alerts ✔</p>
{% endif %}
<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-icon"></div>
<div class="lt-empty-state-title">No active alerts</div>
</div>
{% endif %}
</div><!-- /#events-table-wrap -->
</div><!-- /.lt-frame -->
</section>
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
<div id="suppress-modal" class="modal-overlay" style="display:none">
<div class="modal">
<div class="modal-header">
<h3>Suppress Alert</h3>
<button class="modal-close" onclick="closeSuppressModal()"></button>
<!-- ── 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>
<form id="suppress-form" onsubmit="submitSuppress(event)">
<div class="form-group" style="margin-bottom:10px">
<label>Target Type</label>
<select id="sup-type" name="target_type" onchange="updateSuppressForm()">
<div class="lt-timeline">
{% for e in recent_resolved %}
{%- set dot_cls = 'lt-timeline-item--green' if e.severity == 'info' else 'lt-timeline-item--dim' -%}
<div class="lt-timeline-item {{ dot_cls }}">
<div class="lt-timeline-meta">
<strong class="lt-timeline-actor">{{ e.target_name }}</strong>
{% if e.target_detail %}<span>· {{ e.target_detail }}</span>{% endif %}
<span class="lt-timeline-time event-age" data-ts="{{ e.resolved_at }}">{{ e.resolved_at }}</span>
</div>
<div class="lt-timeline-body">
{{ e.event_type | replace('_', ' ') }}
&nbsp;·&nbsp;
<span class="lt-badge badge-resolved">{{ e.severity }}</span>
&nbsp;·&nbsp; duration
<span class="event-duration" data-first="{{ e.first_seen }}" data-resolved="{{ e.resolved_at }}"></span>
</div>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- ── Quick-suppress modal ─────────────────────────────────────────── -->
<div id="suppress-modal" class="lt-modal-overlay"
role="dialog" aria-modal="true" aria-labelledby="suppress-modal-title" aria-hidden="true">
<div class="lt-modal">
<div class="lt-modal-header">
<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>
<form id="suppress-form">
<div class="lt-modal-body">
<div class="lt-form-group" style="margin-bottom:12px">
<label class="lt-label" for="sup-type">Target Type</label>
<select class="lt-select" id="sup-type" name="target_type">
<option value="host">Host (all interfaces)</option>
<option value="interface">Specific Interface</option>
<option value="unifi_device">UniFi Device</option>
<option value="all">Global Maintenance</option>
</select>
</div>
<div class="form-group" id="sup-name-group" style="margin-bottom:10px">
<label>Target Name</label>
<input type="text" id="sup-name" name="target_name" placeholder="e.g. large1">
<div class="lt-form-group" id="sup-name-group" style="margin-bottom:12px">
<label class="lt-label" for="sup-name">Target Name</label>
<input type="text" class="lt-input" id="sup-name" name="target_name" placeholder="e.g. large1">
</div>
<div class="form-group" id="sup-detail-group" style="margin-bottom:10px;display:none">
<label>Interface <span class="form-hint">(interface type only)</span></label>
<input type="text" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
<div class="lt-form-group" id="sup-detail-group" style="margin-bottom:12px;display:none">
<label class="lt-label" for="sup-detail">Interface <span class="lt-field-hint">(interface type only)</span></label>
<input type="text" class="lt-input" id="sup-detail" name="target_detail" placeholder="e.g. enp35s0">
</div>
<div class="form-group" style="margin-bottom:10px">
<label>Reason <span class="required">*</span></label>
<input type="text" id="sup-reason" name="reason"
<div class="lt-form-group" style="margin-bottom:12px">
<label class="lt-label" for="sup-reason">Reason <span class="required">*</span></label>
<input type="text" class="lt-input" id="sup-reason" name="reason"
placeholder="e.g. Planned switch reboot" required>
</div>
<div class="form-group" style="margin-bottom:0">
<label>Duration</label>
<div class="lt-form-group" style="margin-bottom:0">
<label class="lt-label">Duration</label>
<div class="duration-pills">
<button type="button" class="pill" onclick="setDuration(30, this)">30 min</button>
<button type="button" class="pill" onclick="setDuration(60, this)">1 hr</button>
<button type="button" class="pill" onclick="setDuration(240, this)">4 hr</button>
<button type="button" class="pill" onclick="setDuration(480, this)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDuration(null, this)">Manual ∞</button>
<button type="button" class="pill" data-duration="30">30 min</button>
<button type="button" class="pill" data-duration="60">1 hr</button>
<button type="button" class="pill" data-duration="240">4 hr</button>
<button type="button" class="pill" data-duration="480">8 hr</button>
<button type="button" class="pill pill-manual active" data-duration="">Manual ∞</button>
</div>
<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 class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeSuppressModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Apply</button>
</div>
<div class="lt-modal-footer">
<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>
</form>
</div>
@@ -298,6 +501,85 @@
{% block scripts %}
<script>
setInterval(refreshAll, 30000);
// Start auto-refresh using saved settings interval (default 30 s)
var _savedInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 30;
if (_savedInterval > 0) lt.autoRefresh.start(refreshAll, _savedInterval * 1000);
// When settings change, restart auto-refresh with new interval
window.onGandalfSettingsChanged = function(s) {
lt.autoRefresh.stop();
if (s.refreshInterval > 0) lt.autoRefresh.start(refreshAll, s.refreshInterval * 1000);
};
document.getElementById('suppress-form')?.addEventListener('submit', submitSuppress);
document.getElementById('sup-type')?.addEventListener('change', updateSuppressForm);
function updateEventAges() {
document.querySelectorAll('.event-age[data-ts]').forEach(el => {
el.textContent = lt.time.ago(_toIso(el.dataset.ts));
});
}
updateEventAges();
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 });
// Stat card clicks — filter events table by severity
document.querySelectorAll('.lt-stat-card[data-stat-filter]').forEach(card => {
card.addEventListener('click', () => {
const sev = card.dataset.statFilter;
document.querySelectorAll('.sev-pills .pill').forEach(p => p.classList.remove('active'));
const matchPill = document.querySelector(`.sev-pills .pill[data-sev="${sev}"]`);
if (matchPill) matchPill.classList.add('active');
_filterSev = sev;
applyEventsFilter();
document.getElementById('events-table-wrap')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
card.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); } });
});
</script>
{% endblock %}
+126 -44
View File
@@ -3,13 +3,15 @@
{% block content %}
<div class="page-header">
<h1 class="page-title">Network Inspector</h1>
<p class="page-sub">
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Network Inspector</h1>
<p class="g-page-sub" style="margin-top:4px">
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:8px"></span>
</p>
</div>
</div>
<div class="inspector-layout">
<div class="inspector-main" id="inspector-main">
@@ -24,6 +26,8 @@
{% block scripts %}
<script>
const escHtml = s => lt.escHtml(s);
// ── Switch layout config ─────────────────────────────────────────────────
// keys match the model field returned by the UniFi API
// rows: array of rows, each row is an array of port_idx values
@@ -84,16 +88,46 @@ function portBlockState(d) {
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 ──────────────────────────────────
function portBlockHtml(idx, port, swName, sfpBlock) {
const state = portBlockState(port);
const label = sfpBlock ? 'SFP' : idx;
const numLabel = sfpBlock ? 'SFP' : idx;
const title = port ? escHtml(port.name) : `Port ${idx}`;
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}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}"
onclick="selectPort(this)">${label}</div>`;
title="${title}" aria-label="${title}"
data-action="select-port"><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 ────────────────────────────────────────────
@@ -107,6 +141,9 @@ function renderChassis(swName, sw) {
const downCount = totCount - upCount;
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 = '';
if (layout) {
@@ -116,17 +153,26 @@ function renderChassis(swName, sw) {
// Main port rows
chassisHtml += '<div class="chassis-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) {
const port = portMap[idx];
const isSfp = sfpPortSet.has(idx);
const sfpCls = isSfp ? ' sfp-port' : '';
const state = portBlockState(port);
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}"
data-switch="${escHtml(swName)}" data-port-idx="${idx}"
title="${title}"
onclick="selectPort(this)">${idx}</div>`;
title="${title}" aria-label="${title}"
data-action="select-port"><span class="port-num">${idx}</span>${speedHtml}${lldpHtml}</div>`;
}
chassisHtml += '</div>';
}
@@ -158,7 +204,12 @@ function renderChassis(swName, sw) {
${sw.ip ? `<span class="chassis-ip">${escHtml(sw.ip)}</span>` : ''}
<span class="chassis-meta">${escHtml(meta)}</span>
</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>`;
}
@@ -210,7 +261,7 @@ function renderPanel(swName, idx) {
const poeMaxStr = d.poe_max_power != null ? ` / max ${d.poe_max_power.toFixed(1)}W` : '';
const poeCurStr = (d.poe_power != null && d.poe_power > 0) ? ` / draw <span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '';
poeHtml = `
<div class="panel-section-title">PoE</div>
<div class="lt-divider"><span class="lt-divider-label">PoE</span></div>
<div class="panel-row"><span class="panel-label">Class</span><span class="panel-val">class ${d.poe_class}${poeMaxStr}</span></div>
${d.poe_power != null ? `<div class="panel-row"><span class="panel-label">Draw</span><span class="panel-val">${d.poe_power > 0 ? `<span class="val-amber">${d.poe_power.toFixed(1)}W</span>` : '0W'}</span></div>` : ''}
${d.poe_mode ? `<div class="panel-row"><span class="panel-label">Mode</span><span class="panel-val">${escHtml(d.poe_mode)}</span></div>` : ''}`;
@@ -220,16 +271,16 @@ function renderPanel(swName, idx) {
let trafficHtml = '';
if (d.tx_bytes_rate != null || d.rx_bytes_rate != null) {
trafficHtml = `
<div class="panel-section-title">Traffic</div>
<div class="panel-row"><span class="panel-label">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
<div class="panel-row"><span class="panel-label">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
<div class="lt-divider"><span class="lt-divider-label">Traffic</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Transmit — outgoing from this port">TX</span><span class="panel-val">${fmtRate(d.tx_bytes_rate)}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Receive — incoming to this port">RX</span><span class="panel-val">${fmtRate(d.rx_bytes_rate)}</span></div>`;
}
// Errors / drops section
let errHtml = '';
if (d.tx_errs_rate != null || d.rx_errs_rate != null) {
errHtml = `
<div class="panel-section-title">Errors / Drops</div>
<div class="lt-divider"><span class="lt-divider-label">Errors / Drops</span></div>
<div class="panel-row"><span class="panel-label">TX Err</span><span class="panel-val">${fmtErrors(d.tx_errs_rate)}</span></div>
<div class="panel-row"><span class="panel-label">RX Err</span><span class="panel-val">${fmtErrors(d.rx_errs_rate)}</span></div>
<div class="panel-row"><span class="panel-label">TX Drop</span><span class="panel-val">${fmtErrors(d.tx_drops_rate)}</span></div>
@@ -242,7 +293,7 @@ function renderPanel(swName, idx) {
if (d.lldp && d.lldp.system_name) {
const l = d.lldp;
lldpHtml = `
<div class="panel-section-title">LLDP Neighbor</div>
<div class="lt-divider"><span class="lt-divider-label">LLDP Neighbor</span></div>
<div class="panel-row"><span class="panel-label">System</span><span class="panel-val val-cyan">${escHtml(l.system_name)}</span></div>
${l.port_id ? `<div class="panel-row"><span class="panel-label">Port</span><span class="panel-val">${escHtml(l.port_id)}</span></div>` : ''}
${l.port_desc ? `<div class="panel-row"><span class="panel-label">Port Desc</span><span class="panel-val">${escHtml(l.port_desc)}</span></div>` : ''}
@@ -269,7 +320,7 @@ function renderPanel(swName, idx) {
_apiData.hosts && _apiData.hosts[d.lldp.system_name]);
const diagHtml = hasDiagTarget ? `
<div class="diag-bar">
<button class="btn-diag" onclick="runDiagnostic('${escHtml(swName)}', ${idx})">Run Link Diagnostics</button>
<button class="btn-diag" data-action="run-diagnostic" data-sw="${escHtml(swName)}" data-idx="${idx}">Run Link Diagnostics</button>
<span class="diag-status" id="diag-status"></span>
</div>
<div class="diag-results" id="diag-results"></div>` : '';
@@ -281,14 +332,14 @@ function renderPanel(swName, idx) {
<span class="panel-port-name">${escHtml(d.name)}</span>${isUplinkBadge}
<div class="panel-meta">${escHtml(swName)} · port #${idx}</div>
</div>
<button class="panel-close" onclick="closePanel()">✕</button>
<button class="panel-close" data-action="close-panel" aria-label="Close panel">✕</button>
</div>
<div class="panel-section-title">Link</div>
<div class="lt-divider"><span class="lt-divider-label">Link</span></div>
<div class="panel-row"><span class="panel-label">Status</span><span class="panel-val">${upStr}</span></div>
<div class="panel-row"><span class="panel-label">Speed</span><span class="panel-val">${speedStr}</span></div>
<div class="panel-row"><span class="panel-label">Duplex</span><span class="panel-val">${duplexStr}</span></div>
<div class="panel-row"><span class="panel-label">Auto-neg</span><span class="panel-val val-neutral">${autoneg}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Full = simultaneous send/receive. Half = one direction at a time.">Duplex</span><span class="panel-val">${duplexStr}</span></div>
<div class="panel-row"><span class="panel-label" data-tooltip="Autonegotiation: NIC and switch automatically agree speed and duplex.">Auto-neg</span><span class="panel-val val-neutral">${autoneg}</span></div>
<div class="panel-row"><span class="panel-label">Media</span><span class="panel-val">${escHtml(mediaStr)}</span></div>
${poeHtml}
@@ -326,8 +377,25 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
const swErrTx = (swPort.tx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
const swErrRx = (swPort.rx_errs_rate > 0.001) ? 'val-crit' : 'val-good';
// Detect duplex mismatch (switch full_duplex vs server duplex string)
const swFull = swPort.full_duplex;
const svrFull = (svrData.duplex || '').toLowerCase().includes('full');
const duplexMismatch = swPort.up && svrData.duplex &&
((swFull && !svrFull) || (!swFull && svrFull));
const duplexWarnHtml = duplexMismatch
? `<div class="path-mismatch-alert">⚠ DUPLEX MISMATCH — Switch: ${swFull ? 'Full' : 'Half'} · Server: ${escHtml(svrData.duplex)}</div>`
: '';
// Detect speed mismatch
const swSpd = swPort.speed_mbps, svrSpd = svrData.speed_mbps;
const speedMismatch = swSpd && svrSpd && swSpd > 0 && svrSpd > 0 && swSpd !== svrSpd;
const speedWarnHtml = speedMismatch
? `<div class="path-mismatch-alert">⚠ SPEED MISMATCH — Switch: ${fmtSpeed(swSpd)} · Server: ${fmtSpeed(svrSpd)}</div>`
: '';
return `
<div class="panel-section-title">Path Debug <span class="path-conn-type">${escHtml(connType)}</span></div>
<div class="lt-divider"><span class="lt-divider-label">Path Debug · ${escHtml(connType)}</span></div>
${duplexWarnHtml}${speedWarnHtml}
<div class="path-debug-cols">
<div class="path-col">
<div class="path-col-header">Switch</div>
@@ -347,6 +415,7 @@ function buildPathDebug(swName, swPort, serverName, ifaceName, svrData) {
<div class="path-row"><span>RX</span><span>${fmtRate(svrData.rx_bytes_rate)}</span></div>
<div class="path-row"><span>TX Err</span><span class="${svrErrTx}">${fmtErrors(svrData.tx_errs_rate)}</span></div>
<div class="path-row"><span>RX Err</span><span class="${svrErrRx}">${fmtErrors(svrData.rx_errs_rate)}</span></div>
${svrData.carrier_changes != null ? `<div class="path-row"><span>Carrier Chg</span><span class="${(svrData.carrier_changes||0)>10?'val-crit':(svrData.carrier_changes||0)>2?'val-warn':'val-good'}">${svrData.carrier_changes}</span></div>` : ''}
${sfpDomHtml}
</div>
</div>`;
@@ -358,9 +427,10 @@ function renderInspector(data) {
const main = document.getElementById('inspector-main');
const switches = data.unifi_switches || {};
const upd = data.updated ? `Updated: ${data.updated}` : '';
const updEl = document.getElementById('inspector-updated');
if (updEl) updEl.textContent = upd;
if (updEl && data.updated) {
updEl.textContent = 'Updated: ' + new Date(data.updated + (data.updated.includes('Z') ? '' : 'Z')).toLocaleTimeString();
}
if (!Object.keys(switches).length) {
main.innerHTML = '<p class="empty-state">No switch data available. Monitor may still be initialising.</p>';
@@ -386,18 +456,40 @@ function renderInspector(data) {
// ── Fetch and render ─────────────────────────────────────────────────────
async function loadInspector() {
try {
const resp = await fetch('/api/links');
if (!resp.ok) throw new Error('API error');
const data = await resp.json();
const data = await lt.api.get('/api/links');
renderInspector(data);
} catch (e) {
document.getElementById('inspector-main').innerHTML =
'<p class="empty-state">Failed to load inspector data.</p>';
lt.toast.error('Failed to load inspector data');
}
}
loadInspector();
setInterval(loadInspector, 60000);
var _inspInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60;
if (_inspInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(_inspInterval, 15) * 1000);
window.onGandalfSettingsChanged = function(s) {
lt.autoRefresh.stop();
if (s.refreshInterval > 0) lt.autoRefresh.start(loadInspector, Math.max(s.refreshInterval, 15) * 1000);
};
lt.keys.on('Escape', () => {
if (document.getElementById('inspector-panel').classList.contains('open')) closePanel();
});
document.addEventListener('click', e => {
const portBlock = e.target.closest('[data-action="select-port"]');
if (portBlock) { selectPort(portBlock); return; }
const closeBtn = e.target.closest('[data-action="close-panel"]');
if (closeBtn) { closePanel(); return; }
const diagBtn = e.target.closest('[data-action="run-diagnostic"]');
if (diagBtn) { runDiagnostic(diagBtn.dataset.sw, parseInt(diagBtn.dataset.idx, 10)); return; }
const toggleDiag = e.target.closest('[data-action="toggle-diag"]');
if (toggleDiag) { toggleDiag.parentElement.classList.toggle('diag-open'); return; }
});
// ── Link Diagnostics ─────────────────────────────────────────────────
let _diagPollTimer = null;
@@ -413,22 +505,13 @@ function runDiagnostic(swName, portIdx) {
statusEl.textContent = 'Submitting to Pulse...';
resultsEl.innerHTML = '';
fetch('/api/diagnose', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({switch_name: swName, port_idx: portIdx}),
})
.then(r => r.json())
lt.api.post('/api/diagnose', {switch_name: swName, port_idx: portIdx})
.then(resp => {
if (resp.error) {
statusEl.textContent = 'Error: ' + resp.error;
return;
}
statusEl.textContent = 'Collecting diagnostics via Pulse...';
pollDiagnostic(resp.job_id, statusEl, resultsEl);
})
.catch(e => {
statusEl.textContent = 'Request failed: ' + e;
statusEl.textContent = 'Error: ' + (e.message || 'Request failed');
});
}
@@ -441,8 +524,7 @@ function pollDiagnostic(jobId, statusEl, resultsEl) {
statusEl.textContent = 'Timed out waiting for results.';
return;
}
fetch(`/api/diagnose/${jobId}`)
.then(r => r.json())
lt.api.get(`/api/diagnose/${jobId}`)
.then(resp => {
if (resp.status === 'done') {
clearInterval(_diagPollTimer);
@@ -576,7 +658,7 @@ function renderDiagnosticResults(d, container) {
}).join('');
nicStatHtml = `
<div class="diag-section diag-collapsible">
<div class="diag-section-header diag-toggle" onclick="this.parentElement.classList.toggle('diag-open')">
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
ethtool -S (NIC stats) <span class="diag-toggle-hint">[expand]</span>
</div>
<div class="diag-section-body">
@@ -652,7 +734,7 @@ function renderDiagnosticResults(d, container) {
}).join('');
dmesgHtml = `
<div class="diag-section diag-collapsible">
<div class="diag-section-header diag-toggle" onclick="this.parentElement.classList.toggle('diag-open')">
<div class="diag-section-header diag-toggle" data-action="toggle-diag">
Kernel Events (dmesg) <span class="diag-toggle-hint">[expand]</span>
</div>
<div class="diag-section-body">
+334 -257
View File
@@ -3,14 +3,28 @@
{% block content %}
<div class="page-header">
<h1 class="page-title">Link Debug</h1>
<p class="page-sub">
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Link Debug</h1>
<p class="g-page-sub" style="margin-top:4px">
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.
<span id="links-updated" style="margin-left:10px; color:var(--text-muted); font-size:.85em;"></span>
<span id="links-updated" style="margin-left:8px"></span>
</p>
</div>
</div>
<div class="lt-toolbar" id="links-toolbar" style="display:none">
<div class="lt-toolbar-left">
<div class="lt-search">
<input type="search" class="lt-input lt-search-input" id="links-search"
placeholder="Filter by host or switch name…" autocomplete="off">
</div>
</div>
<div class="lt-toolbar-right">
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="collapse-all">Collapse All</button>
<button class="lt-btn lt-btn-ghost lt-btn-sm" data-action="expand-all">Expand All</button>
</div>
</div>
<div id="links-container">
<div class="link-loading">Loading link statistics</div>
@@ -20,6 +34,8 @@
{% block scripts %}
<script>
const escHtml = s => lt.escHtml(s);
// ── Formatting helpers ────────────────────────────────────────────
function fmtRate(bytesPerSec) {
if (bytesPerSec === null || bytesPerSec === undefined) return '';
@@ -36,6 +52,12 @@ function fmtRateBar(bytesPerSec, linkSpeedMbps) {
return Math.min(100, (mbps / linkSpeedMbps) * 100);
}
function trafficBarClass(pct, isTx) {
if (pct > 85) return 'lt-progress--red';
if (pct > 65) return 'lt-progress--amber';
return isTx ? '' : 'lt-progress--cyan';
}
function fmtSpeed(mbps) {
if (mbps === null || mbps === undefined) return '';
if (mbps >= 1000) return (mbps/1000).toFixed(0) + ' Gbps';
@@ -69,44 +91,34 @@ function fmtBias(ma) {
function fmtErrors(rate) {
if (rate === null || rate === undefined) return '';
if (rate < 0.001) return '<span class="counter-zero">0 /s</span>';
return `<span class="counter-nonzero">${rate.toFixed(3)} /s</span>`;
if (rate < 0.001) return '<span class="val-good">0 /s</span>';
return `<span class="val-crit">${rate.toFixed(3)} /s</span>`;
}
function fmtCarrier(n) {
if (n === null || n === undefined) return '';
const v = parseInt(n);
if (v <= 2) return `<span class="val-good">${v}</span>`;
if (v <= 10) return `<span class="val-warn">${v}</span>`;
return `<span class="val-crit">${v}</span>`;
if (n === 0) return '<span class="counter-zero">0</span>';
return `<span class="counter-nonzero">${n}</span>`;
}
// Power level: returns {cls, pct} for -30..0 dBm scale
// ── SFP/DOM value classification ─────────────────────────────────
function rxPowerClass(dbm) {
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
const pct = Math.max(0, Math.min(100, (dbm + 30) / 30 * 100));
let cls = 'power-ok';
if (dbm < -25 || dbm > 0) cls = 'power-crit';
else if (dbm < -20) cls = 'power-warn';
return {cls, pct};
if (dbm === null || dbm === undefined) return 'val-neutral';
if (dbm < -15) return 'val-crit';
if (dbm < -10) return 'val-warn';
return 'val-good';
}
function txPowerClass(dbm) {
if (dbm === null || dbm === undefined) return {cls:'power-ok', pct:0};
const pct = Math.max(0, Math.min(100, (dbm + 20) / 20 * 100));
let cls = 'power-ok';
if (dbm < -15 || dbm > 2) cls = 'power-crit';
else if (dbm < -10) cls = 'power-warn';
return {cls, pct};
if (dbm === null || dbm === undefined) return 'val-neutral';
if (dbm < -5) return 'val-crit';
return 'val-good';
}
function tempClass(c) {
if (c === null || c === undefined) return 'val-neutral';
if (c > 80) return 'val-crit';
if (c > 60) return 'val-warn';
if (c > 70) return 'val-warn';
return 'val-good';
}
function voltageClass(v) {
if (v === null || v === undefined) return 'val-neutral';
if (v < 3.0 || v > 3.6) return 'val-crit';
@@ -114,294 +126,260 @@ function voltageClass(v) {
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'};
function errorBadges(d) {
const badges = [];
if ((d.tx_errs_rate || 0) > 0.001 || (d.rx_errs_rate || 0) > 0.001)
badges.push('<span class="link-alert-badge">ERR</span>');
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>');
if ((d.carrier_changes || 0) > 3)
badges.push('<span class="link-alert-badge link-alert-amber">FLAP</span>');
return badges.join('');
}
// ── Render a single interface card ────────────────────────────────
// ── Render a single server interface card ─────────────────────────
function renderIfaceCard(ifaceName, d) {
const speed = fmtSpeed(d.speed_mbps);
const duplex = fmtDuplex(d.duplex);
const ptype = portTypeLabel(d.port_type);
const autoneg= d.auto_neg !== undefined ? (d.auto_neg ? 'On' : 'Off') : '';
const linkDet= d.link_detected !== undefined ? (d.link_detected ? '<span class="val-good">Yes</span>' : '<span class="val-crit">No</span>') : '';
const isDown = d.link_detected === false;
const pt = (d.port_type || '').toUpperCase();
const mediaTag = pt === 'FIBRE' || pt === 'SFP' || pt.includes('FIBRE') ? 'type-fibre'
: pt === 'DA' ? 'type-da'
: '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 = '';
const sfp = d.sfp;
if (sfp && Object.keys(sfp).length > 0) {
const tx = txPowerClass(sfp.tx_power_dbm);
const rx = rxPowerClass(sfp.rx_power_dbm);
const tcls = tempClass(sfp.temp_c);
const vcls = voltageClass(sfp.voltage_v);
const vendorStr = [sfp.vendor, sfp.part_no].filter(Boolean).join(' / ') || '';
const sfpTypeStr= [sfp.sfp_type, sfp.connector, sfp.wavelength_nm ? sfp.wavelength_nm + 'nm' : ''].filter(Boolean).join(' · ') || '';
if (d.sfp && Object.keys(d.sfp).length > 0) {
const s = d.sfp;
const rxClass = rxPowerClass(s.rx_power_dbm);
const txClass = txPowerClass(s.tx_power_dbm);
const tmpClass = tempClass(s.temp_c);
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 txPct2 = s.tx_power_dbm != null ? Math.min(100, Math.max(0, (s.tx_power_dbm + 10) / 8 * 100)) : 0;
sfpHtml = `
<div class="sfp-panel">
<div class="sfp-vendor-row">
<span>${escHtml(vendorStr)}</span>
${sfpTypeStr ? `<span style="margin-left:8px;color:var(--text-muted)">${escHtml(sfpTypeStr)}</span>` : ''}
${s.vendor ? `<span>${escHtml(s.vendor)}</span>` : ''}
${s.part_no ? ` / <span>${escHtml(s.part_no)}</span>` : ''}
</div>
<div class="sfp-grid">
<div class="sfp-stat">
<span class="sfp-stat-label">Temp</span>
<span class="sfp-stat-value ${tcls}">${fmtTemp(sfp.temp_c)}</span>
<span class="sfp-stat-label" data-tooltip="SFP module temperature. Normal: below 70°C. Warn: 7085°C. Critical: above 85°C.">Temp</span>
<span class="sfp-stat-value ${tmpClass}">${fmtTemp(s.temp_c)}</span>
</div>
<div class="sfp-stat">
<span class="sfp-stat-label">Voltage</span>
<span class="sfp-stat-value ${vcls}">${fmtVoltage(sfp.voltage_v)}</span>
<span class="sfp-stat-label" data-tooltip="SFP supply voltage. Normal: 3.13.5V.">Voltage</span>
<span class="sfp-stat-value ${vClass}">${fmtVoltage(s.voltage_v)}</span>
</div>
<div class="sfp-stat">
<span class="sfp-stat-label">Bias</span>
<span class="sfp-stat-value">${fmtBias(sfp.bias_ma)}</span>
<span class="sfp-stat-label" data-tooltip="Laser bias current in mA. High values may indicate end-of-life laser diode.">Bias</span>
<span class="sfp-stat-value">${fmtBias(s.bias_ma)}</span>
</div>
<div class="sfp-stat">
<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-label" data-tooltip="Optical transmit power in dBm. Typical good range: -3 to -9 dBm.">TX Power</span>
<span class="sfp-stat-value ${txClass}">${fmtPower(s.tx_power_dbm)}</span>
<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 class="sfp-stat">
<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-label" data-tooltip="Optical receive power in dBm. Typical good range: -3 to -18 dBm. Below -20 dBm may indicate dirty/damaged fiber.">RX Power</span>
<span class="sfp-stat-value ${rxClass}">${fmtPower(s.rx_power_dbm)}</span>
<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>
${s.rx_power_dbm != null && s.tx_power_dbm != null ? `
<div class="sfp-stat">
<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'}">
${sfp.rx_power_dbm !== undefined && sfp.tx_power_dbm !== undefined
? (sfp.rx_power_dbm - sfp.tx_power_dbm).toFixed(2) + ' dBm'
: ''}
</span>
</div>
<span class="sfp-stat-label" data-tooltip="Insertion loss: difference between transmit and receive power. Large negative values indicate fiber loss or connector issues.">RXTX Δ</span>
<span class="sfp-stat-value">${(s.rx_power_dbm - s.tx_power_dbm).toFixed(2)} dBm</span>
</div>` : ''}
</div>
</div>`;
}
return `
<div class="link-iface-card">
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
<div class="link-iface-header">
<span class="link-iface-name">${escHtml(ifaceName)}</span>
${speed !== '' ? `<span class="link-iface-speed">${speed}</span>` : ''}
${ptype.label !== '' ? `<span class="link-iface-type ${ptype.cls}">${escHtml(ptype.label)}</span>` : ''}
<span class="link-iface-speed">${speedStr}</span>
<span class="link-iface-type ${mediaTag}">${escHtml(mediaLabel)}</span>
${errorBadges(d)}
</div>
<div class="link-stats-grid">
<div class="link-stat">
<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-label">Link</span>
<span class="link-stat-value ${isDown ? 'val-crit' : 'val-good'}">${isDown ? 'DOWN' : 'UP'}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">Auto-neg</span>
<span class="link-stat-value val-neutral">${autoneg}</span>
<span class="link-stat-label" data-tooltip="Full = simultaneous send/receive at full speed. Half = one direction at a time, can cause collisions.">Duplex</span>
<span class="link-stat-value">${fmtDuplex(d.duplex)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">Link Det.</span>
<span class="link-stat-value">${linkDet}</span>
<span class="link-stat-label" data-tooltip="Autonegotiation: NIC and switch automatically agree on link speed and duplex mode.">Auto-neg</span>
<span class="link-stat-value">${d.auto_neg == null ? '' : d.auto_neg ? 'On' : 'Off'}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">Carrier Chg</span>
<span class="link-stat-label" data-tooltip="Carrier changes: number of times the link went up or down. High values indicate a flapping or unstable cable/SFP.">Carrier Δ</span>
<span class="link-stat-value">${fmtCarrier(d.carrier_changes)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">TX Errors</span>
<span class="link-stat-label" data-tooltip="Transmit errors per second reported by the kernel network driver.">TX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_errs_rate)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">RX Errors</span>
<span class="link-stat-label" data-tooltip="Receive errors per second reported by the kernel network driver.">RX Err/s</span>
<span class="link-stat-value">${fmtErrors(d.rx_errs_rate)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">TX Drops</span>
<span class="link-stat-label" data-tooltip="Transmit packets dropped per second (ring buffer full or driver overrun).">TX Drop/s</span>
<span class="link-stat-value">${fmtErrors(d.tx_drops_rate)}</span>
</div>
<div class="link-stat">
<span class="link-stat-label">RX Drops</span>
<span class="link-stat-label" data-tooltip="Receive packets dropped per second (ring buffer full or driver overrun).">RX Drop/s</span>
<span class="link-stat-value">${fmtErrors(d.rx_drops_rate)}</span>
</div>
</div>
${(txRate !== undefined || rxRate !== undefined) ? `
<div class="traffic-section">
<div class="traffic-row">
<span class="traffic-label">TX</span>
<div class="traffic-bar-track">
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div>
</div>
<span class="traffic-value">${txStr}</span>
<span class="traffic-label" data-tooltip="Transmit — outgoing traffic from this server">TX</span>
<div class="lt-progress ${trafficBarClass(txPct, true)}"><div class="lt-progress-bar" style="width:${txPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
</div>
<div class="traffic-row">
<span class="traffic-label">RX</span>
<div class="traffic-bar-track">
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div>
<span class="traffic-label" data-tooltip="Receive — incoming traffic to this server">RX</span>
<div class="lt-progress ${trafficBarClass(rxPct, false)}"><div class="lt-progress-bar" style="width:${rxPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
</div>
<span class="traffic-value">${rxStr}</span>
</div>
</div>` : ''}
${sfpHtml}
</div>`;
}
// ── Render a single UniFi switch port card ────────────────────────
function renderPortCard(portName, d) {
const up = d.up;
const speed = up ? fmtSpeed(d.speed_mbps) : 'DOWN';
const duplex = d.full_duplex ? 'Full' : (up ? 'Half' : '');
const media = d.media || '';
const uplinkBadge = d.is_uplink
? '<span class="port-badge port-badge-uplink">UPLINK</span>' : '';
const poeBadge = (d.poe_power != null && d.poe_power > 0)
? `<span class="port-badge port-badge-poe">PoE ${d.poe_power.toFixed(1)}W</span>` : '';
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>` : '';
const poeMaxHtml = (d.poe_class != null)
? `<div class="port-poe-info">PoE class ${d.poe_class}${d.poe_max_power ? ' / max ' + d.poe_max_power.toFixed(1) + 'W' : ''}</div>` : '';
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);
const isDown = !d.up;
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);
const numBadge = d.port_idx != null ? `<span class="port-badge port-badge-num">#${d.port_idx}</span>` : '';
const uplinkBadge = d.is_uplink ? `<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 lldpLine = d.lldp ? `<div class="port-lldp">→ ${escHtml(d.lldp.system_name || '')} (${escHtml(d.lldp.port_id || '')})</div>` : '';
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>` : '';
return `
<div class="link-iface-card${up ? '' : ' port-down'}">
<div class="link-iface-card ${isDown ? 'port-down' : ''}">
<div class="link-iface-header">
<span class="link-iface-name">${escHtml(portName)}</span>
<span class="link-iface-speed ${up ? '' : 'val-crit'}">${speed}</span>
${numBadge}${uplinkBadge}${poeBadge}
${media ? `<span class="link-iface-type type-${media.toLowerCase().includes('sfp') ? 'fibre' : 'copper'}">${escHtml(media)}</span>` : ''}
<span class="link-iface-name">${numBadge} ${escHtml(portName)}</span>
<span class="link-iface-speed">${speedStr}</span>
${uplinkBadge}${poeBadge}
${errorBadges(d)}
</div>
${lldpHtml}${poeMaxHtml}
${lldpLine}${poeLine}
<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">
<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 class="link-stat">
<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 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>
</div>
<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>
</div>
<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>
</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>
${(up && (txRate != null || rxRate != null)) ? `
<div class="traffic-section">
<div class="traffic-row">
<span class="traffic-label">TX</span>
<div class="traffic-bar-track">
<div class="traffic-bar-fill traffic-tx" style="width:${txPct}%"></div>
</div>
<span class="traffic-value">${txStr}</span>
<div class="lt-progress ${trafficBarClass(txPct, true)}"><div class="lt-progress-bar" style="width:${txPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.tx_bytes_rate)}</span>
</div>
<div class="traffic-row">
<span class="traffic-label">RX</span>
<div class="traffic-bar-track">
<div class="traffic-bar-fill traffic-rx" style="width:${rxPct}%"></div>
<div class="lt-progress ${trafficBarClass(rxPct, false)}"><div class="lt-progress-bar" style="width:${rxPct}%"></div></div>
<span class="traffic-value">${fmtRate(d.rx_bytes_rate)}</span>
</div>
<span class="traffic-value">${rxStr}</span>
</div>
</div>` : ''}
</div>`;
}
// ── Render UniFi switches section ─────────────────────────────────
function renderUnifiSwitches(unifiSwitches) {
// ── Render all UniFi switches ─────────────────────────────────────
function renderUnifiSwitches(unifiSwitches, dataUpdated) {
if (!unifiSwitches || !Object.keys(unifiSwitches).length) return '';
const panels = Object.entries(unifiSwitches).map(([swName, sw]) => {
const updStr = dataUpdated
? new Date(dataUpdated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString()
: '';
const html = Object.entries(unifiSwitches).map(([swName, sw]) => {
const ports = sw.ports || {};
const allPorts= Object.entries(ports)
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0));
const upCount = allPorts.filter(([,d]) => d.up).length;
const downCount = allPorts.length - upCount;
const portValues = Object.values(ports);
const portCards = Object.entries(ports)
.sort(([,a],[,b]) => (a.port_idx||0) - (b.port_idx||0))
.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
.map(([pname, d]) => renderPortCard(pname, d))
.join('');
const meta = [
sw.model,
`${upCount} up`,
downCount ? `${downCount} down` : '',
].filter(Boolean).join(' · ');
// PoE utilisation bar
let poebar = '';
if (poe_total_w > 0 && poe_max_w > 0) {
const pct = Math.min(100, (poe_total_w / poe_max_w) * 100);
const cls = pct > 80 ? 'poe-bar-crit' : pct > 60 ? 'poe-bar-warn' : 'poe-bar-ok';
poebar = `<div class="poe-bar-track"><div class="poe-bar-fill ${cls}" style="width:${pct}%"></div></div>`;
}
return `
<div class="link-host-panel" id="unifi-${escHtml(swName)}">
<div class="link-host-title" onclick="togglePanel(this.closest('.link-host-panel'))">
<div class="link-host-panel" id="panel-${CSS.escape(swName)}">
<div class="link-host-title" data-action="toggle-panel">
<span class="link-host-name">${escHtml(swName)}</span>
${sw.ip ? `<span class="link-host-ip">${escHtml(sw.ip)}</span>` : ''}
<span class="link-host-upd">${escHtml(meta)}</span>
<span class="panel-toggle" title="Collapse / expand">[]</span>
</div>
<div class="link-ifaces-grid">
${portCards || '<div class="link-no-data">No port data available.</div>'}
<span class="link-host-ip">${escHtml(sw.ip || '')}</span>
<span class="link-host-upd">${escHtml(sw.model || '')}${updStr ? ' · ' + updStr : ''}${poeLoad}</span>
${poebar}
<span class="panel-toggle">[]</span>
</div>
<div class="link-ifaces-grid">${portCards}</div>
</div>`;
}).join('');
return `
<div class="unifi-section-header">UniFi Switches</div>
<div class="link-host-list">${panels}</div>`;
return `<div class="unifi-section-header">UNIFI SWITCH PORTS</div>${html}`;
}
// ── Collapse / expand panels ───────────────────────────────────────
// ── Panel collapse / expand ───────────────────────────────────────
function togglePanel(panel) {
panel.classList.toggle('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = panel.classList.contains('collapsed') ? '[+]' : '[]';
const id = panel.id;
if (id) {
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
saved[id] = panel.classList.contains('collapsed');
sessionStorage.setItem('gandalfCollapsed', JSON.stringify(saved));
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
collapsed[id] = panel.classList.contains('collapsed');
sessionStorage.setItem('linksCollapsed', JSON.stringify(collapsed));
}
}
function restoreCollapseState() {
const saved = JSON.parse(sessionStorage.getItem('gandalfCollapsed') || '{}');
for (const [id, collapsed] of Object.entries(saved)) {
if (!collapsed) continue;
const collapsed = JSON.parse(sessionStorage.getItem('linksCollapsed') || '{}');
for (const [id, isCollapsed] of Object.entries(collapsed)) {
const panel = document.getElementById(id);
if (panel) {
if (!panel) continue;
if (isCollapsed) {
panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]';
@@ -409,93 +387,192 @@ 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++;
}
}
let swTotal = 0, swDown = 0;
for (const sw of Object.values(unifiSwitches || {})) {
for (const p of Object.values(sw.ports || {})) {
totalPoe += p.poe_power || 0;
swTotal++;
if (!p.up) swDown++;
}
}
const allTotal = totalIfaces + swTotal;
const allDown = downIfaces + swDown;
const downColor = allDown > 0 ? 'var(--red)' : 'var(--green)';
const errColor = errIfaces > 0 ? 'var(--amber)' : 'var(--green)';
const downCardCls = allDown > 0 ? ' lt-stat-card--alert' : '';
const poeCard = totalPoe > 0 ? `
<div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--amber)">⚡</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:var(--amber)">${totalPoe.toFixed(1)}</span>
<span class="lt-stat-label">PoE Load (W)</span>
</div>
</div>` : '';
return `
<div class="lt-stats-grid" style="margin-bottom:16px">
<div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:var(--cyan)">⬡</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:var(--cyan)">${allTotal}</span>
<span class="lt-stat-label">Interfaces</span>
</div>
</div>
<div class="lt-stat-card${downCardCls}">
<span class="lt-stat-icon" aria-hidden="true" style="color:${downColor}">●</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:${downColor}">${allDown}</span>
<span class="lt-stat-label">Ports Down</span>
</div>
</div>
<div class="lt-stat-card">
<span class="lt-stat-icon" aria-hidden="true" style="color:${errColor}">▲</span>
<div class="lt-stat-info">
<span class="lt-stat-value" style="color:${errColor}">${errIfaces}</span>
<span class="lt-stat-label">With Errors</span>
</div>
</div>
${poeCard}
</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-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 = data.updated
? new Date(data.updated.replace(' UTC', 'Z').replace(' ', 'T')).toLocaleTimeString()
: '';
parts.push(`
<div class="link-host-panel" id="panel-${CSS.escape(hostname)}">
<div class="link-host-title" data-action="toggle-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();
document.getElementById('links-toolbar').style.display = '';
applyLinksSearch();
}
// ── Host/switch search filter ─────────────────────────────────────
function applyLinksSearch() {
const q = (document.getElementById('links-search')?.value || '').trim().toLowerCase();
document.querySelectorAll('.link-host-panel').forEach(panel => {
const text = (panel.querySelector('.link-host-name')?.textContent || '').toLowerCase();
panel.style.display = (!q || text.includes(q)) ? '' : 'none';
});
}
function collapseAll() {
document.querySelectorAll('.link-host-panel').forEach(panel => {
panel.classList.add('collapsed');
const btn = panel.querySelector('.panel-toggle');
document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.add('collapsed');
const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[+]';
});
sessionStorage.setItem('gandalfCollapsed', '{}'); // let restore pick it up next time
sessionStorage.setItem('linksCollapsed', JSON.stringify(
Object.fromEntries([...document.querySelectorAll('.link-host-panel')].map(p => [p.id, true]))
));
}
function expandAll() {
document.querySelectorAll('.link-host-panel').forEach(panel => {
panel.classList.remove('collapsed');
const btn = panel.querySelector('.panel-toggle');
document.querySelectorAll('.link-host-panel').forEach(p => {
p.classList.remove('collapsed');
const btn = p.querySelector('.panel-toggle');
if (btn) btn.textContent = '[]';
});
sessionStorage.setItem('gandalfCollapsed', '{}');
sessionStorage.setItem('linksCollapsed', '{}');
}
// ── Render all hosts ──────────────────────────────────────────────
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 warning ────────────────────────────────────────────
function checkLinksStale(updatedStr) {
if (!updatedStr) return;
const age = (Date.now() - new Date(updatedStr + (updatedStr.includes('Z') ? '' : 'Z'))) / 1000;
let banner = document.getElementById('links-stale-banner');
if (age > 120) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'links-stale-banner';
banner.className = 'lt-alert lt-alert--warning';
banner.innerHTML = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>';
document.getElementById('links-container').prepend(banner);
}
banner.querySelector('.lt-alert-msg').textContent =
`Link data may be stale — last updated ${Math.floor(age/60)}m ago.`;
banner.style.display = '';
} else if (banner) {
banner.style.display = 'none';
}
}
// ── Fetch and render ──────────────────────────────────────────────
// ── Fetch + render ────────────────────────────────────────────────
async function loadLinks() {
try {
const resp = await fetch('/api/links');
if (!resp.ok) throw new Error('API error');
const data = await resp.json();
const data = await lt.api.get('/api/links');
if (!data.hosts && !data.unifi_switches) {
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);
checkLinksStale(data.updated);
} catch (e) {
document.getElementById('links-container').innerHTML =
'<p class="empty-state">Failed to load link data.</p>';
'<div class="error-state">Network error loading link statistics.</div>';
lt.toast.error('Failed to load link statistics');
}
}
loadLinks();
setInterval(loadLinks, 60000);
var _linksInterval = (window.gandalfSettings && window.gandalfSettings.refreshInterval) || 60;
if (_linksInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(_linksInterval, 15) * 1000);
window.onGandalfSettingsChanged = function(s) {
lt.autoRefresh.stop();
if (s.refreshInterval > 0) lt.autoRefresh.start(loadLinks, Math.max(s.refreshInterval, 15) * 1000);
};
document.addEventListener('click', e => {
const toggleTitle = e.target.closest('[data-action="toggle-panel"]');
if (toggleTitle) { togglePanel(toggleTitle.closest('.link-host-panel')); return; }
if (e.target.closest('[data-action="collapse-all"]')) { collapseAll(); return; }
if (e.target.closest('[data-action="expand-all"]')) { expandAll(); return; }
});
document.getElementById('links-search')?.addEventListener('input', applyLinksSearch);
</script>
{% endblock %}
+179 -67
View File
@@ -3,79 +3,95 @@
{% block content %}
<div class="page-header">
<h1 class="page-title">Alert Suppressions</h1>
<p class="page-sub">Manage maintenance windows and per-target alert suppression rules.</p>
<div class="lt-page-header">
<div>
<h1 class="lt-page-title">Alert Suppressions</h1>
<p class="g-page-sub" style="margin-top:4px">Manage maintenance windows and per-target alert suppression rules.</p>
</div>
</div>
<!-- ── Create suppression ─────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Create Suppression</h2>
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Create Suppression</h2>
</div>
<div class="form-card">
<form id="create-suppression-form" onsubmit="createSuppression(event)">
<div class="lt-card">
<div class="lt-card-body">
<form id="create-suppression-form">
<div class="form-row">
<div class="form-group">
<label for="s-type">Target Type <span class="required">*</span></label>
<select id="s-type" name="target_type" onchange="onTypeChange()">
<div class="lt-form-group">
<label class="lt-label" for="s-type">Target Type <span class="required">*</span></label>
<select class="lt-select" id="s-type" name="target_type">
<option value="host">Host (all interfaces)</option>
<option value="interface">Specific Interface</option>
<option value="unifi_device">UniFi Device</option>
<option value="all">Global (suppress everything)</option>
</select>
</div>
<div class="form-group" id="name-group">
<label for="s-name">Target Name <span class="required">*</span></label>
<input type="text" id="s-name" name="target_name"
placeholder="hostname or device name" autocomplete="off">
<div class="lt-form-group" id="name-group">
<label class="lt-label" for="s-name">Target Name <span class="required">*</span></label>
<input type="text" class="lt-input" id="s-name" name="target_name"
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 class="form-group" id="detail-group" style="display:none">
<label for="s-detail">Interface Name</label>
<input type="text" id="s-detail" name="target_detail"
<div class="lt-form-group" id="detail-group" style="display:none">
<label class="lt-label" for="s-detail">Interface Name</label>
<input type="text" class="lt-input" id="s-detail" name="target_detail"
placeholder="e.g. enp35s0 or bond0" autocomplete="off">
</div>
</div>
<div class="form-row">
<div class="form-group form-group-wide">
<label for="s-reason">Reason <span class="required">*</span></label>
<input type="text" id="s-reason" name="reason"
<div class="lt-form-group form-group-wide">
<label class="lt-label" for="s-reason">Reason <span class="required">*</span></label>
<input type="text" class="lt-input" id="s-reason" name="reason"
placeholder="e.g. Planned switch maintenance, replacing SFP on large1/enp43s0"
required>
</div>
</div>
<div class="form-row form-row-align">
<div class="form-group">
<label>Duration</label>
<div class="lt-form-group">
<label class="lt-label">Duration</label>
<div class="duration-pills">
<button type="button" class="pill" onclick="setDur(30, this)">30 min</button>
<button type="button" class="pill" onclick="setDur(60, this)">1 hr</button>
<button type="button" class="pill" onclick="setDur(240, this)">4 hr</button>
<button type="button" class="pill" onclick="setDur(480, this)">8 hr</button>
<button type="button" class="pill pill-manual active" onclick="setDur(null, this)">Manual ∞</button>
<button type="button" class="pill" data-duration="30">30 min</button>
<button type="button" class="pill" data-duration="60">1 hr</button>
<button type="button" class="pill" data-duration="240">4 hr</button>
<button type="button" class="pill" data-duration="480">8 hr</button>
<button type="button" class="pill pill-manual active" data-duration="">Manual ∞</button>
</div>
<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 class="form-group form-group-submit">
<button type="submit" class="btn btn-primary btn-lg">🔕 Apply Suppression</button>
<div class="lt-form-group form-group-submit">
<button type="submit" class="lt-btn lt-btn-primary lt-btn-lg">🔕 Apply Suppression</button>
</div>
</div>
</form>
</div>
</div>
</section>
<!-- ── Active suppressions ────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Active Suppressions</h2>
<span class="section-badge">{{ active | length }}</span>
<section class="g-section" id="active-sup-section">
<div class="g-section-header">
<h2 class="g-section-title">Active Suppressions</h2>
<span class="g-section-badge" id="active-sup-badge">{{ active | length }}</span>
</div>
<div id="active-sup-wrap">
{% if active %}
<div class="table-wrap">
<table class="data-table" id="active-sup-table">
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Active Rules</div>
<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>
@@ -85,7 +101,8 @@
<tbody>
{% for s in active %}
<tr id="sup-row-{{ s.id }}">
<td><span class="badge badge-info">{{ s.target_type }}</span></td>
{%- set _sup_badge = {'host':'badge-warning','interface':'badge-info','unifi_device':'badge-purple','all':'badge-critical'} -%}
<td><span class="lt-badge {{ _sup_badge.get(s.target_type, 'badge-neutral') }}">{{ s.target_type }}</span></td>
<td>{{ s.target_name or 'all' }}</td>
<td>{{ s.target_detail or '' }}</td>
<td>{{ s.reason }}</td>
@@ -93,27 +110,38 @@
<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>
<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>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<p class="empty-state">No active suppressions.</p>
<div class="lt-empty-state lt-empty-state--sm" id="no-active-msg">
<div class="lt-empty-state-icon">🔕</div>
<div class="lt-empty-state-title">No active suppressions</div>
<div class="lt-empty-state-body">All alerts are active. Use the form above to silence a host or interface.</div>
</div>
{% endif %}
</div>
</section>
<!-- ── Suppression history ────────────────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2 class="section-title">History</h2>
<span class="section-badge">{{ history | length }}</span>
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">History</h2>
<span class="g-section-badge">{{ history | length }}</span>
</div>
{% if history %}
<div class="table-wrap">
<table class="data-table data-table-sm">
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Suppression Log</div>
<div class="lt-table-wrap">
<table class="lt-table lt-table-sm">
<caption class="lt-sr-only">Suppression history</caption>
<thead>
<tr>
<th>Type</th><th>Target</th><th>Detail</th><th>Reason</th>
@@ -132,9 +160,9 @@
<td class="ts-cell">{% if s.expires_at %}{{ s.expires_at }}{% else %}<em>manual</em>{% endif %}</td>
<td>
{% if s.active %}
<span class="badge badge-ok">Yes</span>
<span class="lt-badge badge-ok">Yes</span>
{% else %}
<span class="badge badge-neutral">No</span>
<span class="lt-badge badge-neutral">No</span>
{% endif %}
</td>
</tr>
@@ -142,16 +170,25 @@
</tbody>
</table>
</div>
</div>
{% else %}
<p class="empty-state">No suppression history yet.</p>
<div class="lt-empty-state lt-empty-state--sm">
<div class="lt-empty-state-icon">📋</div>
<div class="lt-empty-state-title">No suppression history yet</div>
</div>
{% endif %}
</section>
<!-- ── Available targets reference ───────────────────────────────── -->
<section class="section">
<div class="section-header">
<h2 class="section-title">Available Targets</h2>
<section class="g-section">
<div class="g-section-header">
<h2 class="g-section-title">Available Targets</h2>
</div>
<div class="lt-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Host &amp; Interface Reference</div>
<div style="padding:12px 14px">
<div class="targets-grid">
{% for name, host in snapshot.hosts.items() %}
<div class="target-card">
@@ -167,6 +204,8 @@
</div>
{% endfor %}
</div>
</div>
</div>
</section>
{% endblock %}
@@ -193,9 +232,59 @@
}
}
function renderActiveRows(rows) {
const wrap = document.getElementById('active-sup-wrap');
const badge = document.getElementById('active-sup-badge');
if (!rows || !rows.length) {
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm" id="no-active-msg"><div class="lt-empty-state-icon">🔕</div><div class="lt-empty-state-title">No active suppressions</div><div class="lt-empty-state-body">All alerts are active. Use the form above to silence a host or interface.</div></div>';
if (badge) badge.textContent = '0';
return;
}
if (badge) badge.textContent = rows.length;
const SUP_BADGE = {host:'badge-warning', interface:'badge-info', unifi_device:'badge-purple', all:'badge-critical'};
const tbody = rows.map(s => `
<tr id="sup-row-${s.id}">
<td><span class="lt-badge ${SUP_BADGE[s.target_type] || 'badge-neutral'}">${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-frame">
<span class="lt-frame-bl">&#x255A;</span>
<span class="lt-frame-br">&#x255D;</span>
<div class="lt-section-header">Active Rules</div>
<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>
</div>`;
}
async function refreshActive() {
try {
const rows = await lt.api.get('/api/suppressions');
renderActiveRows(rows);
} catch (err) {
showToast('Failed to refresh suppressions', 'warning');
}
}
async function createSuppression(e) {
e.preventDefault();
const form = e.target;
const btn = form.querySelector('[type="submit"]');
btn.classList.add('is-loading');
const payload = {
target_type: form.target_type.value,
target_name: form.target_name ? form.target_name.value : '',
@@ -203,30 +292,53 @@
reason: form.reason.value,
expires_minutes: form.expires_minutes.value ? parseInt(form.expires_minutes.value) : null,
};
const resp = await fetch('/api/suppressions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const data = await resp.json();
if (data.success) {
try {
await lt.api.post('/api/suppressions', payload);
showToast('Suppression applied', 'success');
setTimeout(() => location.reload(), 800);
} else {
showToast(data.error || 'Error', 'error');
form.reset();
onTypeChange();
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) {
if (!confirm('Remove this suppression?')) return;
const resp = await fetch(`/api/suppressions/${id}`, {method:'DELETE'});
const data = await resp.json();
if (data.success) {
try {
await lt.api.delete(`/api/suppressions/${id}`);
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 =
'<div class="lt-empty-state lt-empty-state--sm" id="no-active-msg"><div class="lt-empty-state-icon">🔕</div><div class="lt-empty-state-title">No active suppressions</div><div class="lt-empty-state-body">All alerts are active. Use the form above to silence a host or interface.</div></div>';
if (badge) badge.textContent = '0';
}
showToast('Suppression removed', 'success');
} else {
showToast(data.error || 'Failed to remove suppression', 'error');
} catch (err) {
showToast(err.message || 'Failed to remove suppression', 'error');
}
}
document.getElementById('s-type')?.addEventListener('change', onTypeChange);
document.getElementById('create-suppression-form')?.addEventListener('submit', createSuppression);
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>
{% endblock %}
+479
View File
@@ -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'] == []