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'"> -