diff --git a/app.py b/app.py index a8900b5..11fca38 100644 --- a/app.py +++ b/app.py @@ -96,6 +96,13 @@ def index(): ) +@app.route('/links') +@require_auth +def links_page(): + user = _get_user() + return render_template('links.html', user=user) + + @app.route('/suppressions') @require_auth def suppressions_page(): @@ -139,6 +146,18 @@ def api_network(): return jsonify({'hosts': {}, 'unifi': [], 'updated': None}) +@app.route('/api/links') +@require_auth +def api_links(): + raw = db.get_state('link_stats') + if raw: + try: + return jsonify(json.loads(raw)) + except Exception: + pass + return jsonify({'hosts': {}, 'updated': None}) + + @app.route('/api/events') @require_auth def api_events(): diff --git a/config.json b/config.json index 4a3e966..d8578fa 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,10 @@ { + "ssh": { + "user": "root", + "password": "Server#980000Panda", + "connect_timeout": 5, + "timeout": 20 + }, "unifi": { "controller": "https://10.10.10.1", "api_key": "kyPfIsAVie3hwMD4Bc1MjAu8N7HVPIb8", diff --git a/monitor.py b/monitor.py index 8d09607..521bf95 100644 --- a/monitor.py +++ b/monitor.py @@ -162,6 +162,288 @@ class TicketClient: return None +# -------------------------------------------------------------------------- +# Link stats collector (ethtool + Prometheus traffic metrics) +# -------------------------------------------------------------------------- +class LinkStatsCollector: + """Collects detailed per-interface statistics via SSH (ethtool) and Prometheus.""" + + def __init__(self, cfg: dict, prom: 'PrometheusClient'): + self.prom = prom + ssh = cfg.get('ssh', {}) + self.ssh_user = ssh.get('user', 'root') + self.ssh_pass = ssh.get('password', '') + self.ssh_connect_timeout = ssh.get('connect_timeout', 5) + self.ssh_timeout = ssh.get('timeout', 20) + + # ------------------------------------------------------------------ + # SSH collection + # ------------------------------------------------------------------ + def _ssh_batch(self, ip: str, ifaces: List[str]) -> Dict[str, dict]: + """ + Open one SSH session to *ip* and collect ethtool + SFP DOM data for + all *ifaces*. Returns {iface: {speed_mbps, duplex, ..., sfp: {...}}}. + """ + if not ifaces or not self.ssh_pass: + return {} + + # Validate interface names (kernel names only contain [a-zA-Z0-9_.-]) + safe_ifaces = [i for i in ifaces if re.match(r'^[a-zA-Z0-9_.@-]+$', i)] + if not safe_ifaces: + return {} + + # Build a single shell command: for each iface output ethtool + -m with sentinels + parts = [] + for iface in safe_ifaces: + parts.append( + f'echo "___IFACE:{iface}___";' + f' ethtool "{iface}" 2>/dev/null;' + f' echo "___DOM:{iface}___";' + f' ethtool -m "{iface}" 2>/dev/null;' + f' echo "___END___"' + ) + shell_cmd = ' '.join(parts) + + try: + result = subprocess.run( + [ + 'sshpass', '-p', self.ssh_pass, + 'ssh', + '-o', 'StrictHostKeyChecking=no', + '-o', f'ConnectTimeout={self.ssh_connect_timeout}', + '-o', 'LogLevel=ERROR', + '-o', 'BatchMode=no', + f'{self.ssh_user}@{ip}', + shell_cmd, + ], + capture_output=True, + text=True, + timeout=self.ssh_timeout, + ) + output = result.stdout + except FileNotFoundError: + logger.debug('sshpass not found – skipping ethtool collection') + return {} + except Exception as e: + logger.debug(f'SSH ethtool {ip}: {e}') + return {} + + return self._parse_ssh_output(output) + + @staticmethod + def _parse_ssh_output(output: str) -> Dict[str, dict]: + result: Dict[str, dict] = {} + current_iface: Optional[str] = None + current_section: Optional[str] = None + buf: List[str] = [] + + def flush(iface, section, lines): + if not iface or not lines: + return + text = '\n'.join(lines) + if section == 'ethtool': + result.setdefault(iface, {}).update( + LinkStatsCollector._parse_ethtool(text) + ) + elif section == 'dom': + sfp = LinkStatsCollector._parse_ethtool_m(text) + if sfp: + result.setdefault(iface, {})['sfp'] = sfp + + for line in output.splitlines(): + if line.startswith('___IFACE:') and line.endswith('___'): + flush(current_iface, current_section, buf) + current_iface = line[9:-3] + current_section = 'ethtool' + buf = [] + elif line.startswith('___DOM:') and line.endswith('___'): + flush(current_iface, current_section, buf) + current_iface = line[7:-3] + current_section = 'dom' + buf = [] + elif line == '___END___': + flush(current_iface, current_section, buf) + current_iface = None + current_section = None + buf = [] + else: + buf.append(line) + + flush(current_iface, current_section, buf) + return result + + @staticmethod + def _parse_ethtool(output: str) -> dict: + data: dict = {} + for line in output.splitlines(): + if ':' not in line: + continue + key, _, val = line.partition(':') + key = key.strip() + val = val.strip() + if key == 'Speed': + m = re.match(r'(\d+)Mb/s', val) + if m: + data['speed_mbps'] = int(m.group(1)) + elif key == 'Duplex': + data['duplex'] = val.lower() + elif key == 'Port': + data['port_type'] = val + elif key == 'Auto-negotiation': + data['auto_neg'] = (val.lower() == 'on') + elif key == 'Link detected': + data['link_detected'] = (val.lower() == 'yes') + return data + + @staticmethod + def _parse_ethtool_m(output: str) -> dict: + """Parse ethtool -m (SFP DOM / digital optical monitoring) output.""" + if not output: + return {} + # Skip if module diagnostics unsupported + lower = output.lower() + if 'cannot get' in lower or 'not supported' in lower or 'no sfp' in lower: + return {} + + data: dict = {} + for line in output.splitlines(): + if ':' not in line: + continue + key, _, val = line.partition(':') + key = key.strip() + val = val.strip() + + if key == 'Vendor name': + data['vendor'] = val + elif key == 'Vendor PN': + data['part_no'] = val + elif key == 'Identifier': + m = re.search(r'\((.+?)\)', val) + if m: + data['sfp_type'] = m.group(1) + elif key == 'Connector': + m = re.search(r'\((.+?)\)', val) + if m: + data['connector'] = m.group(1) + elif key == 'Laser wavelength': + m = re.match(r'(\d+)', val) + if m: + data['wavelength_nm'] = int(m.group(1)) + elif key == 'Laser bias current': + # e.g. "4.340 mA" + m = re.match(r'([\d.]+)\s+mA', val) + if m: + data['bias_ma'] = float(m.group(1)) + elif key == 'Laser output power': + # e.g. "0.1234 mW / -9.09 dBm" + m = re.search(r'/\s*([-\d.]+)\s*dBm', val) + if m: + try: + data['tx_power_dbm'] = float(m.group(1)) + except ValueError: + pass + elif 'receiver' in key.lower() and ('power' in key.lower() or 'optical' in key.lower()): + m = re.search(r'/\s*([-\d.]+)\s*dBm', val) + if m: + try: + data['rx_power_dbm'] = float(m.group(1)) + except ValueError: + pass + elif key == 'Module temperature': + # e.g. "36.00 degrees C / 96.80 degrees F" + m = re.match(r'([\d.]+)\s+degrees', val) + if m: + data['temp_c'] = float(m.group(1)) + elif key == 'Module voltage': + # e.g. "3.3180 V" + m = re.match(r'([\d.]+)\s+V', val) + if m: + data['voltage_v'] = float(m.group(1)) + + return data + + # ------------------------------------------------------------------ + # Prometheus traffic / error metrics + # ------------------------------------------------------------------ + def _collect_prom_metrics(self) -> Dict[str, Dict[str, dict]]: + """Return {instance: {device: {tx_bytes_rate, rx_bytes_rate, ...}}}.""" + metrics: Dict[str, Dict[str, dict]] = {} + + queries = [ + ('tx_bytes_rate', 'rate(node_network_transmit_bytes_total[5m])'), + ('rx_bytes_rate', 'rate(node_network_receive_bytes_total[5m])'), + ('tx_errs_rate', 'rate(node_network_transmit_errs_total[5m])'), + ('rx_errs_rate', 'rate(node_network_receive_errs_total[5m])'), + ('tx_drops_rate', 'rate(node_network_transmit_drop_total[5m])'), + ('rx_drops_rate', 'rate(node_network_receive_drop_total[5m])'), + ('carrier_changes', 'node_network_carrier_changes_total'), + ] + + for field, promql in queries: + for r in self.prom.query(promql): + instance = r['metric'].get('instance', '') + device = r['metric'].get('device', '') + if not is_physical_interface(device): + continue + raw = r['value'][1] + try: + val: Optional[float] = float(raw) + if val != val: # NaN + val = None + except (ValueError, TypeError): + val = None + metrics.setdefault(instance, {}).setdefault(device, {})[field] = val + + return metrics + + # ------------------------------------------------------------------ + # Main collection entry point + # ------------------------------------------------------------------ + def collect(self, instance_map: Dict[str, str]) -> dict: + """ + Collect full link stats for all Prometheus-monitored hosts. + + *instance_map*: ``{'10.10.10.2:9100': 'large1', ...}`` + + Returns a dict suitable for ``db.set_state('link_stats', ...)``. + """ + prom_metrics = self._collect_prom_metrics() + result_hosts: Dict[str, Dict[str, dict]] = {} + + for instance, iface_metrics in prom_metrics.items(): + host = instance_map.get(instance, instance.split(':')[0]) + host_ip = instance.split(':')[0] + ifaces = list(iface_metrics.keys()) + + # SSH ethtool collection (one connection per host, all ifaces) + ethtool_data: Dict[str, dict] = {} + if self.ssh_pass and ifaces: + try: + ethtool_data = self._ssh_batch(host_ip, ifaces) + except Exception as e: + logger.warning(f'ethtool collection failed for {host} ({host_ip}): {e}') + + # Merge Prometheus metrics + ethtool data per interface + merged: Dict[str, dict] = {} + for iface in ifaces: + d: dict = {'host_ip': host_ip} + d.update(iface_metrics.get(iface, {})) + eth = ethtool_data.get(iface, {}) + for k, v in eth.items(): + if k != 'sfp': + d[k] = v + if 'sfp' in eth: + d['sfp'] = eth['sfp'] + merged[iface] = d + + result_hosts[host] = merged + + return { + 'hosts': result_hosts, + 'updated': datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC'), + } + + # -------------------------------------------------------------------------- # Helpers # -------------------------------------------------------------------------- @@ -197,6 +479,7 @@ class NetworkMonitor: self.prom = PrometheusClient(prom_url) self.unifi = UnifiClient(self.cfg['unifi']) self.tickets = TicketClient(self.cfg.get('ticket_api', {})) + self.link_stats = LinkStatsCollector(self.cfg, self.prom) mon = self.cfg.get('monitor', {}) self.poll_interval = mon.get('poll_interval', 120) @@ -457,7 +740,14 @@ class NetworkMonitor: db.set_state('network_snapshot', snapshot) db.set_state('last_check', _now_utc()) - # 2. Process alerts (separate Prometheus call for fresh data) + # 2. 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) iface_states = self.prom.get_interface_states() self._process_interfaces(iface_states) diff --git a/static/app.js b/static/app.js index 73095be..12d8c29 100644 --- a/static/app.js +++ b/static/app.js @@ -42,13 +42,13 @@ function updateStatusBar(summary, lastCheck) { const bar = document.querySelector('.status-chips'); if (!bar) return; const chips = []; - if (summary.critical) chips.push(`⬤ ${summary.critical} Critical`); - if (summary.warning) chips.push(`⬤ ${summary.warning} Warning`); - if (!summary.critical && !summary.warning) chips.push('✔ All systems nominal'); + if (summary.critical) chips.push(`● ${summary.critical} CRITICAL`); + if (summary.warning) chips.push(`● ${summary.warning} WARNING`); + if (!summary.critical && !summary.warning) chips.push('✔ ALL SYSTEMS NOMINAL'); bar.innerHTML = chips.join(''); const lc = document.getElementById('last-check'); - if (lc && lastCheck) lc.textContent = `Last check: ${lastCheck}`; + if (lc && lastCheck) lc.textContent = lastCheck; } function updateHostGrid(hosts) { @@ -157,15 +157,17 @@ function updateEventsTable(events) { }).join(''); wrap.innerHTML = ` - - - - - - - - ${rows} -
SeverityTypeTargetDetailDescriptionFirst SeenFailuresTicketActions
`; +
+ + + + + + + + ${rows} +
SevTypeTargetDetailDescriptionFirst SeenFailuresTicketActions
+
`; } // ── Suppression modal (dashboard) ──────────────────────────────────── diff --git a/static/style.css b/static/style.css index 051dde9..a93457e 100644 --- a/static/style.css +++ b/static/style.css @@ -1,747 +1,809 @@ -/* ── Variables ──────────────────────────────────────────────────────── */ +/* ══════════════════════════════════════════════════════════════════════ + GANDALF – Terminal aesthetic (Pulse / TinkerTickets style) + ══════════════════════════════════════════════════════════════════════ */ + +/* ── Variables ────────────────────────────────────────────────────── */ :root { - --blue: #006FFF; - --blue-dark: #00439C; - --blue-dim: rgba(0,111,255,.1); - --green: #10B981; - --red: #EF4444; - --orange: #F59E0B; - --yellow: #FBBF24; - --grey: #6B7280; - --grey-lt: #F3F4F6; - --border: #E5E7EB; - --text: #111827; - --text-sub: #6B7280; - --card-bg: #FFFFFF; - --bg: #F8FAFC; - --radius: 10px; - --shadow: 0 1px 3px rgba(0,0,0,.08), 0 4px 12px rgba(0,0,0,.06); - --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --mono: 'SF Mono', 'Fira Code', Consolas, monospace; + --bg: #0a0a0a; + --bg2: #1a1a1a; + --bg3: #2a2a2a; + --bg-hover: rgba(0,255,65,.07); + + --green: #00ff41; + --green-dim: rgba(0,255,65,.15); + --green-dark: #00cc33; + --green-muted: #008822; + + --amber: #ffb000; + --amber-dim: rgba(255,176,0,.15); + + --cyan: #00ffff; + --cyan-dim: rgba(0,255,255,.12); + + --red: #ff4444; + --red-dim: rgba(255,68,68,.15); + + --orange: #ff8c00; + --orange-dim: rgba(255,140,0,.15); + + --border: rgba(0,255,65,.35); + --border-hi: #00ff41; + + --text: #00ff41; + --text-dim: #00cc33; + --text-muted: #008822; + + --font: 'Courier New','Consolas','Monaco','Menlo',monospace; + + --glow: 0 0 5px #00ff41, 0 0 10px rgba(0,255,65,.4); + --glow-xl: 0 0 8px #00ff41, 0 0 20px rgba(0,255,65,.35); + --glow-amber: 0 0 5px #ffb000, 0 0 10px rgba(255,176,0,.4); + --glow-red: 0 0 5px #ff4444, 0 0 10px rgba(255,68,68,.4); + --glow-cyan: 0 0 5px #00ffff, 0 0 10px rgba(0,255,255,.35); } -/* ── Reset ──────────────────────────────────────────────────────────── */ +/* ── Reset ────────────────────────────────────────────────────────── */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html { scrollbar-color: var(--green-muted) var(--bg2); scrollbar-width: thin; } +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--bg2); } +::-webkit-scrollbar-thumb { background: var(--green-muted); } +::-webkit-scrollbar-thumb:hover { background: var(--green); } + +/* ── Body / CRT ───────────────────────────────────────────────────── */ body { font-family: var(--font); background: var(--bg); color: var(--text); - font-size: 14px; + font-size: 13px; line-height: 1.5; + min-height: 100vh; + position: relative; + animation: flicker .25s ease-in-out 45s infinite; } -a { color: var(--blue); text-decoration: none; } -a:hover { text-decoration: underline; } +/* CRT scanline overlay */ +body::before { + content: ''; + position: fixed; + inset: 0; + background: repeating-linear-gradient( + 0deg, + rgba(0,0,0,.13) 0px, rgba(0,0,0,.13) 1px, + transparent 1px, transparent 2px + ); + pointer-events: none; + z-index: 9999; + animation: scanline 8s linear infinite; +} -/* ── Navbar ─────────────────────────────────────────────────────────── */ -.navbar { - background: linear-gradient(135deg, var(--blue-dark) 0%, var(--blue) 100%); - color: white; +/* Binary data stream corner */ +body::after { + content: '10101010'; + position: fixed; + bottom: 10px; right: 14px; + font-family: var(--font); + font-size: .55rem; + color: var(--green); + opacity: .07; + pointer-events: none; + letter-spacing: 2px; + animation: data-stream 3s steps(1) infinite; +} + +@keyframes scanline { to { transform: translateY(4px); } } +@keyframes flicker { 0%,100%{opacity:1} 10%{opacity:.96} 50%{opacity:.98} } +@keyframes data-stream { + 0% { content:'10101010'; } 25% { content:'01010101'; } + 50% { content:'11001100'; } 75% { content:'00110011'; } +} +@keyframes pulse-glow { + 0%,100% { text-shadow: var(--glow); } + 50% { text-shadow: var(--glow-xl); } +} +@keyframes pulse-red { + 0%,100% { box-shadow: 0 0 0 0 rgba(255,68,68,.5); } + 50% { box-shadow: 0 0 6px 3px rgba(255,68,68,.2); } +} +@keyframes blink { 0%,49%{opacity:1} 50%,100%{opacity:0} } +@keyframes slide-in { + from { transform: translateX(110%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +a { color: var(--amber); text-decoration: none; } +a:hover { text-decoration: underline; text-shadow: var(--glow-amber); } + +/* ── Header ───────────────────────────────────────────────────────── */ +.header { + background: var(--bg2); + border-bottom: 2px solid var(--green); + box-shadow: 0 2px 16px rgba(0,255,65,.12); + padding: 0 28px; + height: 58px; display: flex; align-items: center; - gap: 24px; - padding: 0 24px; - height: 56px; - box-shadow: 0 2px 8px rgba(0,0,0,.2); + justify-content: space-between; + position: relative; + z-index: 100; } +.header::before { content:'╔'; position:absolute; top:-1px; left:-1px; font-size:1.4rem; color:var(--green); text-shadow:var(--glow); line-height:1; } +.header::after { content:'╗'; position:absolute; top:-1px; right:-1px; font-size:1.4rem; color:var(--green); text-shadow:var(--glow); line-height:1; } -.nav-brand { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; +.header-left { display:flex; align-items:center; gap:24px; } +.header-brand { display:flex; flex-direction:column; } + +.header-title { + font-size: 1.35em; + font-weight: bold; + color: var(--amber); + text-shadow: var(--glow-amber); + letter-spacing: .08em; } +.header-title::before { content:'>> '; color:var(--green); text-shadow:var(--glow); } +.header-sub { font-size: .65em; color: var(--text-muted); letter-spacing: .12em; text-transform: uppercase; } -.nav-logo { font-size: 20px; } - -.nav-title { - font-weight: 700; - font-size: 16px; - letter-spacing: .05em; -} - -.nav-sub { - font-size: 11px; - opacity: .7; - font-weight: 400; -} - -.nav-links { - display: flex; - gap: 4px; - flex: 1; -} +.header-nav { display:flex; gap:3px; } .nav-link { - color: rgba(255,255,255,.8); - padding: 6px 14px; - border-radius: 6px; - font-size: 13px; - transition: background .15s, color .15s; + color: var(--text-muted); + padding: 5px 12px; + border: 1px solid transparent; + font-size: .8em; + letter-spacing: .06em; + text-transform: uppercase; + transition: all .15s; } - +.nav-link::before { content:'[ '; } +.nav-link::after { content:' ]'; } .nav-link:hover, .nav-link.active { - background: rgba(255,255,255,.15); - color: white; + color: var(--green); + border-color: var(--border); + background: var(--green-dim); + text-shadow: var(--glow); text-decoration: none; } -.nav-user { - font-size: 12px; - opacity: .8; +.header-right { display:flex; align-items:center; gap:10px; } +.header-user { font-size: .78em; color: var(--text-muted); } +.header-user::before { content:'[USER: '; } +.header-user::after { content:']'; } + +/* ── Main ─────────────────────────────────────────────────────────── */ +.main { max-width: 1500px; margin: 0 auto; padding: 22px 20px; } + +/* ── Section ──────────────────────────────────────────────────────── */ +.section { margin-bottom: 26px; } + +.section-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + border-bottom: 1px solid var(--border); + padding-bottom: 5px; } -/* ── Main layout ─────────────────────────────────────────────────────── */ -.main { max-width: 1400px; margin: 0 auto; padding: 24px 20px; } +.section-title { + font-size: .9em; + font-weight: bold; + color: var(--amber); + text-shadow: var(--glow-amber); + text-transform: uppercase; + letter-spacing: .1em; +} +.section-title::before { content:'╠══ '; color:var(--green); text-shadow:var(--glow); } -.page-header { margin-bottom: 24px; } -.page-title { font-size: 22px; font-weight: 700; } -.page-sub { color: var(--text-sub); margin-top: 4px; } +.section-badge { + font-size: .72em; + font-weight: bold; + color: var(--red); + border: 1px solid var(--red); + padding: 0 5px; + text-shadow: var(--glow-red); +} +.section-badge::before { content:'['; } +.section-badge::after { content:']'; } -/* ── Status bar ──────────────────────────────────────────────────────── */ +/* ── Page header ──────────────────────────────────────────────────── */ +.page-header { margin-bottom: 18px; } +.page-title { + font-size: 1.05em; + font-weight: bold; + color: var(--amber); + text-shadow: var(--glow-amber); + letter-spacing: .06em; +} +.page-title::before { content:'>> '; color:var(--green); text-shadow:var(--glow); } +.page-sub { font-size: .75em; color: var(--text-muted); margin-top: 3px; } + +/* ── Status bar ───────────────────────────────────────────────────── */ .status-bar { display: flex; align-items: center; justify-content: space-between; - gap: 16px; - background: var(--card-bg); + background: var(--bg2); border: 1px solid var(--border); - border-radius: var(--radius); - padding: 12px 20px; - margin-bottom: 24px; - box-shadow: var(--shadow); -} - -.status-chips { display: flex; gap: 8px; flex-wrap: wrap; } - -.chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 5px 12px; - border-radius: 20px; - font-size: 13px; - font-weight: 600; -} - -.chip-critical { background: rgba(239,68,68,.12); color: var(--red); border: 1px solid rgba(239,68,68,.3); } -.chip-warning { background: rgba(245,158,11,.12); color: var(--orange); border: 1px solid rgba(245,158,11,.3); } -.chip-ok { background: rgba(16,185,129,.12); color: var(--green); border: 1px solid rgba(16,185,129,.3); } - -.status-meta { - display: flex; - align-items: center; + padding: 9px 16px; + margin-bottom: 18px; gap: 12px; - white-space: nowrap; -} - -.last-check { font-size: 12px; color: var(--text-sub); } - -.btn-refresh { - background: var(--blue-dim); - border: 1px solid rgba(0,111,255,.3); - color: var(--blue); - border-radius: 6px; - padding: 4px 12px; - font-size: 12px; - cursor: pointer; - transition: background .15s; -} -.btn-refresh:hover { background: rgba(0,111,255,.2); } - -/* ── Sections ────────────────────────────────────────────────────────── */ -.section { margin-bottom: 32px; } - -.section-title { - font-size: 16px; - font-weight: 700; - margin-bottom: 14px; - display: flex; - align-items: center; - gap: 8px; -} - -.section-badge { - font-size: 11px; - font-weight: 600; - background: var(--red); - color: white; - padding: 2px 7px; - border-radius: 10px; -} - -.section-badge:not(.badge-critical) { - background: var(--grey); -} - -/* ── Topology diagram ────────────────────────────────────────────────── */ -.topology { - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px 16px 16px; - margin-bottom: 20px; - text-align: center; - box-shadow: var(--shadow); - overflow-x: auto; -} - -.topo-row { - display: flex; - justify-content: center; - gap: 16px; flex-wrap: wrap; -} - -.topo-row-internet { margin-bottom: 4px; } -.topo-hosts-row { flex-wrap: wrap; gap: 12px; } - -.topo-connectors { - display: flex; - justify-content: center; - gap: 80px; - height: 20px; - margin: 2px 0; -} - -.topo-connectors.single { gap: 0; } -.topo-connectors.wide { gap: 60px; } - -.topo-line { - width: 2px; - height: 100%; - background: var(--border); -} - -.topo-line-labeled { position: relative; } +.status-bar::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.85rem; line-height:1; } +.status-bar::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.85rem; line-height:1; } + +.status-chips { display:flex; gap:7px; flex-wrap:wrap; align-items:center; } + +.chip { + font-size: .78em; + font-weight: bold; + padding: 2px 9px; + border: 1px solid; + letter-spacing: .04em; +} +.chip::before { content:'['; } +.chip::after { content:']'; } +.chip-critical { color:var(--red); border-color:var(--red); text-shadow:var(--glow-red); animation:pulse-glow 2s infinite; } +.chip-warning { color:var(--orange); border-color:var(--orange); } +.chip-ok { color:var(--green); border-color:var(--border); text-shadow:var(--glow); } + +.status-meta { display:flex; align-items:center; gap:10px; white-space:nowrap; } +.last-check { font-size: .72em; color: var(--text-muted); } + +.btn-refresh { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 2px 10px; + font-family: var(--font); + font-size: .75em; + cursor: pointer; + transition: all .15s; +} +.btn-refresh:hover { color:var(--green); border-color:var(--green); background:var(--green-dim); text-shadow:var(--glow); } + +/* ── Topology ─────────────────────────────────────────────────────── */ +.topology { + background: var(--bg2); + border: 1px solid var(--border); + padding: 20px 16px 16px; + margin-bottom: 16px; + text-align: center; + overflow-x: auto; + position: relative; +} +.topology::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.85rem; line-height:1; } +.topology::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.85rem; line-height:1; } + +.topo-row { display:flex; justify-content:center; gap:16px; flex-wrap:wrap; align-items:center; } +.topo-row-internet { margin-bottom:2px; } +.topo-hosts-row { flex-wrap:wrap; gap:10px; } + +.topo-connectors { display:flex; justify-content:center; gap:80px; height:22px; margin:0; } +.topo-connectors.single { gap:0; } +.topo-connectors.wide { gap:44px; } + +.topo-line { width:1px; height:100%; background:var(--green); opacity:.4; } +.topo-line-labeled { position:relative; } .topo-line-labeled::after { content: attr(data-link-label); position: absolute; - left: 6px; - top: 50%; + left: 6px; top: 50%; transform: translateY(-50%); - font-size: 10px; - color: var(--text-dim); + font-size: .62em; + color: var(--amber); + text-shadow: var(--glow-amber); white-space: nowrap; + letter-spacing: .05em; } .topo-node { display: flex; flex-direction: column; align-items: center; - gap: 4px; - padding: 8px 14px; - border-radius: 8px; - border: 1.5px solid var(--border); - background: var(--grey-lt); - min-width: 100px; - font-size: 12px; + gap: 3px; + padding: 7px 12px; + border: 1px solid var(--border); + background: var(--bg3); + min-width: 94px; + font-size: .75em; position: relative; transition: border-color .2s; } +.topo-node::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.8rem; line-height:1; } +.topo-node::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.8rem; line-height:1; } -.topo-internet { - border-color: var(--blue); - background: var(--blue-dim); - font-weight: 600; -} +.topo-internet { border-color:var(--cyan); color:var(--cyan); text-shadow:var(--glow-cyan); font-weight:bold; } +.topo-switch { border-color:var(--amber); color:var(--amber); text-shadow:var(--glow-amber); } +.topo-host { cursor:default; } +.topo-icon { font-size:1.1em; } +.topo-label { font-weight:bold; letter-spacing:.03em; } -.topo-switch { - border-color: var(--blue); - background: var(--blue-dim); -} +.topo-badge { font-size:.68em; padding:1px 5px; border:1px solid; letter-spacing:.03em; } +.topo-badge-up { color:var(--green); border-color:var(--green); text-shadow:var(--glow); } +.topo-badge-down { color:var(--red); border-color:var(--red); text-shadow:var(--glow-red); animation:pulse-glow 1.5s infinite; } +.topo-badge-degraded { color:var(--orange); border-color:var(--orange); } -.topo-host { cursor: default; } +.topo-status-up { border-color:var(--green); box-shadow:0 0 8px rgba(0,255,65,.2); } +.topo-status-down { border-color:var(--red); box-shadow:0 0 8px rgba(255,68,68,.3); } +.topo-status-degraded { border-color:var(--orange); box-shadow:0 0 8px rgba(255,140,0,.2); } -.topo-icon { font-size: 16px; } +.topo-status-dot { width:7px; height:7px; border:1px solid var(--text-muted); background:transparent; position:absolute; top:5px; right:5px; } -.topo-label { - font-weight: 500; - font-size: 11px; - text-align: center; -} - -.topo-badge { - font-size: 10px; - padding: 2px 6px; - border-radius: 4px; - font-weight: 600; -} - -.topo-badge-up { background: rgba(16,185,129,.15); color: var(--green); } -.topo-badge-down { background: rgba(239,68,68,.15); color: var(--red); } -.topo-badge-degraded { background: rgba(245,158,11,.15); color: var(--orange); } - -.topo-status-{{ 'up' }} { border-color: var(--green); } -.topo-status-down { border-color: var(--red); } -.topo-status-degraded { border-color: var(--orange); } - -.topo-status-up { border-color: var(--green); } -.topo-status-dot { - width: 8px; height: 8px; - border-radius: 50%; - background: var(--grey); - position: absolute; - top: 6px; right: 6px; -} - -/* ── Host cards ──────────────────────────────────────────────────────── */ +/* ── Host cards ───────────────────────────────────────────────────── */ .host-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); + gap: 12px; } .host-card { - background: var(--card-bg); - border: 1.5px solid var(--border); - border-radius: var(--radius); - padding: 14px; - box-shadow: var(--shadow); + background: var(--bg2); + border: 1px solid var(--border); + padding: 12px; + position: relative; transition: border-color .2s, box-shadow .2s; } +.host-card::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.85rem; line-height:1; } +.host-card::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.85rem; line-height:1; } +.host-card:hover { border-color:var(--green); box-shadow:0 0 12px rgba(0,255,65,.12); } -.host-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.1); } +.host-card-up { border-left: 3px solid var(--green); } +.host-card-down { border-left: 3px solid var(--red); box-shadow: inset 3px 0 10px rgba(255,68,68,.08); } +.host-card-degraded { border-left: 3px solid var(--orange); } -.host-card-up { border-left: 4px solid var(--green); } -.host-card-down { border-left: 4px solid var(--red); } -.host-card-degraded { border-left: 4px solid var(--orange); } - -.host-card-header { margin-bottom: 10px; } - -.host-name-row { - display: flex; - align-items: center; - gap: 7px; - margin-bottom: 4px; -} +.host-card-header { margin-bottom: 8px; } +.host-name-row { display:flex; align-items:center; gap:6px; margin-bottom:3px; } .host-name { - font-weight: 700; - font-size: 14px; -} - -.host-meta { - display: flex; - gap: 8px; - align-items: center; -} - -.host-ip { - font-family: var(--mono); - font-size: 11px; - color: var(--text-sub); -} - -.host-source { - font-size: 10px; - padding: 1px 6px; - border-radius: 4px; - font-weight: 600; - background: var(--grey-lt); - color: var(--text-sub); -} - -.source-prometheus { color: #E6522C; background: rgba(230,82,44,.1); } -.source-ping { color: var(--blue); background: var(--blue-dim); } - -.iface-list { - border-top: 1px solid var(--border); - padding-top: 8px; - margin-bottom: 10px; -} - -.iface-row { - display: flex; - align-items: center; - gap: 7px; - padding: 3px 0; -} - -.iface-name { - font-family: var(--mono); - font-size: 12px; - flex: 1; - color: var(--text); -} - -.iface-state { - font-size: 11px; - font-weight: 600; -} - -.state-up { color: var(--green); } -.state-down { color: var(--red); } - -.host-ping-note { - font-size: 11px; - color: var(--text-sub); - font-style: italic; - margin-bottom: 10px; - padding-top: 6px; - border-top: 1px solid var(--border); -} - -.host-actions { - border-top: 1px solid var(--border); - padding-top: 8px; -} - -/* ── Status dots ─────────────────────────────────────────────────────── */ -.host-status-dot, .iface-dot, .dot-up, .dot-down, .dot-degraded, .dot-unknown { - display: inline-block; - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; -} - -.dot-up, .host-status-dot.dot-up { background: var(--green); box-shadow: 0 0 0 2px rgba(16,185,129,.2); } -.dot-down, .host-status-dot.dot-down { background: var(--red); box-shadow: 0 0 0 2px rgba(239,68,68,.2); animation: pulse-red 2s infinite; } -.dot-degraded { background: var(--orange); box-shadow: 0 0 0 2px rgba(245,158,11,.2); } -.dot-unknown { background: var(--grey); } - -@keyframes pulse-red { - 0%,100% { box-shadow: 0 0 0 2px rgba(239,68,68,.2); } - 50% { box-shadow: 0 0 0 5px rgba(239,68,68,.4); } -} - -/* ── Badges ──────────────────────────────────────────────────────────── */ -.badge { - display: inline-block; - padding: 2px 8px; - border-radius: 6px; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; + font-weight: bold; + font-size: .88em; + color: var(--amber); + text-shadow: var(--glow-amber); letter-spacing: .04em; } -.badge-critical { background: rgba(239,68,68,.12); color: var(--red); } -.badge-warning { background: rgba(245,158,11,.12); color: var(--orange); } -.badge-info { background: rgba(0,111,255,.1); color: var(--blue); } -.badge-ok { background: rgba(16,185,129,.12); color: var(--green); } -.badge-neutral { background: var(--grey-lt); color: var(--grey); } -.badge-suppressed { background: rgba(107,114,128,.12); color: var(--grey); font-size: 14px; padding: 0; } +.host-meta { display:flex; gap:6px; align-items:center; flex-wrap:wrap; } +.host-ip { font-size:.72em; color:var(--text-muted); letter-spacing:.02em; } -/* ── Tables ──────────────────────────────────────────────────────────── */ +.host-source { + font-size: .65em; + padding: 1px 5px; + border: 1px solid; + letter-spacing: .04em; + font-weight: bold; +} +.source-prometheus { color:#e8703a; border-color:rgba(232,112,58,.4); } +.source-ping { color:var(--cyan); border-color:var(--cyan-dim); } + +.iface-list { border-top:1px solid var(--border); padding-top:6px; margin-bottom:8px; } +.iface-row { display:flex; align-items:center; gap:6px; padding:2px 0; } +.iface-name { font-size:.78em; flex:1; color:var(--text-dim); letter-spacing:.01em; } +.iface-state { font-size:.72em; font-weight:bold; letter-spacing:.04em; } + +.state-up { color:var(--green); text-shadow:var(--glow); } +.state-down { color:var(--red); text-shadow:var(--glow-red); } +.state-initial_down { color:var(--text-muted); } + +.host-ping-note { font-size:.72em; color:var(--text-muted); border-top:1px solid var(--border); padding-top:6px; margin-bottom:8px; } +.host-actions { border-top:1px solid var(--border); padding-top:7px; display:flex; gap:5px; flex-wrap:wrap; } + +/* ── Status dots ──────────────────────────────────────────────────── */ +.host-status-dot, .iface-dot, +.dot-up, .dot-down, .dot-degraded, .dot-unknown, .dot-initial_down { + display: inline-block; + width: 8px; height: 8px; + border: 1px solid; + flex-shrink: 0; +} +.dot-up, .host-status-dot.dot-up { border-color:var(--green); background:var(--green); box-shadow:0 0 4px var(--green); } +.dot-down, .host-status-dot.dot-down { border-color:var(--red); background:var(--red); animation:pulse-red 1.5s infinite; } +.dot-degraded { border-color:var(--orange); background:var(--orange); } +.dot-unknown, .dot-initial_down { border-color:var(--text-muted); background:transparent; } + +/* ── Badges ───────────────────────────────────────────────────────── */ +.badge { + display: inline-block; + font-size: .7em; + font-weight: bold; + padding: 1px 6px; + border: 1px solid; + letter-spacing: .05em; + text-transform: uppercase; +} +.badge::before { content:'['; } +.badge::after { content:']'; } +.badge-critical { color:var(--red); border-color:var(--red); text-shadow:var(--glow-red); } +.badge-warning { color:var(--orange); border-color:var(--orange); } +.badge-info { color:var(--cyan); border-color:var(--cyan-dim); } +.badge-ok { color:var(--green); border-color:var(--border); text-shadow:var(--glow); } +.badge-neutral { color:var(--text-muted); border-color:var(--text-muted); } +.badge-suppressed{ font-size:.9em; padding:0; border:none; color:var(--text-muted); } + +/* ── Tables ───────────────────────────────────────────────────────── */ .table-wrap { - background: var(--card-bg); + background: var(--bg2); border: 1px solid var(--border); - border-radius: var(--radius); - box-shadow: var(--shadow); overflow: hidden; + position: relative; } +.table-wrap::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.85rem; line-height:1; } +.table-wrap::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.85rem; line-height:1; } -.data-table { - width: 100%; - border-collapse: collapse; -} +.data-table { width:100%; border-collapse:collapse; } .data-table th { - background: var(--grey-lt); - padding: 10px 14px; + background: var(--bg3); + padding: 8px 12px; text-align: left; - font-size: 11px; - font-weight: 700; - color: var(--text-sub); + font-size: .7em; + font-weight: bold; + color: var(--amber); text-transform: uppercase; - letter-spacing: .06em; + letter-spacing: .08em; border-bottom: 1px solid var(--border); white-space: nowrap; + text-shadow: var(--glow-amber); } +.data-table th::before { content:'> '; color:var(--green); } .data-table td { - padding: 10px 14px; - border-bottom: 1px solid var(--border); + padding: 7px 12px; + border-bottom: 1px solid rgba(0,255,65,.08); vertical-align: middle; + font-size: .83em; } +.data-table tr:last-child td { border-bottom:none; } +.data-table tr:hover td { background:var(--bg-hover); } -.data-table tr:last-child td { border-bottom: none; } +.row-critical td { background:rgba(255,68,68,.03); } +.row-critical td:first-child { border-left:2px solid var(--red); } +.row-warning td { background:rgba(255,140,0,.03); } +.row-warning td:first-child { border-left:2px solid var(--orange); } +.row-resolved td { opacity:.5; } -.data-table tr:hover td { background: rgba(0,111,255,.03); } +.data-table-sm td, .data-table-sm th { padding:5px 10px; } -.row-critical td { background: rgba(239,68,68,.04); } -.row-critical td:first-child { border-left: 3px solid var(--red); } +.ts-cell { color:var(--text-muted); font-size:.75em; white-space:nowrap; } +.desc-cell { max-width:280px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } +.ticket-link{ color:var(--amber); text-shadow:var(--glow-amber); font-weight:bold; } -.row-warning td { background: rgba(245,158,11,.04); } -.row-warning td:first-child { border-left: 3px solid var(--orange); } +.empty-state { padding:28px; text-align:center; color:var(--text-muted); font-size:.82em; } +.empty-row td{ text-align:center; color:var(--text-muted); } -.row-resolved td { opacity: .6; } - -.data-table-sm td, .data-table-sm th { padding: 7px 12px; font-size: 12px; } - -.ts-cell { font-family: var(--mono); font-size: 11px; color: var(--text-sub); } -.desc-cell { max-width: 300px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.ticket-link { font-family: var(--mono); font-weight: 600; } - -.empty-state { padding: 32px; text-align: center; color: var(--text-sub); } -.empty-row td { text-align: center; color: var(--text-sub); } - -/* ── Buttons ─────────────────────────────────────────────────────────── */ +/* ── Buttons ──────────────────────────────────────────────────────── */ .btn { display: inline-flex; align-items: center; - gap: 6px; - padding: 8px 16px; - border-radius: 6px; - border: none; + gap: 5px; + padding: 6px 14px; + border: 1px solid; cursor: pointer; - font-size: 13px; - font-weight: 600; - transition: opacity .15s, background .15s; + font-family: var(--font); + font-size: .8em; + font-weight: bold; + letter-spacing: .05em; + text-transform: uppercase; + background: transparent; + transition: all .15s; } +.btn:hover { transform: translateY(-1px); } -.btn:hover { opacity: .88; } -.btn:active { opacity: .75; } +.btn-primary { color:var(--green); border-color:var(--green); text-shadow:var(--glow); } +.btn-primary::before { content:'> '; color:var(--amber); } +.btn-primary:hover { background:var(--green-dim); box-shadow:var(--glow); } -.btn-primary { background: var(--blue); color: white; } -.btn-secondary { background: var(--grey-lt); color: var(--text); border: 1px solid var(--border); } -.btn-danger { background: rgba(239,68,68,.1); color: var(--red); border: 1px solid rgba(239,68,68,.2); } -.btn-lg { padding: 10px 20px; font-size: 14px; } +.btn-secondary { color:var(--text-dim); border-color:var(--border); } +.btn-secondary:hover { color:var(--green); border-color:var(--green); background:var(--bg-hover); } + +.btn-danger { color:var(--red); border-color:rgba(255,68,68,.35); } +.btn-danger:hover { background:var(--red-dim); border-color:var(--red); text-shadow:var(--glow-red); } + +.btn-lg { padding:8px 18px; font-size:.85em; } .btn-sm { - padding: 3px 8px; - font-size: 11px; - border-radius: 5px; + padding: 2px 8px; + font-family: var(--font); + font-size: .7em; + font-weight: bold; + border: 1px solid; cursor: pointer; - border: none; - font-weight: 600; - transition: opacity .15s; + background: transparent; + letter-spacing: .04em; + transition: all .15s; } +.btn-suppress { color:var(--text-muted); border-color:var(--text-muted); } +.btn-suppress:hover { color:var(--amber); border-color:var(--amber); } +.btn-danger.btn-sm { color:var(--red); border-color:rgba(255,68,68,.35); } +.btn-danger.btn-sm:hover{ color:var(--red); border-color:var(--red); text-shadow:var(--glow-red); } -.btn-suppress { - background: rgba(107,114,128,.1); - color: var(--grey); - border: 1px solid var(--border) !important; -} - -.btn-suppress:hover { background: rgba(107,114,128,.2); } - -.btn-danger.btn-sm { - background: rgba(239,68,68,.1); - color: var(--red); - border: 1px solid rgba(239,68,68,.2) !important; -} - -/* ── Modal ───────────────────────────────────────────────────────────── */ +/* ── Modal ────────────────────────────────────────────────────────── */ .modal-overlay { position: fixed; inset: 0; - background: rgba(0,0,0,.45); - z-index: 100; + background: rgba(0,0,0,.8); + z-index: 200; display: flex; align-items: center; justify-content: center; - backdrop-filter: blur(2px); } - .modal { - background: var(--card-bg); - border-radius: 12px; - box-shadow: 0 20px 60px rgba(0,0,0,.2); + background: var(--bg2); + border: 1px solid var(--green); + box-shadow: 0 0 30px rgba(0,255,65,.18); width: 480px; max-width: 95vw; - padding: 24px; + padding: 20px; + position: relative; } +.modal::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; } +.modal::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); text-shadow:var(--glow); font-size:.9rem; line-height:1; } .modal-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; + margin-bottom: 16px; + border-bottom: 1px solid var(--border); + padding-bottom: 10px; } - -.modal-header h3 { font-size: 17px; font-weight: 700; } - +.modal-header h3 { font-size:.88em; color:var(--amber); text-shadow:var(--glow-amber); text-transform:uppercase; letter-spacing:.08em; } +.modal-header h3::before { content:'>> '; color:var(--green); } .modal-close { background: none; - border: none; + border: 1px solid var(--border); cursor: pointer; - font-size: 18px; - color: var(--text-sub); - line-height: 1; - padding: 2px 6px; - border-radius: 4px; - transition: background .15s; + font-size: .82em; + color: var(--text-muted); + padding: 2px 8px; + font-family: var(--font); + transition: all .15s; } - -.modal-close:hover { background: var(--grey-lt); } +.modal-close:hover { color:var(--red); border-color:var(--red); } .modal-actions { display: flex; - gap: 10px; + gap: 8px; justify-content: flex-end; - margin-top: 20px; - padding-top: 16px; + margin-top: 16px; + padding-top: 12px; border-top: 1px solid var(--border); } -/* ── Forms ───────────────────────────────────────────────────────────── */ +/* ── Forms ────────────────────────────────────────────────────────── */ .form-card { - background: var(--card-bg); + background: var(--bg2); border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px; - box-shadow: var(--shadow); + padding: 16px; + position: relative; } +.form-card::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.85rem; line-height:1; } +.form-card::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.85rem; line-height:1; } -.form-row { - display: flex; - gap: 16px; - flex-wrap: wrap; - margin-bottom: 14px; -} - -.form-row-align { align-items: flex-end; } - -.form-group { display: flex; flex-direction: column; gap: 5px; min-width: 180px; flex: 1; } -.form-group-wide { flex: 3; } -.form-group-submit { flex: 0 0 auto; min-width: unset; } +.form-row { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:10px; } +.form-row-align { align-items:flex-end; } +.form-group { display:flex; flex-direction:column; gap:4px; min-width:150px; flex:1; } +.form-group-wide{ flex:3; } +.form-group-submit { flex:0 0 auto; min-width:unset; } .form-group label { - font-size: 12px; - font-weight: 600; - color: var(--text-sub); + font-size: .7em; + font-weight: bold; + color: var(--amber); text-transform: uppercase; - letter-spacing: .05em; + letter-spacing: .07em; + text-shadow: var(--glow-amber); } .form-group input, .form-group select { - padding: 8px 10px; + padding: 6px 9px; border: 1px solid var(--border); - border-radius: 6px; - font-size: 13px; - background: white; + font-family: var(--font); + font-size: .8em; + background: var(--bg3); color: var(--text); transition: border-color .15s, box-shadow .15s; } - +.form-group input::placeholder { color: var(--text-muted); } .form-group input:focus, .form-group select:focus { outline: none; - border-color: var(--blue); - box-shadow: 0 0 0 3px var(--blue-dim); + border-color: var(--amber); + box-shadow: 0 0 6px rgba(255,176,0,.18); } +.form-group select option { background: var(--bg3); color: var(--text); } -.form-hint { font-size: 11px; color: var(--text-sub); margin-top: 2px; } -.required { color: var(--red); } - -/* ── Duration pills ──────────────────────────────────────────────────── */ -.duration-pills { - display: flex; - gap: 6px; - flex-wrap: wrap; - margin-bottom: 6px; -} +.form-hint { font-size:.7em; color:var(--text-muted); margin-top:2px; } +.required { color:var(--red); } +/* ── Duration pills ───────────────────────────────────────────────── */ +.duration-pills { display:flex; gap:5px; flex-wrap:wrap; margin-bottom:5px; } .pill { - padding: 5px 12px; - border-radius: 20px; - border: 1.5px solid var(--border); - background: white; - font-size: 12px; - font-weight: 600; + padding: 3px 10px; + border: 1px solid var(--border); + background: transparent; + font-family: var(--font); + font-size: .72em; + font-weight: bold; cursor: pointer; - color: var(--text-sub); + color: var(--text-muted); transition: all .15s; + letter-spacing: .04em; } - -.pill:hover { border-color: var(--blue); color: var(--blue); } - +.pill:hover { border-color:var(--green); color:var(--green); background:var(--green-dim); } .pill.active, -.pill-manual.active { - background: var(--blue); - border-color: var(--blue); - color: white; -} - -/* ── Targets grid (suppressions page) ───────────────────────────────── */ -.targets-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 12px; -} +.pill-manual.active { border-color:var(--amber); color:var(--amber); background:var(--amber-dim); text-shadow:var(--glow-amber); } +/* ── Targets grid ─────────────────────────────────────────────────── */ +.targets-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:10px; } .target-card { - background: var(--card-bg); + background: var(--bg2); border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; + padding: 10px; + position: relative; } +.target-card::before { content:'┌'; position:absolute; top:-1px; left:-1px; color:var(--green); font-size:.78rem; line-height:1; } +.target-card::after { content:'┐'; position:absolute; top:-1px; right:-1px; color:var(--green); font-size:.78rem; line-height:1; } -.target-name { - font-weight: 700; - font-size: 14px; - margin-bottom: 4px; -} - -.target-type { - font-size: 11px; - color: var(--text-sub); - margin-bottom: 8px; -} - -.target-ifaces { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.iface-chip { - font-family: var(--mono); - font-size: 10px; - background: var(--grey-lt); - border-radius: 4px; - padding: 1px 6px; - color: var(--text-sub); -} - -/* ── Card (generic) ──────────────────────────────────────────────────── */ -.card { - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 20px; - box-shadow: var(--shadow); -} - -/* ── Toast notifications ─────────────────────────────────────────────── */ -.toast-container { - position: fixed; - bottom: 24px; - right: 24px; - z-index: 200; - display: flex; - flex-direction: column; - gap: 10px; -} +.target-name { font-weight:bold; font-size:.82em; margin-bottom:3px; color:var(--amber); } +.target-type { font-size:.7em; color:var(--text-muted); margin-bottom:6px; } +.target-ifaces{ display:flex; flex-wrap:wrap; gap:3px; } +.iface-chip { font-family:var(--font); font-size:.65em; background:var(--bg3); border:1px solid var(--border); padding:1px 5px; color:var(--text-dim); } +/* ── Toast ────────────────────────────────────────────────────────── */ +.toast-container { position:fixed; bottom:20px; right:20px; z-index:300; display:flex; flex-direction:column; gap:7px; } .toast { - padding: 12px 20px; - border-radius: 8px; - font-size: 13px; - font-weight: 600; - box-shadow: 0 4px 16px rgba(0,0,0,.15); - animation: slide-in .2s ease; + padding: 9px 16px; + border: 1px solid; + font-family: var(--font); + font-size: .8em; + font-weight: bold; + background: var(--bg2); + animation: slide-in .15s ease; + letter-spacing: .04em; +} +.toast::before { content:'>> '; } +.toast-success { color:var(--green); border-color:var(--green); text-shadow:var(--glow); } +.toast-error { color:var(--red); border-color:var(--red); text-shadow:var(--glow-red); } + +/* ── Link debug page ──────────────────────────────────────────────── */ +.link-host-list { display:flex; flex-direction:column; gap:18px; } + +.link-host-panel { + background: var(--bg2); + border: 1px solid var(--border); + position: relative; +} +.link-host-panel::before { content:'╔'; position:absolute; top:-1px; left:-1px; color:var(--green); text-shadow:var(--glow); font-size:1rem; line-height:1; } +.link-host-panel::after { content:'╗'; position:absolute; top:-1px; right:-1px; color:var(--green); text-shadow:var(--glow); font-size:1rem; line-height:1; } + +.link-host-title { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 16px; + background: var(--bg3); + border-bottom: 1px solid var(--border); +} +.link-host-name { font-weight:bold; font-size:.88em; color:var(--amber); text-shadow:var(--glow-amber); letter-spacing:.05em; } +.link-host-name::before { content:'>> '; color:var(--green); } +.link-host-ip { font-size:.72em; color:var(--text-muted); } +.link-host-upd { font-size:.65em; color:var(--text-muted); margin-left:auto; } + +.link-ifaces-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); } -.toast-success { background: #065f46; color: white; } -.toast-error { background: #7f1d1d; color: white; } +.link-iface-card { + border-right: 1px solid rgba(0,255,65,.15); + border-bottom: 1px solid rgba(0,255,65,.15); + padding: 12px 14px; +} +.link-iface-card:last-child { border-right:none; } -@keyframes slide-in { - from { transform: translateX(120%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } +.link-iface-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(0,255,65,.12); +} +.link-iface-name { font-weight:bold; font-size:.84em; color:var(--amber); text-shadow:var(--glow-amber); flex:1; } +.link-iface-speed { font-size:.75em; color:var(--cyan); text-shadow:var(--glow-cyan); font-weight:bold; } +.link-iface-type { font-size:.65em; color:var(--text-muted); padding:1px 5px; border:1px solid var(--text-muted); letter-spacing:.04em; } +.link-iface-type.type-fibre { color:var(--cyan); border-color:var(--cyan-dim); text-shadow:var(--glow-cyan); } +.link-iface-type.type-copper{ color:var(--green); border-color:var(--border); } +.link-iface-type.type-da { color:var(--amber); border-color:var(--amber-dim); } + +/* Link stats 2-column grid */ +.link-stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 16px; + margin-bottom: 10px; +} +.link-stat { display:flex; flex-direction:column; gap:1px; } +.link-stat-label { font-size:.6em; color:var(--text-muted); text-transform:uppercase; letter-spacing:.07em; } +.link-stat-value { font-size:.78em; font-weight:bold; color:var(--text-dim); } +.val-good { color:var(--green); text-shadow:var(--glow); } +.val-warn { color:var(--orange); } +.val-crit { color:var(--red); text-shadow:var(--glow-red); } +.val-neutral { color:var(--text-muted); } +.val-cyan { color:var(--cyan); text-shadow:var(--glow-cyan); } + +/* Traffic bars */ +.traffic-section { margin-top:8px; padding-top:8px; border-top:1px solid rgba(0,255,65,.1); } +.traffic-row { display:flex; align-items:center; gap:8px; margin-bottom:5px; } +.traffic-label { font-size:.62em; color:var(--text-muted); width:20px; text-transform:uppercase; letter-spacing:.04em; flex-shrink:0; } +.traffic-bar-track { flex:1; height:5px; background:var(--bg); border:1px solid rgba(0,255,65,.2); position:relative; overflow:hidden; } +.traffic-bar-fill { height:100%; position:absolute; left:0; top:0; transition:width .4s; } +.traffic-tx { background:var(--cyan); box-shadow:0 0 3px rgba(0,255,255,.4); } +.traffic-rx { background:var(--green); box-shadow:0 0 3px rgba(0,255,65,.4); } +.traffic-value { font-size:.7em; color:var(--text-dim); width:68px; text-align:right; flex-shrink:0; } + +/* SFP / optical panel */ +.sfp-panel { + margin-top: 10px; + padding: 10px 10px 8px; + background: var(--bg3); + border: 1px solid rgba(0,255,255,.2); + position: relative; +} +.sfp-panel::before { + content: '[ SFP / OPTICAL ]'; + position: absolute; + top: -8px; left: 10px; + font-size: .6em; + color: var(--cyan); + text-shadow: var(--glow-cyan); + background: var(--bg3); + padding: 0 4px; + letter-spacing: .09em; + font-weight: bold; } -/* ── Responsive ──────────────────────────────────────────────────────── */ +.sfp-vendor-row { font-size:.7em; color:var(--text-muted); margin-bottom:8px; } +.sfp-vendor-row span { color:var(--text-dim); } + +.sfp-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px 12px; +} + +.sfp-stat { display:flex; flex-direction:column; gap:1px; } +.sfp-stat-label { font-size:.58em; color:var(--text-muted); text-transform:uppercase; letter-spacing:.07em; } +.sfp-stat-value { font-size:.78em; font-weight:bold; } + +/* Power level with bar */ +.power-row { display:flex; align-items:center; gap:5px; margin-top:1px; } +.power-track { flex:1; height:3px; background:var(--bg); border:1px solid rgba(0,255,65,.2); position:relative; overflow:hidden; } +.power-fill { height:100%; position:absolute; left:0; top:0; transition:width .4s; } +.power-ok { background:var(--green); box-shadow:0 0 3px var(--green); } +.power-warn { background:var(--orange); } +.power-crit { background:var(--red); box-shadow:0 0 3px var(--red); } + +/* Link panel states */ +.link-no-data { padding:14px; color:var(--text-muted); font-size:.78em; text-align:center; } +.link-loading { padding:20px; text-align:center; color:var(--text-muted); font-size:.8em; } +.link-loading::after { content:' ...'; animation:blink 1s step-end infinite; } + +/* Counters (errors/drops) */ +.counter-zero { color:var(--green); } +.counter-nonzero { color:var(--red); text-shadow:var(--glow-red); } + +/* ── Responsive ───────────────────────────────────────────────────── */ @media (max-width: 768px) { - .host-grid { grid-template-columns: 1fr; } - .topology { display: none; } - .form-row { flex-direction: column; } - .status-bar { flex-direction: column; align-items: flex-start; } + .host-grid { grid-template-columns:1fr; } + .topology { display:none; } + .form-row { flex-direction:column; } + .status-bar { flex-direction:column; align-items:flex-start; } + .link-ifaces-grid { grid-template-columns:1fr; } + .sfp-grid { grid-template-columns:1fr 1fr; } + .header-nav { display:none; } } diff --git a/templates/base.html b/templates/base.html index 78d1d90..5fd1ca8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,24 +7,31 @@ - +
{% block content %}{% endblock %} diff --git a/templates/index.html b/templates/index.html index 13d4d18..e14eac1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,33 +3,37 @@ {% block content %} - +
{% if summary.critical %} - ⬤ {{ summary.critical }} Critical + ● {{ summary.critical }} CRITICAL {% endif %} {% if summary.warning %} - ⬤ {{ summary.warning }} Warning + ● {{ summary.warning }} WARNING {% endif %} {% if not summary.critical and not summary.warning %} - ✔ All systems nominal + ✔ ALL SYSTEMS NOMINAL {% endif %}
- Last check: {{ last_check }} - + {{ last_check }} +
- +
-

Network Hosts

+
+

Network Hosts

+
-
-
🌐 Internet
+
+ + Internet +
@@ -87,7 +91,7 @@ {{ name }} {% if suppressed %} - 🔕 + 🔕 {% endif %}
@@ -107,15 +111,19 @@ {% endfor %}
{% else %} -
Monitored via ping only
+
ping-only / no node_exporter
{% endif %}
+ + ↗ Links +
{% else %} @@ -124,10 +132,12 @@
- + {% if snapshot.unifi %}
-

UniFi Devices

+
+

UniFi Devices

+
@@ -145,7 +155,7 @@ @@ -167,70 +177,68 @@ {% endif %} - +
-

- Active Alerts +
+

Active Alerts

{% if summary.critical or summary.warning %} - {{ (summary.critical or 0) + (summary.warning or 0) }} open + {{ (summary.critical or 0) + (summary.warning or 0) }} {% endif %} -

-
+
+
{% if events %} -
- {{ 'Online' if d.connected else 'Offline' }} + {{ 'ONLINE' if d.connected else 'OFFLINE' }} {{ d.name }} {{ d.type }}
- - - - - - - - - - - - - - - {% for e in events %} - {% if e.severity != 'info' %} - - - - - - - - - - - - {% endif %} - {% else %} - - - - {% endfor %} - -
SeverityTypeTargetDetailDescriptionFirst SeenFailuresTicketActions
{{ e.severity }}{{ e.event_type | replace('_', ' ') }}{{ e.target_name }}{{ e.target_detail or '–' }}{{ e.description | truncate(60) }}{{ e.first_seen }}{{ e.consecutive_failures }} - {% if e.ticket_id %} - #{{ e.ticket_id }} - {% else %}–{% endif %} - - -
No active alerts ✔
+
+ + + + + + + + + + + + + + + + {% for e in events %} + {% if e.severity != 'info' %} + + + + + + + + + + + + {% endif %} + {% else %} + + {% endfor %} + +
SevTypeTargetDetailDescriptionFirst SeenFailuresTicketActions
{{ e.severity }}{{ e.event_type | replace('_', ' ') }}{{ e.target_name }}{{ e.target_detail or '–' }}{{ e.description | truncate(60) }}{{ e.first_seen }}{{ e.consecutive_failures }} + {% if e.ticket_id %} + #{{ e.ticket_id }} + {% else %}–{% endif %} + + +
No active alerts ✔
+
{% else %}

No active alerts ✔

{% endif %}
- +