Mkae columns sortabel agian, check hbc version, provide modile html pages
This commit is contained in:
+17
-5
@@ -1,9 +1,21 @@
|
|||||||
Plan
|
Plan the following changes, ask questions to clarify before implementing
|
||||||
|
|
||||||
Heartbeat is a client/server based network monitor and host observer. hbd, the server portion receives heartbeat and state messages from clients and maintaines state and hisgtory of the informations it receives.
|
Re-factor the notification system:
|
||||||
|
- use available libraries for pushover, matrix, email and sms notifications.
|
||||||
|
- notifications have a title/subject: alert_type (recover/warning/critical), a body (info from threshold check) and a link to the host plugin metrix page
|
||||||
|
- define a list of notification channels for each user
|
||||||
|
- notifications are dispatched to users that are listed as managers for the host
|
||||||
|
|
||||||
hbc, the client portion gathers information on various aspects of the
|
|
||||||
system it is running on, and sends it to hbd. Initially this info is basic, like OS make and version, hardware info (CPU type, memory and disks), fileystem info and some resource info. hbc/hbd support a plugin system to extend the info gathered and stored.
|
|
||||||
|
|
||||||
hbd also can send notification based on missed hbc updates, and on violation of pre-set limits for various state paramaters.
|
|
||||||
|
|
||||||
|
1 - correct
|
||||||
|
2 - for now channels are defined globaly
|
||||||
|
3 - matrix-nio)sounds good, homeserver URL, access token, room ID per channel?
|
||||||
|
4 - use the REST api provided by https://voip.ms/api/v1/rest.php
|
||||||
|
5 - The page does not exist yet, point at the host tab in the /plugins
|
||||||
|
6 - per-channel minimum severity is a good idea, go fo it
|
||||||
|
7 - yes
|
||||||
|
|
||||||
|
1 - use base_url, there might not have been any incoming requests yet
|
||||||
|
2 - use same asyncio loop for matrix-nio
|
||||||
|
3 - for now, just silently do nothing
|
||||||
@@ -48,6 +48,7 @@ class OSInfoPlugin(InfoPlugin):
|
|||||||
Dictionary with OS details
|
Dictionary with OS details
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from hbd import __version__ as hbc_version
|
||||||
data = {
|
data = {
|
||||||
"system": platform.system(), # e.g., "Linux", "Darwin", "Windows"
|
"system": platform.system(), # e.g., "Linux", "Darwin", "Windows"
|
||||||
"node": platform.node(), # hostname
|
"node": platform.node(), # hostname
|
||||||
@@ -58,6 +59,7 @@ class OSInfoPlugin(InfoPlugin):
|
|||||||
"architecture": platform.architecture()[0], # e.g., "64bit"
|
"architecture": platform.architecture()[0], # e.g., "64bit"
|
||||||
"python_version": platform.python_version(),
|
"python_version": platform.python_version(),
|
||||||
"python_implementation": platform.python_implementation(),
|
"python_implementation": platform.python_implementation(),
|
||||||
|
"hbc_version": hbc_version,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add Linux-specific distribution info
|
# Add Linux-specific distribution info
|
||||||
|
|||||||
@@ -422,6 +422,13 @@ class Host:
|
|||||||
ddict["managers"] = list(getattr(self, "managers", []))
|
ddict["managers"] = list(getattr(self, "managers", []))
|
||||||
ddict["monitors"] = list(getattr(self, "monitors", []))
|
ddict["monitors"] = list(getattr(self, "monitors", []))
|
||||||
|
|
||||||
|
# hbc version from latest os_info plugin data
|
||||||
|
hbc_version = None
|
||||||
|
latest_os = self.get_latest_plugin_data("os_info")
|
||||||
|
if latest_os:
|
||||||
|
hbc_version = latest_os.get("hbc_version")
|
||||||
|
ddict["hbc_version"] = hbc_version
|
||||||
|
|
||||||
return ddict
|
return ddict
|
||||||
|
|
||||||
def jsons(self):
|
def jsons(self):
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ async def start(
|
|||||||
is_secure = request.secure or forwarded_proto.lower() == "https"
|
is_secure = request.secure or forwarded_proto.lower() == "https"
|
||||||
scheme = "wss" if is_secure else "ws"
|
scheme = "wss" if is_secure else "ws"
|
||||||
heartbeat_ws_url = f"{scheme}://{host}/ws"
|
heartbeat_ws_url = f"{scheme}://{host}/ws"
|
||||||
|
from hbd import __version__ as hbd_version
|
||||||
tmpl = env.get_template("live.html")
|
tmpl = env.get_template("live.html")
|
||||||
body = tmpl.render(
|
body = tmpl.render(
|
||||||
title="Heartbeat",
|
title="Heartbeat",
|
||||||
@@ -255,6 +256,7 @@ async def start(
|
|||||||
request=request,
|
request=request,
|
||||||
heartbeat_ws_url=heartbeat_ws_url,
|
heartbeat_ws_url=heartbeat_ws_url,
|
||||||
extra_scripts=extra_scripts,
|
extra_scripts=extra_scripts,
|
||||||
|
hbd_version=hbd_version,
|
||||||
hosts=[
|
hosts=[
|
||||||
hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)
|
hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -140,4 +140,68 @@
|
|||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive / mobile ── */
|
||||||
|
|
||||||
|
/* Suppress the global transition on mobile to avoid sluggish feel */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
* { transition: none !important; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
overflow: auto;
|
||||||
|
height: auto;
|
||||||
|
font-size: 16px; /* prevent iOS auto-zoom on inputs */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pages that use flex-column full-viewport layout need to relax on mobile */
|
||||||
|
body[style*="height: 100vh"],
|
||||||
|
body {
|
||||||
|
height: auto !important;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Containers: full width, no fixed heights */
|
||||||
|
.container {
|
||||||
|
max-width: 100% !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log section: fixed reasonable height instead of flex-grow */
|
||||||
|
.log-section {
|
||||||
|
flex: none !important;
|
||||||
|
max-height: 40vh !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table section: allow vertical scroll, cap height */
|
||||||
|
.table-section {
|
||||||
|
max-height: 55vh !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
padding: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slightly larger tap targets in tables */
|
||||||
|
#ntable td, #ntable th {
|
||||||
|
padding: 4px 6px !important;
|
||||||
|
font-size: 0.82em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards on plugin/alerts pages */
|
||||||
|
.host-card, .alert-card, .card {
|
||||||
|
padding: 10px !important;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings page tables */
|
||||||
|
table { width: 100%; }
|
||||||
|
|
||||||
|
h1 { font-size: 1.2em !important; }
|
||||||
|
h2 { font-size: 1em !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suppress nav-username text on very narrow screens — avatar/initials is enough */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.nav-username { display: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,55 +24,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-cards {
|
.summary-cards {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
flex-wrap: wrap;
|
||||||
gap: 20px;
|
gap: 10px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card {
|
.summary-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 6px;
|
||||||
padding: 20px;
|
padding: 6px 14px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card.critical {
|
.summary-card.critical { border-left-color: #f44336; }
|
||||||
border-left: 5px solid #f44336;
|
.summary-card.warning { border-left-color: #ff9800; }
|
||||||
}
|
.summary-card.ok { border-left-color: #4caf50; }
|
||||||
|
|
||||||
.summary-card.warning {
|
|
||||||
border-left: 5px solid #ff9800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-card.ok {
|
|
||||||
border-left: 5px solid #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-number {
|
.summary-number {
|
||||||
font-size: 3em;
|
font-size: 1.4em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin: 10px 0;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-number.critical {
|
.summary-number.critical { color: #f44336; }
|
||||||
color: #f44336;
|
.summary-number.warning { color: #ff9800; }
|
||||||
}
|
.summary-number.ok { color: #4caf50; }
|
||||||
|
|
||||||
.summary-number.warning {
|
|
||||||
color: #ff9800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-number.ok {
|
|
||||||
color: #4caf50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-label {
|
.summary-label {
|
||||||
color: #666;
|
color: #666;
|
||||||
text-transform: uppercase;
|
font-size: 0.85em;
|
||||||
font-size: 0.9em;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="/static/style.css" type="text/css" />
|
<link rel="stylesheet" href="/static/style.css" type="text/css" />
|
||||||
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
@@ -15,8 +16,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.nav-links { display: flex; align-items: center; }
|
.nav-links { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||||
.nav a {
|
.nav a {
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -57,5 +60,40 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mobile nav: hamburger toggle ── */
|
||||||
|
.nav-hamburger {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 26px; height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.nav-hamburger span {
|
||||||
|
display: block;
|
||||||
|
height: 3px;
|
||||||
|
background: #555;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.nav-hamburger { display: flex; }
|
||||||
|
.nav-links {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
order: 3;
|
||||||
|
}
|
||||||
|
.nav-links.nav-open { display: flex; }
|
||||||
|
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script src="static/sorttable.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -14,6 +14,26 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.table-section {
|
||||||
|
max-height: 55vh;
|
||||||
|
}
|
||||||
|
.log-section {
|
||||||
|
flex: none;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -59,6 +79,9 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 60vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-section {
|
.log-section {
|
||||||
@@ -82,6 +105,7 @@
|
|||||||
border: 1px solid #e0e0e0;
|
border: 1px solid #e0e0e0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ntable tr:nth-child(even) {
|
#ntable tr:nth-child(even) {
|
||||||
@@ -221,15 +245,24 @@
|
|||||||
var nTable = document;
|
var nTable = document;
|
||||||
var name_idx = {};
|
var name_idx = {};
|
||||||
var c = 0;
|
var c = 0;
|
||||||
|
var HBD_VERSION = "{{ hbd_version }}";
|
||||||
|
|
||||||
|
function hostNameHtml(data) {
|
||||||
|
var nameHtml = data.name;
|
||||||
|
if (!data.hbc_version || data.hbc_version !== HBD_VERSION) {
|
||||||
|
nameHtml += ' 🥀';
|
||||||
|
}
|
||||||
|
return data.dyn ? '<b>' + nameHtml + '</b>' : nameHtml;
|
||||||
|
}
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
name_idx = {};
|
name_idx = {};
|
||||||
nTable = document.getElementById("ntable");
|
nTable = document.getElementById("ntable");
|
||||||
for (var i = 0, row; (row = nTable.rows[i]); i++) {
|
for (var i = 0, row; (row = nTable.rows[i]); i++) {
|
||||||
if (i == 0) continue;
|
if (i == 0) continue;
|
||||||
name = nTable.rows[i].cells[0].innerText;
|
var cell = nTable.rows[i].cells[0];
|
||||||
|
var name = cell.dataset.name || cell.innerText.replace(/\s*🥀\s*$/, '').trim();
|
||||||
name_idx[name] = nTable.rows[i];
|
name_idx[name] = nTable.rows[i];
|
||||||
/* console.log("name_Id[" + name + "]: " + name_idx[name].innerText); */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,11 +313,8 @@
|
|||||||
row.appendChild(c_ipv6state);
|
row.appendChild(c_ipv6state);
|
||||||
row.appendChild(c_ipv6latency);
|
row.appendChild(c_ipv6latency);
|
||||||
row.appendChild(c_ipv6statets);
|
row.appendChild(c_ipv6statets);
|
||||||
if (data.dyn) {
|
c_name.dataset.name = data.name;
|
||||||
c_name.innerHTML = "<b>" + data.name + "</b>";
|
c_name.innerHTML = hostNameHtml(data);
|
||||||
} else {
|
|
||||||
c_name.innerHTML = data.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set alert counts in "x/y" format (unacked/acked)
|
// Set alert counts in "x/y" format (unacked/acked)
|
||||||
var warningUnacked = data.alert_warning_unacked || 0;
|
var warningUnacked = data.alert_warning_unacked || 0;
|
||||||
@@ -346,6 +376,11 @@
|
|||||||
setup();
|
setup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update name cell (version indicator)
|
||||||
|
var nameCell = name_idx[data.name].cells[0];
|
||||||
|
nameCell.dataset.name = data.name;
|
||||||
|
nameCell.innerHTML = hostNameHtml(data);
|
||||||
|
|
||||||
// Update warning and critical counts in "x/y" format (unacked/acked)
|
// Update warning and critical counts in "x/y" format (unacked/acked)
|
||||||
var warningUnacked = data.alert_warning_unacked || 0;
|
var warningUnacked = data.alert_warning_unacked || 0;
|
||||||
var warningAcked = data.alert_warning_acked || 0;
|
var warningAcked = data.alert_warning_acked || 0;
|
||||||
@@ -477,7 +512,7 @@
|
|||||||
<tbody id="ntablebody">
|
<tbody id="ntablebody">
|
||||||
{% for host in hosts %}
|
{% for host in hosts %}
|
||||||
<tr class="{% if host.alert_critical_unacked > 0 or host.alert_critical_acked > 0 %}row-critical{% elif host.alert_warning_unacked > 0 or host.alert_warning_acked > 0 %}row-warning{% endif %}">
|
<tr class="{% if host.alert_critical_unacked > 0 or host.alert_critical_acked > 0 %}row-critical{% elif host.alert_warning_unacked > 0 or host.alert_warning_acked > 0 %}row-warning{% endif %}">
|
||||||
<td>{{ host.name }}</td>
|
<td data-name="{{ host.name }}">{{ host.name }}{% if not host.hbc_version or host.hbc_version != hbd_version %} 🥀{% endif %}</td>
|
||||||
<td style="text-align: center; color: #ff9800; font-weight: bold;">
|
<td style="text-align: center; color: #ff9800; font-weight: bold;">
|
||||||
{%- set warning_unacked = host.alert_warning_unacked -%}
|
{%- set warning_unacked = host.alert_warning_unacked -%}
|
||||||
{%- set warning_acked = host.alert_warning_acked -%}
|
{%- set warning_acked = host.alert_warning_acked -%}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<div class="nav">
|
<div class="nav">
|
||||||
<div class="nav-links">
|
<button class="nav-hamburger" id="nav-hamburger-btn" aria-label="Menu" aria-expanded="false">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</button>
|
||||||
|
<div class="nav-links" id="nav-links">
|
||||||
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
|
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
|
||||||
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Plugin Metrics</a>
|
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Plugin Metrics</a>
|
||||||
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
|
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
|
||||||
@@ -14,6 +17,19 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="nav-initials">{{ (current_user.full_name or current_user.username)[:1] | upper }}</span>
|
<span class="nav-initials">{{ (current_user.full_name or current_user.username)[:1] | upper }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="nav-username">{{ current_user.full_name or current_user.username }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var btn = document.getElementById('nav-hamburger-btn');
|
||||||
|
var links = document.getElementById('nav-links');
|
||||||
|
if (btn && links) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var open = links.classList.toggle('nav-open');
|
||||||
|
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
{% include 'head.html' %}
|
{% include 'head.html' %}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
html, body {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 20px;
|
margin: 20px;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
|
|||||||
@@ -217,6 +217,49 @@
|
|||||||
.channel-field-value { color: #333; word-break: break-all; }
|
.channel-field-value { color: #333; word-break: break-all; }
|
||||||
|
|
||||||
/* ---- Hosts table ---- */
|
/* ---- Hosts table ---- */
|
||||||
|
/* ---- Mobile: collapsible sidebar ---- */
|
||||||
|
.sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #e8eaf6;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #283593;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.sidebar-toggle::after { content: ' ▾'; float: right; }
|
||||||
|
.sidebar-toggle.open::after { content: ' ▴'; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sidebar-toggle { display: block; }
|
||||||
|
|
||||||
|
.settings-layout { flex-direction: column; gap: 0; }
|
||||||
|
|
||||||
|
.settings-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
position: static;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
display: none;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.1);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.sidebar-nav.open { display: block; }
|
||||||
|
.sidebar-nav a { padding: 10px 16px; font-size: 1em; }
|
||||||
|
|
||||||
|
.field-row { flex-direction: column; gap: 4px; }
|
||||||
|
.field-label { width: 100%; font-size: 0.82em; color: #888; }
|
||||||
|
}
|
||||||
.host-bool { text-align: center; }
|
.host-bool { text-align: center; }
|
||||||
.dot-yes { color: #2e7d32; font-size: 1.1em; }
|
.dot-yes { color: #2e7d32; font-size: 1.1em; }
|
||||||
.dot-no { color: #ddd; font-size: 1.1em; }
|
.dot-no { color: #ddd; font-size: 1.1em; }
|
||||||
@@ -233,9 +276,10 @@
|
|||||||
|
|
||||||
<!-- Sidebar navigation -->
|
<!-- Sidebar navigation -->
|
||||||
<nav class="settings-sidebar">
|
<nav class="settings-sidebar">
|
||||||
|
<button class="sidebar-toggle" id="sidebar-toggle" aria-expanded="false">Sections</button>
|
||||||
<div class="sidebar-nav" id="sidebar-nav">
|
<div class="sidebar-nav" id="sidebar-nav">
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<a href="#{{ section.id }}">{{ section.title }}</a>
|
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -428,6 +472,28 @@
|
|||||||
}, { threshold: 0.25 });
|
}, { threshold: 0.25 });
|
||||||
|
|
||||||
sections.forEach(s => observer.observe(s));
|
sections.forEach(s => observer.observe(s));
|
||||||
|
|
||||||
|
// Collapsible sidebar on mobile
|
||||||
|
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||||||
|
var sidebarNav = document.getElementById('sidebar-nav');
|
||||||
|
if (sidebarToggle && sidebarNav) {
|
||||||
|
sidebarToggle.addEventListener('click', function() {
|
||||||
|
var open = sidebarNav.classList.toggle('open');
|
||||||
|
sidebarToggle.classList.toggle('open', open);
|
||||||
|
sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
function closeSidebar() {
|
||||||
|
var sidebarNav = document.getElementById('sidebar-nav');
|
||||||
|
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||||||
|
if (sidebarNav) { sidebarNav.classList.remove('open'); }
|
||||||
|
if (sidebarToggle) {
|
||||||
|
sidebarToggle.classList.remove('open');
|
||||||
|
sidebarToggle.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user