From 6a4a86a5a9a94e2e1ebd68f2c716dabab422e6a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 19:15:30 +0000 Subject: [PATCH 1/6] Answer the deep review: ordered delivery, honest acks, live revocation An external review of the merged alerter work found five blocking problems and a set of operability defects. This addresses all of them. Alert delivery is now serialized per identity, not per socket: the queue and its dispatcher belong to the alerter's name, created at first sight and fed by every connection that ever speaks for it. Two per-connection dispatchers could race a reconnect - an OK accepted on the new socket overtaking a CRITICAL still queued behind the old one, with the shared collapse key leaving the phones stuck on the stale CRITICAL - and every abandoned connection could strand another 64 queued alerts behind a blocked worker. A test reproduces exactly that sequence and asserts WARNING, CRITICAL, OK arrive in the order they were accepted across the reconnect. 333 ok no longer promises what the server cannot do. An alert with no delivery path - no push service, or push disabled - is refused with a 444 naming the reason, instead of being acknowledged and dropped where only the server log would ever know; the sender is the one party that can page some other way. ALERTERS.md now states the contract exactly: 333 means accepted for immediate, in-order delivery, not a durable receipt; the server keeps no alert on disk. Overlong protocol lines are likewise refused (444 line too long) rather than silently processed as a truncation of what the peer actually sent, on the handshake and every line after it. Revoking or re-minting a token now cuts the live connection, daemon or alerter, through the new DisconnectSite - authentication happens once at the greeting and the sockets are long-lived by design, so "revoked at the next connection" used to mean never for a peer that kept its socket up. The credential's type is also chosen at mint time now (the Add-a-box form has a Monitoring box / External alerter selector, the token table shows it, and the fresh-token panel shows an ALERTER greeting instead of sysmon.conf lines for alerter credentials), so a mistaken first greeting can no longer claim a token's kind forever. The credential store fails closed: SetAgentLabel, ClaimAgentKind and CheckAgentToken return errors instead of swallowing bolt failures, the authenticator refuses when its store cannot answer, the kind claim refuses rather than admitting a peer whose binding never stuck, and the nickname endpoint reports a failed write instead of confirming it. A paused sysmond now honors SIGTERM: the pause loop tested only paused and gotsighup, so a service stop during a pause hung until the supervisor escalated to SIGKILL - skipping the graceful state save. The drop path also clears supplementary groups (setgroups before setgid/setuid), which the helper probe's group check already assumed. The systemd unit prepares every path the www-data process cannot create - the backup directory and the audit log alongside the socket directory - since the binary's own root-time preparation never runs under User=www-data; the ReadWritePaths carve-out narrows from all of /var/log to the one audit file. And -foreground now logs to stderr, so the production unit gets warnings and errors in the journal instead of io.Discard; -debug is extra detail, not the price of having logs. Smaller corrections from the same review: the push log records alerter events under source:object so two alerters reporting the same object name stay distinguishable; the Fleet alerter card shows since when or how long gone, strips the ephemeral source port from the address (full value in the tooltip), and hides the rename pencil from non-admins who would only 403; the navigation collapses to the hamburger up to the lg breakpoint instead of hiding overflow links behind an invisible scrollbar; the iOS host row ellipsizes long names with layout priority so the site tag survives; and the ALERTERS.md Python example verifies the certificate hostname and reads the token from the environment instead of source. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NpxQtrW7f2YaKiYL6evoTZ --- docs/ALERTERS.md | 62 +++-- docs/WEB_DEPLOYMENT.md | 28 +- ios/Sysmon/MainView.swift | 6 + ios/Sysmon/Theme.swift | 7 +- src/syswatch.c | 8 +- web-ui/backend/cmd/sysmon-web/main.go | 21 +- web-ui/backend/internal/api/confdist.go | 60 ++++- web-ui/backend/internal/api/router.go | 8 +- web-ui/backend/internal/monitoring/agent.go | 60 ++++- .../backend/internal/monitoring/alerters.go | 126 ++++++--- .../internal/monitoring/alerters_test.go | 249 ++++++++++++++++-- web-ui/backend/internal/monitoring/service.go | 3 + web-ui/backend/internal/push/service.go | 18 +- web-ui/backend/internal/settings/agents.go | 62 +++-- web-ui/backend/static/app.css | 2 +- web-ui/backend/templates/agents.html | 70 +++-- web-ui/backend/templates/base.html | 6 +- web-ui/backend/templates/fleet.html | 25 +- web-ui/sysmon-web.service | 21 +- 19 files changed, 694 insertions(+), 148 deletions(-) diff --git a/docs/ALERTERS.md b/docs/ALERTERS.md index 36f55a3..d5a988d 100644 --- a/docs/ALERTERS.md +++ b/docs/ALERTERS.md @@ -14,14 +14,20 @@ 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`. +**Admin -> Monitoring boxes -> Add a box**, with the credential type +set to **External alerter** - the panel then shows the greeting line +below instead of sysmond config. Mint the 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. +The type is part of the credential: a token minted for an alerter is +refused if something greets with it as a sysmond, and the other way +around. + +Revoking the token on the same page cuts the alerter off immediately - +the live connection is closed and the next attempt is refused. ## Connecting @@ -40,9 +46,10 @@ connection attempt. ## 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). +line may carry at most 4096 bytes; a longer line is refused with +`444 line too long` rather than processed as something shorter than +what was sent (the connection survives). Every reply is one line +starting `333 ` (success) or `444 ` (refusal). ### Handshake (first line, within 20 seconds of connecting) @@ -78,6 +85,19 @@ does the talking. - `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. +- `444 push delivery is disabled ...` means the server currently has + nowhere to send alerts (push switched off, or never configured). The + alert was not accepted; page some other way or retry later. + +What `333 ok` promises, exactly: the alert was accepted for immediate +delivery through the server's push pipeline, in order with everything +else this alerter has sent. It is **not** a durable receipt - alerts +are not written to disk, so what was queued but not yet sent when the +server dies is lost. Anything that cannot be refused (an alert the +server accepted and then failed to deliver because a provider was +down) appears in the server log and the admin Push Log. If an alert +matters enough to survive that, keep it on your side until the +condition clears and re-send OK/CRITICAL transitions as they happen. Semantics, identical to a sysmond's transitions: @@ -89,8 +109,8 @@ Semantics, identical to a sysmond's transitions: 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. + while push is disabled are refused with `444 push delivery is + disabled ...`, never silently dropped. ### Keepalive and goodbye @@ -143,17 +163,25 @@ to be a monitored host on a sysmond, not an alerter. ## Example: Python ```python -import socket, ssl, time +import os, socket, ssl, time HOST, PORT = "sysmon-web.example.net", 1347 -NAME, TOKEN = "backupd", "tok-abc123..." - +NAME = "backupd" +# A credential stays out of source and argv: a mode-0600 file or the +# environment of the service unit that runs this. +TOKEN = os.environ["SYSMON_TOKEN"] + +# Verify the certificate AND its name. sysmon-web puts the names the +# daemons dial it by into its generated certificate (the -agent-names +# flag); start it with the name you use here in that list. Only fall +# back to ctx.check_hostname = False against an old certificate that +# carries no usable name - it weakens the check to "any holder of a +# CA-signed cert", so regenerate the certificate instead if you can. 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) + tls = ctx.wrap_socket(raw, server_hostname=HOST) 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"): diff --git a/docs/WEB_DEPLOYMENT.md b/docs/WEB_DEPLOYMENT.md index 589d301..e80982e 100644 --- a/docs/WEB_DEPLOYMENT.md +++ b/docs/WEB_DEPLOYMENT.md @@ -22,14 +22,15 @@ returns your shell prompt. | Invocation | Behaviour | |---|---| -| `sysmon-web …` | Daemonizes, **silent** (no logs). | +| `sysmon-web …` | Daemonizes, **silent** (nothing is watching stderr). | | `sysmon-web -debug …` | Stays in the foreground, logs to **stderr**. Use this to find out why something won't start. | -| `sysmon-web -foreground …` | Stays in the foreground, still silent. For process supervisors that track the PID themselves (systemd `Type=simple`, OpenBSD `rc.d`). Add `-debug` to also get logs. | +| `sysmon-web -foreground …` | Stays in the foreground, logs to **stderr**. For process supervisors that track the PID themselves (systemd `Type=simple`, OpenBSD `rc.d`) - normal warnings and errors land in the journal / rc log, where an operator can find them. | -Logs are **off unless `-debug`** is given - a daemon shouldn't chatter. -If the service won't come up, the move is always: stop it, run it once -in the foreground with `-debug`, read the error, fix, restart under the -supervisor. +Under a supervisor, ordinary logs are always on: a monitoring server +that drops a page must not also drop the log line saying so. Only the +self-daemonized mode is silent, because its stderr goes nowhere. If +the service won't come up, run it once with `-debug`, read the error, +fix, restart under the supervisor. > Under a supervisor you almost always want `-foreground`. If you let it > self-daemonize under `Type=simple`, systemd sees the parent exit @@ -90,10 +91,13 @@ Use the shipped unit (`web-ui/sysmon-web.service`); the important bits: Type=simple User=www-data Group=www-data -# www-data can't mkdir under root-owned /var/www, so create the socket -# dir as root first (the "+" runs these as root despite User=www-data). +# The binary prepares its own directories only when it starts as root; +# under User=www-data it never does, so the unit prepares every path +# the unprivileged process cannot create (the "+" runs these as root). ExecStartPre=+/bin/mkdir -p /var/www/run ExecStartPre=+/bin/chown www-data:www-data /var/www/run +ExecStartPre=+/usr/bin/install -d -o www-data -g www-data /var/backups/sysmon +ExecStartPre=+/bin/sh -c 'touch /var/log/sysmon-web-audit.log && chown www-data:www-data /var/log/sysmon-web-audit.log' ExecStart=/usr/local/bin/sysmon-web \ -foreground \ -socket /var/www/run/sysmon-web.sock \ @@ -105,9 +109,11 @@ Restart=always Because it runs as `www-data`, the socket is already owned by nginx's user - no `-socket-*` flags needed. The `ExecStartPre` lines create -`/var/www/run` (which `ProtectSystem=strict` also lists in -`ReadWritePaths`). Add `-debug` to the `ExecStart` line temporarily to -get logs in the journal (`journalctl -u sysmon-web -f`). +the socket directory, the backup directory, and the audit log, all +owned by `www-data` (`/var/lib/sysmon` is `StateDirectory=`, which +systemd itself prepares). With `-foreground`, normal logs already go +to the journal (`journalctl -u sysmon-web -f`); `-debug` adds verbose +diagnostics on top. ```sh cp web-ui/sysmon-web.service /etc/systemd/system/ diff --git a/ios/Sysmon/MainView.swift b/ios/Sysmon/MainView.swift index b420b27..2289165 100644 --- a/ios/Sysmon/MainView.swift +++ b/ios/Sysmon/MainView.swift @@ -266,9 +266,15 @@ struct HostRow: View { StatusDot(status: host.overallStatus, pulse: host.isDown && !host.isPaused) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { + // A long hostname ellipsizes rather than wrapping or + // squeezing the tags out of the row; layoutPriority + // makes the badges yield space before the name does. Text(host.hostname) .font(.system(size: 15, weight: .semibold)) .foregroundColor(Theme.ink) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) if !host.siteTag.isEmpty { SiteTag(name: host.siteTag) } diff --git a/ios/Sysmon/Theme.swift b/ios/Sysmon/Theme.swift index cb05c82..43628c9 100644 --- a/ios/Sysmon/Theme.swift +++ b/ios/Sysmon/Theme.swift @@ -81,10 +81,15 @@ struct SiteTag: View { Text(name) .font(.system(size: 9, design: .monospaced)) .foregroundColor(Theme.subtle) + .lineLimit(1) + // Context, not the subject: rows give the name layout + // priority, so when space runs out it is this tag that + // shrinks - truncating in the middle keeps both ends of a + // long site name readable. + .truncationMode(.middle) .padding(.horizontal, 5) .padding(.vertical, 2) .background(Capsule().fill(Theme.surfaceSubtle)) - .lineLimit(1) } } diff --git a/src/syswatch.c b/src/syswatch.c index cc5c88a..e85cafd 100644 --- a/src/syswatch.c +++ b/src/syswatch.c @@ -2033,8 +2033,12 @@ do_watch(char *cmdname, int listenport, char *myhostname) statuschanged = FALSE; } - /* */ - while (paused && (!gotsighup)) + /* stop_daemon must break this loop too: SIGTERM during a + pause otherwise sets the flag and then waits for an unpause + or a SIGHUP that may never come, and the supervisor's + graceful stop times out into a SIGKILL - skipping the very + state save a graceful stop exists for. */ + while (paused && !gotsighup && !stop_daemon) { time(&now_t); service_checks(now_t); diff --git a/web-ui/backend/cmd/sysmon-web/main.go b/web-ui/backend/cmd/sysmon-web/main.go index aa165e6..a862fe7 100644 --- a/web-ui/backend/cmd/sysmon-web/main.go +++ b/web-ui/backend/cmd/sysmon-web/main.go @@ -227,10 +227,17 @@ func main() { // Logging destination: // -debug -> stderr, verbose, stays (foreground). + // -foreground -> stderr. A supervisor is watching, so + // normal warnings and errors belong in + // the journal / rc log - a monitoring + // server that silently drops pages must + // not also silently drop the log line + // saying so. -debug is extra detail, + // not the price of having logs at all. // daemon child (startup) -> the diag pipe, so the parent can relay // a startup failure; signalReady() later // silences us for the rest of the run. - // everything else -> discard (quiet). + // self-daemonized -> discard (nothing is watching stderr). switch { case *debug: // leave log going to stderr @@ -245,6 +252,8 @@ func main() { } else { log.SetOutput(io.Discard) } + case *foreground: + // leave log going to stderr default: log.SetOutput(io.Discard) } @@ -526,7 +535,15 @@ func main() { al, aerr := monitoring.ListenForAgents(*agentListen, certFile, keyFile, monitoringService, func(site, token, addr string) bool { - return settingsStore.CheckAgentToken(site, token, addr) + ok, err := settingsStore.CheckAgentToken(site, token, addr) + if err != nil { + // Fail closed: an authenticator whose store is + // broken refuses, and says so where the operator + // can find it. + log.Printf("agents: token check for %s failed: %v", site, err) + return false + } + return ok }) if aerr != nil { log.Printf("WARNING: %v", aerr) diff --git a/web-ui/backend/internal/api/confdist.go b/web-ui/backend/internal/api/confdist.go index fa26911..97cb6f6 100644 --- a/web-ui/backend/internal/api/confdist.go +++ b/web-ui/backend/internal/api/confdist.go @@ -353,6 +353,11 @@ func (r *Router) handleAgentTokens(w http.ResponseWriter, req *http.Request) { var body struct { Site string `json:"site"` Label string `json:"label"` + // Kind decides at mint time whether this credential belongs + // to a monitoring box or an alert-only peer, instead of + // letting whoever holds it decide with their first greeting. + // Empty means sysmond, which keeps every existing caller. + Kind string `json:"kind"` // Replace has to be asked for. One token per site, so minting // again for a site that already has one silently stops the box // holding the old token - it keeps monitoring and paging, and @@ -387,22 +392,50 @@ func (r *Router) handleAgentTokens(w http.ResponseWriter, req *http.Request) { return } + kind := body.Kind + switch kind { + case "": + kind = settings.KindSysmond + case settings.KindSysmond, settings.KindAlerter: + default: + r.sendError(w, http.StatusBadRequest, "kind must be sysmond or alerter") + return + } + token, err := r.settings.NewAgentToken(body.Site, body.Label) if err != nil { r.sendError(w, http.StatusInternalServerError, err.Error()) return } - // The whole config block, not just the token. The person doing - // this is setting up a box, and the next thing they need is the - // text that goes in its config - with the right server name and - // port in it, which only this process knows. - r.sendJSON(w, map[string]interface{}{ + // Recorded now, not at first handshake: a mistaken first greeting + // must not get to claim the credential's type forever. + r.settings.SetAgentKind(body.Site, kind) + // A replaced token's current holder is cut off now. Waiting for + // its next reconnect means never for a link that stays up. + if body.Replace && r.monitoring != nil { + r.monitoring.DisconnectSite(body.Site) + } + + resp := map[string]interface{}{ "site": body.Site, "token": token, - "config": monitoring.AgentConfigBlock(body.Site, body.Label, - monitoring.AgentDialTarget(), token), - "note": "copy this now - it is stored hashed and cannot be shown again", - }) + "kind": kind, + "note": "copy this now - it is stored hashed and cannot be shown again", + } + if kind == settings.KindAlerter { + // An alerter needs the greeting line and the CA, not a + // sysmon.conf block. + resp["greeting"] = "ALERTER " + body.Site + " " + token + " [application name...]" + resp["dial"] = monitoring.AgentDialTarget() + } else { + // The whole config block, not just the token. The person + // doing this is setting up a box, and the next thing they + // need is the text that goes in its config - with the right + // server name and port in it, which only this process knows. + resp["config"] = monitoring.AgentConfigBlock(body.Site, body.Label, + monitoring.AgentDialTarget(), token) + } + r.sendJSON(w, resp) default: r.sendError(w, http.StatusMethodNotAllowed, "GET or POST") @@ -431,7 +464,14 @@ func (r *Router) handleAgentRevoke(w http.ResponseWriter, req *http.Request) { r.sendError(w, http.StatusInternalServerError, err.Error()) return } - r.sendJSON(w, map[string]interface{}{"site": site, "revoked": true}) + // The revoked credential's live connection dies with it. Revocation + // that only bites at the next reconnect never bites a peer that + // keeps its socket up. + disconnected := false + if r.monitoring != nil { + disconnected = r.monitoring.DisconnectSite(site) + } + r.sendJSON(w, map[string]interface{}{"site": site, "revoked": true, "disconnected": disconnected}) } // GET /api/settings/agents/ca diff --git a/web-ui/backend/internal/api/router.go b/web-ui/backend/internal/api/router.go index 370d77e..aab2c28 100644 --- a/web-ui/backend/internal/api/router.go +++ b/web-ui/backend/internal/api/router.go @@ -81,6 +81,7 @@ func NewRouter(cfg *config.Service, mon *monitoring.Service, pushSvc *push.Servi // service is hot-swapped (see reconfigurePush). if pushSvc != nil { mon.SetAlertSink(pushSvc.ExternalAlert) + mon.SetAlertGate(pushSvc.DeliveryGate) } // Configuration endpoints (admin only - config contains secrets) @@ -771,7 +772,11 @@ func (r *Router) handleAlerterNickname(w http.ResponseWriter, req *http.Request) return } body.Nickname = monitoring.TruncateRunes(body.Nickname, 128) - r.settings.SetAgentLabel(body.Name, body.Nickname) + if err := r.settings.SetAgentLabel(body.Name, body.Nickname); err != nil { + // Never confirm a rename the disk refused. + r.sendError(w, http.StatusInternalServerError, "could not store the nickname: "+err.Error()) + return + } r.sendJSON(w, map[string]string{"name": body.Name, "nickname": body.Nickname}) } @@ -2395,6 +2400,7 @@ func (r *Router) reconfigurePush(pc settings.PushConfig) bool { // the dead service (harmlessly - a stopped service drops them - but // silently). r.monitoring.SetAlertSink(svc.ExternalAlert) + r.monitoring.SetAlertGate(svc.DeliveryGate) return true } diff --git a/web-ui/backend/internal/monitoring/agent.go b/web-ui/backend/internal/monitoring/agent.go index 36b3239..0eb562a 100644 --- a/web-ui/backend/internal/monitoring/agent.go +++ b/web-ui/backend/internal/monitoring/agent.go @@ -113,11 +113,17 @@ func (a *AgentListener) handshake(conn net.Conn) { // 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) + line, truncated, err := readLineBounded(reader) if err != nil { conn.Close() return } + if truncated { + fmt.Fprintf(conn, "444 line too long - maximum %d bytes\r\n", maxLineBytes) + conn.Close() + log.Printf("agents: %s sent an overlong greeting", remote) + return + } fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 || (fields[0] != "HELLO" && fields[0] != "ALERTER") { @@ -225,12 +231,62 @@ func (s *Service) claimKind(site, kind string) string { if st == nil { return "" } - if owner := st.ClaimAgentKind(site, kind); owner != "" { + owner, err := st.ClaimAgentKind(site, kind) + if err != nil { + // Fail closed: a peer admitted while its kind claim silently + // failed to stick is a peer of no recorded kind - the exact + // state this check exists to prevent. + log.Printf("agents: kind claim for %s failed: %v", site, err) + return "temporary credential-store failure - try again" + } + if owner != "" { return "this token belongs to a " + owner } return "" } +// DisconnectSite closes whatever live connection the named identity +// holds - daemon or alerter - and reports whether one was closed. +// +// Authentication happens once, at the greeting, and the connection is +// long-lived by design; without this, revoking or re-minting a token +// changes nothing for a peer that simply never hangs up. The revoke and +// re-mint paths call it after the store is updated, so the next +// connection attempt fails authentication and the current one dies now. +// (A CLI revoke runs in its own process and cannot reach in here; the +// running server still cuts the link at its next protocol exchange +// failure or restart.) +func (s *Service) DisconnectSite(site string) bool { + closed := false + + s.fleetMu.Lock() + for _, d := range s.daemons { + d.mu.Lock() + if d.site == site && d.conn != nil { + d.conn.Close() + closed = true + } + d.mu.Unlock() + } + s.fleetMu.Unlock() + + s.alertersMu.Lock() + if a, ok := s.alerters[site]; ok { + a.mu.Lock() + if a.conn != nil && a.info.Connected { + a.conn.Close() + closed = true + } + a.mu.Unlock() + } + s.alertersMu.Unlock() + + if closed { + log.Printf("agents: disconnected %s (token revoked or replaced)", site) + } + return closed +} + // 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 index fad624e..97bceb9 100644 --- a/web-ui/backend/internal/monitoring/alerters.go +++ b/web-ui/backend/internal/monitoring/alerters.go @@ -78,6 +78,16 @@ type alerter struct { mu sync.Mutex info AlerterInfo conn net.Conn + // pending is this identity's one delivery queue, drained by one + // goroutine for the life of the process. It belongs to the NAME, + // not the socket: when the alerter reconnects, the replacement + // connection feeds the same queue, so an OK sent after a reconnect + // can never overtake a CRITICAL accepted before it - with one + // queue and one dispatcher per socket, two dispatchers raced and + // the collapse key could leave the phone stuck on the stale state. + // It also caps the identity at one queue's worth of backlog, where + // per-connection queues let every reconnect abandon another 64. + pending chan pendingAlert } // pendingAlert is one parsed alert waiting on the push pipeline. @@ -107,20 +117,46 @@ func (s *Service) alerterDisplayName(a *alerter) string { // 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. +// service is hot-swapped. func (s *Service) SetAlertSink(fn func(source, display, object, status, text string)) { s.alertSinkMu.Lock() s.alertSink = fn s.alertSinkMu.Unlock() } +// SetAlertGate names a function that says whether an alert accepted now +// actually has somewhere to go - "" for yes, else the refusal reason. +// Without it (or without a sink at all), ALERT lines are refused rather +// than acknowledged into the void: for a paging interface, "444 " +// beats a 333 for a page that was never going to be sent. +func (s *Service) SetAlertGate(fn func() string) { + s.alertSinkMu.Lock() + s.alertGate = 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 } +// alertRefusal is why an ALERT cannot be accepted right now, or "". +func (s *Service) alertRefusal() string { + s.alertSinkMu.Lock() + sink, gate := s.alertSink, s.alertGate + s.alertSinkMu.Unlock() + if sink == nil { + return "no push delivery is configured on this server" + } + if gate != nil { + if reason := gate(); reason != "" { + return reason + } + } + return "" +} + // 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. @@ -139,14 +175,16 @@ func (s *Service) Alerters() []AlerterInfo { // 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) { +// never buffered, and the caller is told it happened. One hostile line +// costs at most its own truncation, not the server's memory; and a +// protocol line the server did not read in full must be refused, not +// silently processed as something shorter than what was sent. +func readLineBounded(r *bufio.Reader) (line string, truncated bool, err error) { var buf []byte for { - chunk, isPrefix, err := r.ReadLine() - if err != nil { - return "", err + chunk, isPrefix, rerr := r.ReadLine() + if rerr != nil { + return "", false, rerr } if len(buf) < maxLineBytes { take := maxLineBytes - len(buf) @@ -154,9 +192,14 @@ func readLineBounded(r *bufio.Reader) (string, error) { take = len(chunk) } buf = append(buf, chunk[:take]...) + if take < len(chunk) { + truncated = true + } + } else if len(chunk) > 0 { + truncated = true } if !isPrefix { - return string(buf), nil + return string(buf), truncated, nil } } } @@ -180,26 +223,13 @@ func readLineBounded(r *bufio.Reader) (string, error) { // 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. +// page. The queue and its dispatcher belong to the alerter's identity +// (see registerAlerter), so a reconnect keeps one ordered stream. 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 @@ -216,10 +246,17 @@ func (s *Service) runAlerter(name, application, remote string, conn net.Conn, re }() for { - line, err := readLineBounded(reader) + line, truncated, err := readLineBounded(reader) if err != nil { return } + if truncated { + // The server did not read what the peer sent; processing + // the readable prefix would silently accept a different + // message. Refuse it and keep the connection. + fmt.Fprintf(conn, "444 line too long - maximum %d bytes\r\n", maxLineBytes) + continue + } line = strings.TrimSpace(line) if line == "" { continue @@ -241,7 +278,7 @@ func (s *Service) runAlerter(name, application, remote string, conn net.Conn, re fmt.Fprintf(conn, "333 bye\r\n") return case "ALERT": - if msg := s.handleAlertLine(a, name, line, pending); msg == "" { + if msg := s.handleAlertLine(a, name, line); msg == "" { fmt.Fprintf(conn, "333 ok\r\n") } else { fmt.Fprintf(conn, "444 %s\r\n", msg) @@ -254,7 +291,7 @@ func (s *Service) runAlerter(name, application, remote string, conn net.Conn, re // 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 { +func (s *Service) handleAlertLine(a *alerter, name, line string) string { fields := strings.SplitN(line, " ", 4) if len(fields) < 3 { return "usage: ALERT " @@ -277,6 +314,13 @@ func (s *Service) handleAlertLine(a *alerter, name, line string, pending chan<- text = fmt.Sprintf("%s reports %s %s", name, object, status) } + // An alert with no delivery path is refused, not acknowledged into + // the void - the sender is the one party that can do something + // about it (log locally, page some other way). + if reason := s.alertRefusal(); reason != "" { + return reason + " - alert not accepted" + } + p := pendingAlert{ source: name, display: s.alerterDisplayName(a), @@ -285,7 +329,7 @@ func (s *Service) handleAlertLine(a *alerter, name, line string, pending chan<- text: text, } select { - case pending <- p: + case a.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 @@ -310,7 +354,10 @@ func (s *Service) handleAlertLine(a *alerter, name, line string, pending chan<- // 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. +// same rule adoptAgent applies to daemons. The first sight of a name +// also starts its dispatcher: one goroutine per identity, for the life +// of the process, so every connection that ever speaks for this name +// feeds one ordered queue. func (s *Service) registerAlerter(name, application, remote string, conn net.Conn) *alerter { s.alertersMu.Lock() defer s.alertersMu.Unlock() @@ -331,7 +378,8 @@ func (s *Service) registerAlerter(name, application, remote string, conn net.Con return old } a := &alerter{ - conn: conn, + conn: conn, + pending: make(chan pendingAlert, alertQueueDepth), info: AlerterInfo{ Name: name, Application: application, @@ -341,5 +389,23 @@ func (s *Service) registerAlerter(name, application, remote string, conn net.Con }, } s.alerters[name] = a + go s.dispatchAlerts(a) return a } + +// dispatchAlerts is an alerter identity's one delivery worker. It never +// exits: the registry keeps the record (and this goroutine) for the +// life of the process, and the count of identities is bounded by the +// count of minted tokens. +func (s *Service) dispatchAlerts(a *alerter) { + for p := range a.pending { + if sink := s.alertSinkFn(); sink != nil { + sink(p.source, p.display, p.object, p.status, p.text) + } else { + // Only reachable if the sink was unset between accept and + // dispatch; the accept-time gate refuses the common case. + log.Printf("agents: alerter %s sent %s %s with no push service configured - dropped", + p.source, p.status, p.object) + } + } +} diff --git a/web-ui/backend/internal/monitoring/alerters_test.go b/web-ui/backend/internal/monitoring/alerters_test.go index 17dcb0d..a398bdf 100644 --- a/web-ui/backend/internal/monitoring/alerters_test.go +++ b/web-ui/backend/internal/monitoring/alerters_test.go @@ -155,8 +155,8 @@ func TestAlerterSession(t *testing.T) { // 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) + if got, err := store.ClaimAgentKind("backupd", settings.KindAlerter); got != "" || err != nil { + t.Errorf("ClaimAgentKind refused a fresh token: %q, %v", got, err) } tokens, err := store.ListAgentTokens() if err != nil || len(tokens) != 1 { @@ -251,30 +251,26 @@ func TestAlerterOverlongLine(t *testing.T) { }() 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) + if err != nil || !strings.HasPrefix(strings.TrimSpace(reply), "444 line too long") { + t.Fatalf("overlong ALERT = %q, %v, want a 444 line too long refusal", strings.TrimSpace(reply), err) } // The line after the flood still parses - nothing of the overflow - // leaked into the next read. + // leaked into the next read, and the refusal did not cost the + // connection. 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) } + // Nothing of the refused line reached delivery: the server must not + // silently accept a different message than the sender transmitted. + mu.Lock() + if len(texts) != 0 { + t.Errorf("sink saw %d alerts from a refused line, want 0", len(texts)) + } + mu.Unlock() client.Close() <-done } @@ -446,3 +442,222 @@ func TestClaimKindConcurrentFirstUse(t *testing.T) { } } } + +// The queue and dispatcher belong to the alerter's identity, not the +// socket: an OK accepted after a reconnect must never reach the sink +// before a CRITICAL accepted earlier on the old connection - two +// per-connection dispatchers raced exactly that way, and the shared +// collapse key then left the phones stuck on the stale CRITICAL. +func TestAlerterReconnectPreservesOrder(t *testing.T) { + svc := NewService() + + release := make(chan struct{}) + starts := make(chan struct{}, 8) + var mu sync.Mutex + var order []string + svc.SetAlertSink(func(_, _, _, status, _ string) { + starts <- struct{}{} + <-release // delivery is stuck (slow provider) until released + mu.Lock() + order = append(order, status) + mu.Unlock() + }) + + // Connection one accepts two alerts: the first occupies the + // dispatcher (stuck in the sink), the second waits in the queue. + server1, client1 := net.Pipe() + done1 := make(chan struct{}) + go func() { + svc.runAlerter("upsd", "apcupsd", "pipe-1", server1, bufio.NewReader(server1)) + close(done1) + }() + r1 := bufio.NewReader(client1) + send := func(c net.Conn, r *bufio.Reader, line string) string { + t.Helper() + if _, err := c.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(client1, r1, "ALERT WARNING battery on battery power"); got != "333 ok" { + t.Fatalf("first alert answered %q", got) + } + <-starts // dispatcher is now holding WARNING inside the sink + if got := send(client1, r1, "ALERT CRITICAL battery battery low"); got != "333 ok" { + t.Fatalf("second alert answered %q", got) + } + + // The alerter reconnects and reports the recovery on the new link. + server2, client2 := net.Pipe() + done2 := make(chan struct{}) + go func() { + svc.runAlerter("upsd", "apcupsd", "pipe-2", server2, bufio.NewReader(server2)) + close(done2) + }() + <-done1 // old connection fully torn down + r2 := bufio.NewReader(client2) + if got := send(client2, r2, "ALERT OK battery mains power restored"); got != "333 ok" { + t.Fatalf("post-reconnect alert answered %q", got) + } + + close(release) + waitFor(t, "all three alerts to be delivered", func() bool { + mu.Lock() + defer mu.Unlock() + return len(order) == 3 + }) + mu.Lock() + got := strings.Join(order, ",") + mu.Unlock() + if got != "WARNING,CRITICAL,OK" { + t.Fatalf("delivery order = %s, want WARNING,CRITICAL,OK", got) + } + + client2.Close() + <-done2 +} + +// An alert with no delivery path is refused, not acknowledged into the +// void: no sink at all, or a gate that says push is off, both answer +// 444 - and the sender's retry after the path comes back succeeds. +func TestAlerterRefusedWithoutDeliveryPath(t *testing.T) { + svc := NewService() + + server, client := net.Pipe() + done := make(chan struct{}) + go func() { + svc.runAlerter("lonely", "", "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) + } + + // No sink configured at all. + if got := send("ALERT CRITICAL disk full"); !strings.HasPrefix(got, "444") { + t.Fatalf("ALERT with no sink answered %q, want a 444", got) + } + + // A sink, but the gate says push is disabled. + var mu sync.Mutex + delivered := 0 + svc.SetAlertSink(func(_, _, _, _, _ string) { mu.Lock(); delivered++; mu.Unlock() }) + gateReason := "push delivery is disabled on this server" + svc.SetAlertGate(func() string { return gateReason }) + if got := send("ALERT CRITICAL disk full"); !strings.HasPrefix(got, "444 push delivery is disabled") { + t.Fatalf("ALERT with a closed gate answered %q", got) + } + + // Path restored: the same line is now taken, and counted. + gateReason = "" + if got := send("ALERT CRITICAL disk full"); got != "333 ok" { + t.Fatalf("ALERT with an open gate answered %q", got) + } + waitFor(t, "the accepted alert to be delivered", func() bool { + mu.Lock() + defer mu.Unlock() + return delivered == 1 + }) + if list := svc.Alerters(); len(list) != 1 || list[0].Alerts != 1 { + t.Errorf("Alerts = %d, want 1 (refused alerts must not count)", list[0].Alerts) + } + + client.Close() + <-done +} + +// Revoking or re-minting a token must cut the live connection, not just +// the next one: both registries - daemons and alerters - answer to +// DisconnectSite. +func TestDisconnectSiteCutsLiveConnections(t *testing.T) { + svc := NewService() + + // An alerter with its socket up. + aServer, aClient := net.Pipe() + aDone := make(chan struct{}) + go func() { + svc.runAlerter("backupd", "", "pipe-a", aServer, bufio.NewReader(aServer)) + close(aDone) + }() + waitFor(t, "the alerter to register", func() bool { + l := svc.Alerters() + return len(l) == 1 && l[0].Connected + }) + + // A daemon with its socket up. + dServer, dClient := net.Pipe() + svc.adoptAgent("branch2", "pipe-d", dServer, bufio.NewReader(dServer)) + + if !svc.DisconnectSite("backupd") { + t.Error("DisconnectSite(backupd) found nothing to close") + } + select { + case <-aDone: // read loop died with the closed socket + case <-time.After(5 * time.Second): + t.Fatal("alerter connection survived DisconnectSite") + } + if l := svc.Alerters(); len(l) != 1 || l[0].Connected { + t.Errorf("after DisconnectSite, Alerters() = %+v, want disconnected", l) + } + + if !svc.DisconnectSite("branch2") { + t.Error("DisconnectSite(branch2) found nothing to close") + } + // The daemon's socket really is dead: its far end reads EOF. + dClient.SetReadDeadline(time.Now().Add(5 * time.Second)) + if _, err := dClient.Read(make([]byte, 1)); err == nil { + t.Error("daemon connection still readable after DisconnectSite") + } + + if svc.DisconnectSite("ghost") { + t.Error("DisconnectSite(ghost) claimed to close something") + } + aClient.Close() + dClient.Close() +} + +// A broken credential store must fail closed: a kind claim that cannot +// be read or written refuses the handshake instead of admitting a peer +// whose kind never stuck, and label writes report their failure. +func TestStorageFailureFailsClosed(t *testing.T) { + store, err := settings.NewStore(filepath.Join(t.TempDir(), "settings.db")) + if err != nil { + t.Fatal(err) + } + if _, err := store.NewAgentToken("box1", ""); err != nil { + t.Fatal(err) + } + + svc := NewService() + svc.SetGenerations(store) + + // Sanity: works while the store is healthy. + if got := svc.claimKind("box1", settings.KindSysmond); got != "" { + t.Fatalf("healthy claim refused: %q", got) + } + + store.Close() // the "disk failure": every transaction now errors + + if got := svc.claimKind("box1", settings.KindAlerter); got == "" { + t.Error("claimKind admitted a peer over a dead store") + } + if err := store.SetAgentLabel("box1", "new name"); err == nil { + t.Error("SetAgentLabel reported success against a dead store") + } + if _, err := store.CheckAgentToken("box1", "whatever", "addr"); err == nil { + t.Error("CheckAgentToken reported a verdict without an error against a dead store") + } +} diff --git a/web-ui/backend/internal/monitoring/service.go b/web-ui/backend/internal/monitoring/service.go index 972d6c2..cd24b7e 100644 --- a/web-ui/backend/internal/monitoring/service.go +++ b/web-ui/backend/internal/monitoring/service.go @@ -110,6 +110,9 @@ type Service struct { alerters map[string]*alerter alertSinkMu sync.Mutex alertSink func(source, display, object, status, text string) + // alertGate says whether an accepted alert has a live delivery + // path right now ("" = yes, else the refusal for the 444). + alertGate func() string cacheMu sync.Mutex // history, when set, receives every observed host status transition. diff --git a/web-ui/backend/internal/push/service.go b/web-ui/backend/internal/push/service.go index 8d1c1d1..94c5bb1 100644 --- a/web-ui/backend/internal/push/service.go +++ b/web-ui/backend/internal/push/service.go @@ -1039,8 +1039,21 @@ func (s *Service) notifyAll(title, subtitle, body string, data fcmData, prevStat // 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. +// DeliveryGate is why an external alert accepted right now would go +// nowhere, or "". Wired into the monitoring service's ALERT gate so the +// protocol refuses (444) what this service would drop, instead of the +// sender getting a 333 for a page nobody will receive. +func (s *Service) DeliveryGate() string { + if !s.Enabled() { + return "push delivery is disabled on this server" + } + return "" +} + func (s *Service) ExternalAlert(source, display, object, status, text string) { if !s.Enabled() { + // The gate refuses this case at accept time; only a disable + // racing an in-flight alert lands here. log.Printf("push: alerter %s sent %s %s but push is disabled - not delivered", source, status, object) return } @@ -1059,8 +1072,11 @@ func (s *Service) ExternalAlert(source, display, object, status, text string) { } log.Printf("push: alerter %s: %s %s - notifying subscribers", source, status, object) + // Hostname is what the push log records; the qualified name keeps + // two alerters that both report an object called "disk" apart there, + // the same way host alerts log as "site:host". s.notifyAll(title, display, body, fcmData{ - Hostname: object, + Hostname: collapse, Object: collapse, Status: status, Type: "alerter", diff --git a/web-ui/backend/internal/settings/agents.go b/web-ui/backend/internal/settings/agents.go index 7eb42f9..7e1ff56 100644 --- a/web-ui/backend/internal/settings/agents.go +++ b/web-ui/backend/internal/settings/agents.go @@ -48,9 +48,11 @@ type AgentToken struct { } // 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 { +// the nickname alerts display. Missing records are left missing; a +// storage failure is the caller's to report, not to swallow - the UI +// must never confirm a rename the disk refused. +func (s *Store) SetAgentLabel(site, label string) error { + return s.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketAgents) if b == nil { return nil @@ -60,12 +62,12 @@ func (s *Store) SetAgentLabel(site, label string) { return nil } var stored map[string]json.RawMessage - if json.Unmarshal(blob, &stored) != nil { - return nil + if err := json.Unmarshal(blob, &stored); err != nil { + return fmt.Errorf("agent record %s is unreadable: %w", site, err) } enc, err := json.Marshal(label) if err != nil { - return nil + return err } if label == "" { delete(stored, "label") @@ -74,7 +76,7 @@ func (s *Store) SetAgentLabel(site, label string) { } updated, err := json.Marshal(stored) if err != nil { - return nil + return err } return b.Put([]byte(site), updated) }) @@ -113,14 +115,16 @@ func (s *Store) SetAgentKind(site, kind string) { // 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 { +// token cannot both succeed as different kinds. Returns ("", nil) 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), the owning kind when the +// token already belongs to the other class, and a non-nil error when +// storage failed - in which case the claim did NOT stick and the +// caller must fail closed rather than admit an unbound peer. +func (s *Store) ClaimAgentKind(site, kind string) (string, error) { owner := "" - _ = s.db.Update(func(tx *bolt.Tx) error { + err := s.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketAgents) if b == nil { return nil @@ -130,8 +134,8 @@ func (s *Store) ClaimAgentKind(site, kind string) string { return nil } var stored map[string]json.RawMessage - if json.Unmarshal(blob, &stored) != nil { - return nil + if err := json.Unmarshal(blob, &stored); err != nil { + return fmt.Errorf("agent record %s is unreadable: %w", site, err) } existing := "" if raw, ok := stored["kind"]; ok { @@ -146,16 +150,19 @@ func (s *Store) ClaimAgentKind(site, kind string) string { } enc, err := json.Marshal(kind) if err != nil { - return nil + return err } stored["kind"] = enc updated, err := json.Marshal(stored) if err != nil { - return nil + return err } return b.Put([]byte(site), updated) }) - return owner + if err != nil { + return "", err + } + return owner, nil } // GetAgentToken returns the record for a site, without the secret. @@ -222,15 +229,17 @@ func (s *Store) NewAgentToken(site, label string) (string, error) { } // CheckAgentToken reports whether a token may claim a site, and records -// the sighting when it may. -func (s *Store) CheckAgentToken(site, token, addr string) bool { +// the sighting when it may. A storage failure fails closed: an +// authenticator that cannot read or update its own store must refuse, +// not guess. +func (s *Store) CheckAgentToken(site, token, addr string) (bool, error) { var stored struct { AgentToken Hash string `json:"hash"` } ok := false - _ = s.db.Update(func(tx *bolt.Tx) error { + err := s.db.Update(func(tx *bolt.Tx) error { b := tx.Bucket(bucketAgents) if b == nil { return nil @@ -240,7 +249,7 @@ func (s *Store) CheckAgentToken(site, token, addr string) bool { return nil } if err := json.Unmarshal(blob, &stored); err != nil { - return nil + return fmt.Errorf("agent record %s is unreadable: %w", site, err) } if stored.Revoked { return nil @@ -256,11 +265,14 @@ func (s *Store) CheckAgentToken(site, token, addr string) bool { stored.LastAddr = addr updated, err := json.Marshal(stored) if err != nil { - return nil + return err } return b.Put([]byte(site), updated) }) - return ok + if err != nil { + return false, err + } + return ok, nil } // ListAgentTokens returns what is known about each box, without the diff --git a/web-ui/backend/static/app.css b/web-ui/backend/static/app.css index 628b2e1..c553588 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-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 +*,: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\:inline{display:inline}.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\:ml-8{margin-left:2rem}.lg\:flex{display:flex}.lg\:hidden{display:none}.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\: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)))}.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/agents.html b/web-ui/backend/templates/agents.html index fe6cc11..c1bee67 100644 --- a/web-ui/backend/templates/agents.html +++ b/web-ui/backend/templates/agents.html @@ -48,11 +48,13 @@

Monitoring boxes

- Site is ready + + is ready

-

- Do these two things on the box, then start sysmond. -

+

-
+
- Not a sysmond? The same token and CA serve an - alert-only peer: skip the - sysmon.conf lines and greet with - ALERTER <name> <token> - instead - see docs/ALERTERS.md. + Alert-only peers get their own credential type - mint with + External alerter selected + and this panel shows the + ALERTER greeting instead. + See docs/ALERTERS.md.
@@ -139,7 +143,22 @@

- For people. Write down which machine this is - nothing else records it. + For people. Which machine this is - and for an alerter, the + name its alerts display. +

+ +
+ +
+ + +
+

+ Decided here, not by whoever connects first.