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 = `
-
-
-
- | Severity | Type | Target | Detail |
- Description | First Seen | Failures | Ticket | Actions |
-
-
- ${rows}
-
`;
+
+
+
+
+ | Sev | Type | Target | Detail |
+ Description | First Seen | Failures | Ticket | Actions |
+
+
+ ${rows}
+
+
`;
}
// ── 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 @@
-