Inventory links, cutsheet marks on faceplates, and single page titles
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 54s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m1s
Lint / JS (eslint) (pull_request) Successful in 19s
Test / Python Tests (pytest) (push) Successful in 2m6s
Security / Python Security (bandit) (pull_request) Successful in 1m18s
Lint / Python (flake8) (pull_request) Successful in 53s
Test / Python Tests (pytest) (pull_request) Successful in 1m1s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Lint / JS (eslint) (push) Successful in 7s
Lint / Python (flake8) (push) Successful in 54s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Security / Python Security (bandit) (push) Successful in 1m1s
Lint / JS (eslint) (pull_request) Successful in 19s
Test / Python Tests (pytest) (push) Successful in 2m6s
Security / Python Security (bandit) (pull_request) Successful in 1m18s
Lint / Python (flake8) (pull_request) Successful in 53s
Test / Python Tests (pytest) (pull_request) Successful in 1m1s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Device pages get an Inventory button: it opens the device's HomeBox record
once an admin pastes its URL (stored in the new device_links table; only
URLs on inventory.base_url are accepted), and searches the inventory for
the model until then. Tickets link the record too.
Faceplate ports with a documented cable get a corner dot: green matches,
red mismatch, grey documented but unconfirmed. The port's tooltip names
the cable, and a legend toggle (remembered per browser) hides them.
Page titles no longer repeat GANDALF ("Alert History - GANDALF - GANDALF").
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -162,6 +162,17 @@ The port panel's **SFP Module** section lists type, part, serial and faults, plu
|
||||
optical readings (TX/RX power, temperature, voltage, bias) when it reports them. Copper DACs don't.
|
||||
Both ends of a DAC report the same cable serial, so a DAC seen on two ports links to its other end.
|
||||
|
||||
**Cutsheet marks**: a port with a cable documented in the cutsheet gets a dot in its corner: green when
|
||||
it matches what Gandalf sees, red for a mismatch (wrong neighbor, no link, missing port, port conflict),
|
||||
grey when it is documented but can't be confirmed (planned, dark, through a panel, or the far end isn't
|
||||
visible). Hovering the port names the cable. "Cutsheet marks" in the legend hides them; the choice is
|
||||
remembered in the browser.
|
||||
|
||||
**Inventory link**: the device page's 📦 button opens the device's record in the inventory
|
||||
(`inventory.base_url`). Admins set it with ✎ by pasting the record's URL; only URLs on the inventory
|
||||
host are accepted. Until one is set, the button searches the inventory for the model. Tickets include
|
||||
the link too.
|
||||
|
||||
**Port block colours:**
|
||||
| Colour | State |
|
||||
|---|---|
|
||||
@@ -412,6 +423,8 @@ Shared by both processes. Located in the working directory (`/var/www/html/prod/
|
||||
| `ticket_api.api_key` | Tinker Tickets bearer token. Needs `read_write` scope to add recovery notes |
|
||||
| `ticket_api.comment_url` | Tinker comment endpoint (default: `api/ticket_comment_api.php` beside `ticket_api.url`) |
|
||||
| `gandalf.public_url` | Gandalf's own address, for links in tickets (default: `https://gandalf.lotusguild.org`) |
|
||||
| `inventory.base_url` | Asset inventory (HomeBox) address; device inventory links must be on this host (default: `https://inventory.lotusguild.org`) |
|
||||
| `inventory.search_url` | Search link used before a device has a record linked, with `{base}` and `{q}` placeholders (default: `{base}/items?q={q}`) |
|
||||
| `pulse.url` | Pulse worker API base URL (for SSH relay) |
|
||||
| `pulse.worker_id` | Which Pulse worker runs ethtool collection |
|
||||
| `pulse.timeout` | Max seconds to wait for SSH collection per host |
|
||||
@@ -671,6 +684,7 @@ Take LED colors from the model's quick start guide. See the comment at the top o
|
||||
|
||||
### Database Schema Notes
|
||||
|
||||
- `device_links`: each device's inventory URL, by device name; created with the cutsheet tables
|
||||
- `network_events`: one row per active event; `resolved_at` is set when recovered, and
|
||||
`recovery_notified` once the recovery note is on its ticket (column added automatically at monitor startup)
|
||||
- `suppression_rules`: `active=FALSE` when removed; `expires_at` checked at query time
|
||||
|
||||
@@ -18,6 +18,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
from typing import Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from flask import Flask, jsonify, make_response, redirect, render_template, request, send_file, url_for
|
||||
|
||||
@@ -318,7 +319,8 @@ def links_page():
|
||||
@app.route('/device/<path:name>')
|
||||
@require_auth
|
||||
def device_page(name: str):
|
||||
return render_template('device.html', user=_get_user(), device_name=name)
|
||||
user = _get_user()
|
||||
return render_template('device.html', user=user, device_name=name, can_edit='admin' in user['groups'])
|
||||
|
||||
|
||||
@app.route('/inspector')
|
||||
@@ -525,6 +527,7 @@ def api_device(name: str):
|
||||
except Exception as e:
|
||||
logger.error(f'Cutsheet unavailable for {name}: {e}')
|
||||
detail['cutsheet'] = []
|
||||
detail['inventory'] = _inventory_for(name, detail.get('model') or '')
|
||||
return jsonify(detail)
|
||||
|
||||
|
||||
@@ -1000,6 +1003,58 @@ def api_cutsheet():
|
||||
return jsonify(view)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inventory (HomeBox) links
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_INVENTORY_DEFAULT = 'https://inventory.lotusguild.org'
|
||||
|
||||
|
||||
def _inventory_base() -> str:
|
||||
return ((_config().get('inventory') or {}).get('base_url') or _INVENTORY_DEFAULT).rstrip('/')
|
||||
|
||||
|
||||
def _inventory_for(name: str, model: str) -> dict:
|
||||
"""The device's inventory record link, or a search to find it."""
|
||||
base = _inventory_base()
|
||||
template = (_config().get('inventory') or {}).get('search_url') or '{base}/items?q={q}'
|
||||
try:
|
||||
_ensure_cutsheet_schema()
|
||||
url = db.get_device_link(name)
|
||||
except Exception as e:
|
||||
logger.error(f'Inventory link unavailable for {name}: {e}')
|
||||
url = ''
|
||||
return {'url': url, 'search_url': template.format(base=base, q=quote(model or name, safe=''))}
|
||||
|
||||
|
||||
def _validate_inventory_url(value) -> str:
|
||||
"""Only links into the configured inventory site: this URL is shown as a link to every viewer."""
|
||||
url = str(value or '').strip()
|
||||
if not url:
|
||||
return ''
|
||||
if len(url) > 512:
|
||||
raise cutsheet.ValidationError('Inventory link is too long')
|
||||
base = urlparse(_inventory_base())
|
||||
got = urlparse(url)
|
||||
if got.scheme != 'https' or got.netloc.lower() != base.netloc.lower() or got.username or got.password:
|
||||
raise cutsheet.ValidationError(f'Inventory link must start with {base.scheme}://{base.netloc}/')
|
||||
return url
|
||||
|
||||
|
||||
@app.route('/api/device/<path:name>/inventory', methods=['PUT'])
|
||||
@require_auth
|
||||
@require_admin
|
||||
@_cutsheet_write
|
||||
def api_device_inventory(name: str):
|
||||
"""Set or clear (empty url) the device's inventory link."""
|
||||
if len(name) > 255:
|
||||
raise cutsheet.ValidationError('Device name is too long')
|
||||
url = _validate_inventory_url(_json_body().get('url'))
|
||||
db.set_device_link(name, url, _get_user()['username'])
|
||||
logger.info(f'{_get_user()["username"]} set inventory link for {name}: {url or "(removed)"}')
|
||||
return jsonify({'success': True, 'url': url})
|
||||
|
||||
|
||||
@app.route('/api/cutsheet/cables', methods=['POST'])
|
||||
@require_auth
|
||||
@require_admin
|
||||
|
||||
@@ -722,8 +722,38 @@ _CUTSHEET_SCHEMA = (
|
||||
INDEX idx_hist_time (changed_at),
|
||||
INDEX idx_hist_entity (entity, entity_id)
|
||||
) ENGINE=InnoDB""",
|
||||
# Where a device lives in the asset inventory (HomeBox), set by hand on its Gandalf page
|
||||
"""CREATE TABLE IF NOT EXISTS device_links (
|
||||
name VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
inventory_url VARCHAR(512) NOT NULL,
|
||||
updated_by VARCHAR(255) NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB""",
|
||||
)
|
||||
|
||||
|
||||
def get_device_link(name: str) -> str:
|
||||
"""The device's inventory URL, or ''."""
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT inventory_url FROM device_links WHERE name=%s', (name,))
|
||||
row = cur.fetchone()
|
||||
return row['inventory_url'] if row else ''
|
||||
|
||||
|
||||
def set_device_link(name: str, inventory_url: str, user: str) -> None:
|
||||
"""Save a device's inventory URL; an empty URL removes it."""
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
if not inventory_url:
|
||||
cur.execute('DELETE FROM device_links WHERE name=%s', (name,))
|
||||
return
|
||||
cur.execute(
|
||||
"""INSERT INTO device_links (name, inventory_url, updated_by) VALUES (%s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE inventory_url=VALUES(inventory_url), updated_by=VALUES(updated_by)""",
|
||||
(name, inventory_url, user),
|
||||
)
|
||||
|
||||
_ITEM_FIELDS = ('name', 'kind', 'port_count', 'location', 'rack_u', 'rack_height', 'notes')
|
||||
_CABLE_FIELDS = ('label', 'a_device', 'a_port', 'a_side', 'b_device', 'b_port', 'b_side',
|
||||
'cable_type', 'length_m', 'color', 'status', 'label_applied', 'notes')
|
||||
|
||||
@@ -1160,6 +1160,9 @@ class NetworkMonitor:
|
||||
if device:
|
||||
lines.append(f'- {ticket_text.link(f"{device} in Gandalf", ticket_text.device_url(base, device))}')
|
||||
lines.append(f'- {ticket_text.link(f"Alert history for {device}", ticket_text.history_url(base, device))}')
|
||||
inventory = db.get_device_link(device)
|
||||
if inventory:
|
||||
lines.append(f'- {ticket_text.link(f"{device} in the inventory", inventory)}')
|
||||
lines.append(f'- {ticket_text.link("Gandalf dashboard", base + "/")}')
|
||||
return lines
|
||||
|
||||
|
||||
+43
-2
@@ -1,5 +1,5 @@
|
||||
'use strict';
|
||||
/* global DEVICE_NAME, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort, FACEPLATES */
|
||||
/* global DEVICE_NAME, CAN_EDIT, deviceUrl, refreshModem, _toIso, fmtRate, fmtErrors, renderPortInspector, selectPortByIdx, highlightSelectedPort, FACEPLATES */
|
||||
|
||||
// Per-device page: overview, alerts, and a compact interface/port table. Host
|
||||
// rows expand in place; switch and router rows open the port inspector panel.
|
||||
@@ -332,6 +332,47 @@ function updateInterfaceSection(rows, title, hasChassis) {
|
||||
document.getElementById('device-error-count').textContent = rows.filter(rowHasProblem).length;
|
||||
}
|
||||
|
||||
// ── Inventory (HomeBox) link ────────────────────────────────────────────
|
||||
function inventoryActions(d) {
|
||||
const inv = d.inventory || {};
|
||||
const edit = CAN_EDIT
|
||||
? `<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-action="edit-inventory"
|
||||
aria-label="${inv.url ? 'Change' : 'Set'} the inventory link for ${esc(d.name)}">✎</button>` : '';
|
||||
if (inv.url) {
|
||||
return `<a class="lt-btn lt-btn-secondary lt-btn-sm" href="${esc(inv.url)}" target="_blank" rel="noopener">📦 Inventory ↗</a>${edit}`;
|
||||
}
|
||||
if (!inv.search_url) return edit;
|
||||
return `<a class="lt-btn lt-btn-ghost lt-btn-sm" href="${esc(inv.search_url)}" target="_blank" rel="noopener"
|
||||
title="No inventory record is linked yet">📦 Find in inventory ↗</a>${edit}`;
|
||||
}
|
||||
|
||||
function openInventoryEditor() {
|
||||
const inv = (_device && _device.inventory) || {};
|
||||
const form = document.getElementById('inventory-form');
|
||||
form.url.value = inv.url || '';
|
||||
document.getElementById('inventory-search').href = inv.search_url || '#';
|
||||
lt.modal.open('inventory-modal');
|
||||
form.url.focus();
|
||||
}
|
||||
|
||||
async function saveInventory(e) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await lt.api.put(`/api/device/${encodeURIComponent(DEVICE_NAME)}/inventory`, { url: e.target.url.value.trim() });
|
||||
lt.modal.close('inventory-modal');
|
||||
lt.toast.success(e.target.url.value.trim() ? 'Inventory link saved' : 'Inventory link removed');
|
||||
await loadDevice();
|
||||
} catch (err) {
|
||||
lt.toast.error(err.message || 'Save failed');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.closest('[data-action="edit-inventory"]')) openInventoryEditor();
|
||||
});
|
||||
const _inventoryForm = document.getElementById('inventory-form');
|
||||
if (_inventoryForm) _inventoryForm.addEventListener('submit', saveInventory);
|
||||
|
||||
function render(d) {
|
||||
_device = d;
|
||||
document.getElementById('device-dot').className = `dot-${d.status || 'unknown'}`;
|
||||
@@ -340,7 +381,7 @@ function render(d) {
|
||||
(d.updated ? ` · updated ${new Date(_toIso(d.updated)).toLocaleTimeString()}` : '');
|
||||
|
||||
const unifi = d.source === 'unifi';
|
||||
document.getElementById('device-actions').innerHTML = `
|
||||
document.getElementById('device-actions').innerHTML = inventoryActions(d) + `
|
||||
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress" data-sup-type="${unifi ? 'unifi_device' : 'host'}"
|
||||
data-sup-name="${esc(d.name)}" data-sup-detail="" aria-label="Suppress alerts for ${esc(d.name)}">🔕 Suppress</button>`;
|
||||
|
||||
|
||||
+29
-7
@@ -107,7 +107,13 @@ function portLabelSvg(p, port) {
|
||||
return `<text class="fp-label" x="${x}" y="${y}" text-anchor="middle"${size}>${p.text || p.idx}</text>${bolt}`;
|
||||
}
|
||||
|
||||
function faceplatePortSvg(p, port, swName, title) {
|
||||
// Cutsheet status dot in a port's corner: ok (matches), bad (mismatch), doc (documented, not confirmed)
|
||||
function cutsheetMarkSvg(mark, x, y) {
|
||||
if (!mark) return '';
|
||||
return `<circle class="fp-mark fp-mark-${mark}" cx="${x}" cy="${y}" r="3.2"/>`;
|
||||
}
|
||||
|
||||
function faceplatePortSvg(p, port, swName, title, mark) {
|
||||
// The drawn port number can differ from the index UniFi reports (see anyPort)
|
||||
const idx = (p.anyPort && port) ? port.port_idx : p.idx;
|
||||
const attrs = `class="fp-port${port && port.up ? ' fp-up' : ''}" data-switch="${lt.escHtml(swName)}" data-port-idx="${idx}"
|
||||
@@ -117,6 +123,7 @@ function faceplatePortSvg(p, port, swName, title) {
|
||||
<title>${title}</title>
|
||||
${sfpSvg(port, p.w, p.h)}
|
||||
<rect class="fp-hit" x="-1" y="-1" width="${p.w + 2}" height="${p.h + 2}" rx="2"/>
|
||||
${cutsheetMarkSvg(mark, p.w - 3, 3)}
|
||||
</g>
|
||||
${sfpLedSvg(p, port)}${portLabelSvg(p, port)}`;
|
||||
}
|
||||
@@ -125,6 +132,7 @@ function faceplatePortSvg(p, port, swName, title) {
|
||||
<title>${title}</title>
|
||||
<g transform="scale(1 ${p.flip ? -1 : 1})">${rj45Svg(port, p)}</g>
|
||||
<rect class="fp-hit" x="-18" y="-${p.flip ? 11 : 17}" width="36" height="28" rx="2"/>
|
||||
${cutsheetMarkSvg(mark, 14, p.flip ? 13 : -13)}
|
||||
</g>${portLabelSvg(p, port)}`;
|
||||
}
|
||||
|
||||
@@ -469,9 +477,17 @@ function faceplateLegendSwatch(cls) {
|
||||
return `<span class="fp-legend-led fp-led-${cls}"></span>`;
|
||||
}
|
||||
|
||||
function faceplateLegendHtml(spec) {
|
||||
const items = spec.legend.map(([cls, text]) =>
|
||||
const CUTSHEET_MARK_LEGEND = [['ok', 'Matches cutsheet'], ['bad', 'Cutsheet mismatch'], ['doc', 'Documented, unconfirmed']];
|
||||
|
||||
function faceplateLegendHtml(spec, marks) {
|
||||
let items = spec.legend.map(([cls, text]) =>
|
||||
`<div class="chassis-legend-item">${faceplateLegendSwatch(cls)}${text}</div>`).join('');
|
||||
if (marks && marks.size) {
|
||||
items += CUTSHEET_MARK_LEGEND.filter(([m]) => marks.has(m)).map(([m, text]) =>
|
||||
`<div class="chassis-legend-item fp-mark-key"><svg class="fp-legend-icon" viewBox="0 0 8 8"><circle class="fp-mark fp-mark-${m}" cx="4" cy="4" r="3.2"/></svg>${text}</div>`).join('');
|
||||
items += `<label class="chassis-legend-item fp-mark-toggle"><input type="checkbox" data-action="toggle-cutsheet-marks"
|
||||
${document.body.classList.contains('fp-marks-off') ? '' : 'checked'}> Cutsheet marks</label>`;
|
||||
}
|
||||
const hasDetail = spec.ports.length || spec.outlets;
|
||||
return `<div class="chassis-legend">
|
||||
${items}
|
||||
@@ -479,14 +495,20 @@ function faceplateLegendHtml(spec) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role) }
|
||||
// ctx: { swName, portMap, outlets, online, titleFor(port, idx, role), cutsheetFor?(idx) -> {mark, text} }
|
||||
function renderFaceplate(model, ctx) {
|
||||
const spec = FACEPLATES[model];
|
||||
const anyPort = Object.values(ctx.portMap)[0];
|
||||
const marks = new Set();
|
||||
const ports = spec.ports.map(p => {
|
||||
const port = ctx.portMap[p.idx] || (p.anyPort ? anyPort : undefined);
|
||||
const title = ctx.titleFor(port, p.idx, spec.roles && spec.roles[p.idx]);
|
||||
return faceplatePortSvg(p, port, ctx.swName, title);
|
||||
let title = ctx.titleFor(port, p.idx, spec.roles && spec.roles[p.idx]);
|
||||
const cs = ctx.cutsheetFor ? ctx.cutsheetFor((p.anyPort && port) ? port.port_idx : p.idx) : null;
|
||||
if (cs) {
|
||||
marks.add(cs.mark);
|
||||
title += lt.escHtml(` · ${cs.text}`);
|
||||
}
|
||||
return faceplatePortSvg(p, port, ctx.swName, title, cs && cs.mark);
|
||||
}).join('');
|
||||
const outletData = Object.fromEntries((ctx.outlets || []).map(o => [o.index, o]));
|
||||
const outlets = (spec.outlets || []).map(o => faceplateOutletSvg(o, outletData[o.idx])).join('');
|
||||
@@ -510,7 +532,7 @@ function renderFaceplate(model, ctx) {
|
||||
${ports}
|
||||
</svg>
|
||||
</div>
|
||||
</div>${faceplateLegendHtml(spec)}`;
|
||||
</div>${faceplateLegendHtml(spec, marks)}`;
|
||||
}
|
||||
|
||||
// Where an fp-scroll panel should start: its port area
|
||||
|
||||
@@ -112,6 +112,32 @@ function cutsheetPanelHtml(swName, idx) {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// The faceplate dot for one port: the worst state of the cables documented on it
|
||||
const MARK_RANK = { bad: 3, doc: 2, ok: 1 };
|
||||
|
||||
function cutsheetMarkFor(idx) {
|
||||
const entries = ((_apiData && _apiData.cutsheet) || []).filter(e => e.port === String(idx) && e.status !== 'retired');
|
||||
if (!entries.length) return null;
|
||||
let best = null;
|
||||
entries.forEach(e => {
|
||||
const mark = CUTSHEET_PROBLEM.has(e.verdict) ? 'bad' : (e.verdict === 'match' ? 'ok' : 'doc');
|
||||
if (!best || MARK_RANK[mark] > MARK_RANK[best.mark]) {
|
||||
best = { mark, text: `cable ${e.label || 'unlabeled'}: ${e.detail}` };
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
document.addEventListener('change', e => {
|
||||
if (!e.target.matches('[data-action="toggle-cutsheet-marks"]')) return;
|
||||
const off = !e.target.checked;
|
||||
document.body.classList.toggle('fp-marks-off', off);
|
||||
try { localStorage.setItem('gandalf.cutsheetMarks', off ? 'off' : 'on'); } catch (_) { /* private mode */ }
|
||||
});
|
||||
try {
|
||||
if (localStorage.getItem('gandalf.cutsheetMarks') === 'off') document.body.classList.add('fp-marks-off');
|
||||
} catch (_) { /* private mode */ }
|
||||
|
||||
// ── SFP module section of the port panel ────────────────────────────────
|
||||
function sfpPanelHtml(sfp) {
|
||||
if (!sfp) return '';
|
||||
@@ -206,6 +232,7 @@ function renderChassis(swName, sw) {
|
||||
if (FACEPLATES[model]) {
|
||||
return `<div class="inspector-chassis">${header}${renderFaceplate(model, {
|
||||
swName, portMap, outlets: sw.outlets, online: sw.online, titleFor: faceplatePortTitle,
|
||||
cutsheetFor: cutsheetMarkFor,
|
||||
})}</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1422,3 +1422,12 @@ polygon.fp-led-off { fill: #6b6c71; }
|
||||
.ev-cards .ev-c-desc { flex: 1 1 100%; color: var(--text-muted); white-space: normal; overflow: visible; }
|
||||
.ev-cards .ev-c-action { flex: 1 1 100%; }
|
||||
}
|
||||
|
||||
/* ── Cutsheet marks on faceplates ──────────────────────────────────── */
|
||||
.fp-mark { stroke: #101418; stroke-width: 1.2; pointer-events: none; }
|
||||
.fp-mark-ok { fill: var(--accent-green, #00ff88); }
|
||||
.fp-mark-bad { fill: var(--status-critical, #e0312b); }
|
||||
.fp-mark-doc { fill: #9aa7b4; }
|
||||
body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
.fp-mark-toggle { cursor: pointer; gap: 6px; }
|
||||
.fp-mark-toggle input { margin: 0; }
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#030508">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%23030508'/%3E%3Cpath d='M8 2l5 3v6l-5 3-5-3V5z' fill='none' stroke='%2300e5ff' stroke-width='1.5'/%3E%3C/svg%3E">
|
||||
<title>{% block title %}GANDALF{% endblock %} — GANDALF</title>
|
||||
<title>{% block title %}Network Monitor{% endblock %} — GANDALF</title>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Cutsheet – GANDALF{% endblock %}
|
||||
{% block title %}Cutsheet{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="lt-page-header">
|
||||
|
||||
+29
-2
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ device_name }} – GANDALF{% endblock %}
|
||||
{% block title %}{{ device_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="lt-page-header">
|
||||
@@ -46,10 +46,37 @@
|
||||
</section>
|
||||
|
||||
{% include "_modem_panel.html" %}
|
||||
|
||||
{% if can_edit %}
|
||||
<div id="inventory-modal" class="lt-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="inventory-title" aria-hidden="true">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header">
|
||||
<h3 class="lt-modal-title" id="inventory-title">Inventory link</h3>
|
||||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="inventory-form">
|
||||
<div class="lt-modal-body">
|
||||
<p class="cs-hint">Open this device's record in the inventory and paste its address here. Leave it empty to remove the link.</p>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="inventory-url">Inventory record URL</label>
|
||||
<input class="lt-input" id="inventory-url" name="url" type="url" inputmode="url" autocomplete="off"
|
||||
placeholder="https://inventory.lotusguild.org/item/…">
|
||||
</div>
|
||||
<p class="cs-hint"><a id="inventory-search" href="#" target="_blank" rel="noopener">Search the inventory for this model ↗</a></p>
|
||||
</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">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>const DEVICE_NAME = {{ device_name | tojson }};</script>
|
||||
<script>const DEVICE_NAME = {{ device_name | tojson }};
|
||||
const CAN_EDIT = {{ 'true' if can_edit else 'false' }};</script>
|
||||
<script src="{{ url_for('static', filename='faceplates.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='port-inspector.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='device.js') }}"></script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Alert History – GANDALF{% endblock %}
|
||||
{% block title %}Alert History{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="lt-page-header">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard – GANDALF{% endblock %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Suppressions – GANDALF{% endblock %}
|
||||
{% block title %}Suppressions{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ def client():
|
||||
db.count_active_events.return_value = 0
|
||||
db.get_status_summary.return_value = {'critical': 0, 'warning': 0, 'info': 0, 'resolved_24h': 0}
|
||||
db.check_suppressed.return_value = False
|
||||
db.get_device_link.return_value = ''
|
||||
with patch.object(gandalf, '_cfg', CONFIG):
|
||||
yield gandalf.app.test_client()
|
||||
|
||||
@@ -376,3 +377,53 @@ class TestHealth:
|
||||
def test_limit_follows_a_long_poll_interval(self, client):
|
||||
assert self._health(client, 1000, poll=300).status_code == 200
|
||||
assert self._health(client, 1600, poll=300).status_code == 503
|
||||
|
||||
|
||||
class TestPageTitles:
|
||||
@pytest.mark.parametrize('path,title', [('/', 'Dashboard — GANDALF'), ('/events', 'Alert History — GANDALF'),
|
||||
('/cutsheet', 'Cutsheet — GANDALF')])
|
||||
def test_name_appears_once(self, client, path, title):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
html = client.get(path, headers=AUTH).get_data(as_text=True)
|
||||
assert f'<title>{title}</title>' in html
|
||||
|
||||
|
||||
class TestInventoryLinks:
|
||||
def _put(self, client, url, headers=AUTH, name='USW Flex'):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
return client.put(f'/api/device/{name}/inventory', json={'url': url}, headers=headers)
|
||||
|
||||
def test_device_api_has_link_and_search(self, client):
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
gandalf.db.get_device_link.return_value = 'https://inventory.lotusguild.org/item/abc'
|
||||
inv = client.get('/api/device/USW Flex', headers=AUTH).get_json()['inventory']
|
||||
assert inv == {'url': 'https://inventory.lotusguild.org/item/abc',
|
||||
'search_url': 'https://inventory.lotusguild.org/items?q=USF5P'}
|
||||
|
||||
def test_admin_sets_and_clears(self, client):
|
||||
assert self._put(client, ' https://inventory.lotusguild.org/item/abc ').status_code == 200
|
||||
gandalf.db.set_device_link.assert_called_with('USW Flex', 'https://inventory.lotusguild.org/item/abc', 'jared')
|
||||
assert self._put(client, '').status_code == 200
|
||||
gandalf.db.set_device_link.assert_called_with('USW Flex', '', 'jared')
|
||||
|
||||
@pytest.mark.parametrize('url', ['http://inventory.lotusguild.org/item/a', 'https://evil.example/item/a',
|
||||
'https://inventory.lotusguild.org.evil.example/x', 'javascript:alert(1)',
|
||||
'https://user:pw@inventory.lotusguild.org/x',
|
||||
'https://inventory.lotusguild.org/' + 'a' * 600])
|
||||
def test_only_inventory_links_are_accepted(self, client, url):
|
||||
assert self._put(client, url).status_code == 400
|
||||
assert not gandalf.db.set_device_link.called
|
||||
|
||||
def test_configured_host(self, client):
|
||||
cfg = dict(CONFIG, inventory={'base_url': 'https://homebox.example'})
|
||||
with patch.object(gandalf, '_cfg', cfg):
|
||||
assert self._put(client, 'https://homebox.example/item/1').status_code == 200
|
||||
assert self._put(client, 'https://inventory.lotusguild.org/item/1').status_code == 400
|
||||
|
||||
def test_needs_admin_and_json(self, client):
|
||||
user = dict(AUTH, **{'Remote-Groups': 'staff'})
|
||||
with patch.object(gandalf, '_cfg', dict(CONFIG, auth=dict(CONFIG['auth'], allowed_groups=['admin', 'staff']))):
|
||||
assert self._put(client, 'https://inventory.lotusguild.org/x', headers=user).status_code == 403
|
||||
gandalf._cutsheet_schema_ready = True
|
||||
resp = client.put('/api/device/USW Flex/inventory', data='url=x', headers=AUTH)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -30,6 +30,7 @@ def db():
|
||||
mock_db.get_events_for_target.return_value = []
|
||||
mock_db.cutsheet_load.return_value = {'items': [], 'cables': [], 'port_labels': []}
|
||||
mock_db.get_pending_recovery_notes.return_value = []
|
||||
mock_db.get_device_link.return_value = ''
|
||||
yield mock_db
|
||||
|
||||
|
||||
@@ -593,6 +594,13 @@ class TestTicketContext:
|
||||
assert '(https://gandalf.test/device/agg#port-7)' in desc
|
||||
assert 'https://gandalf.test/device/large1' in desc
|
||||
assert 'Opened automatically by Gandalf' in desc
|
||||
assert 'inventory' not in desc
|
||||
|
||||
def test_inventory_link_when_set(self, mon, db):
|
||||
db.get_device_link.return_value = 'https://inv.test/item/abc'
|
||||
self._mon(mon)._ticket_interface(1, 'large1', 'enp1s0f0', 2)
|
||||
assert '- [large1 in the inventory](https://inv.test/item/abc)' in self._desc(mon)
|
||||
db.get_device_link.assert_called_with('large1')
|
||||
|
||||
def test_dead_host_uses_the_last_known_port(self, mon, db):
|
||||
mon._known_uplinks = {'large1': {'seen': '2026-11-10 10:00:00 UTC', 'uplinks': [
|
||||
|
||||
Reference in New Issue
Block a user