diff --git a/README.md b/README.md
index 495e35e..d193f0d 100644
--- a/README.md
+++ b/README.md
@@ -342,6 +342,13 @@ local user. Without it the daemon still runs, but it has no fallback
if the kernel refuses to send on a raw socket after dropping
privileges.
+To start at boot: `misc/sysmond.service` (systemd) and
+`misc/rc.d/sysmond` (OpenBSD rc.d) are ready to copy into place; the
+web UI's counterparts are `web-ui/sysmon-web.service` and
+`web-ui/rc.d/sysmon_web`. The comments at the top of each say where it
+goes and what to enable. sysmond's files set no user on purpose: it
+must start as root (raw ICMP sockets) and drops privileges on its own.
+
`examples/` also has `sysmon.conf.full`, which shows every directive, and
`sysmon.conf.fleet` - the 500-object wireless ISP in the screenshots
above. The fleet file is addressed entirely on loopback, so
diff --git a/android/app/src/main/java/com/sysmon/app/Models.kt b/android/app/src/main/java/com/sysmon/app/Models.kt
index af60df7..90b4e3b 100644
--- a/android/app/src/main/java/com/sysmon/app/Models.kt
+++ b/android/app/src/main/java/com/sysmon/app/Models.kt
@@ -16,6 +16,11 @@ data class LoginResponse(
@Serializable
data class Host(
@SerialName("object_name") val objectName: String = "",
+ // objectName's two halves: the bare name the owning daemon knows,
+ // and which daemon that is. Shown separately - a name and a small
+ // site tag - never re-joined into "site:host".
+ @SerialName("local_name") val localName: String = "",
+ val site: String = "",
val hostname: String,
val description: String = "",
@SerialName("ipv4_address") val ipv4: String = "",
@@ -41,6 +46,8 @@ data class Host(
val isDown: Boolean get() = overallStatus == "CRITICAL"
val isWarning: Boolean get() = overallStatus == "WARNING"
val isOK: Boolean get() = overallStatus == "OK"
+ // "local" is the single-box case, where naming the site says nothing.
+ val siteTag: String get() = if (site == "local") "" else site
}
/**
@@ -160,12 +167,19 @@ data class TestPushResponse(val status: String = "", val warning: String? = null
data class HistoryEvent(
val timestamp: String = "",
@SerialName("object_name") val objectName: String = "",
+ @SerialName("local_name") val localName: String = "",
+ val site: String = "",
val hostname: String = "",
val description: String = "",
@SerialName("prev_status") val prevStatus: String = "",
@SerialName("new_status") val newStatus: String = "",
@SerialName("prev_duration_seconds") val prevDuration: Long = 0
-)
+) {
+ // Bare name plus a separate site tag; the qualified objectName is
+ // only the fallback against a server that predates the split.
+ val displayName: String get() = localName.ifEmpty { objectName.ifEmpty { hostname } }
+ val siteTag: String get() = if (site == "local") "" else site
+}
@Serializable
data class HistoryResponse(
diff --git a/android/app/src/main/java/com/sysmon/app/ui/Components.kt b/android/app/src/main/java/com/sysmon/app/ui/Components.kt
index 755b97b..7baf00a 100644
--- a/android/app/src/main/java/com/sysmon/app/ui/Components.kt
+++ b/android/app/src/main/java/com/sysmon/app/ui/Components.kt
@@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -342,6 +343,26 @@ fun StatsRow(stats: Stats?) {
}
}
+/**
+ * The owning sysmond's name as a small muted pill. Kept deliberately
+ * quiet - it is context, not the subject - and absent entirely on a
+ * single-box install, where the row looks exactly as it always did.
+ */
+@Composable
+fun SiteTag(name: String) {
+ Text(
+ text = name,
+ fontSize = 9.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ modifier = Modifier
+ .padding(start = 6.dp)
+ .clip(RoundedCornerShape(50))
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .padding(horizontal = 5.dp, vertical = 1.dp)
+ )
+}
+
@Composable
fun HostRow(host: Host, onClick: (() -> Unit)? = null) {
Card {
@@ -359,8 +380,16 @@ fun HostRow(host: Host, onClick: (() -> Unit)? = null) {
Text(
text = host.hostname,
style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onBackground
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ // A long hostname ellipsizes rather than pushing the
+ // site tag (and PAUSED pill) out of the row.
+ modifier = Modifier.weight(1f, fill = false)
)
+ if (host.siteTag.isNotEmpty()) {
+ SiteTag(host.siteTag)
+ }
if (host.paused) {
Text(
text = "PAUSED",
diff --git a/android/app/src/main/java/com/sysmon/app/ui/HistoryScreen.kt b/android/app/src/main/java/com/sysmon/app/ui/HistoryScreen.kt
index 40489fa..f9df180 100644
--- a/android/app/src/main/java/com/sysmon/app/ui/HistoryScreen.kt
+++ b/android/app/src/main/java/com/sysmon/app/ui/HistoryScreen.kt
@@ -27,6 +27,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
@@ -196,12 +197,27 @@ private fun HistoryRow(ev: HistoryEvent, clock: Long) {
StatusDot(ev.newStatus)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = ev.objectName.ifEmpty { ev.hostname },
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.onBackground,
- modifier = Modifier.weight(1f)
- )
+ // Bare name with the owning box as its own quiet tag,
+ // not the overloaded "site:host" string.
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = ev.displayName,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ // fill=false: take only what the name needs, but
+ // never more than leaves the site tag visible - a
+ // long name must ellipsize, not shove the tag out.
+ modifier = Modifier.weight(1f, fill = false)
+ )
+ if (ev.siteTag.isNotEmpty()) {
+ SiteTag(ev.siteTag)
+ }
+ }
Text(
// clock in the expression makes the age
// recompose on the ticker, not only on new data.
diff --git a/android/app/src/main/java/com/sysmon/app/ui/HostDetailSheet.kt b/android/app/src/main/java/com/sysmon/app/ui/HostDetailSheet.kt
index c7f116c..a0914ab 100644
--- a/android/app/src/main/java/com/sysmon/app/ui/HostDetailSheet.kt
+++ b/android/app/src/main/java/com/sysmon/app/ui/HostDetailSheet.kt
@@ -58,6 +58,9 @@ fun HostDetailSheet(host: Host, onDismiss: () -> Unit) {
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground
)
+ if (host.siteTag.isNotEmpty()) {
+ SiteTag(host.siteTag)
+ }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
diff --git a/docs/ALERTERS.md b/docs/ALERTERS.md
new file mode 100644
index 0000000..36f55a3
--- /dev/null
+++ b/docs/ALERTERS.md
@@ -0,0 +1,175 @@
+# Alerters: sending alerts to sysmon-web without being a sysmond
+
+An alerter is a daemon with something to say and no fleet of hosts
+behind it - a backup job, a UPS script, a RAID monitor, a cron
+watchdog. It connects to the same TLS listener the monitoring boxes
+use, proves itself with the same kind of minted token, and then sends
+alerts. Those alerts ride the exact push pipeline a sysmond's host
+transitions ride, with the same priorities: CRITICAL is loud on the
+phones, WARNING and OK are quiet.
+
+An alerter is never part of the fleet. It has no config to manage, no
+hosts to poll, no generations to roll out. The Fleet page lists it in
+its own "Alerters" section; the config editor and the map never see it.
+
+## Getting a token
+
+Same as a monitoring box: **Admin -> Monitoring boxes -> Add a box**.
+Mint a token under the name the alerter will use (letters, digits,
+`-`, `_`; max 64 chars). The name is the alerter's identity - it
+appears in notifications and on the Fleet page - so name the thing,
+not the machine: `backupd`, not `server3`.
+
+Revoking the token on the same page cuts the alerter off at its next
+connection attempt.
+
+## Connecting
+
+- **Transport**: TLS to sysmon-web's agent port (default `1347`, the
+ `-agent-listen` flag). TLS is required, not negotiable - the first
+ line carries a bearer token.
+- **Server certificate**: sysmon-web generates a self-signed
+ certificate on first start (or uses `-agent-cert`/`-agent-key`).
+ Verify against that certificate - the same `aggregator-ca.pem` a
+ monitoring box pins. Skipping verification hands your token to
+ whoever answers the port first.
+- The connection is long-lived. Stay connected and send alerts as they
+ happen; reconnect with backoff when the link drops. The server never
+ polls an alerter, so a silent alerter costs nothing.
+
+## Protocol
+
+Text lines, terminated by `\n` (a trailing `\r` is tolerated). One
+line may carry at most 4096 bytes; anything past that on the same
+line is discarded, not buffered. Every reply is one line starting
+`333 ` (success) or `444 ` (refusal).
+
+### Handshake (first line, within 20 seconds of connecting)
+
+ ALERTER [application name...]
+
+- `333 welcome` - authenticated; send alerts from here on.
+- `444 rejected` - bad name/token pair, or the token is revoked. The
+ socket closes; back off before retrying.
+- `444 this token belongs to a sysmond` - the token was minted for (and
+ first used by) a monitoring box; a token keeps the kind of its first
+ handshake forever. Mint a separate token for the alerter.
+
+Everything after the token is what the application calls itself -
+free text up to 128 characters, e.g. `Bacula 15.0 nightly backups`.
+It shows on the Fleet page and in alerts. Optional but worth sending:
+the token name identifies, this describes.
+
+The greeting verb is what separates an alerter from a monitoring box:
+a sysmond says `HELLO` and gets polled, an alerter says `ALERTER` and
+does the talking.
+
+### Sending an alert
+
+ ALERT
+
+- `` names the thing the alert is about (same character rules
+ as the alerter name). One alerter can alert about many objects.
+- `` is free-form to end of line, up to 512 characters
+ (anything longer is truncated, not refused). Optional - omitted, a
+ plain "name reports object STATUS" is generated.
+- Reply is `333 ok` once accepted, or `444 ` for a malformed
+ line. A `444` never closes the connection; fix the line and carry on.
+- `444 busy - ...` means the server's delivery pipeline is backed up
+ and the alert was **not** accepted. Retry the same line after a short
+ delay; `333 ok` is the only reply that means the alert was taken.
+
+Semantics, identical to a sysmond's transitions:
+
+- **CRITICAL** delivers loud: sound, heads-up on Android, time-sensitive
+ on iOS.
+- **WARNING** and **OK** deliver quiet: they land in the notification
+ shade without a sound. Send `OK` when the condition clears - it
+ replaces the earlier alert on the phones rather than stacking a
+ second notification, because `:` is the collapse
+ key, exactly as host alerts collapse per host.
+- Delivery honors the master push switch in the admin UI; alerts sent
+ while push is disabled are acknowledged and dropped, and the server
+ log says so.
+
+### Keepalive and goodbye
+
+ PING -> 333 pong
+ QUIT -> 333 bye (server closes)
+
+Send `PING` every minute or so if your network kills idle
+connections; the server does not require it.
+
+## Names, nicknames, and what an alert shows
+
+Three names are in play, in order of what alerts display:
+
+1. **Nickname** - optional, set by an admin on the Fleet page's
+ Alerters card (the pencil next to "Shows as"). Wins when set.
+2. **Application name** - what the alerter declared at handshake.
+3. **Token name** - the identity; the fallback when nothing else is set.
+
+The token name is what keys everything internally - collapse keys,
+logs, the registry - so renaming a nickname never re-keys anything.
+
+## What the web UI does with alerts
+
+- Push notifications to every subscribed phone, with the priority
+ routing above.
+- The admin **Push Log** records each fan-out like any other.
+- The **Fleet page** shows the alerter: connected or gone, what it
+ shows as (nickname or application name), its address, how many
+ alerts it has sent, and the last one.
+
+Alerts are fire-and-forget by design: they are not stored as host
+state, do not appear on the dashboard, and are not replayed to phones
+that subscribe later. If a thing needs its state *tracked*, it wants
+to be a monitored host on a sysmond, not an alerter.
+
+## Example: shell
+
+```sh
+# One-shot alert via openssl s_client (BSD echo; adjust for your shell).
+{
+ echo 'ALERTER backupd tok-abc123... Bacula 15.0 nightly backups'
+ sleep 1
+ echo 'ALERT CRITICAL nightly-backup tape jam in drive 2'
+ sleep 1
+ echo 'QUIT'
+} | openssl s_client -quiet -connect sysmon-web.example.net:1347 \
+ -CAfile aggregator-ca.pem -verify_return_error
+```
+
+## Example: Python
+
+```python
+import socket, ssl, time
+
+HOST, PORT = "sysmon-web.example.net", 1347
+NAME, TOKEN = "backupd", "tok-abc123..."
+
+ctx = ssl.create_default_context(cafile="aggregator-ca.pem")
+ctx.check_hostname = False # self-signed cert carries no hostname
+
+def connect():
+ raw = socket.create_connection((HOST, PORT), timeout=20)
+ tls = ctx.wrap_socket(raw)
+ f = tls.makefile("rw", newline="\n")
+ f.write(f"ALERTER {NAME} {TOKEN} Bacula 15.0 nightly backups\n"); f.flush()
+ if not f.readline().startswith("333"):
+ raise RuntimeError("rejected")
+ return f
+
+def alert(f, status, obj, text):
+ f.write(f"ALERT {status} {obj} {text}\n"); f.flush()
+ return f.readline().startswith("333")
+
+f = connect()
+alert(f, "CRITICAL", "nightly-backup", "tape jam in drive 2")
+# ... later, when it clears:
+alert(f, "OK", "nightly-backup", "backup completed after operator fixed the jam")
+```
+
+Reconnect on any read/write error, with backoff (the monitoring boxes
+use a few seconds, doubling to a minute; do the same). The token is a
+credential - keep it out of argv and world-readable files.
diff --git a/docs/WEB_DEPLOYMENT.md b/docs/WEB_DEPLOYMENT.md
index 502646e..589d301 100644
--- a/docs/WEB_DEPLOYMENT.md
+++ b/docs/WEB_DEPLOYMENT.md
@@ -115,6 +115,15 @@ systemctl daemon-reload
systemctl enable --now sysmon-web
```
+One deliberate limitation: the shipped unit's `ProtectSystem=strict`
+sandbox leaves `/etc` read-only, so saving the **local**
+`/etc/sysmon.conf` from the config editor fails under it (the save
+writes `/etc/sysmon.conf.tmp` and renames, which needs a writable
+`/etc` directory). Editing per-box configs over the agent link - the
+normal fleet flow - is unaffected. If you want local editor saves on
+this host, add a drop-in with `ReadWritePaths=-/etc` and make the file
+and directory writable by `www-data`.
+
### 3.2 nginx server block
See `web-ui/nginx.conf.example` for the full TLS version. Minimum:
diff --git a/ios/Sysmon/HistoryView.swift b/ios/Sysmon/HistoryView.swift
index 02eea98..23c7f5e 100644
--- a/ios/Sysmon/HistoryView.swift
+++ b/ios/Sysmon/HistoryView.swift
@@ -114,9 +114,15 @@ struct HistoryRow: View {
StatusDot(status: event.newStatus)
VStack(alignment: .leading, spacing: 3) {
HStack {
- Text(event.objectName.isEmpty ? event.hostname : event.objectName)
+ // Bare name with the owning box as its own quiet tag,
+ // not the overloaded "site:host" string.
+ Text(event.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Theme.ink)
+ .lineLimit(1)
+ if !event.siteTag.isEmpty {
+ SiteTag(name: event.siteTag)
+ }
Spacer()
Text(relativeTime(event.timestamp))
.font(.system(size: 11))
diff --git a/ios/Sysmon/MainView.swift b/ios/Sysmon/MainView.swift
index ab377c5..b420b27 100644
--- a/ios/Sysmon/MainView.swift
+++ b/ios/Sysmon/MainView.swift
@@ -269,6 +269,9 @@ struct HostRow: View {
Text(host.hostname)
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Theme.ink)
+ if !host.siteTag.isEmpty {
+ SiteTag(name: host.siteTag)
+ }
if host.isPaused {
Text("PAUSED")
.font(.system(size: 8, weight: .bold))
diff --git a/ios/Sysmon/Models.swift b/ios/Sysmon/Models.swift
index 4195161..10568b3 100644
--- a/ios/Sysmon/Models.swift
+++ b/ios/Sysmon/Models.swift
@@ -25,6 +25,11 @@ struct SubscribeResponse: Codable {
struct Host: Codable, Identifiable, Equatable {
let objectName: String?
+ // objectName's two halves: the bare name the owning daemon knows,
+ // and which daemon that is. Shown separately - a name and a small
+ // site tag - never re-joined into "site:host".
+ let localName: String?
+ let site: String?
let hostname: String
let description: String?
let ipv4Address: String?
@@ -55,9 +60,16 @@ struct Host: Codable, Identifiable, Equatable {
var isDown: Bool { overallStatus == "CRITICAL" }
var isWarning: Bool { overallStatus == "WARNING" }
var isOK: Bool { overallStatus == "OK" }
+ // "local" is the single-box case, where naming the site says nothing.
+ var siteTag: String {
+ let s = site ?? ""
+ return s == "local" ? "" : s
+ }
enum CodingKeys: String, CodingKey {
case objectName = "object_name"
+ case localName = "local_name"
+ case site
case hostname
case description
case ipv4Address = "ipv4_address"
@@ -202,6 +214,8 @@ struct StatusDelta: Codable {
struct HistoryEvent: Codable, Identifiable, Equatable {
let timestamp: String
let objectName: String
+ let localName: String?
+ let site: String?
let hostname: String
let description: String?
let prevStatus: String
@@ -210,9 +224,22 @@ struct HistoryEvent: Codable, Identifiable, Equatable {
var id: String { timestamp + objectName + prevStatus + newStatus }
+ // Bare name plus a separate site tag; the qualified objectName is
+ // only the fallback against a server that predates the split.
+ var displayName: String {
+ if let n = localName, !n.isEmpty { return n }
+ return objectName.isEmpty ? hostname : objectName
+ }
+ var siteTag: String {
+ let s = site ?? ""
+ return s == "local" ? "" : s
+ }
+
enum CodingKeys: String, CodingKey {
case timestamp
case objectName = "object_name"
+ case localName = "local_name"
+ case site
case hostname
case description
case prevStatus = "prev_status"
diff --git a/ios/Sysmon/Theme.swift b/ios/Sysmon/Theme.swift
index a3845ca..cb05c82 100644
--- a/ios/Sysmon/Theme.swift
+++ b/ios/Sysmon/Theme.swift
@@ -71,6 +71,23 @@ extension View {
}
}
+// The owning sysmond's name as a small muted capsule. Deliberately
+// quiet - it is context, not the subject - and callers skip it entirely
+// on a single-box install, where rows look exactly as they always did.
+struct SiteTag: View {
+ let name: String
+
+ var body: some View {
+ Text(name)
+ .font(.system(size: 9, design: .monospaced))
+ .foregroundColor(Theme.subtle)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 2)
+ .background(Capsule().fill(Theme.surfaceSubtle))
+ .lineLimit(1)
+ }
+}
+
// Status dot with a soft glow halo; pulses when the status is CRITICAL
// so a down host is impossible to miss at a glance.
struct StatusDot: View {
diff --git a/misc/rc.d/sysmond b/misc/rc.d/sysmond
new file mode 100644
index 0000000..e15a0c9
--- /dev/null
+++ b/misc/rc.d/sysmond
@@ -0,0 +1,33 @@
+#!/bin/ksh
+#
+# OpenBSD rc.d(8) script for sysmond.
+# Install as /etc/rc.d/sysmond (chmod 555), then:
+# rcctl enable sysmond
+# rcctl start sysmond
+# Config somewhere other than the compiled-in default:
+# rcctl set sysmond flags -f /etc/sysmon.conf
+#
+# sysmond starts as root - raw ICMP sockets, and /var/db/sysmon has to
+# be created and handed over before the drop - and revokes root on its
+# own once that is done (nobody, or daemon as the fallback). That is why
+# there is no daemon_user here. It also forks into the background by
+# itself, so rc_bg stays off; the forked child keeps the same argv, so
+# rc.subr's pgrep check finds it.
+
+daemon="/usr/local/bin/sysmond"
+
+. /etc/rc.d/rc.subr
+
+# Refuse to start with a config that does not parse. Better to fail the
+# rcctl start loudly than to come up monitoring nothing.
+rc_pre() {
+ ${daemon} -t ${daemon_flags}
+}
+
+# "sysmond reload" re-parses the config and only HUPs the running
+# daemon if it passes - safer than rc.subr's default blind pkill -HUP.
+rc_reload() {
+ ${daemon} reload ${daemon_flags}
+}
+
+rc_cmd $1
diff --git a/misc/sysmond.service b/misc/sysmond.service
new file mode 100644
index 0000000..9a35e27
--- /dev/null
+++ b/misc/sysmond.service
@@ -0,0 +1,40 @@
+[Unit]
+Description=sysmond network monitoring daemon
+Documentation=https://github.com/yellowman/sysmon
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+# -d keeps sysmond in the foreground so systemd supervises the real
+# process instead of a fork parent that exits immediately.
+#
+# No User= line, deliberately: sysmond must start as root (raw ICMP
+# sockets, creating /var/db/sysmon and handing it over) and drops to
+# nobody (or daemon) by itself once that work is done. Setting User=
+# here would break the drop, not harden it.
+#
+# Config somewhere other than the compiled-in default: add the same
+# "-f /path/to/sysmon.conf" to both Exec lines below.
+Type=exec
+ExecStartPre=/usr/local/bin/sysmond -t
+ExecStart=/usr/local/bin/sysmond -d
+# "sysmond reload" re-parses the config and only HUPs the daemon if it
+# passes, so a typo cannot take down a running fleet.
+ExecReload=/usr/local/bin/sysmond reload
+Restart=on-failure
+RestartSec=5
+
+# Hardening, within what the design allows. NoNewPrivileges is absent
+# on purpose: the setuid sysmon-ping-helper is the fallback if the
+# kernel refuses raw-socket sends after the privilege drop, and
+# NoNewPrivileges would stop it elevating exactly when it is needed.
+PrivateTmp=true
+ProtectHome=true
+# ProtectSystem=full keeps /usr, /boot and /etc read-only. The seed
+# config being unwritable is by design; everything sysmond writes
+# (state, pidfile, config generations) lives under /var/db/sysmon,
+# and /var stays writable.
+ProtectSystem=full
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/config.h b/src/config.h
index 8730fa2..31d17c5 100644
--- a/src/config.h
+++ b/src/config.h
@@ -12,6 +12,7 @@
#include
#include
#include
+#include
#include
#if (defined(__svr4__) || defined(unixware)) /* slo-laris */
#include
diff --git a/src/syswatch.c b/src/syswatch.c
index f0b35bb..cc5c88a 100644
--- a/src/syswatch.c
+++ b/src/syswatch.c
@@ -1802,6 +1802,15 @@ void revoke_root_if_necessary()
return;
}
+ pw = sysmon_drop_user();
+ if (pw == NULL)
+ {
+ print_err(1, "WARNING: Cannot drop root privileges - neither "
+ "'nobody' nor 'daemon' exists");
+ return;
+ }
+ drop_user = pw->pw_name;
+
/*
* ICMP needs no helper to survive the drop: the raw sockets were
* opened while still root, a raw socket's privilege is checked at
@@ -1810,33 +1819,66 @@ void revoke_root_if_necessary()
* for a platform that refuses to send on them post-drop - which the
* send path discovers from the send itself (EPERM/EACCES) rather
* than assuming it here.
+ *
+ * The probe cannot be access(X_OK): we are still root here, so that
+ * answers yes for a helper the post-drop identity cannot run. What
+ * the fallback actually needs is setuid root plus an execute bit
+ * that reaches the user we are about to become - the install puts
+ * it at 4750 root:, so group-exec counts only when the
+ * group matches, world-exec always does. The drop below clears
+ * supplementary groups to exactly pw_gid, so the primary gid is
+ * the whole story and this check matches post-drop reality.
*/
if (!disable_icmp)
{
- if (access(PING_HELPER_PATH, X_OK) == 0) {
- use_ping_helper = 1;
- print_err(0, "revoke_root: ICMP raw sockets opened while root stay usable after the drop; %s stands by as the fallback", PING_HELPER_PATH);
- } else {
+ struct stat hst;
+
+ if (stat(PING_HELPER_PATH, &hst) == 0)
+ {
+ if ((hst.st_mode & S_ISUID) && hst.st_uid == 0 &&
+ ((hst.st_mode & S_IXOTH) ||
+ ((hst.st_mode & S_IXGRP) && hst.st_gid == pw->pw_gid)))
+ {
+ use_ping_helper = 1;
+ print_err(0, "revoke_root: ICMP raw sockets opened while root stay usable after the drop; %s stands by as the fallback", PING_HELPER_PATH);
+ }
+ else
+ {
+ print_err(0, "revoke_root: ICMP raw sockets opened while root stay usable after the drop");
+ print_err(0, "revoke_root: helper at %s is not setuid root and executable as %s (mode %04o, gid %d) - only matters if this platform refuses raw-socket sends after a privilege drop",
+ PING_HELPER_PATH, drop_user,
+ (unsigned int)(hst.st_mode & 07777),
+ (int)hst.st_gid);
+ }
+ }
+ else
+ {
print_err(0, "revoke_root: ICMP raw sockets opened while root stay usable after the drop");
print_err(0, "revoke_root: no fallback helper at %s - only matters if this platform refuses raw-socket sends after a privilege drop", PING_HELPER_PATH);
}
}
- pw = sysmon_drop_user();
- if (pw == NULL)
- {
- print_err(1, "WARNING: Cannot drop root privileges - neither "
- "'nobody' nor 'daemon' exists");
- return;
- }
- drop_user = pw->pw_name;
-
if (debug)
{
print_err(0, "revoke_root: Dropping privileges from root (uid=0) to user '%s' (uid=%d)",
drop_user, pw->pw_uid);
}
+ /*
+ * Supplementary groups first, while still root: setgid/setuid do
+ * not touch them, and a process that keeps root's supplementary
+ * groups after "dropping" root has not dropped much. Clearing them
+ * also makes the helper probe above honest - from here on, pw_gid
+ * really is the only group this process holds, so a helper the
+ * probe called unusable genuinely is.
+ */
+ if (setgroups(1, &pw->pw_gid) != 0)
+ {
+ perror("revoke_root: setgroups");
+ print_err(1, "WARNING: Failed to drop supplementary groups");
+ return;
+ }
+
/* Drop privileges */
if (setgid(pw->pw_gid) != 0)
{
diff --git a/web-ui/backend/api/openapi.yaml b/web-ui/backend/api/openapi.yaml
index b8eaf86..e141a5b 100644
--- a/web-ui/backend/api/openapi.yaml
+++ b/web-ui/backend/api/openapi.yaml
@@ -652,6 +652,54 @@ paths:
message:
type: string
+ /api/alerters:
+ get:
+ tags: [Monitoring]
+ summary: List alerters
+ description: Alert-only peers seen since this process started - connected or not. Never part of the fleet.
+ responses:
+ '200':
+ description: Alerters listed
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ alerters:
+ type: array
+ items:
+ type: object
+ properties:
+ name: { type: string }
+ application: { type: string, description: What the peer says it is, from its handshake }
+ nickname: { type: string, description: The admin's label; alerts prefer it }
+ addr: { type: string }
+ connected: { type: boolean }
+ connected_at: { type: string, format: date-time }
+ last_seen: { type: string, format: date-time }
+ last_alert_at: { type: string, format: date-time }
+ last_alert: { type: string }
+ alerts: { type: integer }
+ count:
+ type: integer
+
+ /api/alerters/nickname:
+ put:
+ tags: [Admin]
+ summary: Set an alerter's nickname
+ description: The name its alerts display in place of what the application calls itself. Empty clears it.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ name: { type: string }
+ nickname: { type: string }
+ responses:
+ '200':
+ description: Nickname stored
+
/api/admin/session-log:
get:
tags: [Admin]
diff --git a/web-ui/backend/internal/api/router.go b/web-ui/backend/internal/api/router.go
index b70762f..370d77e 100644
--- a/web-ui/backend/internal/api/router.go
+++ b/web-ui/backend/internal/api/router.go
@@ -76,6 +76,12 @@ func NewRouter(cfg *config.Service, mon *monitoring.Service, pushSvc *push.Servi
if pushSvc != nil {
r.push.Store(pushSvc)
}
+ // Alerter traffic (alert-only peers on the agent listener) delivers
+ // through whatever push service is current; re-pointed whenever the
+ // service is hot-swapped (see reconfigurePush).
+ if pushSvc != nil {
+ mon.SetAlertSink(pushSvc.ExternalAlert)
+ }
// Configuration endpoints (admin only - config contains secrets)
r.mux.HandleFunc("/api/config", auth.RequireAdmin(r.handleConfig))
@@ -95,6 +101,8 @@ func NewRouter(cfg *config.Service, mon *monitoring.Service, pushSvc *push.Servi
r.mux.HandleFunc("/api/monitoring/alerts", r.handleMonitoringAlerts)
r.mux.HandleFunc("/api/monitoring/traps", r.handleMonitoringTraps)
r.mux.HandleFunc("/api/sites", r.handleSites)
+ r.mux.HandleFunc("/api/alerters", r.handleAlerters)
+ r.mux.HandleFunc("/api/alerters/nickname", auth.RequireAdmin(r.handleAlerterNickname))
// Config distribution. Everything that changes what a box runs is
// admin-only and POST, so no stale tab or link prefetch can deliver a
@@ -684,9 +692,87 @@ func (r *Router) handleMonitoringAlerts(w http.ResponseWriter, req *http.Request
}
// handleSites lists the fleet, so a site picker can offer names rather
-// than asking someone to type one.
+// than asking someone to type one. minted counts the boxes with tokens
+// here at all - connected or not. The editors use it to decide whether
+// an unselected page may take the only box for granted: one minted box
+// is unambiguous, several are a question, even when just one happens
+// to be online.
func (r *Router) handleSites(w http.ResponseWriter, req *http.Request) {
- r.sendJSON(w, map[string]interface{}{"sites": r.monitoring.Sites()})
+ minted := 0
+ if r.settings != nil {
+ if tokens, err := r.settings.ListAgentTokens(); err == nil {
+ for _, t := range tokens {
+ // Alerters have no config to edit, so they do not
+ // count toward the pickers' ambiguity. A token nothing
+ // has used yet has no kind and counts conservatively.
+ if !t.Revoked && t.Kind != settings.KindAlerter {
+ minted++
+ }
+ }
+ }
+ }
+ r.sendJSON(w, map[string]interface{}{
+ "sites": r.monitoring.Sites(),
+ "minted": minted,
+ })
+}
+
+// handleAlerters lists the alert-only peers seen since this process
+// started - connected or not. They are shown beside the fleet, never in
+// it: an alerter has no hosts, no config, no generations, just alerts.
+func (r *Router) handleAlerters(w http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodGet {
+ r.sendError(w, http.StatusMethodNotAllowed, "Only GET allowed")
+ return
+ }
+ list := r.monitoring.Alerters()
+ // The nickname lives on the minted token's record, where the admin
+ // edits it; joined in here so the card can show it.
+ if r.settings != nil {
+ for i := range list {
+ if tok, ok := r.settings.GetAgentToken(list[i].Name); ok {
+ list[i].Nickname = tok.Label
+ }
+ }
+ }
+ r.sendJSON(w, map[string]interface{}{
+ "alerters": list,
+ "count": len(list),
+ })
+}
+
+// handleAlerterNickname sets (or clears, with an empty label) the
+// operator's nickname for an alerter - the name its alerts display in
+// place of whatever the application calls itself.
+func (r *Router) handleAlerterNickname(w http.ResponseWriter, req *http.Request) {
+ if req.Method != http.MethodPut {
+ r.sendError(w, http.StatusMethodNotAllowed, "Only PUT allowed")
+ return
+ }
+ if r.settings == nil {
+ r.sendError(w, http.StatusServiceUnavailable, "no settings store")
+ return
+ }
+ var body struct {
+ Name string `json:"name"`
+ Nickname string `json:"nickname"`
+ }
+ if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
+ r.sendError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+ // Only a token an alerter has actually claimed is renameable here.
+ // A sysmond's token answering to this endpoint would let its Label
+ // (which other pages use for other things) be edited under the
+ // guise of an alerter nickname.
+ tok, ok := r.settings.GetAgentToken(body.Name)
+ if !ok || tok.Kind != settings.KindAlerter {
+ r.sendError(w, http.StatusNotFound, "no such alerter")
+ return
+ }
+ body.Nickname = monitoring.TruncateRunes(body.Nickname, 128)
+ r.settings.SetAgentLabel(body.Name, body.Nickname)
+ r.sendJSON(w, map[string]string{"name": body.Name, "nickname": body.Nickname})
}
func (r *Router) handleMonitoringTraps(w http.ResponseWriter, req *http.Request) {
@@ -2305,6 +2391,10 @@ func (r *Router) reconfigurePush(pc settings.PushConfig) bool {
return false
}
r.push.Store(svc)
+ // The alerter sink must follow the swap or alerts keep flowing into
+ // the dead service (harmlessly - a stopped service drops them - but
+ // silently).
+ r.monitoring.SetAlertSink(svc.ExternalAlert)
return true
}
diff --git a/web-ui/backend/internal/monitoring/agent.go b/web-ui/backend/internal/monitoring/agent.go
index 8d427c1..36b3239 100644
--- a/web-ui/backend/internal/monitoring/agent.go
+++ b/web-ui/backend/internal/monitoring/agent.go
@@ -9,6 +9,8 @@ import (
"strings"
"sync"
"time"
+
+ "sysmon-web/internal/settings"
)
// Daemons that dial in.
@@ -107,15 +109,19 @@ func (a *AgentListener) handshake(conn net.Conn) {
conn.SetDeadline(time.Now().Add(20 * time.Second))
reader := bufio.NewReader(conn)
- line, err := reader.ReadString('\n')
+ // Bounded, not ReadString: this line arrives BEFORE any token
+ // check, so an unauthenticated peer streaming a newline-free flood
+ // must cost at most maxLineBytes of memory, not everything it can
+ // push before the deadline.
+ line, err := readLineBounded(reader)
if err != nil {
conn.Close()
return
}
fields := strings.Fields(strings.TrimSpace(line))
- if len(fields) < 2 || fields[0] != "HELLO" {
- fmt.Fprintf(conn, "444 expected HELLO \r\n")
+ if len(fields) < 2 || (fields[0] != "HELLO" && fields[0] != "ALERTER") {
+ fmt.Fprintf(conn, "444 expected HELLO or ALERTER \r\n")
conn.Close()
log.Printf("agents: %s did not greet us properly", remote)
return
@@ -141,12 +147,43 @@ func (a *AgentListener) handshake(conn net.Conn) {
return
}
+ // The verb the peer greeted with claims what kind of thing it is. A
+ // token that already belongs to the other class is refused: letting
+ // a sysmond's token greet as an alerter (or the reverse) would flip
+ // how every page counts and treats the site, on the say-so of
+ // whoever holds the token.
+ kind := settings.KindSysmond
+ if fields[0] == "ALERTER" {
+ kind = settings.KindAlerter
+ }
+ if refusal := a.svc.claimKind(site, kind); refusal != "" {
+ fmt.Fprintf(conn, "444 %s\r\n", refusal)
+ conn.Close()
+ log.Printf("agents: refused %s greeting as %s for site %q: %s", remote, kind, site, refusal)
+ return
+ }
+
if _, err := fmt.Fprintf(conn, "333 welcome\r\n"); err != nil {
conn.Close()
return
}
conn.SetDeadline(time.Time{}) // long-lived from here
+ // An alerter is not a monitoring box: nothing is polled, nothing is
+ // adopted, it just sends alerts down this socket - see alerters.go.
+ if fields[0] == "ALERTER" {
+ // Anything after the token is what the application calls
+ // itself - free text, shown beside the name on the Fleet page
+ // and in the alerts it sends.
+ app := ""
+ if len(fields) >= 4 {
+ app = TruncateRunes(strings.Join(fields[3:], " "), 128)
+ }
+ log.Printf("agents: alerter %s connected from %s", site, remote)
+ a.svc.runAlerter(site, app, remote, conn, reader)
+ return
+ }
+
a.svc.adoptAgent(site, remote, conn, reader)
log.Printf("agents: site %s connected from %s", site, remote)
@@ -177,6 +214,23 @@ func ValidSiteName(s string) bool {
return true
}
+// claimKind records what a token's peer identified as at handshake and
+// refuses a token that already belongs to the other class. Returns ""
+// to proceed, else the complaint for the 444. The check and the write
+// happen in one store transaction (ClaimAgentKind), so two concurrent
+// first handshakes cannot both claim a fresh token as different kinds;
+// a reconnect whose kind already matches costs no write at all.
+func (s *Service) claimKind(site, kind string) string {
+ st := s.Generations()
+ if st == nil {
+ return ""
+ }
+ if owner := st.ClaimAgentKind(site, kind); owner != "" {
+ return "this token belongs to a " + owner
+ }
+ return ""
+}
+
// adoptAgent puts a dialled-in daemon into the fleet.
//
// A site reconnecting replaces its old entry rather than adding a second:
diff --git a/web-ui/backend/internal/monitoring/alerters.go b/web-ui/backend/internal/monitoring/alerters.go
new file mode 100644
index 0000000..fad624e
--- /dev/null
+++ b/web-ui/backend/internal/monitoring/alerters.go
@@ -0,0 +1,345 @@
+package monitoring
+
+import (
+ "bufio"
+ "fmt"
+ "log"
+ "net"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+ "unicode/utf8"
+)
+
+// Alerters: peers that are not sysmond.
+//
+// A backup job, a UPS script, a cron watchdog - things with something to
+// say and no fleet of hosts behind them. They authenticate exactly like a
+// monitoring box (one minted token, same TLS listener) but greet with
+// ALERTER instead of HELLO, and from then on the conversation is inverted:
+// nothing is polled, the peer just sends alerts and they ride the same
+// push pipeline with the same priorities a sysmond's transitions do.
+// They are never part of the fleet - no config, no hosts, no generations -
+// just alerters that share the web UI's delivery machinery.
+//
+// The protocol is documented for implementors in docs/ALERTERS.md; the
+// two files must agree.
+
+// maxAlertText bounds what one alert can put into a push notification
+// and the logs, in runes. Anything longer is truncated, not refused -
+// the alert still matters even when its author was verbose.
+const maxAlertText = 512
+
+// maxLineBytes bounds one protocol line. An alerter is deliberately the
+// lower-trust peer class, and a line is read before it is parsed - so
+// the read itself must not be a way to spend this server's memory.
+const maxLineBytes = 4096
+
+// alertQueueDepth is how many alerts may wait on the push pipeline
+// before new ones are refused with "444 busy" so the client knows to
+// retry. Alerts are rare and the queue exists only to keep the
+// protocol reply from waiting on FCM/APNs.
+const alertQueueDepth = 64
+
+// TruncateRunes cuts s to at most n runes, never splitting one - a cut
+// at a byte offset turns multi-byte text into U+FFFD garbage downstream.
+func TruncateRunes(s string, n int) string {
+ if utf8.RuneCountInString(s) <= n {
+ return s
+ }
+ return string([]rune(s)[:n])
+}
+
+// AlerterInfo is one alert-only peer, as the UI shows it.
+type AlerterInfo struct {
+ Name string `json:"name"`
+ // Application is what the peer says it is - free text from its
+ // handshake ("Bacula 15.0 nightly backups"). The name identifies;
+ // this describes.
+ Application string `json:"application,omitempty"`
+ // Nickname is the operator's optional label for this alerter, from
+ // the minted token's record. Filled in by the API layer; alerts
+ // prefer it over Application when both exist.
+ Nickname string `json:"nickname,omitempty"`
+ Addr string `json:"addr"`
+ Connected bool `json:"connected"`
+ ConnectedAt time.Time `json:"connected_at"`
+ // Pointers, not values: omitempty never omits a struct, and a
+ // year-1 timestamp on an alerter that has not alerted yet is worse
+ // than no field.
+ LastSeen *time.Time `json:"last_seen,omitempty"`
+ LastAlertAt *time.Time `json:"last_alert_at,omitempty"`
+ LastAlert string `json:"last_alert,omitempty"` // "CRITICAL tape: jam in drive 2"
+ Alerts uint64 `json:"alerts"`
+}
+
+type alerter struct {
+ mu sync.Mutex
+ info AlerterInfo
+ conn net.Conn
+}
+
+// pendingAlert is one parsed alert waiting on the push pipeline.
+type pendingAlert struct {
+ source, display, object, status, text string
+}
+
+// alerterDisplayName is what an alert shows as its sender: the
+// operator's nickname (the minted token's label) when one is set, else
+// what the application calls itself, else the token name. The token
+// name stays the identity everywhere - collapse keys, logs, the
+// registry - a rename must never re-key anything.
+func (s *Service) alerterDisplayName(a *alerter) string {
+ a.mu.Lock()
+ name, app := a.info.Name, a.info.Application
+ a.mu.Unlock()
+ if st := s.Generations(); st != nil {
+ if tok, ok := st.GetAgentToken(name); ok && tok.Label != "" {
+ return tok.Label
+ }
+ }
+ if app != "" {
+ return app
+ }
+ return name
+}
+
+// SetAlertSink names the function alerter traffic is delivered to -
+// in practice push.Service.ExternalAlert, re-pointed whenever the push
+// service is hot-swapped. Nil means alerts are acknowledged and dropped,
+// which is correct for a deployment that has not configured push.
+func (s *Service) SetAlertSink(fn func(source, display, object, status, text string)) {
+ s.alertSinkMu.Lock()
+ s.alertSink = fn
+ s.alertSinkMu.Unlock()
+}
+
+func (s *Service) alertSinkFn() func(source, display, object, status, text string) {
+ s.alertSinkMu.Lock()
+ defer s.alertSinkMu.Unlock()
+ return s.alertSink
+}
+
+// Alerters lists every alerter seen since this process started, by
+// name. Disconnected ones stay listed: "it was here and left" is
+// information, and the record is only memory.
+func (s *Service) Alerters() []AlerterInfo {
+ s.alertersMu.Lock()
+ defer s.alertersMu.Unlock()
+ out := make([]AlerterInfo, 0, len(s.alerters))
+ for _, a := range s.alerters {
+ a.mu.Lock()
+ out = append(out, a.info)
+ a.mu.Unlock()
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out
+}
+
+// readLineBounded returns the next line, holding at most maxLineBytes
+// of it in memory - the rest of an overlong line is read and discarded,
+// never buffered. One hostile line costs at most its own truncation,
+// not the server's memory.
+func readLineBounded(r *bufio.Reader) (string, error) {
+ var buf []byte
+ for {
+ chunk, isPrefix, err := r.ReadLine()
+ if err != nil {
+ return "", err
+ }
+ if len(buf) < maxLineBytes {
+ take := maxLineBytes - len(buf)
+ if take > len(chunk) {
+ take = len(chunk)
+ }
+ buf = append(buf, chunk[:take]...)
+ }
+ if !isPrefix {
+ return string(buf), nil
+ }
+ }
+}
+
+// runAlerter owns an authenticated alerter connection until it drops.
+// Called from the listener's handshake goroutine; blocks for the life
+// of the connection.
+//
+// The wire protocol, one CRLF (or LF) line per exchange:
+//
+// -> ALERT
+// <- 333 ok
+// -> PING
+// <- 333 pong
+// -> QUIT
+// <- 333 bye
+//
+// Anything else answers 444 without closing the connection - one bad
+// line should not cost an alerter its link.
+//
+// Delivery is decoupled from the reply: a push fan-out can take tens of
+// seconds against a slow provider, and holding the 333 back that long
+// makes a well-behaved client time out, reconnect, and resend the same
+// page. One dispatcher goroutine per connection keeps alerts in order;
+// it drains what is queued even after the connection drops.
+func (s *Service) runAlerter(name, application, remote string, conn net.Conn, reader *bufio.Reader) {
+ a := s.registerAlerter(name, application, remote, conn)
+
+ pending := make(chan pendingAlert, alertQueueDepth)
+ go func() {
+ for p := range pending {
+ if sink := s.alertSinkFn(); sink != nil {
+ sink(p.source, p.display, p.object, p.status, p.text)
+ } else {
+ log.Printf("agents: alerter %s sent %s %s with no push service configured - dropped",
+ p.source, p.status, p.object)
+ }
+ }
+ }()
+
+ defer func() {
+ conn.Close()
+ close(pending)
+ // A reconnect replaces this connection on the shared record
+ // before this goroutine notices its read failing - only the
+ // record's CURRENT connection may declare it disconnected, or
+ // the replacement is immediately (and permanently) shown gone.
+ a.mu.Lock()
+ mine := a.conn == conn
+ if mine {
+ a.info.Connected = false
+ }
+ a.mu.Unlock()
+ if mine {
+ log.Printf("agents: alerter %s (%s) disconnected", name, remote)
+ }
+ }()
+
+ for {
+ line, err := readLineBounded(reader)
+ if err != nil {
+ return
+ }
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ now := time.Now().UTC()
+ a.mu.Lock()
+ a.info.LastSeen = &now
+ a.mu.Unlock()
+
+ verb := line
+ if i := strings.IndexByte(line, ' '); i >= 0 {
+ verb = line[:i]
+ }
+ switch strings.ToUpper(verb) {
+ case "PING":
+ fmt.Fprintf(conn, "333 pong\r\n")
+ case "QUIT":
+ fmt.Fprintf(conn, "333 bye\r\n")
+ return
+ case "ALERT":
+ if msg := s.handleAlertLine(a, name, line, pending); msg == "" {
+ fmt.Fprintf(conn, "333 ok\r\n")
+ } else {
+ fmt.Fprintf(conn, "444 %s\r\n", msg)
+ }
+ default:
+ fmt.Fprintf(conn, "444 unknown command (ALERT/PING/QUIT)\r\n")
+ }
+ }
+}
+
+// handleAlertLine parses "ALERT " and queues
+// it for delivery. Returns "" on success, else the complaint for the 444.
+func (s *Service) handleAlertLine(a *alerter, name, line string, pending chan<- pendingAlert) string {
+ fields := strings.SplitN(line, " ", 4)
+ if len(fields) < 3 {
+ return "usage: ALERT "
+ }
+ status := strings.ToUpper(fields[1])
+ switch status {
+ case "CRITICAL", "WARNING", "OK":
+ default:
+ return "status must be CRITICAL, WARNING or OK"
+ }
+ object := fields[2]
+ if !ValidSiteName(object) {
+ return "object name: letters, digits, - and _ only, max 64"
+ }
+ text := ""
+ if len(fields) == 4 {
+ text = TruncateRunes(strings.TrimSpace(fields[3]), maxAlertText)
+ }
+ if text == "" {
+ text = fmt.Sprintf("%s reports %s %s", name, object, status)
+ }
+
+ p := pendingAlert{
+ source: name,
+ display: s.alerterDisplayName(a),
+ object: object,
+ status: status,
+ text: text,
+ }
+ select {
+ case pending <- p:
+ default:
+ // The push pipeline is badly backed up. The alert is NOT
+ // accepted, and the client must hear that: a 333 here would
+ // tell a compliant alerter its page was delivered when it was
+ // dropped, and the one page that matters would be lost with
+ // only a server-side log line to show for it. 444 never
+ // closes the connection, so the client just retries.
+ log.Printf("agents: alerter %s: delivery queue full - refusing %s %s", name, status, object)
+ return "busy - delivery queue is full, retry shortly"
+ }
+
+ // Bookkeeping counts accepted alerts only; a refused one never
+ // happened as far as the Fleet page is concerned.
+ now := time.Now().UTC()
+ a.mu.Lock()
+ a.info.Alerts++
+ a.info.LastAlertAt = &now
+ a.info.LastAlert = fmt.Sprintf("%s %s: %s", status, object, text)
+ a.mu.Unlock()
+ return ""
+}
+
+// registerAlerter puts a connection into the registry. A name
+// reconnecting replaces its old link rather than adding a second, the
+// same rule adoptAgent applies to daemons.
+func (s *Service) registerAlerter(name, application, remote string, conn net.Conn) *alerter {
+ s.alertersMu.Lock()
+ defer s.alertersMu.Unlock()
+ if s.alerters == nil {
+ s.alerters = make(map[string]*alerter)
+ }
+ if old, ok := s.alerters[name]; ok {
+ old.mu.Lock()
+ if old.conn != nil && old.info.Connected {
+ old.conn.Close()
+ }
+ old.conn = conn
+ old.info.Application = application
+ old.info.Addr = remote
+ old.info.Connected = true
+ old.info.ConnectedAt = time.Now().UTC()
+ old.mu.Unlock()
+ return old
+ }
+ a := &alerter{
+ conn: conn,
+ info: AlerterInfo{
+ Name: name,
+ Application: application,
+ Addr: remote,
+ Connected: true,
+ ConnectedAt: time.Now().UTC(),
+ },
+ }
+ s.alerters[name] = a
+ return a
+}
diff --git a/web-ui/backend/internal/monitoring/alerters_test.go b/web-ui/backend/internal/monitoring/alerters_test.go
new file mode 100644
index 0000000..17dcb0d
--- /dev/null
+++ b/web-ui/backend/internal/monitoring/alerters_test.go
@@ -0,0 +1,448 @@
+package monitoring
+
+import (
+ "bufio"
+ "fmt"
+ "net"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "sysmon-web/internal/settings"
+)
+
+// A captured sink call.
+type sunkAlert struct {
+ source, display, object, status, text string
+}
+
+// waitFor polls cond until it holds or the test gives up. Delivery to
+// the sink is asynchronous by design - the 333 comes back before the
+// push pipeline runs - so every assertion about the sink has to wait,
+// not look.
+func waitFor(t *testing.T, what string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(2 * time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for %s", what)
+}
+
+// The alerter protocol, driven end to end over a pipe: handshake is the
+// listener's job, so this starts where it hands over - an authenticated
+// connection - and exercises ALERT/PING/QUIT, the display-name
+// preference, and the registry the Fleet page reads.
+func TestAlerterSession(t *testing.T) {
+ store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := store.NewAgentToken("backupd", ""); err != nil {
+ t.Fatal(err)
+ }
+
+ svc := NewService()
+ svc.SetGenerations(store)
+
+ var mu sync.Mutex
+ var sunk []sunkAlert
+ svc.SetAlertSink(func(source, display, object, status, text string) {
+ mu.Lock()
+ sunk = append(sunk, sunkAlert{source, display, object, status, text})
+ mu.Unlock()
+ })
+ sunkLen := func() int {
+ mu.Lock()
+ defer mu.Unlock()
+ return len(sunk)
+ }
+
+ server, client := net.Pipe()
+ done := make(chan struct{})
+ go func() {
+ svc.runAlerter("backupd", "Bacula 15.0 nightly backups", "pipe", server, bufio.NewReader(server))
+ close(done)
+ }()
+
+ r := bufio.NewReader(client)
+ send := func(line string) string {
+ t.Helper()
+ if _, err := client.Write([]byte(line + "\n")); err != nil {
+ t.Fatalf("write %q: %v", line, err)
+ }
+ reply, err := r.ReadString('\n')
+ if err != nil {
+ t.Fatalf("no reply to %q: %v", line, err)
+ }
+ return strings.TrimSpace(reply)
+ }
+
+ if got := send("PING"); got != "333 pong" {
+ t.Errorf("PING answered %q", got)
+ }
+ if got := send("ALERT CRITICAL tape jam in drive 2"); got != "333 ok" {
+ t.Errorf("ALERT answered %q", got)
+ }
+ if got := send("ALERT BOGUS tape whatever"); !strings.HasPrefix(got, "444") {
+ t.Errorf("bad status answered %q, want a 444", got)
+ }
+ if got := send("ALERT CRITICAL bad:name text"); !strings.HasPrefix(got, "444") {
+ t.Errorf("bad object answered %q, want a 444", got)
+ }
+ if got := send("NONSENSE"); !strings.HasPrefix(got, "444") {
+ t.Errorf("unknown verb answered %q, want a 444", got)
+ }
+ // A 444 must not have cost the connection.
+ if got := send("ALERT OK tape cleared"); got != "333 ok" {
+ t.Errorf("ALERT after a 444 answered %q", got)
+ }
+
+ waitFor(t, "two alerts to reach the sink", func() bool { return sunkLen() == 2 })
+ mu.Lock()
+ first := sunk[0]
+ mu.Unlock()
+ if first.source != "backupd" || first.object != "tape" ||
+ first.status != "CRITICAL" || first.text != "jam in drive 2" {
+ t.Errorf("first alert = %+v", first)
+ }
+ // No nickname set: the display name is what the application calls
+ // itself.
+ if first.display != "Bacula 15.0 nightly backups" {
+ t.Errorf("display = %q, want the application name", first.display)
+ }
+
+ // The registry the Fleet page reads.
+ list := svc.Alerters()
+ if len(list) != 1 || list[0].Name != "backupd" || !list[0].Connected ||
+ list[0].Application != "Bacula 15.0 nightly backups" || list[0].Alerts != 2 {
+ t.Errorf("Alerters() = %+v", list)
+ }
+ if !strings.Contains(list[0].LastAlert, "OK tape") {
+ t.Errorf("LastAlert = %q", list[0].LastAlert)
+ }
+ if list[0].LastSeen == nil || list[0].LastAlertAt == nil {
+ t.Errorf("LastSeen/LastAlertAt = %v/%v, want both set", list[0].LastSeen, list[0].LastAlertAt)
+ }
+
+ // An admin nickname beats the application name from the next alert on.
+ store.SetAgentLabel("backupd", "Nightly Backups")
+ if got := send("ALERT WARNING tape drive temperature high"); got != "333 ok" {
+ t.Errorf("ALERT answered %q", got)
+ }
+ waitFor(t, "the third alert to reach the sink", func() bool { return sunkLen() == 3 })
+ mu.Lock()
+ last := sunk[len(sunk)-1]
+ mu.Unlock()
+ if last.display != "Nightly Backups" {
+ t.Errorf("display after nickname = %q, want the nickname", last.display)
+ }
+
+ if got := send("QUIT"); got != "333 bye" {
+ t.Errorf("QUIT answered %q", got)
+ }
+ <-done
+ if list := svc.Alerters(); len(list) != 1 || list[0].Connected {
+ t.Errorf("after QUIT, Alerters() = %+v, want the record kept but disconnected", list)
+ }
+
+ // The token record learns its kind at handshake time in the real
+ // path (claimKind -> ClaimAgentKind) - prove the recorded kind
+ // sticks and that labels round-trip beside it.
+ if got := store.ClaimAgentKind("backupd", settings.KindAlerter); got != "" {
+ t.Errorf("ClaimAgentKind refused a fresh token: %q", got)
+ }
+ tokens, err := store.ListAgentTokens()
+ if err != nil || len(tokens) != 1 {
+ t.Fatalf("ListAgentTokens: %v, %d", err, len(tokens))
+ }
+ if tokens[0].Kind != settings.KindAlerter || tokens[0].Label != "Nightly Backups" {
+ t.Errorf("token record = %+v", tokens[0])
+ }
+}
+
+// A reconnect replaces the old connection on the shared record; when the
+// replaced connection's goroutine finally notices its read failing, it
+// must not mark the record disconnected - that would show the live
+// replacement as gone until its next alert.
+func TestAlerterReconnectKeepsNewConnection(t *testing.T) {
+ svc := NewService()
+
+ server1, _ := net.Pipe()
+ done1 := make(chan struct{})
+ go func() {
+ svc.runAlerter("upsd", "apcupsd", "pipe-1", server1, bufio.NewReader(server1))
+ close(done1)
+ }()
+ waitFor(t, "the first connection to register", func() bool {
+ l := svc.Alerters()
+ return len(l) == 1 && l[0].Connected
+ })
+
+ // Same name dials in again: the registry closes the old socket,
+ // which is what makes the first goroutine exit.
+ server2, client2 := net.Pipe()
+ done2 := make(chan struct{})
+ go func() {
+ svc.runAlerter("upsd", "apcupsd", "pipe-2", server2, bufio.NewReader(server2))
+ close(done2)
+ }()
+
+ select {
+ case <-done1:
+ case <-time.After(5 * time.Second):
+ t.Fatal("replaced connection's goroutine never exited")
+ }
+ // The old goroutine has fully torn down; the record must still say
+ // connected, because the connection it tore down was not the
+ // record's current one.
+ if l := svc.Alerters(); len(l) != 1 || !l[0].Connected || l[0].Addr != "pipe-2" {
+ t.Errorf("after replacement, Alerters() = %+v, want connected via pipe-2", l)
+ }
+
+ // And the replacement really is live.
+ r := bufio.NewReader(client2)
+ if _, err := client2.Write([]byte("PING\n")); err != nil {
+ t.Fatalf("write on replacement: %v", err)
+ }
+ if reply, err := r.ReadString('\n'); err != nil || strings.TrimSpace(reply) != "333 pong" {
+ t.Fatalf("replacement PING = %q, %v", strings.TrimSpace(reply), err)
+ }
+ client2.Close()
+ <-done2
+ if l := svc.Alerters(); len(l) != 1 || l[0].Connected {
+ t.Errorf("after the replacement dropped, Alerters() = %+v, want disconnected", l)
+ }
+}
+
+// One hostile line must cost at most its own truncation: the reader
+// holds no more than maxLineBytes of it, the alert text is cut to
+// maxAlertText runes, and the connection survives to serve the next
+// line.
+func TestAlerterOverlongLine(t *testing.T) {
+ svc := NewService()
+
+ var mu sync.Mutex
+ var texts []string
+ svc.SetAlertSink(func(_, _, _, _ string, text string) {
+ mu.Lock()
+ texts = append(texts, text)
+ mu.Unlock()
+ })
+
+ server, client := net.Pipe()
+ done := make(chan struct{})
+ go func() {
+ svc.runAlerter("chatty", "", "pipe", server, bufio.NewReader(server))
+ close(done)
+ }()
+
+ // Four times the line bound, no newline until the end.
+ long := "ALERT CRITICAL disk " + strings.Repeat("x", 4*maxLineBytes) + "\n"
+ go func() {
+ // net.Pipe writes block until read; feed it from the side.
+ client.Write([]byte(long))
+ }()
+ r := bufio.NewReader(client)
+ reply, err := r.ReadString('\n')
+ if err != nil || strings.TrimSpace(reply) != "333 ok" {
+ t.Fatalf("overlong ALERT = %q, %v, want 333 ok", strings.TrimSpace(reply), err)
+ }
+
+ waitFor(t, "the truncated alert to reach the sink", func() bool {
+ mu.Lock()
+ defer mu.Unlock()
+ return len(texts) == 1
+ })
+ mu.Lock()
+ text := texts[0]
+ mu.Unlock()
+ if got := len([]rune(text)); got > maxAlertText {
+ t.Errorf("alert text is %d runes, want at most %d", got, maxAlertText)
+ }
+
+ // The line after the flood still parses - nothing of the overflow
+ // leaked into the next read.
+ if _, err := client.Write([]byte("PING\n")); err != nil {
+ t.Fatalf("write after flood: %v", err)
+ }
+ if reply, err := r.ReadString('\n'); err != nil || strings.TrimSpace(reply) != "333 pong" {
+ t.Fatalf("PING after flood = %q, %v", strings.TrimSpace(reply), err)
+ }
+ client.Close()
+ <-done
+}
+
+// TruncateRunes must never split a multi-byte rune - that is its whole
+// reason to exist over a byte slice.
+func TestTruncateRunes(t *testing.T) {
+ if got := TruncateRunes("hello", 10); got != "hello" {
+ t.Errorf("short string changed: %q", got)
+ }
+ if got := TruncateRunes("hello", 3); got != "hel" {
+ t.Errorf("ASCII cut = %q", got)
+ }
+ // Each of these is one rune, several bytes.
+ s := strings.Repeat("é世\U0001f600", 4) // é 世 😀
+ got := TruncateRunes(s, 5)
+ if n := len([]rune(got)); n != 5 {
+ t.Errorf("cut to %d runes, want 5", n)
+ }
+ if !strings.HasPrefix(s, got) {
+ t.Errorf("cut %q is not a prefix of the input", got)
+ }
+}
+
+// claimKind: the greeting verb claims what a token's peer is, first
+// claim wins forever, and the other class is refused - a sysmond's
+// token cannot quietly become an alerter or the reverse.
+func TestClaimKind(t *testing.T) {
+ store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := store.NewAgentToken("box1", ""); err != nil {
+ t.Fatal(err)
+ }
+
+ svc := NewService()
+ svc.SetGenerations(store)
+
+ if got := svc.claimKind("box1", settings.KindSysmond); got != "" {
+ t.Errorf("first claim refused: %q", got)
+ }
+ if tok, _ := store.GetAgentToken("box1"); tok.Kind != settings.KindSysmond {
+ t.Errorf("kind after first claim = %q", tok.Kind)
+ }
+ if got := svc.claimKind("box1", settings.KindSysmond); got != "" {
+ t.Errorf("reclaim of the same kind refused: %q", got)
+ }
+ if got := svc.claimKind("box1", settings.KindAlerter); !strings.Contains(got, "sysmond") {
+ t.Errorf("cross-kind claim answered %q, want a refusal naming the owner", got)
+ }
+ if tok, _ := store.GetAgentToken("box1"); tok.Kind != settings.KindSysmond {
+ t.Errorf("kind after refused claim = %q, must be unchanged", tok.Kind)
+ }
+
+ // No record: not claimKind's problem (the handshake authenticated
+ // already; only a concurrent revoke gets here).
+ if got := svc.claimKind("ghost", settings.KindAlerter); got != "" {
+ t.Errorf("claim without a record refused: %q", got)
+ }
+ // No store at all: same.
+ if got := NewService().claimKind("box1", settings.KindAlerter); got != "" {
+ t.Errorf("claim without a store refused: %q", got)
+ }
+}
+
+// A full delivery queue must refuse the alert, not acknowledge it:
+// "333 ok" is a delivery promise, and a compliant client only resends
+// what was refused. Refused alerts also must not count as sent.
+func TestAlerterQueueFullRefuses(t *testing.T) {
+ svc := NewService()
+
+ release := make(chan struct{})
+ starts := make(chan struct{}, alertQueueDepth+8)
+ svc.SetAlertSink(func(_, _, _, _, _ string) {
+ starts <- struct{}{}
+ <-release // hold the dispatcher so the queue backs up
+ })
+
+ server, client := net.Pipe()
+ done := make(chan struct{})
+ go func() {
+ svc.runAlerter("floody", "", "pipe", server, bufio.NewReader(server))
+ close(done)
+ }()
+
+ r := bufio.NewReader(client)
+ send := func(line string) string {
+ t.Helper()
+ if _, err := client.Write([]byte(line + "\n")); err != nil {
+ t.Fatalf("write %q: %v", line, err)
+ }
+ reply, err := r.ReadString('\n')
+ if err != nil {
+ t.Fatalf("no reply to %q: %v", line, err)
+ }
+ return strings.TrimSpace(reply)
+ }
+
+ // The first alert occupies the dispatcher (wait until it is held in
+ // the sink, so the queue is empty again)...
+ if got := send("ALERT OK disk zero"); got != "333 ok" {
+ t.Fatalf("first alert answered %q", got)
+ }
+ <-starts
+ // ...then exactly alertQueueDepth more fill the buffer.
+ for i := 0; i < alertQueueDepth; i++ {
+ if got := send("ALERT OK disk filler"); got != "333 ok" {
+ t.Fatalf("filler %d answered %q", i, got)
+ }
+ }
+ // The next one has nowhere to go: it must be a 444, not a false ok.
+ if got := send("ALERT CRITICAL disk the one that matters"); !strings.HasPrefix(got, "444 busy") {
+ t.Fatalf("overflow alert answered %q, want a 444 busy refusal", got)
+ }
+
+ // Unblock delivery, let the backlog drain, and the same line is
+ // accepted on retry - the connection survived the refusal.
+ close(release)
+ for i := 0; i < alertQueueDepth; i++ {
+ <-starts
+ }
+ if got := send("ALERT CRITICAL disk the one that matters"); got != "333 ok" {
+ t.Fatalf("retry after drain answered %q", got)
+ }
+
+ // Only accepted alerts counted: 1 + depth + 1, not the refusal.
+ want := uint64(alertQueueDepth + 2)
+ if list := svc.Alerters(); len(list) != 1 || list[0].Alerts != want {
+ t.Errorf("Alerts = %d, want %d (refused alerts must not count)", list[0].Alerts, want)
+ }
+
+ client.Close()
+ <-done
+}
+
+// Two first-use handshakes racing with the same fresh token must not
+// both win: the claim is one store transaction, so exactly one side
+// records its kind and the other is refused.
+func TestClaimKindConcurrentFirstUse(t *testing.T) {
+ store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+
+ svc := NewService()
+ svc.SetGenerations(store)
+
+ for i := 0; i < 10; i++ {
+ site := fmt.Sprintf("box%d", i)
+ if _, err := store.NewAgentToken(site, ""); err != nil {
+ t.Fatal(err)
+ }
+ results := make(chan string, 2)
+ go func() { results <- svc.claimKind(site, settings.KindSysmond) }()
+ go func() { results <- svc.claimKind(site, settings.KindAlerter) }()
+ a, b := <-results, <-results
+ refused := 0
+ if a != "" {
+ refused++
+ }
+ if b != "" {
+ refused++
+ }
+ if refused != 1 {
+ t.Fatalf("site %s: %d refusals (%q / %q), want exactly one winner", site, refused, a, b)
+ }
+ }
+}
diff --git a/web-ui/backend/internal/monitoring/history.go b/web-ui/backend/internal/monitoring/history.go
index 3ebd772..8f9d5da 100644
--- a/web-ui/backend/internal/monitoring/history.go
+++ b/web-ui/backend/internal/monitoring/history.go
@@ -24,8 +24,14 @@ const (
// HistoryEvent is one observed host state transition - the raw material
// of "what has been going up and down" over the last 48 hours.
type HistoryEvent struct {
- Timestamp string `json:"timestamp"` // RFC3339
- ObjectName string `json:"object_name"`
+ Timestamp string `json:"timestamp"` // RFC3339
+ ObjectName string `json:"object_name"`
+ // Site and LocalName are ObjectName's two halves, carried separately
+ // so no display ever has to render the "site:object" key itself -
+ // the UIs show the bare name with the site as its own minimized
+ // element. ObjectName stays the identity everywhere.
+ Site string `json:"site,omitempty"`
+ LocalName string `json:"local_name,omitempty"`
Hostname string `json:"hostname"`
Description string `json:"description,omitempty"`
PrevStatus string `json:"prev_status"`
@@ -161,6 +167,12 @@ func (h *HistoryStore) Recent(limit int, window time.Duration) []HistoryEvent {
if json.Unmarshal(v, &ev) != nil {
continue
}
+ // Rows written before Site/LocalName existed carry only the
+ // qualified key; split it on the way out so every consumer
+ // sees the same shape regardless of the row's age.
+ if ev.LocalName == "" {
+ ev.Site, ev.LocalName = SplitQualified(ev.ObjectName)
+ }
t, err := time.Parse(time.RFC3339, ev.Timestamp)
if err != nil {
// A row whose timestamp cannot be read has no age, so it
diff --git a/web-ui/backend/internal/monitoring/history_test.go b/web-ui/backend/internal/monitoring/history_test.go
index 89467ce..dbe51ec 100644
--- a/web-ui/backend/internal/monitoring/history_test.go
+++ b/web-ui/backend/internal/monitoring/history_test.go
@@ -88,3 +88,57 @@ func TestRecentWindow(t *testing.T) {
t.Fatalf("30d window missed the 72h-old event: %d rows", len(got))
}
}
+
+// Rows written before Site/LocalName existed carry only the qualified
+// "site:object" key; Recent must split it on the way out so old and new
+// rows serve the same shape. New rows keep what Append stored.
+func TestRecentBackfillsSiteAndLocalName(t *testing.T) {
+ h, err := OpenHistory(filepath.Join(t.TempDir(), "history.db"))
+ if err != nil {
+ t.Fatalf("OpenHistory: %v", err)
+ }
+ defer h.Close()
+
+ // A pre-split row, planted directly the way an old release wrote it.
+ old := HistoryEvent{
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ ObjectName: "branch2:coreswitch",
+ PrevStatus: "OK",
+ NewStatus: "CRITICAL",
+ }
+ data, _ := json.Marshal(old)
+ h.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket(bucketHistoryEvents)
+ id, _ := b.NextSequence()
+ return b.Put([]byte(fmt.Sprintf("%012d", id)), data)
+ })
+ // A pre-split local row: no colon, so there is no site to find.
+ h.Append([]HistoryEvent{{
+ ObjectName: "mailhost",
+ PrevStatus: "OK",
+ NewStatus: "WARNING",
+ }})
+ // A current row, with the halves carried explicitly.
+ h.Append([]HistoryEvent{{
+ ObjectName: "branch3:fw",
+ Site: "branch3",
+ LocalName: "fw",
+ PrevStatus: "WARNING",
+ NewStatus: "OK",
+ }})
+
+ got := h.Recent(10, 0)
+ if len(got) != 3 {
+ t.Fatalf("Recent returned %d events, want 3: %+v", len(got), got)
+ }
+ // Newest first.
+ if got[0].Site != "branch3" || got[0].LocalName != "fw" {
+ t.Errorf("current row = site %q local %q, want branch3/fw", got[0].Site, got[0].LocalName)
+ }
+ if got[1].Site != "" || got[1].LocalName != "mailhost" {
+ t.Errorf("old local row = site %q local %q, want \"\"/mailhost", got[1].Site, got[1].LocalName)
+ }
+ if got[2].Site != "branch2" || got[2].LocalName != "coreswitch" {
+ t.Errorf("old fleet row = site %q local %q, want branch2/coreswitch", got[2].Site, got[2].LocalName)
+ }
+}
diff --git a/web-ui/backend/internal/monitoring/service.go b/web-ui/backend/internal/monitoring/service.go
index 4f82393..972d6c2 100644
--- a/web-ui/backend/internal/monitoring/service.go
+++ b/web-ui/backend/internal/monitoring/service.go
@@ -104,6 +104,13 @@ type Service struct {
daemons []*daemon
sessionLog *SessionLogger
+ // Alerters: alert-only peers on the agent listener. Not part of the
+ // fleet - see alerters.go.
+ alertersMu sync.Mutex
+ alerters map[string]*alerter
+ alertSinkMu sync.Mutex
+ alertSink func(source, display, object, status, text string)
+
cacheMu sync.Mutex
// history, when set, receives every observed host status transition.
history *HistoryStore
@@ -668,6 +675,8 @@ func (s *Service) storeSnapshotLocked(status *models.SysmonStatus) []HistoryEven
if old, ok := prev[hostKey(h)]; ok && old != h.OverallStatus {
transitions = append(transitions, HistoryEvent{
ObjectName: h.ObjectName,
+ Site: h.Site,
+ LocalName: h.LocalName,
Hostname: h.Hostname,
Description: h.Description,
PrevStatus: old,
diff --git a/web-ui/backend/internal/push/service.go b/web-ui/backend/internal/push/service.go
index 96eeac0..8d1c1d1 100644
--- a/web-ui/backend/internal/push/service.go
+++ b/web-ui/backend/internal/push/service.go
@@ -948,7 +948,13 @@ func (s *Service) notifyAll(title, subtitle, body string, data fcmData, prevStat
fcm, apns, _ := s.clients()
subs := s.ListSubscriptions()
sent := 0
+ // badge < 0 means "say nothing about the badge": alerter alerts
+ // have no view of the fleet's unacked count, and a badge of 0
+ // would wrongly clear the icon.
badgePtr := &badge
+ if badge < 0 {
+ badgePtr = nil
+ }
hostname, status, collapseKey := data.Hostname, data.Status, data.Object
for _, sub := range subs {
@@ -1022,6 +1028,45 @@ func (s *Service) notifyAll(title, subtitle, body string, data fcmData, prevStat
})
}
+// ExternalAlert fans out an alert from an alerter - a peer that is not
+// a sysmond but pages through the same pipeline with the same
+// priorities: CRITICAL is loud, everything else is quiet. source names
+// the alerter, object the thing it is alerting about; together they
+// form the collapse key, so a newer alert about the same thing replaces
+// the older one on the phones, exactly as host transitions do. Honors
+// the master enable switch the same way the state watcher does.
+// display is what the alert SHOWS as its sender - the operator's
+// nickname, or what the application calls itself, falling back to
+// source. source alone is identity: it keys the collapse and the logs,
+// so a rename never re-keys anything.
+func (s *Service) ExternalAlert(source, display, object, status, text string) {
+ if !s.Enabled() {
+ log.Printf("push: alerter %s sent %s %s but push is disabled - not delivered", source, status, object)
+ return
+ }
+ status = strings.ToUpper(status)
+ title := fmt.Sprintf("%s %s", object, status)
+ critical := status == "CRITICAL"
+ collapse := source + ":" + object
+ if display == "" {
+ display = source
+ }
+ // The sender's name rides in the body so Android shows it too;
+ // subtitles only render on iOS.
+ body := text
+ if display != object {
+ body = display + ": " + text
+ }
+
+ log.Printf("push: alerter %s: %s %s - notifying subscribers", source, status, object)
+ s.notifyAll(title, display, body, fcmData{
+ Hostname: object,
+ Object: collapse,
+ Status: status,
+ Type: "alerter",
+ }, "", -1, critical)
+}
+
func (s *Service) watchLoop() {
defer s.wg.Done()
defer func() {
diff --git a/web-ui/backend/internal/push/service_test.go b/web-ui/backend/internal/push/service_test.go
index 1d69d3b..49bb794 100644
--- a/web-ui/backend/internal/push/service_test.go
+++ b/web-ui/backend/internal/push/service_test.go
@@ -252,6 +252,39 @@ var errRandom = json.Unmarshal([]byte("x"), &struct{}{})
func i64(v int64) *int64 { return &v }
+// Alerter alerts ride the same fan-out as host transitions, honoring the
+// master enable switch, and show the sender's display name.
+func TestExternalAlert(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "push.db")
+ svc, err := NewService(Config{Enabled: true}, dbPath, nil)
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ defer svc.Stop()
+
+ fake := newFakeFCM()
+ svc.fcm = fcmClientFor(t, fake)
+ if _, _, err := svc.Subscribe("tok-phone", PlatformAndroid, "pixel", "chris", "", ""); err != nil {
+ t.Fatalf("Subscribe: %v", err)
+ }
+
+ svc.ExternalAlert("backupd", "Nightly Backups", "tape", "CRITICAL", "jam in drive 2")
+ if got := fake.sendCount("tok-phone"); got != 1 {
+ t.Fatalf("alert reached %d devices, want 1", got)
+ }
+ subs := svc.ListSubscriptions()
+ if len(subs) != 1 || subs[0].LastPushStatus != "ok" || subs[0].PushCount != 1 {
+ t.Errorf("delivery not recorded: %+v", subs)
+ }
+
+ // The push switch is the master: disabled means dropped, not queued.
+ svc.Reconfigure(Config{Enabled: false})
+ svc.ExternalAlert("backupd", "Nightly Backups", "tape", "OK", "cleared")
+ if got := fake.sendCount("tok-phone"); got != 1 {
+ t.Errorf("a disabled service still delivered (%d sends)", got)
+ }
+}
+
func TestCheckDetail(t *testing.T) {
cases := []struct {
name string
diff --git a/web-ui/backend/internal/settings/agents.go b/web-ui/backend/internal/settings/agents.go
index ebd0990..7eb42f9 100644
--- a/web-ui/backend/internal/settings/agents.go
+++ b/web-ui/backend/internal/settings/agents.go
@@ -22,6 +22,15 @@ func hashToken(t string) string {
return hex.EncodeToString(sum[:])
}
+// What a token's peer turned out to be. A token is minted before its
+// box ever connects, so the kind is recorded at first handshake - a
+// sysmond says HELLO, an alerter says ALERTER - and stays empty for a
+// token nothing has used yet.
+const (
+ KindSysmond = "sysmond"
+ KindAlerter = "alerter"
+)
+
// AgentToken is one monitoring box's credential.
//
// One per box, revocable here, is the whole reason daemons dial out rather
@@ -31,12 +40,124 @@ type AgentToken struct {
Site string `json:"site"`
Token string `json:"-"` // never leaves this process after creation
Label string `json:"label,omitempty"`
+ Kind string `json:"kind,omitempty"` // KindSysmond/KindAlerter, "" until first seen
Created time.Time `json:"created"`
LastSeen time.Time `json:"last_seen,omitempty"`
LastAddr string `json:"last_addr,omitempty"`
Revoked bool `json:"revoked,omitempty"`
}
+// SetAgentLabel renames a token's human label - for alerters this is
+// the nickname alerts display. Missing records are left missing.
+func (s *Store) SetAgentLabel(site, label string) {
+ _ = s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket(bucketAgents)
+ if b == nil {
+ return nil
+ }
+ blob := b.Get([]byte(site))
+ if blob == nil {
+ return nil
+ }
+ var stored map[string]json.RawMessage
+ if json.Unmarshal(blob, &stored) != nil {
+ return nil
+ }
+ enc, err := json.Marshal(label)
+ if err != nil {
+ return nil
+ }
+ if label == "" {
+ delete(stored, "label")
+ } else {
+ stored["label"] = enc
+ }
+ updated, err := json.Marshal(stored)
+ if err != nil {
+ return nil
+ }
+ return b.Put([]byte(site), updated)
+ })
+}
+
+// SetAgentKind records what a token's peer identified as at handshake.
+// Idempotent; a missing record is left missing (the handshake already
+// authenticated against it, so this only races a concurrent revoke).
+func (s *Store) SetAgentKind(site, kind string) {
+ _ = s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket(bucketAgents)
+ if b == nil {
+ return nil
+ }
+ blob := b.Get([]byte(site))
+ if blob == nil {
+ return nil
+ }
+ var stored map[string]json.RawMessage
+ if json.Unmarshal(blob, &stored) != nil {
+ return nil
+ }
+ enc, err := json.Marshal(kind)
+ if err != nil {
+ return nil
+ }
+ stored["kind"] = enc
+ updated, err := json.Marshal(stored)
+ if err != nil {
+ return nil
+ }
+ return b.Put([]byte(site), updated)
+ })
+}
+
+// ClaimAgentKind records what a token's peer identified as, first
+// claim wins forever: read, check and write happen inside one bolt
+// transaction, so two concurrent first handshakes with the same fresh
+// token cannot both succeed as different kinds. Returns "" when the
+// claim stands (recorded now, already recorded, or no record to claim
+// against - the handshake already authenticated, so a missing record
+// only races a concurrent revoke), else the kind the token already
+// belongs to.
+func (s *Store) ClaimAgentKind(site, kind string) string {
+ owner := ""
+ _ = s.db.Update(func(tx *bolt.Tx) error {
+ b := tx.Bucket(bucketAgents)
+ if b == nil {
+ return nil
+ }
+ blob := b.Get([]byte(site))
+ if blob == nil {
+ return nil
+ }
+ var stored map[string]json.RawMessage
+ if json.Unmarshal(blob, &stored) != nil {
+ return nil
+ }
+ existing := ""
+ if raw, ok := stored["kind"]; ok {
+ _ = json.Unmarshal(raw, &existing)
+ }
+ if existing == kind {
+ return nil // steady state: no write, no refusal
+ }
+ if existing != "" {
+ owner = existing
+ return nil
+ }
+ enc, err := json.Marshal(kind)
+ if err != nil {
+ return nil
+ }
+ stored["kind"] = enc
+ updated, err := json.Marshal(stored)
+ if err != nil {
+ return nil
+ }
+ return b.Put([]byte(site), updated)
+ })
+ return owner
+}
+
// GetAgentToken returns the record for a site, without the secret.
// Used to tell "this site has no token yet" from "this site has a live
// token that something in the field is using".
diff --git a/web-ui/backend/static/app.css b/web-ui/backend/static/app.css
index 56e517b..628b2e1 100644
--- a/web-ui/backend/static/app.css
+++ b/web-ui/backend/static/app.css
@@ -1 +1 @@
-*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.-top-2{top:-.5rem}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-full{left:100%}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-14{top:3.5rem}.top-2{top:.5rem}.top-20{top:5rem}.top-4{top:1rem}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.-mb-px{margin-bottom:-1px}.-mt-0\.5{margin-top:-.125rem}.-mt-1{margin-top:-.25rem}.-mt-2{margin-top:-.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-\[600px\]{height:600px}.max-h-24{max-height:6rem}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.max-h-\[36rem\]{max-height:36rem}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-20{width:5rem}.w-4{width:1rem}.w-40{width:10rem}.w-44{width:11rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[16rem\]{min-width:16rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[150px\]{max-width:150px}.max-w-\[15rem\]{max-width:15rem}.max-w-\[180px\]{max-width:180px}.max-w-\[18rem\]{max-width:18rem}.max-w-\[200px\]{max-width:200px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-\[2\]{flex:2}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.translate-y-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-0{--tw-translate-y:0px}.translate-y-4{--tw-translate-y:1rem}.transform,.translate-y-4{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.self-end{align-self:flex-end}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-width:1px}.border-2{border-width:2px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-300{--tw-border-opacity:1;border-color:rgb(252 211 77/var(--tw-border-opacity,1))}.border-amber-500{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-emerald-200{--tw-border-opacity:1;border-color:rgb(167 243 208/var(--tw-border-opacity,1))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.border-gray-50{--tw-border-opacity:1;border-color:rgb(249 250 251/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-red-100{--tw-border-opacity:1;border-color:rgb(254 226 226/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-yellow-300{--tw-border-opacity:1;border-color:rgb(253 224 71/var(--tw-border-opacity,1))}.border-yellow-400{--tw-border-opacity:1;border-color:rgb(250 204 21/var(--tw-border-opacity,1))}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-l-amber-400{--tw-border-opacity:1;border-left-color:rgb(251 191 36/var(--tw-border-opacity,1))}.border-l-blue-300{--tw-border-opacity:1;border-left-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-l-red-500{--tw-border-opacity:1;border-left-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-t-gray-900{--tw-border-opacity:1;border-top-color:rgb(17 24 39/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-200{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-50\/40{background-color:rgba(255,251,235,.4)}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-cyan-600{--tw-bg-opacity:1;background-color:rgb(8 145 178/var(--tw-bg-opacity,1))}.bg-emerald-100{--tw-bg-opacity:1;background-color:rgb(209 250 229/var(--tw-bg-opacity,1))}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-50\/40{background-color:rgba(240,253,244,.4)}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-orange-50{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-purple-600{--tw-bg-opacity:1;background-color:rgb(147 51 234/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-50\/50{background-color:hsla(0,86%,97%,.5)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/60{background-color:hsla(0,0%,100%,.6)}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-600{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity,1))}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-75{--tw-bg-opacity:0.75}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-blue-50{--tw-gradient-from:#eff6ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(239,246,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-slate-50{--tw-gradient-from:#f8fafc var(--tw-gradient-from-position);--tw-gradient-to:rgba(248,250,252,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-50{--tw-gradient-to:#f9fafb var(--tw-gradient-to-position)}.to-green-600{--tw-gradient-to:#16a34a var(--tw-gradient-to-position)}.to-indigo-50{--tw-gradient-to:#eef2ff var(--tw-gradient-to-position)}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1\.5{padding-bottom:.375rem}.pb-4{padding-bottom:1rem}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pr-3{padding-right:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:JetBrains Mono,ui-monospace,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-amber-900{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-blue-900{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-emerald-900{--tw-text-opacity:1;color:rgb(6 78 59/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-orange-800{--tw-text-opacity:1;color:rgb(154 52 18/var(--tw-text-opacity,1))}.text-purple-500{--tw-text-opacity:1;color:rgb(168 85 247/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-red-900{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.text-yellow-900{--tw-text-opacity:1;color:rgb(113 63 18/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-blue-200{--tw-ring-opacity:1;--tw-ring-color:rgb(191 219 254/var(--tw-ring-opacity,1))}.ring-red-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 202 202/var(--tw-ring-opacity,1))}.ring-red-400{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:border:after{content:var(--tw-content);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:bg-amber-200:hover{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.hover\:bg-amber-700:hover{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.hover\:bg-blue-100:hover{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-200:hover{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-cyan-700:hover{--tw-bg-opacity:1;background-color:rgb(14 116 144/var(--tw-bg-opacity,1))}.hover\:bg-emerald-50:hover{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.hover\:bg-emerald-700:hover{--tw-bg-opacity:1;background-color:rgb(4 120 87/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.hover\:bg-purple-700:hover{--tw-bg-opacity:1;background-color:rgb(126 34 206/var(--tw-bg-opacity,1))}.hover\:bg-red-100:hover{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.hover\:bg-red-200:hover{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-yellow-700:hover{--tw-bg-opacity:1;background-color:rgb(161 98 7/var(--tw-bg-opacity,1))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-blue-900:hover{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-red-900:hover{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:border-transparent:focus{border-color:transparent}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-gray-900:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(17 24 39/var(--tw-ring-opacity,1))}.focus\:ring-green-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(34 197 94/var(--tw-ring-opacity,1))}.disabled\:text-gray-400:disabled{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:block{display:block}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:after\:border-white:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.peer:focus~.peer-focus\:ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.peer:focus~.peer-focus\:ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}@media (min-width:640px){.sm\:ml-8{margin-left:2rem}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:space-x-5>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.25rem*var(--tw-space-x-reverse));margin-left:calc(1.25rem*(1 - var(--tw-space-x-reverse)))}.sm\:p-8{padding:2rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-4{grid-column:span 4/span 4}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}
\ No newline at end of file
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.\!visible{visibility:visible!important}.visible{visibility:visible}.collapse{visibility:collapse}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.-top-2{top:-.5rem}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-full{left:100%}.right-0{right:0}.right-4{right:1rem}.top-0{top:0}.top-14{top:3.5rem}.top-2{top:.5rem}.top-20{top:5rem}.top-4{top:1rem}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.-mb-px{margin-bottom:-1px}.-mt-0\.5{margin-top:-.125rem}.-mt-1{margin-top:-.25rem}.-mt-2{margin-top:-.5rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-\[600px\]{height:600px}.max-h-24{max-height:6rem}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.max-h-\[36rem\]{max-height:36rem}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-20{width:5rem}.w-4{width:1rem}.w-40{width:10rem}.w-44{width:11rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-96{width:24rem}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[16rem\]{min-width:16rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[150px\]{max-width:150px}.max-w-\[15rem\]{max-width:15rem}.max-w-\[180px\]{max-width:180px}.max-w-\[18rem\]{max-width:18rem}.max-w-\[200px\]{max-width:200px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-\[2\]{flex:2}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.translate-y-0{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-0{--tw-translate-y:0px}.translate-y-4{--tw-translate-y:1rem}.transform,.translate-y-4{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-2{-moz-column-gap:.5rem;column-gap:.5rem}.gap-x-6{-moz-column-gap:1.5rem;column-gap:1.5rem}.gap-y-0\.5{row-gap:.125rem}.gap-y-1{row-gap:.25rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.25rem*var(--tw-space-x-reverse));margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-x-6>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.5rem*var(--tw-space-x-reverse));margin-left:calc(1.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-8>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(2rem*var(--tw-space-x-reverse));margin-left:calc(2rem*(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(243 244 246/var(--tw-divide-opacity,1))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.self-end{align-self:flex-end}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b-lg{border-bottom-right-radius:.5rem;border-bottom-left-radius:.5rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-width:1px}.border-2{border-width:2px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-300{--tw-border-opacity:1;border-color:rgb(252 211 77/var(--tw-border-opacity,1))}.border-amber-500{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-emerald-200{--tw-border-opacity:1;border-color:rgb(167 243 208/var(--tw-border-opacity,1))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.border-gray-50{--tw-border-opacity:1;border-color:rgb(249 250 251/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-red-100{--tw-border-opacity:1;border-color:rgb(254 226 226/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-yellow-300{--tw-border-opacity:1;border-color:rgb(253 224 71/var(--tw-border-opacity,1))}.border-yellow-400{--tw-border-opacity:1;border-color:rgb(250 204 21/var(--tw-border-opacity,1))}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-l-amber-400{--tw-border-opacity:1;border-left-color:rgb(251 191 36/var(--tw-border-opacity,1))}.border-l-blue-300{--tw-border-opacity:1;border-left-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-l-red-500{--tw-border-opacity:1;border-left-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-t-gray-900{--tw-border-opacity:1;border-top-color:rgb(17 24 39/var(--tw-border-opacity,1))}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-200{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-50\/40{background-color:rgba(255,251,235,.4)}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0/var(--tw-bg-opacity,1))}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-cyan-600{--tw-bg-opacity:1;background-color:rgb(8 145 178/var(--tw-bg-opacity,1))}.bg-emerald-100{--tw-bg-opacity:1;background-color:rgb(209 250 229/var(--tw-bg-opacity,1))}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-50\/40{background-color:rgba(240,253,244,.4)}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-orange-50{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-purple-600{--tw-bg-opacity:1;background-color:rgb(147 51 234/var(--tw-bg-opacity,1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-50\/50{background-color:hsla(0,86%,97%,.5)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/60{background-color:hsla(0,0%,100%,.6)}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-600{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity,1))}.bg-opacity-40{--tw-bg-opacity:0.4}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-opacity-75{--tw-bg-opacity:0.75}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-blue-50{--tw-gradient-from:#eff6ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(239,246,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-slate-50{--tw-gradient-from:#f8fafc var(--tw-gradient-from-position);--tw-gradient-to:rgba(248,250,252,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-50{--tw-gradient-to:#f9fafb var(--tw-gradient-to-position)}.to-green-600{--tw-gradient-to:#16a34a var(--tw-gradient-to-position)}.to-indigo-50{--tw-gradient-to:#eef2ff var(--tw-gradient-to-position)}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1\.5{padding-bottom:.375rem}.pb-4{padding-bottom:1rem}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pl-8{padding-left:2rem}.pr-3{padding-right:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:JetBrains Mono,ui-monospace,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-amber-900{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-blue-900{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-emerald-800{--tw-text-opacity:1;color:rgb(6 95 70/var(--tw-text-opacity,1))}.text-emerald-900{--tw-text-opacity:1;color:rgb(6 78 59/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-orange-800{--tw-text-opacity:1;color:rgb(154 52 18/var(--tw-text-opacity,1))}.text-purple-500{--tw-text-opacity:1;color:rgb(168 85 247/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-red-900{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.text-yellow-900{--tw-text-opacity:1;color:rgb(113 63 18/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-75{opacity:.75}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-blue-200{--tw-ring-opacity:1;--tw-ring-color:rgb(191 219 254/var(--tw-ring-opacity,1))}.ring-blue-300{--tw-ring-opacity:1;--tw-ring-color:rgb(147 197 253/var(--tw-ring-opacity,1))}.ring-red-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 202 202/var(--tw-ring-opacity,1))}.ring-red-400{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:border:after{content:var(--tw-content);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.hover\:bg-amber-200:hover{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.hover\:bg-amber-700:hover{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.hover\:bg-blue-100:hover{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-200:hover{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity,1))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-cyan-700:hover{--tw-bg-opacity:1;background-color:rgb(14 116 144/var(--tw-bg-opacity,1))}.hover\:bg-emerald-50:hover{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.hover\:bg-emerald-700:hover{--tw-bg-opacity:1;background-color:rgb(4 120 87/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-300:hover{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-500:hover{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.hover\:bg-gray-600:hover{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.hover\:bg-gray-700:hover{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:bg-green-700:hover{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.hover\:bg-indigo-700:hover{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.hover\:bg-purple-700:hover{--tw-bg-opacity:1;background-color:rgb(126 34 206/var(--tw-bg-opacity,1))}.hover\:bg-red-100:hover{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.hover\:bg-red-200:hover{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.hover\:bg-red-50:hover{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-yellow-700:hover{--tw-bg-opacity:1;background-color:rgb(161 98 7/var(--tw-bg-opacity,1))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-blue-900:hover{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-red-600:hover{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.hover\:text-red-900:hover{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:border-transparent:focus{border-color:transparent}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-gray-900:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(17 24 39/var(--tw-ring-opacity,1))}.focus\:ring-green-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(34 197 94/var(--tw-ring-opacity,1))}.disabled\:text-gray-400:disabled{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:block{display:block}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:after\:border-white:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.peer:focus~.peer-focus\:ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.peer:focus~.peer-focus\:ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}@media (min-width:640px){.sm\:ml-8{margin-left:2rem}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:space-x-5>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1.25rem*var(--tw-space-x-reverse));margin-left:calc(1.25rem*(1 - var(--tw-space-x-reverse)))}.sm\:p-8{padding:2rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-span-4{grid-column:span 4/span 4}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:1024px){.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}@media (min-width:1280px){.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}}
\ No newline at end of file
diff --git a/web-ui/backend/templates/admin.html b/web-ui/backend/templates/admin.html
index eb6f409..64c7b03 100644
--- a/web-ui/backend/templates/admin.html
+++ b/web-ui/backend/templates/admin.html
@@ -158,7 +158,7 @@ Users
:title="user.role === 'admin' ? 'Demote to user' : 'Promote to admin'">
- { if (ok) deleteUser(user.username) })"
class="px-2 py-1 text-red-500 hover:bg-red-50 rounded text-xs"
x-show="user.username !== currentUser"
title="Delete user">
@@ -823,9 +823,9 @@ Shutdown Sysmon Daemon
try {
const result = await apiCall('/api/admin/debug', { method: 'POST' });
this.commandOutput = result?.response || 'No output received';
- alert('Debug logging toggled. Response: ' + (result?.response || 'Success'));
+ uiAlert('Debug logging toggled. Response: ' + (result?.response || 'Success'));
} catch (e) {
- alert('Failed to toggle debug: ' + (e?.message || e));
+ uiAlert('Failed to toggle debug: ' + (e?.message || e));
}
},
@@ -833,23 +833,23 @@ Shutdown Sysmon Daemon
try {
const result = await apiCall('/api/admin/snmpd', { method: 'POST' });
this.commandOutput = result?.response || 'No output received';
- alert('SNMP debug logging toggled. Response: ' + (result?.response || 'Success'));
+ uiAlert('SNMP debug logging toggled. Response: ' + (result?.response || 'Success'));
} catch (e) {
- alert('Failed to toggle SNMP debug: ' + (e?.message || e));
+ uiAlert('Failed to toggle SNMP debug: ' + (e?.message || e));
}
},
async expireDNS() {
- if (!confirm('This will force re-resolution of all hostnames. Continue?')) {
+ if (!await uiConfirm('This will force re-resolution of all hostnames. Continue?')) {
return;
}
try {
const result = await apiCall('/api/admin/expiredns', { method: 'POST' });
this.commandOutput = result?.response || 'DNS cache expired';
- alert('DNS cache expired successfully');
+ uiAlert('DNS cache expired successfully');
} catch (e) {
- alert('Failed to expire DNS: ' + (e?.message || e));
+ uiAlert('Failed to expire DNS: ' + (e?.message || e));
}
},
@@ -859,7 +859,7 @@ Shutdown Sysmon Daemon
this.commandOutput = result?.output || 'No queue data received';
this.revealCommandOutput();
} catch (e) {
- alert('Failed to get queue status: ' + (e?.message || e));
+ uiAlert('Failed to get queue status: ' + (e?.message || e));
}
},
@@ -869,7 +869,7 @@ Shutdown Sysmon Daemon
this.commandOutput = result?.info || 'No FD info received';
this.revealCommandOutput();
} catch (e) {
- alert('Failed to get FD info: ' + (e?.message || e));
+ uiAlert('Failed to get FD info: ' + (e?.message || e));
}
},
@@ -884,20 +884,20 @@ Shutdown Sysmon Daemon
async killDaemon() {
if (this.killitText !== 'SHUTDOWN') {
- alert('You must type SHUTDOWN to confirm');
+ uiAlert('You must type SHUTDOWN to confirm');
return;
}
try {
const result = await apiCall('/api/admin/killit', { method: 'POST' });
this.commandOutput = result?.response || 'Shutdown initiated';
- alert('Daemon shutdown initiated. The web UI will stop working shortly.');
+ uiAlert('Daemon shutdown initiated. The web UI will stop working shortly.');
// Reset confirmation
this.killitConfirm = false;
this.killitText = '';
} catch (e) {
- alert('Failed to shutdown daemon: ' + (e?.message || e));
+ uiAlert('Failed to shutdown daemon: ' + (e?.message || e));
}
},
@@ -915,7 +915,7 @@ Shutdown Sysmon Daemon
async createUser() {
if (!this.newUser.username || !this.newUser.password) {
- alert('Username and password required');
+ uiAlert('Username and password required');
return;
}
try {
@@ -928,7 +928,7 @@ Shutdown Sysmon Daemon
this.showAddUser = false;
this.loadUsers();
} catch (e) {
- alert('Failed to create user: ' + e.message);
+ uiAlert('Failed to create user: ' + e.message);
}
},
@@ -937,20 +937,20 @@ Shutdown Sysmon Daemon
await apiCall('/api/auth/users/' + encodeURIComponent(username), { method: 'DELETE' });
this.loadUsers();
} catch (e) {
- alert('Failed to delete user: ' + e.message);
+ uiAlert('Failed to delete user: ' + e.message);
}
},
async resetPassword(username) {
- const pw = prompt('New password for ' + username + ':');
+ const pw = await uiPrompt('New password for ' + username + ':');
if (pw === null) return;
if (pw.length < 8) {
- alert('Password must be at least 8 characters.');
+ uiAlert('Password must be at least 8 characters.');
return;
}
- const confirm2 = prompt('Confirm new password:');
+ const confirm2 = await uiPrompt('Confirm new password:');
if (confirm2 !== pw) {
- alert('Passwords do not match.');
+ uiAlert('Passwords do not match.');
return;
}
try {
@@ -959,16 +959,16 @@ Shutdown Sysmon Daemon
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw })
});
- alert('Password updated for ' + username + '. Their existing sessions were revoked.');
+ uiAlert('Password updated for ' + username + '. Their existing sessions were revoked.');
} catch (e) {
- alert('Failed to reset password: ' + e.message);
+ uiAlert('Failed to reset password: ' + e.message);
}
},
async toggleRole(user) {
const newRole = user.role === 'admin' ? 'user' : 'admin';
const verb = newRole === 'admin' ? 'Promote' : 'Demote';
- if (!confirm(`${verb} ${user.username} to ${newRole}?`)) return;
+ if (!await uiConfirm(`${verb} ${user.username} to ${newRole}?`)) return;
try {
await apiCall('/api/auth/users/' + encodeURIComponent(user.username), {
method: 'PUT',
@@ -977,7 +977,7 @@ Shutdown Sysmon Daemon
});
this.loadUsers();
} catch (e) {
- alert('Failed to change role: ' + e.message);
+ uiAlert('Failed to change role: ' + e.message);
}
},
@@ -1099,7 +1099,7 @@ Shutdown Sysmon Daemon
},
async deleteFCM() {
- if (!confirm('Remove the stored FCM credentials? Android push will stop until you upload a new key.')) return;
+ if (!await uiConfirm('Remove the stored FCM credentials? Android push will stop until you upload a new key.')) return;
this.pushBusy = true;
try {
this.pushCfg = await apiCall('/api/settings/push/fcm-credentials', { method: 'DELETE' });
@@ -1130,7 +1130,7 @@ Shutdown Sysmon Daemon
},
async deleteAPNs() {
- if (!confirm('Remove the stored APNs certificate? iOS push will stop until you upload a new one.')) return;
+ if (!await uiConfirm('Remove the stored APNs certificate? iOS push will stop until you upload a new one.')) return;
this.pushBusy = true;
try {
this.pushCfg = await apiCall('/api/settings/push/apns', { method: 'DELETE' });
@@ -1165,7 +1165,7 @@ Shutdown Sysmon Daemon
async kickDevice(sub) {
const name = sub.label || 'this device';
- if (!confirm(`Remove ${name} (${sub.platform})?\n\nThis device will stop receiving push notifications immediately.`)) {
+ if (!await uiConfirm(`Remove ${name} (${sub.platform})?\n\nThis device will stop receiving push notifications immediately.`)) {
return;
}
try {
@@ -1174,7 +1174,7 @@ Shutdown Sysmon Daemon
});
this.pushSubs = this.pushSubs.filter(s => s.device_token !== sub.device_token);
} catch (e) {
- alert('Failed to remove device: ' + e.message);
+ uiAlert('Failed to remove device: ' + e.message);
}
},
diff --git a/web-ui/backend/templates/agents.html b/web-ui/backend/templates/agents.html
index 5d7564f..fe6cc11 100644
--- a/web-ui/backend/templates/agents.html
+++ b/web-ui/backend/templates/agents.html
@@ -98,6 +98,15 @@
with that file. Both are necessary.
+
+
+
+ Not a sysmond? The same token and CA serve an
+ alert-only peer : skip the
+ sysmon.conf lines and greet with
+ ALERTER <name> <token>
+ instead - see docs/ALERTERS.md .
+
@@ -268,10 +277,10 @@
// The token cannot be shown again, so closing an uncopied panel
// asks first. `copied` is the two-second button flash; copiedOnce
// is the fact that matters here.
- closeFresh() {
+ async closeFresh() {
if (!this.fresh.token) return;
if (!this.copiedOnce &&
- !confirm('You did not copy the config. The token in it cannot ' +
+ !await uiConfirm('You did not copy the config. The token in it cannot ' +
'be shown again.\n\nClose anyway?')) return;
this.fresh = {};
this.copiedOnce = false;
@@ -316,7 +325,7 @@
// and when it last reported.
async remint(a) {
const when = this.seen(a);
- if (!confirm('Mint a new token for ' + a.site + '?\n\n' +
+ if (!await uiConfirm('Mint a new token for ' + a.site + '?\n\n' +
'The box using the current one stops reporting until you put ' +
'the new token on it. It keeps monitoring and paging.\n\n' +
'Last seen: ' + when)) return;
@@ -325,7 +334,7 @@
},
async revoke(a) {
- if (!confirm('Revoke the token for ' + a.site + '?\n\n' +
+ if (!await uiConfirm('Revoke the token for ' + a.site + '?\n\n' +
'That box stops reporting here at once. It keeps monitoring ' +
'and paging.')) return;
try {
diff --git a/web-ui/backend/templates/base.html b/web-ui/backend/templates/base.html
index c261bc0..9e32235 100644
--- a/web-ui/backend/templates/base.html
+++ b/web-ui/backend/templates/base.html
@@ -302,6 +302,123 @@
function hostLabel(h) { return h.local_name || h.object_name || h.hostname; }
function hostSite(h) { return h.site && h.site !== 'local' ? h.site : ''; }
+ // In-page dialogs, replacing window.alert/confirm/prompt across
+ // the UI. The native ones look like the browser, not this page,
+ // and block the event loop; these draw a card styled like the
+ // rest of the UI and return promises:
+ // uiAlert(msg) resolves when dismissed
+ // uiConfirm(msg, opts) resolves true/false
+ // uiPrompt(msg, initial) resolves the string, or null on cancel
+ // opts: title, okText, cancelText, danger (red confirm button).
+ // Calls queue: a dialog opened while one is up waits its turn.
+ const uiDialog = (() => {
+ let chain = Promise.resolve();
+
+ function show(kind, message, opts) {
+ opts = opts || {};
+ return new Promise(resolve => {
+ const overlay = document.createElement('div');
+ overlay.className = 'fixed inset-0 z-50 bg-gray-900 bg-opacity-40 flex items-center justify-center p-4';
+
+ const card = document.createElement('div');
+ card.className = 'bg-white rounded-lg shadow-xl border border-gray-200 w-full max-w-md';
+ overlay.appendChild(card);
+
+ const title = document.createElement('div');
+ title.className = 'px-5 pt-4 text-base font-semibold text-gray-900';
+ title.textContent = opts.title ||
+ (kind === 'confirm' ? 'Please confirm' : kind === 'prompt' ? 'Input needed' : 'Notice');
+ card.appendChild(title);
+
+ const body = document.createElement('div');
+ body.className = 'px-5 py-3 text-sm text-gray-700 whitespace-pre-line break-words';
+ body.textContent = message == null ? '' : String(message);
+ card.appendChild(body);
+
+ let input = null;
+ if (kind === 'prompt') {
+ input = document.createElement('input');
+ input.type = 'text';
+ input.value = opts.value != null ? String(opts.value) : '';
+ input.placeholder = opts.placeholder || '';
+ input.className = 'mx-5 mb-2 block px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
+ input.style.width = 'calc(100% - 2.5rem)';
+ card.appendChild(input);
+ }
+
+ const footer = document.createElement('div');
+ footer.className = 'px-5 py-3 mt-2 bg-gray-50 rounded-b-lg flex justify-end gap-2';
+ card.appendChild(footer);
+
+ const cancelValue = kind === 'prompt' ? null : kind === 'confirm' ? false : undefined;
+ function close(result) {
+ document.removeEventListener('keydown', onKey, true);
+ overlay.remove();
+ resolve(result);
+ }
+
+ if (kind !== 'alert') {
+ const cancelBtn = document.createElement('button');
+ cancelBtn.type = 'button';
+ cancelBtn.textContent = opts.cancelText || 'Cancel';
+ cancelBtn.className = 'px-4 py-2 text-sm rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-100';
+ cancelBtn.addEventListener('click', () => close(cancelValue));
+ footer.appendChild(cancelBtn);
+ }
+
+ const okBtn = document.createElement('button');
+ okBtn.type = 'button';
+ okBtn.textContent = opts.okText || 'OK';
+ okBtn.className = 'px-4 py-2 text-sm rounded-lg text-white ' +
+ (opts.danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700');
+ okBtn.addEventListener('click', () =>
+ close(kind === 'prompt' ? input.value : kind === 'confirm' ? true : undefined));
+ footer.appendChild(okBtn);
+
+ // The backdrop is a cancel, like clicking away from a menu.
+ overlay.addEventListener('mousedown', e => {
+ if (e.target === overlay) close(cancelValue);
+ });
+
+ function onKey(e) {
+ if (e.key === 'Escape') {
+ e.preventDefault(); e.stopPropagation();
+ close(cancelValue);
+ } else if (e.key === 'Enter' && (kind !== 'prompt' || e.target === input)) {
+ e.preventDefault(); e.stopPropagation();
+ okBtn.click();
+ }
+ }
+ document.addEventListener('keydown', onKey, true);
+
+ document.body.appendChild(overlay);
+ (input || okBtn).focus();
+ if (input) input.select();
+ });
+ }
+
+ function queued(kind, message, opts) {
+ const run = chain.then(() => show(kind, message, opts));
+ chain = run.then(() => {}, () => {});
+ return run;
+ }
+
+ return {
+ alert: (m, o) => queued('alert', m, o),
+ confirm: (m, o) => queued('confirm', m, o),
+ prompt: (m, o) => queued('prompt', m, o),
+ };
+ })();
+ function uiAlert(message, opts) { return uiDialog.alert(message, opts); }
+ function uiConfirm(message, opts) { return uiDialog.confirm(message, opts); }
+ // Second argument mirrors native prompt(msg, initial); an options
+ // object also works.
+ function uiPrompt(message, valueOrOpts) {
+ const opts = (valueOrOpts !== null && typeof valueOrOpts === 'object')
+ ? valueOrOpts : { value: valueOrOpts };
+ return uiDialog.prompt(message, opts);
+ }
+
// The site filter as a query parameter, for pages that fetch.
function siteQuery(sep) {
const v = localStorage.getItem('sysmon-site') || '';
@@ -460,7 +577,7 @@
sessionStorage.clear();
window.location.href = '/login.html';
} else {
- alert('Logout failed: the server did not confirm the session was ended.');
+ uiAlert('Logout failed: the server did not confirm the session was ended.');
}
}
diff --git a/web-ui/backend/templates/config.html b/web-ui/backend/templates/config.html
index f4e4ed9..15950d2 100644
--- a/web-ui/backend/templates/config.html
+++ b/web-ui/backend/templates/config.html
@@ -4,8 +4,12 @@
{{define "content"}}
-
-
+
+
Editing
-
+
No connected sysmond
+ Choose a box…
@@ -63,7 +69,26 @@ No sysmond is connected
-
+
+
+
+
Which box?
+
+ More than one monitoring box is minted here, so this page will
+ not guess which one you mean. Pick the box to edit.
+
+
+
+
+
+
+
+
+
@@ -148,7 +173,8 @@
Configuration Conflict
Version:
-
Unsaved changes
+
+ Unsaved changes
@@ -208,17 +234,34 @@
Configuration Conflict
Configuration - Structured Editor
-
+
+
+
+ Unsaved changes
+
Reload
-
+
+
+
+
+
+
+
@@ -654,7 +697,7 @@
-
+
@@ -705,18 +748,25 @@
-
+
+
-
+
-
+
+ none
@@ -1361,6 +1411,7 @@ Other Advanced Options
// Empty means the local sysmon.conf.
site: localStorage.getItem('sysmon-site') || '',
sitePickerSites: [],
+ mintedCount: 0,
sitesLoaded: false,
remoteGeneration: 0,
pickerError: '',
@@ -1369,11 +1420,36 @@ Other Advanced Options
const s = this.sitePickerSites.find(x => x.site === this.site);
return s ? (s.description || s.site) : this.site;
},
- pickSite(v) { localStorage.setItem('sysmon-site', v); window.location.reload(); },
+ pickSite(v, el) {
+ const go = () => { localStorage.setItem('sysmon-site', v); window.location.reload(); };
+ if (this.structuredHasChanges || this.modified) {
+ this.confirmLeave(go).then(left => {
+ // Staying: snap the select back to the box being edited.
+ if (!left && el) el.value = this.site;
+ });
+ return;
+ }
+ go();
+ },
+
+ // The one question every way off this page funnels through,
+ // styled like the page. Resolves true when the user chose to go
+ // (and beforeunload is told not to ask a second time).
+ async confirmLeave(go) {
+ const ok = await uiConfirm(
+ 'You have unsaved changes in the config editor. Leave this page and throw them away?',
+ { title: 'Unsaved changes', okText: 'Discard and leave', cancelText: 'Stay here', danger: true });
+ if (ok) {
+ this.allowLeave = true;
+ go();
+ }
+ return ok;
+ },
async loadSitesForPicker() {
try {
const r = await (await authFetch('/api/sites')).json();
this.sitePickerSites = r.sites || [];
+ this.mintedCount = r.minted ?? this.sitePickerSites.length;
} catch (e) {
this.sitePickerSites = [];
this.pickerError = 'site list unavailable';
@@ -1384,10 +1460,13 @@ Other Advanced Options
this.sitePickerSites.push({ site: this.site,
description: this.site + ' (no longer in the fleet)', reachable: false });
}
- // No selection yet: default to the first connected site, but
- // do not write it to storage - the nav's "All sites" stays
- // what the operator chose for the status pages.
- if (!this.site && this.sitePickerSites.length) {
+ // No selection yet: take the box for granted only when just
+ // ONE is minted - minted, not connected, is what measures
+ // ambiguity. Three minted boxes with one online must still
+ // ask, or an edit lands on whichever box happened to be up.
+ // The auto-pick is not written to storage - the nav's "All
+ // sites" stays what the operator chose for the status pages.
+ if (!this.site && this.sitePickerSites.length && this.mintedCount <= 1) {
this.site = this.sitePickerSites[0].site;
}
this.sitesLoaded = true;
@@ -1399,6 +1478,13 @@ Other Advanced Options
originalStructuredConfig: null,
structuredHasChanges: false,
+ // Failed-save state: originalIndex -> "IP address and check type",
+ // plus the banner text. Live-updated as the user fixes hosts.
+ invalidHosts: {},
+ validationError: '',
+ // Set once the styled leave dialog got a "discard" answer, so
+ // beforeunload does not ask the same question again.
+ allowLeave: false,
// Raw editor state
activeTab: 'editor',
@@ -1460,6 +1546,10 @@ Other Advanced Options
// Sites first: the fetches below need to know which box.
await this.loadSitesForPicker();
if (!this.sitePickerSites.length) { this.loadingStructured = false; return; }
+ // Several boxes minted, none chosen: load nothing. Which box
+ // an edit is aimed at is a question, not a guess - pickSite
+ // reloads the page once it is answered.
+ if (!this.site) { this.loadingStructured = false; return; }
this.fetchConfigRaw();
this.loadConfigStructured();
@@ -1474,6 +1564,33 @@ Other Advanced Options
this.$watch('structuredConfig', () => {
this.checkStructuredChanges();
}, { deep: true });
+
+ // In-app navigation with unsaved edits gets a real modal
+ // instead of silent loss. Capture phase, so it runs before
+ // the browser follows the link; modifier-clicks open a new
+ // tab and lose nothing, so they pass through.
+ document.addEventListener('click', (e) => {
+ if (!(this.structuredHasChanges || this.modified)) return;
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.button !== 0) return;
+ const a = e.target.closest ? e.target.closest('a[href]') : null;
+ if (!a || a.target === '_blank') return;
+ const href = a.getAttribute('href');
+ if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
+ e.preventDefault();
+ e.stopPropagation();
+ this.confirmLeave(() => { window.location.href = a.href; });
+ }, true);
+
+ // Tab close and browser navigation cannot show a custom
+ // modal (the platform only allows its own prompt), so the
+ // generic one stays as the backstop - suppressed when the
+ // styled dialog already got a "discard" answer.
+ window.addEventListener('beforeunload', (e) => {
+ if (!this.allowLeave && (this.structuredHasChanges || this.modified)) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ });
},
checkStructuredChanges() {
@@ -1482,6 +1599,17 @@ Other Advanced Options
const current = JSON.stringify(this.structuredConfig);
const original = JSON.stringify(this.originalStructuredConfig);
this.structuredHasChanges = current !== original;
+
+ // While a failed save is on screen, keep its marks honest:
+ // fixing a host clears its red row immediately, and fixing
+ // the last one clears the banner - live feedback instead of
+ // save-and-see.
+ if (this.validationError) {
+ this.invalidHosts = this.findInvalidHosts();
+ if (Object.keys(this.invalidHosts).length === 0) {
+ this.validationError = '';
+ }
+ }
},
async onSwitchToRaw() {
@@ -1505,12 +1633,12 @@ Other Advanced Options
if (data.generation) this.remoteGeneration = data.generation;
this.modified = false;
} catch (e) {
- alert('Failed to load configuration: ' + (e?.message || e));
+ uiAlert('Failed to load configuration: ' + (e?.message || e));
}
},
async saveConfigRaw() {
- if (!confirm('Save configuration changes?')) return;
+ if (!await uiConfirm('Save configuration changes?')) return;
this.saving = true;
this.versionConflict = false;
@@ -1544,7 +1672,7 @@ Other Advanced Options
this.fetchBackups();
} catch (e) {
if (!this.versionConflict) {
- alert('Failed to save configuration: ' + e.message);
+ uiAlert('Failed to save configuration: ' + e.message);
}
} finally {
this.saving = false;
@@ -1552,7 +1680,7 @@ Other Advanced Options
},
async reloadRaw() {
- if (!confirm('Reload sysmon daemon with current configuration?')) return;
+ if (!await uiConfirm('Reload sysmon daemon with current configuration?')) return;
this.reloading = true;
try {
@@ -1562,7 +1690,7 @@ Other Advanced Options
}
this.showSuccess('Sysmon reloaded successfully');
} catch (e) {
- alert('Failed to reload sysmon: ' + (e?.message || e));
+ uiAlert('Failed to reload sysmon: ' + (e?.message || e));
} finally {
this.reloading = false;
}
@@ -1581,7 +1709,7 @@ Other Advanced Options
},
async restoreBackup(filename) {
- if (!confirm(`Restore backup ${filename}? This will replace the current configuration.`)) return;
+ if (!await uiConfirm(`Restore backup ${filename}? This will replace the current configuration.`)) return;
try {
const response = await authFetch(`/api/backups/${filename}/restore`, { method: 'POST' });
@@ -1591,7 +1719,7 @@ Other Advanced Options
this.showSuccess('Backup restored successfully');
await this.fetchConfigRaw();
} catch (e) {
- alert('Failed to restore backup: ' + e.message);
+ uiAlert('Failed to restore backup: ' + e.message);
}
},
@@ -1630,17 +1758,17 @@ Other Advanced Options
this.originalStructuredConfig = JSON.parse(JSON.stringify(this.structuredConfig));
this.structuredHasChanges = false;
} else {
- alert('Failed to load configuration');
+ uiAlert('Failed to load configuration');
}
} catch (e) {
console.error('Failed to load config:', e);
- alert('Error loading configuration: ' + e.message);
+ uiAlert('Error loading configuration: ' + e.message);
}
this.loadingStructured = false;
},
async reloadStructured() {
- if (confirm('Reload configuration from file? Unsaved changes will be lost.')) {
+ if (await uiConfirm('Reload configuration from file? Unsaved changes will be lost.')) {
await this.loadConfigStructured();
}
},
@@ -1678,30 +1806,66 @@ Other Advanced Options
window.location.href = '/login.html';
} else {
const error = await response.text();
- alert('Failed to save configuration: ' + error);
+ uiAlert('Failed to save configuration: ' + error);
}
} catch (e) {
console.error('Failed to save config:', e);
- alert('Error saving configuration: ' + e.message);
+ uiAlert('Error saving configuration: ' + e.message);
}
this.savingStructured = false;
},
+ // What a row is called in a message: the best identity it has.
+ hostRef(host, index) {
+ return host.hostname || host.ip || ('host #' + (index + 1));
+ },
+
+ // Recompute which hosts cannot be saved and why. Returns the
+ // invalid map; shared by save-time validation and the live
+ // re-check that clears marks as the user fixes rows.
+ findInvalidHosts() {
+ const bad = {};
+ (this.structuredConfig.hosts || []).forEach((host, index) => {
+ const missing = [];
+ if (!host.ip) missing.push('IP address');
+ if (!host.type) missing.push('check type');
+ if (missing.length) bad[index] = missing.join(' and ');
+ });
+ return bad;
+ },
+
validateConfigStructured() {
- // Validate hosts
- for (const host of this.structuredConfig.hosts) {
- if (!host.ip || !host.type) {
- alert('All hosts must have IP address and check type specified');
- return false;
- }
- // Hostname defaults to IP if not set (this is done on save/backend)
+ // Hostname defaults to IP if not set (done on save/backend),
+ // so only IP and check type can actually block a save.
+ const bad = this.findInvalidHosts();
+ this.invalidHosts = bad;
+ const indexes = Object.keys(bad);
+ if (indexes.length === 0) {
+ this.validationError = '';
+ return true;
}
- return true;
+ // Name the offenders, not just the rule. The rows are also
+ // sorted to the top of the list and tinted red.
+ const names = indexes.slice(0, 8).map(i =>
+ this.hostRef(this.structuredConfig.hosts[i], Number(i)) + ' (missing ' + bad[i] + ')');
+ const more = indexes.length > 8 ? ', and ' + (indexes.length - 8) + ' more' : '';
+ this.validationError = 'Not saved: ' + indexes.length +
+ (indexes.length === 1 ? ' host is' : ' hosts are') +
+ ' missing required fields - ' + names.join(', ') + more +
+ '. They are highlighted in red at the top of the host list; edit each to fill in what is missing.';
+ // Make sure the highlighted rows can actually be seen: open
+ // the section, drop any filter hiding them, and go there.
+ this.sections.hosts = true;
+ this.hostSearch = '';
+ this.$nextTick(() => {
+ document.getElementById('hosts-card')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ });
+ return false;
},
addSpawn() {
if (!this.newSpawn.name || !this.newSpawn.command) {
- alert('Spawn name and command are required');
+ uiAlert('Spawn name and command are required');
return;
}
@@ -1714,31 +1878,40 @@ Other Advanced Options
this.newSpawn.command = '';
},
- editSpawn(index) {
+ async editSpawn(index) {
const spawn = this.structuredConfig.spawns[index];
- const newName = prompt('Spawn name:', spawn.name);
+ const newName = await uiPrompt('Spawn name:', spawn.name);
if (newName === null) return;
- const newCommand = prompt('Spawn command:', spawn.command);
+ const newCommand = await uiPrompt('Spawn command:', spawn.command);
if (newCommand === null) return;
this.structuredConfig.spawns[index].name = newName;
this.structuredConfig.spawns[index].command = newCommand;
},
- deleteSpawn(index) {
- if (confirm('Delete this spawn command?')) {
+ async deleteSpawn(index) {
+ if (await uiConfirm('Delete this spawn command?')) {
this.structuredConfig.spawns.splice(index, 1);
}
},
+ // Invalid rows float to the top while a failed save is being
+ // fixed, so "highlighted in red at the top" is literally true.
+ sortInvalidFirst(list) {
+ if (Object.keys(this.invalidHosts).length === 0) return list;
+ return list.slice().sort((a, b) =>
+ (this.invalidHosts[b.originalIndex] ? 1 : 0) - (this.invalidHosts[a.originalIndex] ? 1 : 0));
+ },
+
get filteredHosts() {
if (!this.hostSearch || this.hostSearch.trim() === '') {
- return (this.structuredConfig.hosts || []).map((host, index) => ({ host, originalIndex: index }));
+ return this.sortInvalidFirst(
+ (this.structuredConfig.hosts || []).map((host, index) => ({ host, originalIndex: index })));
}
const search = this.hostSearch.toLowerCase().trim();
- return (this.structuredConfig.hosts || [])
+ return this.sortInvalidFirst((this.structuredConfig.hosts || [])
.map((host, index) => ({ host, originalIndex: index }))
.filter(({ host }) => {
const hostname = (host.hostname || '').toLowerCase();
@@ -1748,7 +1921,7 @@ Other Advanced Options
return hostname.includes(search) ||
ip.includes(search) ||
description.includes(search);
- });
+ }));
},
addHost() {
@@ -1839,8 +2012,11 @@ Other Advanced Options
},
saveHostModal() {
- if (!this.editingHost.ip || !this.editingHost.type) {
- alert('IP address and check type are required');
+ const missing = [];
+ if (!this.editingHost.ip) missing.push('an IP address');
+ if (!this.editingHost.type) missing.push('a check type');
+ if (missing.length) {
+ uiAlert('This host still needs ' + missing.join(' and ') + '.');
return;
}
@@ -1925,8 +2101,8 @@ Other Advanced Options
this.dependencySuggestions[hostIndex] = [];
},
- deleteHost(index) {
- if (confirm('Delete this host?')) {
+ async deleteHost(index) {
+ if (await uiConfirm('Delete this host?')) {
this.structuredConfig.hosts.splice(index, 1);
}
},
diff --git a/web-ui/backend/templates/dashboard.html b/web-ui/backend/templates/dashboard.html
index a541794..69c021f 100644
--- a/web-ui/backend/templates/dashboard.html
+++ b/web-ui/backend/templates/dashboard.html
@@ -385,7 +385,7 @@
this.poller.invalidate();
setTimeout(() => this.fetchData(), 1000);
} catch (e) {
- alert(`Failed to acknowledge: ${e.message}`);
+ uiAlert(`Failed to acknowledge: ${e.message}`);
}
},
@@ -396,7 +396,7 @@
this.poller.invalidate();
setTimeout(() => this.fetchData(), 1000);
} catch (e) {
- alert(`Failed to un-acknowledge: ${e.message}`);
+ uiAlert(`Failed to un-acknowledge: ${e.message}`);
}
},
@@ -429,7 +429,7 @@
this.poller.invalidate();
setTimeout(() => this.fetchData(), 1000);
} catch (e) {
- alert(`Failed to update host: ${e.message}`);
+ uiAlert(`Failed to update host: ${e.message}`);
}
},
@@ -449,7 +449,7 @@
navigator.clipboard.writeText(xmlContent).then(() => {
this.showNotification('XML copied to clipboard!');
}).catch(err => {
- alert('Failed to copy XML: ' + err.message);
+ uiAlert('Failed to copy XML: ' + err.message);
});
}
},
@@ -459,7 +459,7 @@
navigator.clipboard.writeText(responseText).then(() => {
this.showNotification(`Response '${command}' copied to clipboard!`);
}).catch(err => {
- alert('Failed to copy response: ' + err.message);
+ uiAlert('Failed to copy response: ' + err.message);
});
}
}
diff --git a/web-ui/backend/templates/fleet.html b/web-ui/backend/templates/fleet.html
index c435f50..1413d10 100644
--- a/web-ui/backend/templates/fleet.html
+++ b/web-ui/backend/templates/fleet.html
@@ -138,6 +138,58 @@