- Each PDU, UPS and power strip card lists its outlets that feed
something, draw power or are off, with live watts. Each device fed
gets a power badge with what it draws. Hovering a power card or a
device draws its feeds as dotted lines and keeps both ends lit.
- What an outlet feeds: a saved assignment, else the outlet's UniFi name
when it names a device (or the modem), else a MAC in its name that
belongs to a host NIC (the monitor's host_macs) or UniFi device.
- Admins click an outlet to set what it feeds, nothing, or automatic.
Saved in a new power_feeds table (created with the cutsheet tables);
PUT /api/topology/power/<device>/<outlet>, admin and JSON only, with
the device, outlet and target checked. UniFi is never changed.
- An outlet that is off but feeds something is listed under "to look at".
- Power gear is shown by default again, now that it has something to say.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A bar above the diagram lists what needs attention: devices down,
degraded or alerting, cables down or last seen elsewhere, mislabelled
ports, cables with errors. Click one to zoom to it; a missing device
is one entry, with where it was last seen. All clear says so.
- Problem cables glow and are always drawn, whatever the filters; problem
cards get a red or amber border. Healthy cables are drawn quietly.
- Each server's slower second cable (its 1G management link) is hidden
by default while healthy: "Server 1G links" shows them. That was most
of the clutter. "10G only" is gone, since this covers it.
- Power gear is off by default until the power map (part 2) gives the
rail something to show.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drawn to true scale, the 4U servers dwarfed the switches and no label
could be read without zooming. Every device is now a card of the same
height: its name and state on top, and below that its real faceplate
(cropped to the ports) or, for a server, its front and a socket per NIC.
Cables meet the card edge right at the port, with a short tail to it.
The internet, modem and gateway sit side by side along the top, and
unknown devices share the row of the gear on their switch, so the whole
network fits a normal screen. On a phone the canvas takes the diagram's
shape instead of leaving most of it empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The curved cables piled on top of each other. Cables now run like cable
management: straight down from the port into the tray above the row they
go to, along a lane of their own (10G on the upper lanes), then straight
down into the socket. Nothing overlaps and crossings are square. Devices
sit clear of cables passing down through their row, under their fastest
cable.
Each switch's unknown devices are one block with a bundled cable; click
it to open them, or show them one by one or hide them from the toolbar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dashboard's hand-drawn diagram was out of date. /topology replaces it
with the network as the switches report it, every device drawn to scale
from its faceplate or cutsheet front, and one cable per physical link
leaving the port it is plugged into.
- topology.py builds the graph: switch-to-switch cables seen from both ends
are merged, servers get one cable per NIC, the gateway's internet port
goes to the modem and on to the internet, unknown neighbours are stubs,
and gear with no live cable is drawn where it was last seen.
- Ports named after a different device than the one plugged in are flagged.
- Cables show medium, speed, state and traffic; click one for its details,
hover a device to trace its path to the internet; pan, zoom and filters.
- The monitor now keeps host_macs (MAC to host NIC) in link_stats.
- The old dashboard diagram and its JS and CSS are removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gandalf only comments on tickets, so a ticket can stay open long after its
alert cleared (pbs's ticket sat open for a month), and an alert can stay
active for days once nobody is looking.
- The monitor reads Tinker's open ticket list (tickets_api.php, read scope)
every 15 minutes and saves it. A failed read keeps the previous list.
- Active alerts get a stale badge after monitor.stale_alert_days (3), and a
"ticket closed" badge when their ticket was closed while still active.
- A Needs Follow-up panel lists open tickets whose every alert cleared at
least monitor.stale_ticket_hours (24) ago.
- A "TO FOLLOW UP" status chip counts both. If the open ticket list is over
2 hours old, the ticket flags are left off rather than guessed.
- Replaced an — in the alert count notice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A UniFi Network update on 2026-11-12 dropped the UDM's WAN, Smart Power
power-cycled the modem, and Gandalf opened two tickets for the one event.
- Modem and WAN reports from one check cycle open a single ticket, led by
the most urgent, with the rest listed under "Also seen in this incident".
A report while the modem has an open alert, or one that ended under 30
minutes ago, becomes a comment on that ticket instead.
- UniFi update log entries (NETWORK_UPDATED, FIRMWARE_UPDATED_V2 and the
like) add a note to modem/WAN tickets from 20 minutes before the update
to 15 after, and hold new modem/WAN tickets for 15 minutes.
- A Maintenance button for admins starts a global suppression for 30 min
to 4 hours, with a banner on every page and End now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gear on the rear posts usually still faces the front (the StarTech's RPDU
and ZimaBoard shelf), so "which posts" and "which way the ports face" are
now separate fields (mount, facing). The page has three views: Front (front
posts), Rear posts (rear-post gear seen from the front, i.e. what is behind
the front gear) and Rear (from behind). An item seen from the side it
doesn't face shows its back. Facing defaults to front, so existing items
keep their meaning.
Shelves are drawn at true scale with their contents ordered by a new
"Position on shelf". The IceWhale ZimaBoard 832 is traced from IceWhale's
labelled photo of its port end (ZimaDocs): Mini-DP, two RJ45-over-USB 3.0
stacks with link/activity LEDs, DC jack, gunmetal case, gold PCB edge and
black base. Its RJ45 link lights follow the host's physical NICs and the
status glow follows the host.
New cutsheet_items columns facing and slot, added on first use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Page titles read "Rack | GANDALF", ranges use a hyphen (U26-42,
1-100 of 300), empty cells show "-", and dash-separated phrases use a
colon, comma or full stop. In the shared base.js only the on-screen strings
changed (boot line, placeholders, offline banner and toast).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Items Gandalf has no live faceplate for can pick a Front drawing: the
UniFi NVR Pro (drawn from the CC0 NetBox devicetype-library image), the
Sliger CX4712 4U and Rosewill RSV-L4500U 4U cases (from driveAtlas's
layouts), and generic 1U/2U/4U servers. The power light shows the linked
device's live status. They export with the rest of the rack.
New cutsheet_items column chassis, added on first use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Items are mounted from the front, the rear, or full depth. On a 4-post
rack a front and a rear item can share a unit; a full-depth one takes
both sides, and the rear view shows its back. Overlaps are checked per
side. Shelf contents take the shelf's side. A Front / Rear switch flips
the drawing.
Racks are saved in Gandalf (new cutsheet_racks table) and edited on the
rack page by admins: name, height, notes, add and delete. Renaming moves
the rack's items; saving the first rack carries over the items of the
config default it replaces. Config racks remain the fallback.
Faceplates are stretched to the full width only when the drawing is
close to the slot's proportions, so the PDU is not squashed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cutsheet items gain a "Gandalf device" link, so an item named after the
product (UDM-Pro) shows the live device's faceplate, ports and status.
Cable checks, suggestions, tickets and the device page treat an item and
its linked device as one. Without this, product-named items drew as bare
name plates.
Items can sit on a shelf instead of a rack unit; the shelf draws what is
on it with live status. Items have a rack, the page shows a tab per rack
(config "racks"), and a rack can be downloaded as SVG or PNG.
The rack is centred, long runs of empty units fold into one band (with a
toggle), every device fills the 19" width, the UPS shows its front only,
and the rack keeps its own palette so it reads in light mode.
New cutsheet_items columns rack, shelf and device are added on first use.
Deleting a shelf, or making it something else, is refused while it holds
items; renaming it carries over.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rack front is drawn from the rack units on cutsheet items. Switches
with a faceplate use it, with live port state; patch panels get a generic
panel with port labels; everything else is a labelled plate. Each
documented port shows its cutsheet state, and hovering a port lights up
every port on the same cable run (through patch panels) and names the
cables. Clicking opens the switch port or the cable.
Overlapping items and items past the top of the rack are listed instead
of drawn over another; where two claim the same top unit, the first one
recorded wins. rack.units sets the height (default 42).
/api/rack builds on the cutsheet view; cutsheet.rack_layout and
cutsheet.port_marks hold the placement and per-port logic.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
/events lists every alert from the last 24 hours to 90 days, open or
cleared, with how long each lasted, failed checks and its ticket. Filters
(device, type, severity, state, range) live in the URL, so device pages
and tickets link straight to a device's history. A "Recurring" table
lists alerts that fired more than once on the same target. On phones the
rows become cards.
/api/events/history backs it (100 per page). The existing /api/events is
unchanged.
/health and the dashboard's monitor indicator now call the monitor
stalled after five missed polls (at least 10 minutes) instead of a fixed
20, and /health reports the monitor's age.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Trace a cord: pick a panel port, press Start, and unplug the cord at the
panel. The page polls a new admin-only endpoint that reads every switch
port's state from UniFi (cached 2 s), shows the one port that dropped
with a button to record the cable, and confirms when it's plugged back
in. Several ports dropping at once, or none within 3 minutes, is
explained rather than guessed at.
Mismatch alerts: the monitor runs the cutsheet check every poll. Each
mismatched installed run (wrong neighbor, no link, missing port, port
conflict) keeps one cable_mismatch event on its switch, resolved when
fixed, and opens a P4 ticket after cutsheet.mismatch_ticket_hours
(default 24, 0 never). Suppressing the switch or port stops the ticket.
Also fixes buttons with the hidden attribute still showing, which left a
Delete button on the new-cable form.
Closes#12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new Cutsheet page records where every cable is meant to go and compares
it with what Gandalf discovers.
- Inventory for what discovery can't see: patch panels, wall jacks,
shelves and rack positions. Panels and jacks pass through front to rear.
- Cables with two endpoints, ID, type, length, color, status (planned,
installed, dark, retired) and notes; the far end may be unterminated.
- Each cable's full run is traced through panels and jacks, and runs
ending on a switch port are checked against its discovered neighbor
(LLDP, client table, or a DAC serial seen at both ends): matches,
wrong neighbor, no link, missing port, port conflict, unverified, idle.
- Undocumented links: live switch ports no cable mentions, one click to
add with what Gandalf already knows. Port panels show the planned cable.
- Labels to make: cable IDs and panel-port labels not yet applied, with a
print view, for a handheld label maker.
- CSV export, and all-or-nothing import that upserts by cable ID.
- Cables, panels and jacks appear in the dashboard search.
Editing needs the admin group and a JSON request body. Every change is
written to cutsheet_history in the same transaction. Tables are created
on first use.
Refs #12
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the port blocks on device pages with SVG faceplates traced from
reference images: Dream Machine Pro, USW Pro 24 PoE, USW Aggregation,
USW Flex, USW Flex Mini, USP PDU Pro and UPS 2U Pro. Port LEDs use each
model's documented colors, and ports show a cable or module when up.
Remove SWITCH_LAYOUTS, which had wrong data: the aggregation switch was
numbered across the rows instead of odd over even, and the USW Flex had
an SFP cage it doesn't have. Models without a faceplate use plain blocks.
Show PDU and UPS outlets with on/off state and auto power-cycle, and let
the UPS page show its panel even though it reports no ports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the all-switches Inspector page with a per-device front panel,
port table and detail panel. /inspector now redirects to the dashboard,
and /device/<name>#port-<n> links directly to a port.
Restore neighbor names after UniFi's LLDP change: read local_port_idx
and resolve neighbors by MAC using Prometheus NIC data, UniFi devices
and the wired client table. This also brings back Run Diagnostics on
server ports, which depends on a named neighbor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Modem and WAN:
- Poll the BGW320 status pages read-only for light levels, PON and WAN
state, with a WAN / Fiber panel, 7-day Rx trend, and alerts on WAN down,
optical limits, and Rx drift
- Read WAN outages and Smart Power restarts from the UniFi system log,
since outages can be over between 5 minute polls
Device pages:
- Add device search to the dashboard and a page per host, switch, router,
AP, PDU, or modem
- Move all Link Debug data into expandable rows there; /links redirects
- Collect UDM ports and UniFi device details (firmware, load, WAN, outlets)
- Reorder the dashboard
Monitoring fixes:
- Alert when a configured host stops reporting: its series disappear
rather than reporting down, so a dead host never raised an alert
- Alert on Prometheus outages instead of blanking the dashboard
- Treat a failed Pulse ping as unknown rather than host unreachable
- Stop new switch ports reporting lifetime bytes as one interval's rate
- Run database sessions in UTC: other server time zones shifted displayed
ages and timed suppression expiries
Also log every avatar 404 reason, add a favicon, and correct the WAN link
labels (1G on Port 9, not 10G SFP+).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Using || 30 / || 60 as a fallback treats refreshInterval=0 (Off) as
falsy and replaces it with the default, causing auto-refresh to start
even when the user saved 'Off'. Replace with nullish coalescing (??)
so only null/undefined triggers the default.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Global suppressions (target_type='all') have an empty target_name, so
the selectattr filter never matched them, leaving no visual indicator
when a global maintenance window was active. Pre-compute has_global_sup
before the host loop and OR it into the badge condition.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
monitor.py: _ticket_interface/_ticket_unifi/_ticket_unreachable all used
`if tid and is_new` to guard db.set_ticket_id(). Since is_new is True only
on the first upsert (consec=1) but tickets are created at consec>=fail_thresh
(default 2), is_new is always False when the ticket is created, so the
ticket link never appeared in the UI. Changed to `if tid:`.
links.html: JSON.parse(sessionStorage.getItem(...)) in togglePanel and
restoreCollapseState had no try-catch. Corrupt/stale session storage would
throw an uncaught SyntaxError. Also wrapped all sessionStorage.setItem
calls in try-catch to defend against storage-full / private-browsing errors.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously switching ports while a diagnostic was running left the
setInterval timer active, causing the result to be written into the
old (now detached) DOM elements and never shown to the user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The check `!data.hosts && !data.unifi_switches` never caught empty
objects `{}`, which are truthy. Replace with Object.keys length checks
so the friendly "no data yet" banner renders when both collections
are empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The reason input had `required` for browser validation but was missing
`aria-required="true"`, so screen readers did not announce it as required.
Matches the fix already applied to the equivalent field in base.html.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same bug as was just fixed in links.html: data.updated is stored as
"YYYY-MM-DD HH:MM:SS UTC" by monitor.py, so appending 'Z' produced
"…UTCZ" — an invalid date. The stale-data warning and Updated timestamp
in Inspector were silently showing "Invalid Date" and the stale overlay
never fired. Fixed to use _toIso() (already global via app.js).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Security: add require_admin decorator; apply to POST/DELETE /api/suppressions
and /suppressions page. Previously any user in allowed_groups could create or
delete suppressions even though the nav restricts the UI to admins.
Bug: links.html "Updated:" timestamp and stale-warning both produced
Invalid Date because the raw "YYYY-MM-DD HH:MM:SS UTC" string was appended
with 'Z' instead of being normalised through _toIso(). Fix both call sites to
use _toIso(), and remove the now-redundant local _toIso redefinition.
Style: use `with open(sentinel, 'w'): pass` consistently (was open().close()
at avatar JPEG validation path).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- updateSuppressForm() now sets required + aria-required on sup-name/sup-detail
when target type changes; sup-reason gets static aria-required="true"
- onTypeChange() in suppressions page syncs aria-required on s-name
- s-name in suppressions.html gets initial required/aria-required (default type=host)
- Duration pills in both modal and suppressions page now have descriptive
aria-label ("30 minutes", "1 hour", etc.) alongside the group aria-label
- setDuration() in app.js accepts optional {expiresId,pillSel,hintId} opts so
logic lives in one place; suppressions.html setDur() delegates to it
- Post-create form reset uses setDur() instead of manually patching DOM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- inspector.html: show orange '⚠ Stale: HH:MM' with tooltip when link_stats data is >15 min old (previously just showed the time with no visual warning)
- style.css: add .g-stale-warn helper class (orange, bold) for the stale indicator
- diagnose.py: remove supported_modes accumulation from parse_ethtool() — field was collected but never consumed by analyze() or displayed anywhere
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- inspector.html: collapsible section hint text now toggles between [expand]/[collapse] when clicked
- inspector.html: timeout and connection-loss during diagnostic poll now show a Retry button instead of a dead end
- inspector.html: 429 rate-limit response shows a clear human-readable message instead of generic error
- app.py: empty link_stats fallback now includes unifi_switches:{} for schema consistency with real data shape
- index.html: pagination overflow notice now says "export all as JSON" (opens in new tab) instead of misleadingly linking to raw API as navigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- inspector.html: fix LLDP neighbor label in port blocks — port.lldp_table never exists; data is at port.lldp (dict with system_name/chassis_id); both port block renderers corrected
- db.py: remove dead 'target_detail IS NULL' branch in suppression check — target_detail is always stored as '' not NULL; query simplified to target_detail=''
- app.py: resolve cache_dir/cache_file/sentinel to absolute paths; guard against path escape before use
- app.py: wrap sentinel os.path.getmtime() in try/except OSError to handle TOCTOU deletion race
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app.py: wrap int(cache_ttl) in try/except so a misconfigured non-integer value falls back to 3600 instead of raising ValueError
- base.html: use Jinja2 tojson filter for ticket_web_url to ensure proper JS string escaping regardless of URL contents
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app.py: validate server_name from LLDP with fullmatch before use in logs/lookups (prevents log injection)
- app.py: validate each mgmt_ip candidate before assigning host_ip (avoids assigning non-IP string that then fails later check)
- app.py: log actual exception in link_stats JSON parse error
- inspector.html: clear _diagPollTimer in closePanel() so timer doesn't orphan when panel is closed mid-poll
- monitor.py: sleep 30s after a monitor loop exception before resuming normal poll interval
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
security:
- Fix bare open(sentinel, 'w').close() file descriptor leak; use
context manager instead
- Store requesting username in _diag_jobs at creation time; return 403
from api_diagnose_poll if the polling user does not match the job owner
accessibility:
- Add aria-live="polite" aria-atomic="true" to .status-chips container
so screen readers announce critical/warning count changes on refresh
- Add aria-controls="events-table-wrap" to critical and warning stat
cards so assistive tech knows these buttons control the events table
- Add aria-hidden sync to topology setCollapsed() — hidden topology
content is now removed from the accessibility tree when collapsed,
preventing keyboard focus from entering invisible elements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The command palette button used an inline onclick handler while every
other interactive element in base.html uses data-action + event
delegation. Now consistent: data-action="open-cmdpalette" handled in
the global footer click listener.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add role="button" tabindex="0" aria-expanded to .link-host-title
in both static and JS-rendered panels (host panels + UniFi switches)
- Sync aria-expanded in togglePanel(), restoreCollapseState(),
collapseAll(), and expandAll()
- Add keydown handler (Enter/Space) so panel headers are keyboard-operable
- Add role="region" aria-label to inspector main chassis area
- Add role="complementary" aria-label to inspector port detail panel
- Replace last inline date-parse in renderLinks() with _toIso() helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace all var declarations in base.html, index.html scripts with
const/let (const for bindings that are never reassigned, let otherwise)
- Add _toIso() helper to links.html script block and replace the two
inline .replace(' UTC','Z').replace(' ','T') patterns with it
- Replace var with const in links.html _linksInterval
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add role="group" + aria-label to duration-pills and sev-pills containers
- Add aria-pressed to severity filter, duration, and refresh-interval pills
- Keep aria-pressed in sync with JS (setDuration, applyRefreshPillUI, modal reset)
- Add aria-label to events-search, host-search, links-search inputs
- Add aria-label to host and UniFi device suppress buttons in templates
- Replace dynamic style color strings in links.html stat cards with TDS
utility classes (lt-text-red/green/amber) via downCls/errCls variables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace .empty-state (removed class) with TDS lt-empty-state--sm in
both error branches of renderInspector() and loadInspector()
- Diagnostic run button: add aria-label, apply lt-btn TDS classes for
consistent styling instead of custom btn-diag-only styling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- lt-divider--unifi / lt-divider-label--unifi: replace hardcoded margin
and cyan label color on the UniFi switch section divider
- lt-text-amber / lt-text-cyan on stat card icons and values (matches
same migration done in index.html)
- lt-stats-grid--mb: margin-bottom:16px on the summary stats grid
- g-page-sub-aside: replaces margin-left:8px on the updated timestamp
span in links and inspector page subtitle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- lt-notif-empty: replaces all hardcoded padding/font/color/align on
the empty-state and loading/error text in the notification bell panel
- lt-notif-view-all: replaces width/text-align/display/font-size inline
style on the 'View dashboard' footer link
- lt-notif-dot: moves border-radius:50%;margin-top from inline style
(only background color remains inline, which is dynamic per-severity)
- Initial 'Loading…' text in the panel HTML uses lt-notif-empty
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Stat card icons and values: style="color:var(--red)" etc replaced with
lt-text-red, lt-text-amber, lt-text-cyan, lt-text-green (defined in
base.css with both color and glow-shadow)
- Host search input: style="width:180px" extracted to .lt-search-input--sm
- base.html: suppress modal form groups use lt-form-group--last for last
item (already committed); lt-divider--compact applied to settings divider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Suppress modal form groups: strip margin-bottom:12px (lt-form-group
already has margin-bottom in TDS); use lt-form-group--last for the
final group where zero margin is needed
- Keyboard shortcuts table: remove width:100% (lt-table is already full-
width in base.css)
- Settings divider: replace style=margin override with .lt-divider--compact
- Topology bus section: move max-width:860px into .topo-bus-section rule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Active events now carry an is_suppressed boolean (added in api_status()
and the index() route via check_suppressed() against the pre-loaded
suppression list). The events table renders a muted '🔕 sup' badge next
to the severity and dims the entire row (.row-suppressed) so operators
can immediately see which firing alerts are silenced.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>