diff --git a/Dockerfile b/Dockerfile index e409b61..eca6e27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,8 +30,11 @@ RUN apk add --no-cache \ iptables \ sqlite \ iproute2 \ + openresolv \ ca-certificates +# Traffic control CLI: `/sbin/tc` is provided by Alpine's iproute2 (listed above). + EXPOSE 8080 COPY --from=builder /out/netplug /usr/local/bin/netplug diff --git a/cmd/netplug/main.go b/cmd/netplug/main.go index 831d235..1da9746 100644 --- a/cmd/netplug/main.go +++ b/cmd/netplug/main.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log" + "log/slog" "net/http" "os" "os/signal" @@ -20,6 +21,8 @@ import ( "netplug-go/internal/app" "netplug-go/internal/assets" "netplug-go/internal/db" + "netplug-go/internal/pcq" + "netplug-go/internal/view" "netplug-go/internal/wireguard" webstatic "netplug-go/web/static" ) @@ -47,6 +50,9 @@ func main() { if err := db.Migrate(sqlDB); err != nil { log.Fatal(err) } + if err := db.ApplySchemaPatches(sqlDB); err != nil { + log.Fatal(err) + } if err := db.BootstrapAdmin(sqlDB); err != nil { log.Fatal(err) } @@ -59,10 +65,16 @@ func main() { sessionManager.Cookie.SameSite = http.SameSiteLaxMode sessionManager.Cookie.Secure = cfg.CookieSecure + var svcLog *slog.Logger + if cfg.Debug { + svcLog = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + } + svc := &app.Services{ - DB: sqlDB, - Sessions: sessionManager, - Config: cfg, + DB: sqlDB, + Sessions: sessionManager, + Config: cfg, + Logger: svcLog, StartedAt: time.Now(), } @@ -80,6 +92,7 @@ func main() { http.ServeFileFS(w, r, webstatic.FS(), webstatic.PlugIconPath()) }) + view.SetPCQDisabled(cfg.PCQDisabled) app.RegisterRoutes(r, svc) server := &http.Server{ @@ -104,6 +117,16 @@ func main() { log.Printf("wireguard startup: config not applied: %s", res.Text) } else { log.Printf("wireguard startup: interface is up") + if !cfg.PCQDisabled { + if _, err := pcq.Apply(sqlDB, cfg.WGInterface, false, pcq.ApplyOpts{ + Debug: cfg.Debug, + Logger: svc.Logger, + }); err != nil { + if svc.Logger == nil { + log.Printf("pcq startup: %v", err) + } + } + } } } @@ -121,4 +144,3 @@ func main() { fmt.Fprintf(os.Stderr, "shutdown error: %v\n", err) } } - diff --git a/docker-compose.yml b/docker-compose.yml index 6399bb1..5f3972a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: args: GO_VERSION: ${GO_VERSION:-1.24} GIT_COMMIT: ${GIT_COMMIT:-} - image: ${IMAGE_NAME:-netplug-wireguard}:${IMAGE_TAG:-latest} + #image: ${IMAGE_NAME:-netplug-wireguard}:${IMAGE_TAG:-latest} container_name: ${NETPLUG_CONTAINER_NAME:-netplug-wireguard} restart: ${NETPLUG_RESTART_POLICY:-unless-stopped} init: true @@ -41,6 +41,8 @@ services: HTTP_ADDR: ${HTTP_ADDR:-:8080} WG_INTERFACE: ${WG_INTERFACE:-wg0} WIREGUARD_SYNC_INTERVAL_SEC: ${WIREGUARD_SYNC_INTERVAL_SEC:-30} + NETPLUG_PCQ_DISABLE: ${NETPLUG_PCQ_DISABLE:-} + NETPLUG_DEBUG: ${NETPLUG_DEBUG:-} COOKIE_SECURE: ${COOKIE_SECURE:-false} BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-} BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} diff --git a/internal/app/config.go b/internal/app/config.go index bd37f61..3b788a2 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strconv" + "strings" ) type Config struct { @@ -13,7 +14,9 @@ type Config struct { DBPath string CookieSecure bool WGInterface string - WGInterval int // seconds + WGInterval int // seconds + PCQDisabled bool // NETPLUG_PCQ_DISABLE removes tc shaping managed by NetPlug + Debug bool // NETPLUG_DEBUG enables JSON structured debug logs (e.g. PCQ tc apply) ProjectRoot string } @@ -38,6 +41,9 @@ func LoadConfig() (Config, error) { } wgIface := env("WG_INTERFACE", "wg0") + pcqDisable := envBoolFlexible("NETPLUG_PCQ_DISABLE", false) + debug := envBoolFlexible("NETPLUG_DEBUG", false) + wgInterval := 30 if v := os.Getenv("WIREGUARD_SYNC_INTERVAL_SEC"); v != "" { n, err := strconv.Atoi(v) @@ -54,6 +60,8 @@ func LoadConfig() (Config, error) { CookieSecure: cookieSecure, WGInterface: wgIface, WGInterval: wgInterval, + PCQDisabled: pcqDisable, + Debug: debug, ProjectRoot: root, }, nil } @@ -71,3 +79,20 @@ func env(key, fallback string) string { return fallback } +func envBoolFlexible(key string, fallback bool) bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + switch v { + case "": + return fallback + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + b, err := strconv.ParseBool(v) + if err != nil { + return fallback + } + return b + } +} diff --git a/internal/app/groups_handlers.go b/internal/app/groups_handlers.go new file mode 100644 index 0000000..5211178 --- /dev/null +++ b/internal/app/groups_handlers.go @@ -0,0 +1,391 @@ +package app + +import ( + "database/sql" + "fmt" + "net/http" + "strconv" + "strings" + + "netplug-go/internal/view" + "netplug-go/internal/wireguard" + + "github.com/go-chi/chi/v5" +) + +func (h *Handlers) GroupsPage(w http.ResponseWriter, r *http.Request) { + type row struct { + ID string + Name string + Description string + MemberCount int + } + rows, err := h.svc.DB.Query(` + SELECT + g.id, + g.name, + COALESCE(g.description, ''), + (SELECT COUNT(*) FROM vpn_group_members m WHERE m.group_id = g.id) + FROM vpn_groups g + ORDER BY g.name COLLATE NOCASE ASC + LIMIT 300 + `) + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var groups []row + for rows.Next() { + var g row + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MemberCount); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + groups = append(groups, g) + } + if err := rows.Err(); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + msg := strings.TrimSpace(r.URL.Query().Get("msg")) + msgType := strings.TrimSpace(r.URL.Query().Get("msgType")) + + view.Render(w, r, "groups.tmpl", view.M{ + "Title": "Groups", + "Groups": groups, + "FlashMsg": msg, + "FlashMsgType": msgType, + }) +} + +func (h *Handlers) GroupCreatePost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Redirect(w, r, "/ui/groups", http.StatusFound) + return + } + desc := strings.TrimSpace(r.FormValue("description")) + id := wireguard.NewID() + _, err := h.svc.DB.Exec(`INSERT INTO vpn_groups (id, name, description) VALUES (?, ?, ?)`, id, name, nullIfEmptyStr(desc)) + if err != nil { + http.Redirect(w, r, "/ui/groups?msg="+urlQueryEscape("A group with that name may already exist.")+"&msgType=error", http.StatusFound) + return + } + http.Redirect(w, r, "/ui/groups/"+id, http.StatusFound) +} + +func (h *Handlers) GroupAddModalPartial(w http.ResponseWriter, r *http.Request) { + view.RenderPartial(w, r, "partials/add_group_modal.tmpl", view.M{}) +} + +func nullIfEmptyStr(s string) any { + if strings.TrimSpace(s) == "" { + return nil + } + return s +} + +func (h *Handlers) GroupDeletePost(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + http.NotFound(w, r) + return + } + res, err := h.svc.DB.Exec(`DELETE FROM vpn_groups WHERE id = ?`, id) + if err != nil { + http.Redirect(w, r, "/ui/groups/"+id+"?msg="+urlQueryEscape("Could not delete group.")+"&msgType=error", http.StatusFound) + return + } + n, _ := res.RowsAffected() + if n == 0 { + http.NotFound(w, r) + return + } + http.Redirect(w, r, "/ui/groups", http.StatusFound) +} + +func (h *Handlers) GroupMemberAddPost(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + if groupID == "" { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + userID := strings.TrimSpace(r.FormValue("vpn_user_id")) + if userID == "" { + redirectOrHTMX(w, r, "/ui/groups/"+groupID) + return + } + _, err := h.svc.DB.Exec( + `INSERT OR IGNORE INTO vpn_group_members (group_id, vpn_user_id) VALUES (?, ?)`, + groupID, userID, + ) + if err != nil { + redirectOrHTMX(w, r, "/ui/groups/"+groupID+"?msg="+urlQueryEscape("Could not add member.")+"&msgType=error") + return + } + h.reconcilePCQ() + redirectOrHTMX(w, r, "/ui/groups/"+groupID) +} + +func (h *Handlers) GroupMemberBulkAddPost(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + if groupID == "" { + http.NotFound(w, r) + return + } + var n int + if err := h.svc.DB.QueryRow(`SELECT COUNT(*) FROM vpn_groups WHERE id = ?`, groupID).Scan(&n); err != nil || n == 0 { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + ids := r.Form["vpn_user_id"] + if len(ids) == 0 { + redirectOrHTMXWithToast(w, r, "/ui/groups/"+groupID, "danger", "Select at least one user.") + return + } + added := 0 + for _, uid := range ids { + uid = strings.TrimSpace(uid) + if uid == "" { + continue + } + res, err := h.svc.DB.Exec( + `INSERT OR IGNORE INTO vpn_group_members (group_id, vpn_user_id) VALUES (?, ?)`, + groupID, uid, + ) + if err != nil { + redirectOrHTMXWithToast(w, r, "/ui/groups/"+groupID, "danger", "Could not add members.") + return + } + ra, _ := res.RowsAffected() + added += int(ra) + } + h.reconcilePCQ() + msg := fmt.Sprintf("Added %d member(s).", added) + if added == 0 { + msg = "No new members added (selected users were already in this group)." + } + redirectOrHTMXWithToast(w, r, "/ui/groups/"+groupID, "success", msg) +} + +func redirectOrHTMX(w http.ResponseWriter, r *http.Request, path string) { + redirectOrHTMXWithToast(w, r, path, "", "") +} + +// redirectOrHTMXWithToast sets HX-Trigger toast when toastMsg is non-empty (HTMX responses only). +func redirectOrHTMXWithToast(w http.ResponseWriter, r *http.Request, path string, toastType, toastMsg string) { + if strings.EqualFold(r.Header.Get("HX-Request"), "true") { + if strings.TrimSpace(toastMsg) != "" { + tt := strings.TrimSpace(toastType) + if tt == "" { + tt = "success" + } + w.Header().Set("HX-Trigger", `{"toast":{"type":`+strconv.Quote(tt)+`,"message":`+strconv.Quote(toastMsg)+`}}`) + } + w.Header().Set("HX-Redirect", path) + w.WriteHeader(http.StatusSeeOther) + return + } + http.Redirect(w, r, path, http.StatusFound) +} + +func (h *Handlers) GroupMemberRemovePost(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + userID := strings.TrimSpace(chi.URLParam(r, "user_id")) + if groupID == "" || userID == "" { + http.NotFound(w, r) + return + } + _, err := h.svc.DB.Exec(`DELETE FROM vpn_group_members WHERE group_id = ? AND vpn_user_id = ?`, groupID, userID) + if err != nil { + redirectOrHTMX(w, r, "/ui/groups/"+groupID+"?msg="+urlQueryEscape("Could not remove member.")+"&msgType=error") + return + } + h.reconcilePCQ() + redirectOrHTMX(w, r, "/ui/groups/"+groupID) +} + +type groupPickUser struct { + ID string + Username string + TunnelIP string +} + +func queryUsersNotInGroup(db *sql.DB, groupID string) ([]groupPickUser, error) { + rows, err := db.Query(` + SELECT u.id, u.username, COALESCE(u.allowed_ips, '') + FROM vpn_users u + WHERE NOT EXISTS ( + SELECT 1 FROM vpn_group_members m + WHERE m.group_id = ? AND m.vpn_user_id = u.id + ) + ORDER BY u.username COLLATE NOCASE ASC + LIMIT 500 + `, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []groupPickUser + for rows.Next() { + var o groupPickUser + var allowed string + if err := rows.Scan(&o.ID, &o.Username, &allowed); err != nil { + return nil, err + } + o.TunnelIP = firstTunnelIPCIDR(allowed) + out = append(out, o) + } + return out, rows.Err() +} + +func firstTunnelIPCIDR(allowedIPs string) string { + s := strings.TrimSpace(allowedIPs) + if s == "" { + return "" + } + if i := strings.IndexByte(s, ','); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + return s +} + +func (h *Handlers) GroupDeleteModalPartial(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + if groupID == "" { + http.NotFound(w, r) + return + } + var gName string + if err := h.svc.DB.QueryRow(`SELECT name FROM vpn_groups WHERE id = ? LIMIT 1`, groupID).Scan(&gName); err != nil { + http.NotFound(w, r) + return + } + view.RenderPartial(w, r, "partials/delete_group_modal.tmpl", view.M{ + "GroupID": groupID, + "GroupName": gName, + }) +} + +func (h *Handlers) GroupMemberRemoveModalPartial(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + userID := strings.TrimSpace(chi.URLParam(r, "user_id")) + if groupID == "" || userID == "" { + http.NotFound(w, r) + return + } + var username string + if err := h.svc.DB.QueryRow(`SELECT username FROM vpn_users WHERE id = ? LIMIT 1`, userID).Scan(&username); err != nil { + http.NotFound(w, r) + return + } + view.RenderPartial(w, r, "partials/remove_member_modal.tmpl", view.M{ + "GroupID": groupID, + "UserID": userID, + "Username": username, + }) +} + +func (h *Handlers) GroupAddMembersModalPartial(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + if groupID == "" { + http.NotFound(w, r) + return + } + var gName string + err := h.svc.DB.QueryRow(`SELECT name FROM vpn_groups WHERE id = ? LIMIT 1`, groupID).Scan(&gName) + if err != nil { + http.NotFound(w, r) + return + } + candidates, err := queryUsersNotInGroup(h.svc.DB, groupID) + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + view.RenderPartial(w, r, "partials/group_add_members_modal.tmpl", view.M{ + "GroupID": groupID, + "GroupName": gName, + "Candidates": candidates, + }) +} + +func (h *Handlers) GroupDetailPage(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + if groupID == "" { + http.NotFound(w, r) + return + } + + var gName, gDesc string + err := h.svc.DB.QueryRow(`SELECT name, COALESCE(description, '') FROM vpn_groups WHERE id = ? LIMIT 1`, groupID).Scan(&gName, &gDesc) + if err != nil { + http.NotFound(w, r) + return + } + + type memberRow struct { + UserID string + Username string + TunnelIP string + } + mrows, err := h.svc.DB.Query(` + SELECT u.id, u.username, COALESCE(u.allowed_ips, '') + FROM vpn_group_members m + JOIN vpn_users u ON u.id = m.vpn_user_id + WHERE m.group_id = ? + ORDER BY u.username COLLATE NOCASE ASC + `, groupID) + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer mrows.Close() + + var members []memberRow + for mrows.Next() { + var m memberRow + var allowed sql.NullString + if err := mrows.Scan(&m.UserID, &m.Username, &allowed); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if allowed.Valid { + m.TunnelIP = firstTunnelIPCIDR(allowed.String) + } + members = append(members, m) + } + if err := mrows.Err(); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + msg := strings.TrimSpace(r.URL.Query().Get("msg")) + msgType := strings.TrimSpace(r.URL.Query().Get("msgType")) + + view.Render(w, r, "group_detail.tmpl", view.M{ + "Title": gName, + "GroupID": groupID, + "GroupName": gName, + "GroupDesc": gDesc, + "Members": members, + "FlashMsg": msg, + "FlashMsgType": msgType, + }) +} diff --git a/internal/app/handlers.go b/internal/app/handlers.go index 4a28b78..b7d68ad 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -401,6 +401,7 @@ func (h *Handlers) SetupWireGuardPost(w http.ResponseWriter, r *http.Request) { return } _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) + h.reconcilePCQ() http.Redirect(w, r, "/ui", http.StatusFound) } @@ -588,6 +589,7 @@ func (h *Handlers) SetupWireGuardImportPost(w http.ResponseWriter, r *http.Reque return } _ = wireguard.ApplyConfig(dataDir, h.svc.Config.WGInterface) + h.reconcilePCQ() http.Redirect(w, r, "/ui", http.StatusFound) } @@ -1142,6 +1144,7 @@ func (h *Handlers) UserCreatePost(w http.ResponseWriter, r *http.Request) { _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) + h.reconcilePCQ() http.Redirect(w, r, "/ui/users", http.StatusFound) } @@ -1345,6 +1348,7 @@ func (h *Handlers) UserUpdatePost(w http.ResponseWriter, r *http.Request) { _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) + h.reconcilePCQ() // Close modal and show a toast; then refresh list. w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -1395,6 +1399,7 @@ func (h *Handlers) UserDeletePost(w http.ResponseWriter, r *http.Request) { _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) + h.reconcilePCQ() msg := "User deleted." if strings.TrimSpace(username) != "" { @@ -1565,6 +1570,7 @@ func (h *Handlers) UserTogglePost(w http.ResponseWriter, r *http.Request) { } _ = wireguard.WriteWireGuardConfig(h.svc.DB, h.svc.Config.DataDir) _ = wireguard.ApplyConfig(h.svc.Config.DataDir, h.svc.Config.WGInterface) + h.reconcilePCQ() http.Redirect(w, r, "/ui/users", http.StatusFound) } @@ -1693,6 +1699,7 @@ func (h *Handlers) WireGuardSavePost(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape(err.Error()), http.StatusFound) return } + h.reconcilePCQ() http.Redirect(w, r, "/ui/wireguard?msgType="+urlQueryEscape(res.Type)+"&msg="+urlQueryEscape(res.Text), http.StatusFound) } @@ -1702,6 +1709,7 @@ func (h *Handlers) WireGuardReloadPost(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui/wireguard?msgType=error&msg="+urlQueryEscape(err.Error()), http.StatusFound) return } + h.reconcilePCQ() http.Redirect(w, r, "/ui/wireguard?msgType="+urlQueryEscape(res.Type)+"&msg="+urlQueryEscape(res.Text), http.StatusFound) } @@ -1738,6 +1746,7 @@ func (h *Handlers) WireGuardAPIPut(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"save failed"}`, http.StatusInternalServerError) return } + h.reconcilePCQ() w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "message": res.Text, diff --git a/internal/app/pcq_handlers.go b/internal/app/pcq_handlers.go new file mode 100644 index 0000000..2cc56dc --- /dev/null +++ b/internal/app/pcq_handlers.go @@ -0,0 +1,429 @@ +package app + +import ( + "database/sql" + "fmt" + "log" + "math" + "net" + "net/http" + "strconv" + "strings" + + "netplug-go/internal/pcq" + "netplug-go/internal/view" + + "github.com/go-chi/chi/v5" +) + +func parseIPv4Host(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + if strings.Contains(s, "/") { + host, _, err := net.ParseCIDR(strings.TrimSpace(s)) + if err != nil || host == nil || host.To4() == nil { + return "" + } + return host.To4().String() + } + ip := net.ParseIP(s).To4() + if ip == nil { + return "" + } + return ip.String() +} + +func groupMemberTunnelIPs(dbConn *sql.DB, groupID string) ([]string, error) { + rows, err := dbConn.Query(` + SELECT COALESCE(u.allowed_ips, '') + FROM vpn_group_members m + JOIN vpn_users u ON u.id = m.vpn_user_id + WHERE m.group_id = ? + `, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + + var ips []string + for rows.Next() { + var allowed sql.NullString + if err := rows.Scan(&allowed); err != nil { + return nil, err + } + if !allowed.Valid { + continue + } + tip := firstTunnelIPCIDR(allowed.String) + if tip != "" && parseIPv4Host(tip) != "" { + ips = append(ips, tip) + } + } + return ips, rows.Err() +} + +func mbpsInputToKbps(form string) *int { + form = strings.TrimSpace(form) + if form == "" { + return nil + } + v, err := strconv.ParseFloat(form, 64) + if err != nil || v <= 0 { + return nil + } + k := int(math.Round(v * 1024)) + if k <= 0 { + return nil + } + return &k +} + +func kbpsDisplayMbps(k *int) string { + if k == nil || *k <= 0 { + return "" + } + mb := float64(*k) / 1024.0 + s := strconv.FormatFloat(mb, 'f', 1, 64) + s = strings.TrimRight(strings.TrimRight(s, "0"), ".") + if s == "" { + s = "0" + } + return s +} + +func ptrPositiveKbps(v sql.NullInt64) *int { + if !v.Valid || v.Int64 <= 0 { + return nil + } + x := int(v.Int64) + return &x +} + +func pcqLimitsSummary(dl, ul sql.NullInt64) string { + switch { + case dl.Valid && dl.Int64 > 0 && ul.Valid && ul.Int64 > 0: + return kbpsDisplayMbps(ptrPositiveKbps(dl)) + "↓ " + kbpsDisplayMbps(ptrPositiveKbps(ul)) + "↑ Mbps" + case dl.Valid && dl.Int64 > 0: + return kbpsDisplayMbps(ptrPositiveKbps(dl)) + "↓ Mbps" + case ul.Valid && ul.Int64 > 0: + return kbpsDisplayMbps(ptrPositiveKbps(ul)) + "↑ Mbps" + default: + return "—" + } +} + +func kbpsToMbpsInput(v sql.NullInt64) string { + if !v.Valid || v.Int64 <= 0 { + return "" + } + mb := float64(v.Int64) / 1024.0 + return fmt.Sprintf("%.4g", mb) +} + +func (h *Handlers) PCQOverviewPage(w http.ResponseWriter, r *http.Request) { + type presetRow struct { + GroupID string + Name string + Members int + PCQSummary string + Classifier string + HasLimits bool + IsDisabled bool + } + type sansRow struct { + GroupID string + Name string + Members int + } + + withRows, err := h.svc.DB.Query(` + SELECT g.id, + g.name, + COALESCE((SELECT COUNT(*) FROM vpn_group_members m WHERE m.group_id = g.id), 0), + p.download_limit_kbps, + p.upload_limit_kbps, + p.pcq_classifier, + IFNULL(p.is_disabled, 0) + FROM vpn_groups g + INNER JOIN vpn_group_pcq p ON p.group_id = g.id + ORDER BY g.name COLLATE NOCASE ASC + LIMIT 400 + `) + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer withRows.Close() + + var presets []presetRow + for withRows.Next() { + var rec presetRow + var dl, ul sql.NullInt64 + var clf sql.NullString + var disabled int + if err := withRows.Scan(&rec.GroupID, &rec.Name, &rec.Members, &dl, &ul, &clf, &disabled); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if clf.Valid && strings.TrimSpace(clf.String) != "" { + rec.Classifier = clf.String + } else { + rec.Classifier = "dual" + } + rec.HasLimits = (dl.Valid && dl.Int64 > 0) || (ul.Valid && ul.Int64 > 0) + rec.PCQSummary = pcqLimitsSummary(dl, ul) + rec.IsDisabled = disabled == 1 + presets = append(presets, rec) + } + if err := withRows.Err(); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + noPresetRows, err := h.svc.DB.Query(` + SELECT g.id, + g.name, + COALESCE((SELECT COUNT(*) FROM vpn_group_members m WHERE m.group_id = g.id), 0) + FROM vpn_groups g + WHERE NOT EXISTS (SELECT 1 FROM vpn_group_pcq p WHERE p.group_id = g.id) + ORDER BY g.name COLLATE NOCASE ASC + LIMIT 400 + `) + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer noPresetRows.Close() + + var sans []sansRow + for noPresetRows.Next() { + var rec sansRow + if err := noPresetRows.Scan(&rec.GroupID, &rec.Name, &rec.Members); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + sans = append(sans, rec) + } + if err := noPresetRows.Err(); err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + msg := strings.TrimSpace(r.URL.Query().Get("msg")) + msgType := strings.TrimSpace(r.URL.Query().Get("msgType")) + + view.Render(w, r, "pcq_overview.tmpl", view.M{ + "Title": "Queues", + "QueuePresets": presets, + "GroupsSansQueue": sans, + "FlashMsg": msg, + "FlashMsgType": msgType, + }) +} + +func (h *Handlers) GroupPCQPage(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + if groupID == "" { + http.NotFound(w, r) + return + } + + var gName string + err := h.svc.DB.QueryRow(`SELECT name FROM vpn_groups WHERE id = ? LIMIT 1`, groupID).Scan(&gName) + if err != nil { + http.NotFound(w, r) + return + } + + tunnelIPs, err := groupMemberTunnelIPs(h.svc.DB, groupID) + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + var ( + dlKbps, ulKbps, burstDlKbps, burstUlKbps sql.NullInt64 + classifier string + isDisabled int + ) + presetErr := h.svc.DB.QueryRow(` + SELECT download_limit_kbps, upload_limit_kbps, burst_download_kbps, burst_upload_kbps, + IFNULL(pcq_classifier, 'dual'), IFNULL(is_disabled, 0) + FROM vpn_group_pcq WHERE group_id = ? + `, groupID).Scan(&dlKbps, &ulKbps, &burstDlKbps, &burstUlKbps, &classifier, &isDisabled) + if presetErr != nil && presetErr != sql.ErrNoRows { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + if presetErr == sql.ErrNoRows { + classifier = "dual" + } + + previewLines, err := pcq.PlanCommandsForGroup(h.svc.DB, h.svc.Config.WGInterface, groupID) + previewText := strings.Join(previewLines, "\n") + if err != nil { + previewText = "# error building tc plan: " + err.Error() + } + + msg := strings.TrimSpace(r.URL.Query().Get("msg")) + msgType := strings.TrimSpace(r.URL.Query().Get("msgType")) + + view.Render(w, r, "group_pcq.tmpl", view.M{ + "Title": "Queue — " + gName, + "GroupID": groupID, + "GroupName": gName, + "DownloadMbpsInput": kbpsToMbpsInput(dlKbps), + "UploadMbpsInput": kbpsToMbpsInput(ulKbps), + "BurstDownloadInput": kbpsToMbpsInput(burstDlKbps), + "BurstUploadInput": kbpsToMbpsInput(burstUlKbps), + "ClassifierMode": classifier, + "IsDisabled": isDisabled == 1, + "HasPreset": presetErr != sql.ErrNoRows, + "LinuxTCPreview": previewText, + "WGInterface": h.svc.Config.WGInterface, + "MemberTunnelIPs": tunnelIPs, + "MemberTunnelIPsCount": len(tunnelIPs), + "FlashMsg": msg, + "FlashMsgType": msgType, + }) +} + +func (h *Handlers) GroupPCQSavePost(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + if groupID == "" { + http.NotFound(w, r) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + + dest := "/ui/queues/" + groupID + + var n int + if err := h.svc.DB.QueryRow(`SELECT COUNT(*) FROM vpn_groups WHERE id = ?`, groupID).Scan(&n); err != nil || n == 0 { + http.NotFound(w, r) + return + } + + if r.FormValue("clear_pcq") == "on" { + if _, err := h.svc.DB.Exec(`DELETE FROM vpn_group_pcq WHERE group_id = ?`, groupID); err != nil { + http.Redirect(w, r, dest+"?msg="+urlQueryEscape("Could not clear queue settings.")+"&msgType=error", http.StatusFound) + return + } + h.reconcilePCQ() + http.Redirect(w, r, dest+"?msg="+urlQueryEscape("Queue settings cleared.")+"&msgType=success", http.StatusFound) + return + } + + mode := strings.ToLower(strings.TrimSpace(r.FormValue("pcq_classifier"))) + switch mode { + case "dst-download", "src-upload", "dual": + default: + mode = "dual" + } + + dl := mbpsInputToKbps(r.FormValue("download_mbps")) + ul := mbpsInputToKbps(r.FormValue("upload_mbps")) + bd := mbpsInputToKbps(r.FormValue("burst_download_mbps")) + bu := mbpsInputToKbps(r.FormValue("burst_upload_mbps")) + + var dlSQL, ulSQL, bdSQL, buSQL any + if dl != nil { + dlSQL = *dl + } + if ul != nil { + ulSQL = *ul + } + if bd != nil { + bdSQL = *bd + } + if bu != nil { + buSQL = *bu + } + + _, err := h.svc.DB.Exec(` + INSERT INTO vpn_group_pcq ( + group_id, download_limit_kbps, upload_limit_kbps, + burst_download_kbps, burst_upload_kbps, pcq_classifier, + updated_at + ) VALUES (?,?,?,?,?,?, datetime('now')) + ON CONFLICT(group_id) DO UPDATE SET + download_limit_kbps = excluded.download_limit_kbps, + upload_limit_kbps = excluded.upload_limit_kbps, + burst_download_kbps = excluded.burst_download_kbps, + burst_upload_kbps = excluded.burst_upload_kbps, + pcq_classifier = excluded.pcq_classifier, + updated_at = excluded.updated_at + `, groupID, dlSQL, ulSQL, bdSQL, buSQL, mode) + + if err != nil { + http.Redirect(w, r, dest+"?msg="+urlQueryEscape("Could not save queue settings.")+"&msgType=error", http.StatusFound) + return + } + h.reconcilePCQ() + http.Redirect(w, r, dest+"?msg="+urlQueryEscape("Queue settings saved.")+"&msgType=success", http.StatusFound) +} + +func (h *Handlers) GroupPCQTogglePost(w http.ResponseWriter, r *http.Request) { + groupID := strings.TrimSpace(chi.URLParam(r, "id")) + if groupID == "" { + http.NotFound(w, r) + return + } + + _ = r.ParseForm() + redirectTo := strings.TrimSpace(r.FormValue("redirect")) + if redirectTo == "" { + redirectTo = "/ui/queues/" + groupID + } + + var current int + err := h.svc.DB.QueryRow(`SELECT is_disabled FROM vpn_group_pcq WHERE group_id = ?`, groupID).Scan(¤t) + if err == sql.ErrNoRows { + http.Redirect(w, r, redirectTo+"?msg="+urlQueryEscape("No queue preset found for this group.")+"&msgType=error", http.StatusFound) + return + } + if err != nil { + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + newVal := 0 + if current == 0 { + newVal = 1 + } + if _, err := h.svc.DB.Exec( + `UPDATE vpn_group_pcq SET is_disabled = ?, updated_at = datetime('now') WHERE group_id = ?`, + newVal, groupID, + ); err != nil { + http.Redirect(w, r, redirectTo+"?msg="+urlQueryEscape("Could not update queue status.")+"&msgType=error", http.StatusFound) + return + } + h.reconcilePCQ() + + msg := "Queue enabled." + if newVal == 1 { + msg = "Queue disabled." + } + http.Redirect(w, r, redirectTo+"?msg="+urlQueryEscape(msg)+"&msgType=success", http.StatusFound) +} + +func (h *Handlers) reconcilePCQ() { + if h == nil || h.svc.DB == nil { + return + } + if h.svc.Config.PCQDisabled { + return + } + opts := pcq.ApplyOpts{ + Debug: h.svc.Config.Debug, + Logger: h.svc.Logger, + } + if _, err := pcq.Apply(h.svc.DB, h.svc.Config.WGInterface, false, opts); err != nil && h.svc.Logger == nil { + log.Printf("pcq.Apply: %v", err) + } +} diff --git a/internal/app/routes.go b/internal/app/routes.go index cb1f48c..4a149d4 100644 --- a/internal/app/routes.go +++ b/internal/app/routes.go @@ -2,6 +2,7 @@ package app import ( "net/http" + "strings" "github.com/go-chi/chi/v5" ) @@ -37,6 +38,38 @@ func RegisterRoutes(r chi.Router, svc *Services) { r.Get("/ui", h.UIPage) r.Get("/ui/users", h.UsersPage) + r.Get("/ui/groups", h.GroupsPage) + r.Post("/ui/groups", h.GroupCreatePost) + r.Get("/ui/queues", h.PCQOverviewPage) + r.Get("/ui/queues/{id}", h.GroupPCQPage) + r.Post("/ui/queues/{id}", h.GroupPCQSavePost) + r.Post("/ui/queues/{id}/toggle", h.GroupPCQTogglePost) + r.Get("/ui/groups/{id}/queues", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + http.NotFound(w, r) + return + } + http.Redirect(w, r, "/ui/queues/"+id, http.StatusMovedPermanently) + }) + r.Post("/ui/groups/{id}/queues", h.GroupPCQSavePost) + r.Get("/ui/pcq", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui/queues", http.StatusMovedPermanently) + }) + r.Get("/ui/groups/{id}/pcq", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + http.NotFound(w, r) + return + } + http.Redirect(w, r, "/ui/queues/"+id, http.StatusMovedPermanently) + }) + r.Post("/ui/groups/{id}/pcq", h.GroupPCQSavePost) + r.Get("/ui/groups/{id}", h.GroupDetailPage) + r.Post("/ui/groups/{id}/delete", h.GroupDeletePost) + r.Post("/ui/groups/{id}/members/bulk", h.GroupMemberBulkAddPost) + r.Post("/ui/groups/{id}/members", h.GroupMemberAddPost) + r.Post("/ui/groups/{id}/members/{user_id}/remove", h.GroupMemberRemovePost) r.Post("/ui/users", h.UserCreatePost) r.Post("/ui/users/{id}/edit", h.UserUpdatePost) r.Post("/ui/users/{id}/delete", h.UserDeletePost) @@ -54,6 +87,10 @@ func RegisterRoutes(r chi.Router, svc *Services) { r.Get("/partials/ui/active-connections", h.ActiveConnectionsPartial) r.Get("/partials/overview/all", h.OverviewAllPartial) r.Get("/partials/users/add-modal", h.AddUserModalPartial) + r.Get("/partials/groups/add-modal", h.GroupAddModalPartial) + r.Get("/partials/groups/{id}/delete-modal", h.GroupDeleteModalPartial) + r.Get("/partials/groups/{id}/add-members-modal", h.GroupAddMembersModalPartial) + r.Get("/partials/groups/{id}/members/{user_id}/remove-modal", h.GroupMemberRemoveModalPartial) r.Get("/partials/users/{id}/edit-modal", h.EditUserModalPartial) r.Get("/partials/users/{id}/qr-modal", h.UserQRModalPartial) r.Get("/partials/users/{id}/delete-modal", h.UserDeleteModalPartial) @@ -70,4 +107,3 @@ func RegisterRoutes(r chi.Router, svc *Services) { r.Get("/api/users/{id}/config", h.UserConfigAPIGet) }) } - diff --git a/internal/app/services.go b/internal/app/services.go index f56db45..c570807 100644 --- a/internal/app/services.go +++ b/internal/app/services.go @@ -2,6 +2,7 @@ package app import ( "database/sql" + "log/slog" "time" "github.com/alexedwards/scs/v2" @@ -11,7 +12,7 @@ type Services struct { DB *sql.DB Sessions *scs.SessionManager Config Config + Logger *slog.Logger // set when NETPLUG_DEBUG JSON logging is enabled StartedAt time.Time } - diff --git a/internal/assets/vendor/app.css b/internal/assets/vendor/app.css index a78409d..a83360c 100644 --- a/internal/assets/vendor/app.css +++ b/internal/assets/vendor/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.17 | 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:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;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:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,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}.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}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-3{right:-.75rem}.left-0{left:0}.left-3{left:.75rem}.right-1{right:.25rem}.right-1\.5{right:.375rem}.right-2{right:.5rem}.right-3{right:.75rem}.right-6{right:1.5rem}.top-0{top:0}.top-1\/2{top:50%}.top-3{top:.75rem}.top-6{top:1.5rem}.top-\[1\.85rem\]{top:1.85rem}.z-10{z-index:10}.z-50{z-index:50}.z-\[100\]{z-index:100}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-8{margin-top:2rem;margin-bottom:2rem}.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}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-\[360px\]{max-height:360px}.max-h-\[90vh\]{max-height:90vh}.max-h-\[calc\(100vh-3rem\)\]{max-height:calc(100vh - 3rem)}.min-h-0{min-height:0}.min-h-\[min\(36vh\2c 220px\)\]{min-height:min(36vh,220px)}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-2{width:.5rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[280px\]{width:280px}.w-\[360px\]{width:360px}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[720px\]{min-width:720px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-\[400px\]{max-width:400px}.max-w-\[calc\(100vw-3rem\)\]{max-width:calc(100vw - 3rem)}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.transform{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 pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*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-\[rgba\(148\2c 163\2c 184\2c 0\.045\)\]>:not([hidden])~:not([hidden]){border-color:rgba(148,163,184,.045)}.divide-\[rgba\(148\2c 163\2c 184\2c 0\.06\)\]>:not([hidden])~:not([hidden]){border-color:rgba(148,163,184,.06)}.self-stretch{align-self:stretch}.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;white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-\[rgba\(148\2c 163\2c 184\2c 0\.048\)\]{border-color:rgba(148,163,184,.048)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.055\)\]{border-color:rgba(148,163,184,.055)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.06\)\]{border-color:rgba(148,163,184,.06)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.07\)\]{border-color:rgba(148,163,184,.07)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.08\)\]{border-color:rgba(148,163,184,.08)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.09\)\]{border-color:rgba(148,163,184,.09)}.border-emerald-600{--tw-border-opacity:1;border-color:rgb(5 150 105/var(--tw-border-opacity,1))}.border-emerald-700{--tw-border-opacity:1;border-color:rgb(4 120 87/var(--tw-border-opacity,1))}.border-emerald-900\/60{border-color:rgba(6,78,59,.6)}.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-200\/80{border-color:rgba(229,231,235,.8)}.border-gray-200\/90{border-color:rgba(229,231,235,.9)}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.border-red-500\/30{border-color:rgba(239,68,68,.3)}.border-red-700{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity,1))}.border-red-900{--tw-border-opacity:1;border-color:rgb(127 29 29/var(--tw-border-opacity,1))}.border-red-900\/35{border-color:rgba(127,29,29,.35)}.border-red-900\/60{border-color:rgba(127,29,29,.6)}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.border-slate-600{--tw-border-opacity:1;border-color:rgb(71 85 105/var(--tw-border-opacity,1))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-slate-700\/35{border-color:rgba(51,65,85,.35)}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-slate-800\/70{border-color:rgba(30,41,59,.7)}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-\[\#0a0d12\]{--tw-bg-opacity:1;background-color:rgb(10 13 18/var(--tw-bg-opacity,1))}.bg-\[\#10b981\]{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-\[\#152031\]{--tw-bg-opacity:1;background-color:rgb(21 32 49/var(--tw-bg-opacity,1))}.bg-\[\#1e2d42\]{--tw-bg-opacity:1;background-color:rgb(30 45 66/var(--tw-bg-opacity,1))}.bg-\[\#1e2d42\]\/80{background-color:rgba(30,45,66,.8)}.bg-\[\#e2e8f0\]{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-\[var\(--np-canvas\)\]{background-color:var(--np-canvas)}.bg-\[var\(--np-field\)\]{background-color:var(--np-field)}.bg-\[var\(--np-panel\)\]{background-color:var(--np-panel)}.bg-amber-400{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-black\/60{background-color:rgba(0,0,0,.6)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-emerald-600\/20{background-color:rgba(5,150,105,.2)}.bg-emerald-950{--tw-bg-opacity:1;background-color:rgb(2 44 34/var(--tw-bg-opacity,1))}.bg-emerald-950\/70{background-color:rgba(2,44,34,.7)}.bg-gray-100\/80{background-color:rgba(243,244,246,.8)}.bg-gray-200\/80{background-color:rgba(229,231,235,.8)}.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-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-purple-600\/20{background-color:rgba(147,51,234,.2)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:rgba(239,68,68,.1)}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-950\/25{background-color:rgba(69,10,10,.25)}.bg-red-950\/40{background-color:rgba(69,10,10,.4)}.bg-red-950\/50{background-color:rgba(69,10,10,.5)}.bg-red-950\/80{background-color:rgba(69,10,10,.8)}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-slate-800\/80{background-color:rgba(30,41,59,.8)}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-slate-900\/30{background-color:rgba(15,23,42,.3)}.bg-slate-900\/45{background-color:rgba(15,23,42,.45)}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-slate-950\/25{background-color:rgba(2,6,23,.25)}.bg-slate-950\/35{background-color:rgba(2,6,23,.35)}.bg-slate-950\/60{background-color:rgba(2,6,23,.6)}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-\[radial-gradient\(ellipse_at_center\2c _\#141f2e_0\%\2c _\#0c111c_55\%\2c _\#0a0d12_100\%\)\]{background-image:radial-gradient(ellipse at center,#141f2e 0,#0c111c 55%,#0a0d12 100%)}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-\[var\(--np-raised\)\]{--tw-gradient-from:var(--np-raised) var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,0%,100%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-amber-300{--tw-gradient-from:#fcd34d var(--tw-gradient-from-position);--tw-gradient-to:rgba(252,211,77,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-emerald-400{--tw-gradient-from:#34d399 var(--tw-gradient-from-position);--tw-gradient-to:rgba(52,211,153,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-emerald-500{--tw-gradient-from:#10b981 var(--tw-gradient-from-position);--tw-gradient-to:rgba(16,185,129,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-\[var\(--np-panel\)\]{--tw-gradient-to:var(--np-panel) var(--tw-gradient-to-position)}.to-amber-400{--tw-gradient-to:#fbbf24 var(--tw-gradient-to-position)}.to-emerald-500{--tw-gradient-to:#10b981 var(--tw-gradient-to-position)}.to-teal-500{--tw-gradient-to:#14b8a6 var(--tw-gradient-to-position)}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.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-3\.5{padding-left:.875rem;padding-right:.875rem}.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-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.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-3{padding-bottom:.75rem}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pr-10{padding-right:2.5rem}.pr-20{padding-right:5rem}.pr-24{padding-right:6rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pr-\[4\.25rem\]{padding-right:4.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[11px\]{font-size:11px}.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-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-5{line-height:1.25rem}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.12em\]{letter-spacing:.12em}.tracking-\[0\.14em\]{letter-spacing:.14em}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-\[\#718096\]{--tw-text-opacity:1;color:rgb(113 128 150/var(--tw-text-opacity,1))}.text-\[\#94a3b8\]{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-emerald-50{--tw-text-opacity:1;color:rgb(236 253 245/var(--tw-text-opacity,1))}.text-emerald-500{--tw-text-opacity:1;color:rgb(16 185 129/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/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-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-purple-300{--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.text-red-100{--tw-text-opacity:1;color:rgb(254 226 226/var(--tw-text-opacity,1))}.text-red-200{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-sky-400{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.text-slate-100{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.opacity-40{opacity:.4}.opacity-90{opacity:.9}.shadow-\[0_0_0_1px_rgba\(148\2c 163\2c 184\2c 0\.04\)\]{--tw-shadow:0 0 0 1px rgba(148,163,184,.04);--tw-shadow-colored:0 0 0 1px var(--tw-shadow-color)}.shadow-\[0_0_0_1px_rgba\(148\2c 163\2c 184\2c 0\.04\)\],.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-none{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.shadow-none,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-black\/40{--tw-shadow-color:rgba(0,0,0,.4);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.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);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-emerald-700\/40{--tw-ring-color:rgba(4,120,87,.4)}.ring-slate-500\/25{--tw-ring-color:rgba(100,116,139,.25)}.ring-slate-700{--tw-ring-opacity:1;--tw-ring-color:rgb(51 65 85/var(--tw-ring-opacity,1))}.blur{--tw-blur:blur(8px)}.blur,.grayscale{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)}.grayscale{--tw-grayscale:grayscale(100%)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-blur-sm{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.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}.duration-300{transition-duration:.3s}.file\:mr-4::file-selector-button{margin-right:1rem}.file\:rounded-lg::file-selector-button{border-radius:.5rem}.file\:border-0::file-selector-button{border-width:0}.file\:bg-slate-800::file-selector-button{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.file\:px-3::file-selector-button{padding-left:.75rem;padding-right:.75rem}.file\:py-2::file-selector-button{padding-top:.5rem;padding-bottom:.5rem}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.file\:text-slate-100::file-selector-button{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.placeholder\:text-gray-500::-moz-placeholder{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.placeholder\:text-gray-500::placeholder{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.placeholder\:text-gray-600::-moz-placeholder{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.placeholder\:text-gray-600::placeholder{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.placeholder\:text-slate-500::-moz-placeholder{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.placeholder\:text-slate-500::placeholder{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,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\: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)}.last\:border-b-0:last-child{border-bottom-width:0}.hover\:border-gray-400:hover{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.hover\:bg-\[\#0d9668\]:hover{--tw-bg-opacity:1;background-color:rgb(13 150 104/var(--tw-bg-opacity,1))}.hover\:bg-\[\#1e2d42\]:hover{--tw-bg-opacity:1;background-color:rgb(30 45 66/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-500:hover{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.hover\:bg-emerald-600\/30:hover{background-color:rgba(5,150,105,.3)}.hover\:bg-emerald-900:hover{--tw-bg-opacity:1;background-color:rgb(6 78 59/var(--tw-bg-opacity,1))}.hover\:bg-emerald-950:hover{--tw-bg-opacity:1;background-color:rgb(2 44 34/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\/80:hover{background-color:rgba(229,231,235,.8)}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/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-purple-600\/30:hover{background-color:rgba(147,51,234,.3)}.hover\:bg-red-500:hover{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.hover\:bg-red-950:hover{--tw-bg-opacity:1;background-color:rgb(69 10 10/var(--tw-bg-opacity,1))}.hover\:bg-slate-700:hover{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.hover\:bg-slate-800:hover{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.hover\:from-emerald-600:hover{--tw-gradient-from:#059669 var(--tw-gradient-from-position);--tw-gradient-to:rgba(5,150,105,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.hover\:to-teal-600:hover{--tw-gradient-to:#0d9488 var(--tw-gradient-to-position)}.hover\:text-gray-200:hover{--tw-text-opacity:1;color:rgb(229 231 235/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-800:hover{--tw-text-opacity:1;color:rgb(31 41 55/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-slate-100:hover{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:file\:bg-slate-700::file-selector-button:hover{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.focus\:border-\[\#10b981\]:focus,.focus\:border-emerald-500:focus{--tw-border-opacity:1;border-color:rgb(16 185 129/var(--tw-border-opacity,1))}.focus\:border-emerald-600:focus{--tw-border-opacity:1;border-color:rgb(5 150 105/var(--tw-border-opacity,1))}.focus\:border-purple-500:focus{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.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-\[\#10b981\]:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(16 185 129/var(--tw-ring-opacity,1))}.focus\:ring-\[\#10b981\]\/30:focus{--tw-ring-color:rgba(16,185,129,.3)}.focus\:ring-\[\#10b981\]\/35:focus{--tw-ring-color:rgba(16,185,129,.35)}.focus\:ring-emerald-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(16 185 129/var(--tw-ring-opacity,1))}.focus\:ring-emerald-500\/20:focus{--tw-ring-color:rgba(16,185,129,.2)}.focus\:ring-emerald-500\/30:focus{--tw-ring-color:rgba(16,185,129,.3)}.focus\:ring-emerald-500\/40:focus{--tw-ring-color:rgba(16,185,129,.4)}.focus\:ring-emerald-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(5 150 105/var(--tw-ring-opacity,1))}.focus\:ring-purple-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(168 85 247/var(--tw-ring-opacity,1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus\:ring-offset-\[\#0f172a\]:focus{--tw-ring-offset-color:#0f172a}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-\[var\(--np-field\)\]:disabled{background-color:var(--np-field)}.disabled\:text-gray-400:disabled{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.disabled\:opacity-50:disabled{opacity:.5}.peer:checked~.peer-checked\:bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/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))}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.042\)\]:is(.dark *){border-color:rgba(148,163,184,.042)}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.055\)\]:is(.dark *){border-color:rgba(148,163,184,.055)}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.06\)\]:is(.dark *){border-color:rgba(148,163,184,.06)}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.085\)\]:is(.dark *){border-color:rgba(148,163,184,.085)}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.09\)\]:is(.dark *){border-color:rgba(148,163,184,.09)}.dark\:border-emerald-500:is(.dark *){--tw-border-opacity:1;border-color:rgb(16 185 129/var(--tw-border-opacity,1))}.dark\:border-emerald-500\/30:is(.dark *){border-color:rgba(16,185,129,.3)}.dark\:border-emerald-900:is(.dark *){--tw-border-opacity:1;border-color:rgb(6 78 59/var(--tw-border-opacity,1))}.dark\:border-red-900:is(.dark *){--tw-border-opacity:1;border-color:rgb(127 29 29/var(--tw-border-opacity,1))}.dark\:border-yellow-900:is(.dark *){--tw-border-opacity:1;border-color:rgb(113 63 18/var(--tw-border-opacity,1))}.dark\:bg-\[\#0a0d12\]:is(.dark *){--tw-bg-opacity:1;background-color:rgb(10 13 18/var(--tw-bg-opacity,1))}.dark\:bg-\[var\(--np-field\)\]:is(.dark *){background-color:var(--np-field)}.dark\:bg-\[var\(--np-muted\)\]:is(.dark *){background-color:var(--np-muted)}.dark\:bg-\[var\(--np-panel\)\]:is(.dark *){background-color:var(--np-panel)}.dark\:bg-emerald-500\/10:is(.dark *){background-color:rgba(16,185,129,.1)}.dark\:bg-yellow-950:is(.dark *){--tw-bg-opacity:1;background-color:rgb(66 32 6/var(--tw-bg-opacity,1))}.dark\:from-amber-500:is(.dark *){--tw-gradient-from:#f59e0b var(--tw-gradient-from-position);--tw-gradient-to:rgba(245,158,11,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.dark\:from-emerald-500:is(.dark *){--tw-gradient-from:#10b981 var(--tw-gradient-from-position);--tw-gradient-to:rgba(16,185,129,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.dark\:to-amber-600:is(.dark *){--tw-gradient-to:#d97706 var(--tw-gradient-to-position)}.dark\:to-emerald-600:is(.dark *){--tw-gradient-to:#059669 var(--tw-gradient-to-position)}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.dark\:text-emerald-500:is(.dark *){--tw-text-opacity:1;color:rgb(16 185 129/var(--tw-text-opacity,1))}.dark\:text-gray-100:is(.dark *){--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:text-gray-200:is(.dark *){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-300:is(.dark *){--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-gray-500:is(.dark *){--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-red-500:is(.dark *){--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.dark\:text-slate-500:is(.dark *){--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.dark\:text-yellow-200:is(.dark *){--tw-text-opacity:1;color:rgb(254 240 138/var(--tw-text-opacity,1))}.dark\:placeholder-gray-500:is(.dark *)::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:placeholder-gray-500:is(.dark *)::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:shadow-none:is(.dark *){--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.dark\:hover\:border-\[rgba\(148\2c 163\2c 184\2c 0\.12\)\]:hover:is(.dark *){border-color:rgba(148,163,184,.12)}.dark\:hover\:bg-\[\#2a3f5c\]:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(42 63 92/var(--tw-bg-opacity,1))}.dark\:hover\:bg-\[var\(--np-muted\)\]:hover:is(.dark *){background-color:var(--np-muted)}.dark\:hover\:bg-emerald-950:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(2 44 34/var(--tw-bg-opacity,1))}.dark\:hover\:bg-red-950:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(69 10 10/var(--tw-bg-opacity,1))}.dark\:hover\:text-gray-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:hover\:text-gray-200:hover:is(.dark *){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:hover\:text-gray-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:focus\:border-emerald-600:focus:is(.dark *){--tw-border-opacity:1;border-color:rgb(5 150 105/var(--tw-border-opacity,1))}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:block{display:block}.sm\:w-auto{width:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-start{align-items:flex-start}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:gap-0{gap:0}.sm\:p-8{padding:2rem}}@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\:gap-10{gap:2.5rem}.lg\:border-l{border-left-width:1px}.lg\:border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.lg\:pl-10{padding-left:2.5rem}.dark\:lg\:border-\[rgba\(148\2c 163\2c 184\2c 0\.06\)\]:is(.dark *){border-color:rgba(148,163,184,.06)}} \ 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.17 | 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:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;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:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,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}.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}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-right-3{right:-.75rem}.left-0{left:0}.left-3{left:.75rem}.right-1{right:.25rem}.right-1\.5{right:.375rem}.right-2{right:.5rem}.right-3{right:.75rem}.right-6{right:1.5rem}.top-0{top:0}.top-1\/2{top:50%}.top-3{top:.75rem}.top-6{top:1.5rem}.top-\[1\.85rem\]{top:1.85rem}.z-10{z-index:10}.z-50{z-index:50}.z-\[100\]{z-index:100}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.my-8{margin-top:2rem;margin-bottom:2rem}.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}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-8{margin-top:2rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-full{height:100%}.h-screen{height:100vh}.max-h-96{max-height:24rem}.max-h-\[360px\]{max-height:360px}.max-h-\[90vh\]{max-height:90vh}.max-h-\[calc\(100vh-3rem\)\]{max-height:calc(100vh - 3rem)}.min-h-0{min-height:0}.min-h-\[min\(36vh\2c 220px\)\]{min-height:min(36vh,220px)}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-14{width:3.5rem}.w-2{width:.5rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[280px\]{width:280px}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0}.min-w-\[720px\]{min-width:720px}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-\[18rem\]{max-width:18rem}.max-w-\[400px\]{max-width:400px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.transform{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 pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.cursor-text{cursor:text}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,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}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*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-\[rgba\(148\2c 163\2c 184\2c 0\.045\)\]>:not([hidden])~:not([hidden]){border-color:rgba(148,163,184,.045)}.divide-\[rgba\(148\2c 163\2c 184\2c 0\.06\)\]>:not([hidden])~:not([hidden]){border-color:rgba(148,163,184,.06)}.self-stretch{align-self:stretch}.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;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-\[rgba\(148\2c 163\2c 184\2c 0\.045\)\]{border-color:rgba(148,163,184,.045)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.048\)\]{border-color:rgba(148,163,184,.048)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.055\)\]{border-color:rgba(148,163,184,.055)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.06\)\]{border-color:rgba(148,163,184,.06)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.07\)\]{border-color:rgba(148,163,184,.07)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.08\)\]{border-color:rgba(148,163,184,.08)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.09\)\]{border-color:rgba(148,163,184,.09)}.border-\[rgba\(148\2c 163\2c 184\2c 0\.12\)\]{border-color:rgba(148,163,184,.12)}.border-amber-500\/30{border-color:rgba(245,158,11,.3)}.border-emerald-500\/30{border-color:rgba(16,185,129,.3)}.border-emerald-600{--tw-border-opacity:1;border-color:rgb(5 150 105/var(--tw-border-opacity,1))}.border-emerald-700{--tw-border-opacity:1;border-color:rgb(4 120 87/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-200\/80{border-color:rgba(229,231,235,.8)}.border-gray-200\/90{border-color:rgba(229,231,235,.9)}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.border-red-500\/30{border-color:rgba(239,68,68,.3)}.border-red-500\/40{border-color:rgba(239,68,68,.4)}.border-red-700{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity,1))}.border-red-700\/60{border-color:rgba(185,28,28,.6)}.border-red-900{--tw-border-opacity:1;border-color:rgb(127 29 29/var(--tw-border-opacity,1))}.border-red-900\/35{border-color:rgba(127,29,29,.35)}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.border-slate-600{--tw-border-opacity:1;border-color:rgb(71 85 105/var(--tw-border-opacity,1))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-slate-700\/35{border-color:rgba(51,65,85,.35)}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-slate-800\/70{border-color:rgba(30,41,59,.7)}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-\[\#080d12\]{--tw-bg-opacity:1;background-color:rgb(8 13 18/var(--tw-bg-opacity,1))}.bg-\[\#0a0d12\]{--tw-bg-opacity:1;background-color:rgb(10 13 18/var(--tw-bg-opacity,1))}.bg-\[\#10b981\]{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-\[\#152031\]{--tw-bg-opacity:1;background-color:rgb(21 32 49/var(--tw-bg-opacity,1))}.bg-\[\#1e2d42\]{--tw-bg-opacity:1;background-color:rgb(30 45 66/var(--tw-bg-opacity,1))}.bg-\[\#1e2d42\]\/80{background-color:rgba(30,45,66,.8)}.bg-\[var\(--np-canvas\)\]{background-color:var(--np-canvas)}.bg-\[var\(--np-field\)\]{background-color:var(--np-field)}.bg-\[var\(--np-muted\)\]{background-color:var(--np-muted)}.bg-\[var\(--np-panel\)\]{background-color:var(--np-panel)}.bg-amber-400{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:rgba(245,158,11,.1)}.bg-amber-600\/80{background-color:rgba(217,119,6,.8)}.bg-amber-950\/30{background-color:rgba(69,26,3,.3)}.bg-amber-950\/40{background-color:rgba(69,26,3,.4)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-black\/60{background-color:rgba(0,0,0,.6)}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/15{background-color:rgba(16,185,129,.15)}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-emerald-600\/20{background-color:rgba(5,150,105,.2)}.bg-emerald-950{--tw-bg-opacity:1;background-color:rgb(2 44 34/var(--tw-bg-opacity,1))}.bg-emerald-950\/30{background-color:rgba(2,44,34,.3)}.bg-gray-100\/80{background-color:rgba(243,244,246,.8)}.bg-gray-200\/80{background-color:rgba(229,231,235,.8)}.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-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-purple-600\/20{background-color:rgba(147,51,234,.2)}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:rgba(239,68,68,.1)}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-950\/25{background-color:rgba(69,10,10,.25)}.bg-red-950\/40{background-color:rgba(69,10,10,.4)}.bg-red-950\/50{background-color:rgba(69,10,10,.5)}.bg-red-950\/60{background-color:rgba(69,10,10,.6)}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-slate-800\/80{background-color:rgba(30,41,59,.8)}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-slate-900\/30{background-color:rgba(15,23,42,.3)}.bg-slate-900\/45{background-color:rgba(15,23,42,.45)}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-slate-950\/25{background-color:rgba(2,6,23,.25)}.bg-slate-950\/35{background-color:rgba(2,6,23,.35)}.bg-slate-950\/40{background-color:rgba(2,6,23,.4)}.bg-slate-950\/60{background-color:rgba(2,6,23,.6)}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-\[radial-gradient\(ellipse_at_center\2c _\#141f2e_0\%\2c _\#0c111c_55\%\2c _\#0a0d12_100\%\)\]{background-image:radial-gradient(ellipse at center,#141f2e 0,#0c111c 55%,#0a0d12 100%)}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-\[var\(--np-raised\)\]{--tw-gradient-from:var(--np-raised) var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,0%,100%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-amber-300{--tw-gradient-from:#fcd34d var(--tw-gradient-from-position);--tw-gradient-to:rgba(252,211,77,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-emerald-400{--tw-gradient-from:#34d399 var(--tw-gradient-from-position);--tw-gradient-to:rgba(52,211,153,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-emerald-500{--tw-gradient-from:#10b981 var(--tw-gradient-from-position);--tw-gradient-to:rgba(16,185,129,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-\[var\(--np-panel\)\]{--tw-gradient-to:var(--np-panel) var(--tw-gradient-to-position)}.to-amber-400{--tw-gradient-to:#fbbf24 var(--tw-gradient-to-position)}.to-emerald-500{--tw-gradient-to:#10b981 var(--tw-gradient-to-position)}.to-teal-500{--tw-gradient-to:#14b8a6 var(--tw-gradient-to-position)}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.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-3\.5{padding-left:.875rem;padding-right:.875rem}.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-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.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-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pr-10{padding-right:2.5rem}.pr-20{padding-right:5rem}.pr-24{padding-right:6rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pr-\[4\.25rem\]{padding-right:4.25rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[0\.65rem\]{font-size:.65rem}.text-\[11px\]{font-size:11px}.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-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-5{line-height:1.25rem}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.12em\]{letter-spacing:.12em}.tracking-\[0\.14em\]{letter-spacing:.14em}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-\[\#718096\]{--tw-text-opacity:1;color:rgb(113 128 150/var(--tw-text-opacity,1))}.text-\[\#94a3b8\]{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-amber-200{--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.text-amber-300\/70{color:rgba(252,211,77,.7)}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-emerald-200{--tw-text-opacity:1;color:rgb(167 243 208/var(--tw-text-opacity,1))}.text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-emerald-400\/90{color:rgba(52,211,153,.9)}.text-emerald-500{--tw-text-opacity:1;color:rgb(16 185 129/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/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-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-purple-300{--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.text-red-200{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-sky-400{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.text-slate-100{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity,1))}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.shadow-\[0_0_0_1px_rgba\(148\2c 163\2c 184\2c 0\.04\)\]{--tw-shadow:0 0 0 1px rgba(148,163,184,.04);--tw-shadow-colored:0 0 0 1px var(--tw-shadow-color)}.shadow-\[0_0_0_1px_rgba\(148\2c 163\2c 184\2c 0\.04\)\],.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-none{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000}.shadow-none,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.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);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-emerald-700\/40{--tw-ring-color:rgba(4,120,87,.4)}.ring-slate-500\/25{--tw-ring-color:rgba(100,116,139,.25)}.ring-slate-700{--tw-ring-opacity:1;--tw-ring-color:rgb(51 65 85/var(--tw-ring-opacity,1))}.blur{--tw-blur:blur(8px)}.blur,.grayscale{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)}.grayscale{--tw-grayscale:grayscale(100%)}.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)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.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}.duration-300{transition-duration:.3s}.file\:mr-4::file-selector-button{margin-right:1rem}.file\:rounded-lg::file-selector-button{border-radius:.5rem}.file\:border-0::file-selector-button{border-width:0}.file\:bg-slate-800::file-selector-button{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.file\:px-3::file-selector-button{padding-left:.75rem;padding-right:.75rem}.file\:py-2::file-selector-button{padding-top:.5rem;padding-bottom:.5rem}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.file\:text-slate-100::file-selector-button{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.placeholder\:text-gray-500::-moz-placeholder{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.placeholder\:text-gray-500::placeholder{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.placeholder\:text-gray-600::-moz-placeholder{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.placeholder\:text-gray-600::placeholder{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.placeholder\:text-slate-500::-moz-placeholder{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.placeholder\:text-slate-500::placeholder{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,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\: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)}.last\:border-b-0:last-child{border-bottom-width:0}.hover\:border-emerald-500\/25:hover{border-color:rgba(16,185,129,.25)}.hover\:border-gray-400:hover{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.hover\:bg-\[\#0d9668\]:hover{--tw-bg-opacity:1;background-color:rgb(13 150 104/var(--tw-bg-opacity,1))}.hover\:bg-\[\#1e2d42\]:hover{--tw-bg-opacity:1;background-color:rgb(30 45 66/var(--tw-bg-opacity,1))}.hover\:bg-\[rgba\(148\2c 163\2c 184\2c 0\.08\)\]:hover{background-color:rgba(148,163,184,.08)}.hover\:bg-amber-500\/20:hover{background-color:rgba(245,158,11,.2)}.hover\:bg-amber-600:hover{--tw-bg-opacity:1;background-color:rgb(217 119 6/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-500:hover{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.hover\:bg-emerald-500\/25:hover{background-color:rgba(16,185,129,.25)}.hover\:bg-emerald-500\/\[0\.06\]:hover{background-color:rgba(16,185,129,.06)}.hover\:bg-emerald-500\/\[0\.08\]:hover{background-color:rgba(16,185,129,.08)}.hover\:bg-emerald-600\/30:hover{background-color:rgba(5,150,105,.3)}.hover\:bg-emerald-900:hover{--tw-bg-opacity:1;background-color:rgb(6 78 59/var(--tw-bg-opacity,1))}.hover\:bg-emerald-950:hover{--tw-bg-opacity:1;background-color:rgb(2 44 34/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\/80:hover{background-color:rgba(229,231,235,.8)}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/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-purple-600\/30:hover{background-color:rgba(147,51,234,.3)}.hover\:bg-red-500:hover{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.hover\:bg-red-600\/70:hover{background-color:rgba(220,38,38,.7)}.hover\:bg-red-950:hover{--tw-bg-opacity:1;background-color:rgb(69 10 10/var(--tw-bg-opacity,1))}.hover\:bg-slate-700:hover{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.hover\:bg-slate-800:hover{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.hover\:from-emerald-600:hover{--tw-gradient-from:#059669 var(--tw-gradient-from-position);--tw-gradient-to:rgba(5,150,105,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.hover\:to-teal-600:hover{--tw-gradient-to:#0d9488 var(--tw-gradient-to-position)}.hover\:text-amber-300:hover{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.hover\:text-emerald-200:hover{--tw-text-opacity:1;color:rgb(167 243 208/var(--tw-text-opacity,1))}.hover\:text-emerald-300:hover{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.hover\:text-gray-100:hover{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.hover\:text-gray-200:hover{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.hover\:text-gray-300:hover{--tw-text-opacity:1;color:rgb(209 213 219/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-800:hover{--tw-text-opacity:1;color:rgb(31 41 55/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-slate-100:hover{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:file\:bg-slate-700::file-selector-button:hover{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.focus\:border-\[\#10b981\]:focus,.focus\:border-emerald-500:focus{--tw-border-opacity:1;border-color:rgb(16 185 129/var(--tw-border-opacity,1))}.focus\:border-emerald-600:focus{--tw-border-opacity:1;border-color:rgb(5 150 105/var(--tw-border-opacity,1))}.focus\:border-purple-500:focus{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.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-\[\#10b981\]:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(16 185 129/var(--tw-ring-opacity,1))}.focus\:ring-\[\#10b981\]\/30:focus{--tw-ring-color:rgba(16,185,129,.3)}.focus\:ring-\[\#10b981\]\/35:focus{--tw-ring-color:rgba(16,185,129,.35)}.focus\:ring-emerald-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(16 185 129/var(--tw-ring-opacity,1))}.focus\:ring-emerald-500\/20:focus{--tw-ring-color:rgba(16,185,129,.2)}.focus\:ring-emerald-500\/30:focus{--tw-ring-color:rgba(16,185,129,.3)}.focus\:ring-emerald-500\/40:focus{--tw-ring-color:rgba(16,185,129,.4)}.focus\:ring-emerald-600:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(5 150 105/var(--tw-ring-opacity,1))}.focus\:ring-purple-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(168 85 247/var(--tw-ring-opacity,1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus\:ring-offset-\[\#0f172a\]:focus{--tw-ring-offset-color:#0f172a}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-\[var\(--np-field\)\]:disabled{background-color:var(--np-field)}.disabled\:text-gray-400:disabled{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.disabled\:opacity-50:disabled{opacity:.5}.peer:checked~.peer-checked\:bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/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))}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.042\)\]:is(.dark *){border-color:rgba(148,163,184,.042)}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.055\)\]:is(.dark *){border-color:rgba(148,163,184,.055)}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.06\)\]:is(.dark *){border-color:rgba(148,163,184,.06)}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.085\)\]:is(.dark *){border-color:rgba(148,163,184,.085)}.dark\:border-\[rgba\(148\2c 163\2c 184\2c 0\.09\)\]:is(.dark *){border-color:rgba(148,163,184,.09)}.dark\:border-emerald-500:is(.dark *){--tw-border-opacity:1;border-color:rgb(16 185 129/var(--tw-border-opacity,1))}.dark\:border-emerald-500\/30:is(.dark *){border-color:rgba(16,185,129,.3)}.dark\:border-emerald-900:is(.dark *){--tw-border-opacity:1;border-color:rgb(6 78 59/var(--tw-border-opacity,1))}.dark\:border-red-900:is(.dark *){--tw-border-opacity:1;border-color:rgb(127 29 29/var(--tw-border-opacity,1))}.dark\:border-yellow-900:is(.dark *){--tw-border-opacity:1;border-color:rgb(113 63 18/var(--tw-border-opacity,1))}.dark\:bg-\[\#0a0d12\]:is(.dark *){--tw-bg-opacity:1;background-color:rgb(10 13 18/var(--tw-bg-opacity,1))}.dark\:bg-\[var\(--np-field\)\]:is(.dark *){background-color:var(--np-field)}.dark\:bg-\[var\(--np-muted\)\]:is(.dark *){background-color:var(--np-muted)}.dark\:bg-\[var\(--np-panel\)\]:is(.dark *){background-color:var(--np-panel)}.dark\:bg-emerald-500\/10:is(.dark *){background-color:rgba(16,185,129,.1)}.dark\:bg-yellow-950:is(.dark *){--tw-bg-opacity:1;background-color:rgb(66 32 6/var(--tw-bg-opacity,1))}.dark\:from-amber-500:is(.dark *){--tw-gradient-from:#f59e0b var(--tw-gradient-from-position);--tw-gradient-to:rgba(245,158,11,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.dark\:from-emerald-500:is(.dark *){--tw-gradient-from:#10b981 var(--tw-gradient-from-position);--tw-gradient-to:rgba(16,185,129,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.dark\:to-amber-600:is(.dark *){--tw-gradient-to:#d97706 var(--tw-gradient-to-position)}.dark\:to-emerald-600:is(.dark *){--tw-gradient-to:#059669 var(--tw-gradient-to-position)}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.dark\:text-emerald-500:is(.dark *){--tw-text-opacity:1;color:rgb(16 185 129/var(--tw-text-opacity,1))}.dark\:text-gray-100:is(.dark *){--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:text-gray-200:is(.dark *){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:text-gray-300:is(.dark *){--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.dark\:text-gray-500:is(.dark *){--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.dark\:text-red-500:is(.dark *){--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.dark\:text-slate-500:is(.dark *){--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.dark\:text-yellow-200:is(.dark *){--tw-text-opacity:1;color:rgb(254 240 138/var(--tw-text-opacity,1))}.dark\:placeholder-gray-500:is(.dark *)::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:placeholder-gray-500:is(.dark *)::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.dark\:shadow-none:is(.dark *){--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.dark\:hover\:border-\[rgba\(148\2c 163\2c 184\2c 0\.12\)\]:hover:is(.dark *){border-color:rgba(148,163,184,.12)}.dark\:hover\:bg-\[\#2a3f5c\]:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(42 63 92/var(--tw-bg-opacity,1))}.dark\:hover\:bg-\[var\(--np-muted\)\]:hover:is(.dark *){background-color:var(--np-muted)}.dark\:hover\:bg-emerald-950:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(2 44 34/var(--tw-bg-opacity,1))}.dark\:hover\:bg-red-950:hover:is(.dark *){--tw-bg-opacity:1;background-color:rgb(69 10 10/var(--tw-bg-opacity,1))}.dark\:hover\:text-gray-100:hover:is(.dark *){--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.dark\:hover\:text-gray-200:hover:is(.dark *){--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.dark\:hover\:text-gray-300:hover:is(.dark *){--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.dark\:focus\:border-emerald-600:focus:is(.dark *){--tw-border-opacity:1;border-color:rgb(5 150 105/var(--tw-border-opacity,1))}@media (min-width:640px){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:block{display:block}.sm\:w-auto{width:auto}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-start{align-items:flex-start}.sm\:items-center{align-items:center}.sm\:justify-end{justify-content:flex-end}.sm\:justify-between{justify-content:space-between}.sm\:gap-0{gap:0}.sm\:p-8{padding:2rem}}@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\:gap-10{gap:2.5rem}.lg\:border-l{border-left-width:1px}.lg\:border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.lg\:pl-10{padding-left:2.5rem}.dark\:lg\:border-\[rgba\(148\2c 163\2c 184\2c 0\.06\)\]:is(.dark *){border-color:rgba(148,163,184,.06)}} \ No newline at end of file diff --git a/internal/db/schema_patches.go b/internal/db/schema_patches.go new file mode 100644 index 0000000..a7a683f --- /dev/null +++ b/internal/db/schema_patches.go @@ -0,0 +1,51 @@ +package db + +import "database/sql" + +// ApplySchemaPatches runs idempotent DDL for installs that already applied an older baseline schema. +func ApplySchemaPatches(db *sql.DB) error { + if db == nil { + return nil + } + const patch = ` +CREATE TABLE IF NOT EXISTS vpn_groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS vpn_group_members ( + group_id TEXT NOT NULL, + vpn_user_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (group_id, vpn_user_id), + FOREIGN KEY(group_id) REFERENCES vpn_groups(id) ON DELETE CASCADE, + FOREIGN KEY(vpn_user_id) REFERENCES vpn_users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_vpn_group_members_user ON vpn_group_members(vpn_user_id); + +DROP INDEX IF EXISTS idx_vpn_group_firewall_rules_group; +DROP TABLE IF EXISTS vpn_group_firewall_rules; + +CREATE TABLE IF NOT EXISTS vpn_group_pcq ( + group_id TEXT PRIMARY KEY, + download_limit_kbps INTEGER NULL, + upload_limit_kbps INTEGER NULL, + burst_download_kbps INTEGER NULL, + burst_upload_kbps INTEGER NULL, + pcq_classifier TEXT NOT NULL DEFAULT 'dual', + is_disabled INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(group_id) REFERENCES vpn_groups(id) ON DELETE CASCADE +); +` + if _, err := db.Exec(patch); err != nil { + return err + } + // Idempotent column addition for existing installs (ignore error if column already exists). + _, _ = db.Exec(`ALTER TABLE vpn_group_pcq ADD COLUMN is_disabled INTEGER NOT NULL DEFAULT 0`) + return nil +} diff --git a/internal/pcq/apply.go b/internal/pcq/apply.go new file mode 100644 index 0000000..19bd909 --- /dev/null +++ b/internal/pcq/apply.go @@ -0,0 +1,80 @@ +package pcq + +import ( + "database/sql" + "log/slog" + "sync" +) + +var applyMu sync.Mutex + +// Apply rebuilds tc state on wgIface from vpn_group_pcq + memberships. When disabled, only removes netplug qdiscs. +func Apply(db *sql.DB, wgIface string, disabled bool, opts ApplyOpts) ([]string, error) { + applyMu.Lock() + defer applyMu.Unlock() + + if lg, ok := opts.log(); ok { + lg.Info("netplug.pcq.apply.begin", + slog.String("component", "pcq"), + slog.String("wg_iface", wgIface), + slog.Bool("disabled", disabled), + ) + } + + var peers map[string]PeerLimit + var err error + if !disabled { + peers, err = LoadPeerLimits(db) + if err != nil { + if lg, ok := opts.log(); ok { + lg.Error("netplug.pcq.peers_load_failed", + slog.String("component", "pcq"), + slog.String("err", err.Error()), + ) + } + return nil, err + } + } + if peers == nil { + peers = make(map[string]PeerLimit) + } + + if lg, ok := opts.log(); ok { + nd, nu := summarizePeerDirections(peers) + lg.Info("netplug.pcq.peers_resolved", + slog.String("component", "pcq"), + slog.Int("peer_count", len(peers)), + slog.Bool("needs_download_shaping", nd), + slog.Bool("needs_upload_shaping", nu), + slog.Any("peers", peersApplyRecords(peers)), + ) + } + + cmds := Plan(wgIface, peers) + lines := FormatPlan(cmds) + + if lg, ok := opts.log(); ok { + lg.Info("netplug.pcq.plan_built", + slog.String("component", "pcq"), + slog.Int("command_count", len(cmds)), + ) + } + + if err := runCommands(cmds, opts); err != nil { + if lg, ok := opts.log(); ok { + lg.Error("netplug.pcq.exec_failed", + slog.String("component", "pcq"), + slog.String("err", err.Error()), + ) + } + return lines, err + } + + if lg, ok := opts.log(); ok { + lg.Info("netplug.pcq.apply_complete", + slog.String("component", "pcq"), + slog.Int("commands_executed", len(cmds)), + ) + } + return lines, nil +} diff --git a/internal/pcq/apply_linux.go b/internal/pcq/apply_linux.go new file mode 100644 index 0000000..7f864f6 --- /dev/null +++ b/internal/pcq/apply_linux.go @@ -0,0 +1,57 @@ +//go:build linux + +package pcq + +import ( + "bytes" + "fmt" + "log/slog" + "os/exec" + "strings" + "time" +) + +func runCommands(cmds [][]string, opts ApplyOpts) error { + lg, debug := opts.log() + for i, argv := range cmds { + if len(argv) == 0 { + continue + } + if debug { + lg.Info("netplug.pcq.tc.command", + slog.String("component", "pcq"), + slog.String("phase", "start"), + slog.Int("index", i), + slog.Any("argv", argv), + ) + } + start := time.Now() + c := exec.Command(argv[0], argv[1:]...) + var buf bytes.Buffer + c.Stderr = &buf + c.Stdout = &buf + err := c.Run() + out := strings.TrimSpace(buf.String()) + if debug { + args := []any{ + slog.String("component", "pcq"), + slog.String("phase", "done"), + slog.Int("index", i), + slog.String("program", argv[0]), + slog.Duration("duration", time.Since(start)), + slog.Bool("ok", err == nil), + } + if out != "" { + args = append(args, slog.String("output", out)) + } + if err != nil { + args = append(args, slog.String("exec_err", err.Error())) + } + lg.Info("netplug.pcq.tc.command", args...) + } + if err != nil { + return fmt.Errorf("%s: %w: %s", argv[0], err, out) + } + } + return nil +} diff --git a/internal/pcq/apply_stub.go b/internal/pcq/apply_stub.go new file mode 100644 index 0000000..d079dd5 --- /dev/null +++ b/internal/pcq/apply_stub.go @@ -0,0 +1,22 @@ +//go:build !linux + +package pcq + +import "log/slog" + +func runCommands(cmds [][]string, opts ApplyOpts) error { + if lg, ok := opts.log(); ok { + for i, argv := range cmds { + if len(argv) == 0 { + continue + } + lg.Info("netplug.pcq.tc.command_skipped", + slog.String("component", "pcq"), + slog.String("reason", "non_linux_build"), + slog.Int("index", i), + slog.Any("argv", argv), + ) + } + } + return nil +} diff --git a/internal/pcq/debug_log.go b/internal/pcq/debug_log.go new file mode 100644 index 0000000..343ec52 --- /dev/null +++ b/internal/pcq/debug_log.go @@ -0,0 +1,62 @@ +package pcq + +import ( + "log/slog" + "sort" +) + +// ApplyOpts enables structured logging when NETPLUG_DEBUG (or equivalent) is on at the caller. +type ApplyOpts struct { + Debug bool + Logger *slog.Logger +} + +func (o ApplyOpts) log() (*slog.Logger, bool) { + if !o.Debug || o.Logger == nil { + return nil, false + } + return o.Logger, true +} + +type peerApplyRecord struct { + TunnelIPv4 string `json:"tunnel_ipv4"` + DownloadKbps int `json:"download_kbps,omitempty"` + UploadKbps int `json:"upload_kbps,omitempty"` + BurstDownloadKbps int `json:"burst_download_kbps,omitempty"` + BurstUploadKbps int `json:"burst_upload_kbps,omitempty"` +} + +func peersApplyRecords(peers map[string]PeerLimit) []peerApplyRecord { + if len(peers) == 0 { + return nil + } + keys := make([]string, 0, len(peers)) + for ip := range peers { + keys = append(keys, ip) + } + sort.Strings(keys) + out := make([]peerApplyRecord, 0, len(keys)) + for _, ip := range keys { + pl := peers[ip] + out = append(out, peerApplyRecord{ + TunnelIPv4: ip, + DownloadKbps: pl.DownloadKbps, + UploadKbps: pl.UploadKbps, + BurstDownloadKbps: pl.BurstDownloadKbps, + BurstUploadKbps: pl.BurstUploadKbps, + }) + } + return out +} + +func summarizePeerDirections(peers map[string]PeerLimit) (needsDownload, needsUpload bool) { + for _, pl := range peers { + if pl.DownloadKbps > 0 { + needsDownload = true + } + if pl.UploadKbps > 0 { + needsUpload = true + } + } + return needsDownload, needsUpload +} diff --git a/internal/pcq/load.go b/internal/pcq/load.go new file mode 100644 index 0000000..6abf020 --- /dev/null +++ b/internal/pcq/load.go @@ -0,0 +1,151 @@ +package pcq + +import ( + "database/sql" + "strings" +) + +// PeerLimit is the effective bitrate cap(s) applied to outbound/inbound shaping for one tunnel IPv4. +type PeerLimit struct { + DownloadKbps int + UploadKbps int + // Optional burst knobs (Mbps↔stored kb/s same as elsewhere in the app). Used only when > 0. + BurstDownloadKbps int + BurstUploadKbps int +} + +func nz64(v sql.NullInt64) int { + if !v.Valid || v.Int64 <= 0 { + return 0 + } + return int(v.Int64) +} + +func classifyRow(dl, ul, bd, bu sql.NullInt64, clf string) (edl, eul, ebd, ebu int) { + switch clf { + case "dst-download": + return nz64(dl), 0, nz64(bd), 0 + case "src-upload": + return 0, nz64(ul), 0, nz64(bu) + default: + return nz64(dl), nz64(ul), nz64(bd), nz64(bu) + } +} + +func mergeMin(cur, add int) int { + if add <= 0 { + return cur + } + if cur <= 0 || add < cur { + return add + } + return cur +} + +// LoadPeerLimits returns map[tunnelIPv4]=merged limits across all groups where the user is an enabled WG peer. +func LoadPeerLimits(db *sql.DB) (map[string]PeerLimit, error) { + rows, err := db.Query(` + SELECT COALESCE(u.allowed_ips,''), + p.download_limit_kbps, + p.upload_limit_kbps, + p.burst_download_kbps, + p.burst_upload_kbps, + IFNULL(TRIM(p.pcq_classifier),'dual') + FROM vpn_users u + INNER JOIN vpn_group_members m ON m.vpn_user_id = u.id + INNER JOIN vpn_group_pcq p ON p.group_id = m.group_id + WHERE u.server_id = 'wireguard' AND u.is_enabled = 1 AND p.is_disabled = 0 + `) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]PeerLimit) + for rows.Next() { + var allowedIPs string + var dl, ul, bd, bu sql.NullInt64 + var clf string + if err := rows.Scan(&allowedIPs, &dl, &ul, &bd, &bu, &clf); err != nil { + return nil, err + } + ip := TunnelIPv4(allowedIPs) + if ip == "" { + continue + } + edl, eul, ebd, ebu := classifyRow(dl, ul, bd, bu, clf) + if edl <= 0 && eul <= 0 { + continue + } + pl, ok := out[ip] + if !ok { + pl = PeerLimit{} + } + pl.DownloadKbps = mergeMin(pl.DownloadKbps, edl) + pl.UploadKbps = mergeMin(pl.UploadKbps, eul) + pl.BurstDownloadKbps = mergeMin(pl.BurstDownloadKbps, ebd) + pl.BurstUploadKbps = mergeMin(pl.BurstUploadKbps, ebu) + out[ip] = pl + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +// LoadPeerLimitsForGroup returns merged limits using only vpn_group_members and vpn_group_pcq for groupID +// (this group's preset only—not merged with caps from other groups). +func LoadPeerLimitsForGroup(db *sql.DB, groupID string) (map[string]PeerLimit, error) { + groupID = strings.TrimSpace(groupID) + if groupID == "" { + return map[string]PeerLimit{}, nil + } + + rows, err := db.Query(` + SELECT COALESCE(u.allowed_ips,''), + p.download_limit_kbps, + p.upload_limit_kbps, + p.burst_download_kbps, + p.burst_upload_kbps, + IFNULL(TRIM(p.pcq_classifier),'dual') + FROM vpn_users u + INNER JOIN vpn_group_members m ON m.vpn_user_id = u.id AND m.group_id = ? + INNER JOIN vpn_group_pcq p ON p.group_id = m.group_id + WHERE u.server_id = 'wireguard' AND u.is_enabled = 1 AND p.is_disabled = 0 + `, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]PeerLimit) + for rows.Next() { + var allowedIPs string + var dl, ul, bd, bu sql.NullInt64 + var clf string + if err := rows.Scan(&allowedIPs, &dl, &ul, &bd, &bu, &clf); err != nil { + return nil, err + } + ip := TunnelIPv4(allowedIPs) + if ip == "" { + continue + } + edl, eul, ebd, ebu := classifyRow(dl, ul, bd, bu, clf) + if edl <= 0 && eul <= 0 { + continue + } + pl, ok := out[ip] + if !ok { + pl = PeerLimit{} + } + pl.DownloadKbps = mergeMin(pl.DownloadKbps, edl) + pl.UploadKbps = mergeMin(pl.UploadKbps, eul) + pl.BurstDownloadKbps = mergeMin(pl.BurstDownloadKbps, ebd) + pl.BurstUploadKbps = mergeMin(pl.BurstUploadKbps, ebu) + out[ip] = pl + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/pcq/plan.go b/internal/pcq/plan.go new file mode 100644 index 0000000..487d2f3 --- /dev/null +++ b/internal/pcq/plan.go @@ -0,0 +1,237 @@ +package pcq + +import ( + "database/sql" + "fmt" + "math" + "sort" + "strings" +) + +// kbpsToRateSpec converts stored kb/s (1024 kb per Mbps from the UI) to an iproute tc rate literal. +func kbpsToRateSpec(kbps int) string { + if kbps <= 0 { + return "" + } + mbps := float64(kbps) / 1024.0 + bitPerSec := int64(math.Max(8000, math.Round(mbps*1e6))) + return fmt.Sprintf("%dbit", bitPerSec) +} + +func burstBytesPreferringConfig(burstKbps, rateKbps int) int { + var fromRate int + if rateKbps > 0 { + mbps := float64(rateKbps) / 1024.0 + bitPerSec := mbps * 1e6 + fromRate = int(math.Max(1600, math.Round(bitPerSec/8.0*0.05))) + } + if burstKbps > 0 { + mbpsBurst := float64(burstKbps) / 1024.0 + bitPerSec := mbpsBurst * 1e6 + fromBurst := int(math.Max(1600, math.Round(bitPerSec/8.0*0.05))) + if fromRate > 0 && fromBurst > fromRate { + return fromRate + } + return fromBurst + } + if fromRate > 0 { + return fromRate + } + return 12500 +} + +// peerShapeAppend appends shaping-only tc argument vectors for peers (no qdisc teardown or root resets). +func peerShapeAppend(iface string, peers map[string]PeerLimit) [][]string { + if peers == nil { + peers = make(map[string]PeerLimit) + } + var cmds [][]string + if iface == "" || len(peers) == 0 { + return cmds + } + + keys := make([]string, 0, len(peers)) + for ip := range peers { + keys = append(keys, ip) + } + sort.Strings(keys) + + needsDL, needsUL := false, false + for _, ip := range keys { + pl := peers[ip] + if pl.DownloadKbps > 0 { + needsDL = true + } + if pl.UploadKbps > 0 { + needsUL = true + } + } + + const rootCeil = "100000mbit" + if needsDL { + cmds = append(cmds, []string{"tc", "qdisc", "add", "dev", iface, "root", "handle", "1:", "htb", "default", "30"}) + cmds = append(cmds, []string{ + "tc", "class", "add", "dev", iface, "parent", "1:", "classid", "1:1", "htb", + "rate", rootCeil, "ceil", rootCeil, + }) + cmds = append(cmds, []string{ + "tc", "class", "add", "dev", iface, "parent", "1:1", "classid", "1:30", "htb", + "rate", rootCeil, "ceil", rootCeil, "prio", "7", + }) + fi := uint32(50) + minor := uint32(100) + for _, ip := range keys { + pl := peers[ip] + if pl.DownloadKbps <= 0 { + continue + } + classStr := fmt.Sprintf("1:%x", minor) + rateSpec := kbpsToRateSpec(pl.DownloadKbps) + if rateSpec == "" { + minor++ + continue + } + cl := []string{ + "tc", "class", "add", "dev", iface, "parent", "1:1", "classid", classStr, + "htb", "rate", rateSpec, "ceil", rateSpec, "prio", "5", + } + if pl.BurstDownloadKbps > 0 { + b := burstBytesPreferringConfig(pl.BurstDownloadKbps, pl.DownloadKbps) + cl = append(cl, "burst", fmt.Sprintf("%d", b), "cburst", fmt.Sprintf("%d", b)) + } + cmds = append(cmds, cl) + cmds = append(cmds, []string{ + "tc", "filter", "add", "dev", iface, "protocol", "ip", "parent", "1:", "prio", fmt.Sprintf("%d", fi), + "u32", "match", "ip", "dst", ip + "/32", "flowid", classStr, + }) + fi++ + minor++ + if minor > 0xff00 { + break + } + } + } + + if needsUL { + cmds = append(cmds, []string{"tc", "qdisc", "add", "dev", iface, "handle", "ffff:", "ingress"}) + pi := uint32(10) + for _, ip := range keys { + pl := peers[ip] + if pl.UploadKbps <= 0 { + continue + } + r := kbpsToRateSpec(pl.UploadKbps) + if r == "" { + continue + } + burst := burstBytesPreferringConfig(pl.BurstUploadKbps, pl.UploadKbps) + cmds = append(cmds, []string{ + "tc", "filter", "add", "dev", iface, "parent", "ffff:", "protocol", "ip", "prio", fmt.Sprintf("%d", pi), + "u32", "match", "ip", "src", ip + "/32", + "police", "rate", r, "burst", fmt.Sprintf("%d", burst), + "mtu", "1500", + "conform-exceed", "drop", + }) + pi++ + } + } + return cmds +} + +// Plan returns shell-style tc argument vectors for a full apply (starts with teardown of existing netplug root/ingress). +func Plan(wgIface string, peers map[string]PeerLimit) [][]string { + if peers == nil { + peers = make(map[string]PeerLimit) + } + iface := strings.TrimSpace(wgIface) + var cmds [][]string + cmds = append(cmds, []string{"sh", "-c", "tc qdisc del dev " + iface + " ingress 2>/dev/null || true"}) + cmds = append(cmds, []string{"sh", "-c", "tc qdisc del dev " + iface + " root 2>/dev/null || true"}) + if iface == "" { + return cmds + } + + if len(peers) == 0 { + return cmds + } + + return append(cmds, peerShapeAppend(iface, peers)...) +} + +// PlanForPeers returns shaping-only vectors for peers (omit iface-wide teardown), for UI previews scoped to one source of limits. +func PlanForPeers(wgIface string, peers map[string]PeerLimit) [][]string { + return peerShapeAppend(strings.TrimSpace(wgIface), peers) +} + +// PlanCommands loads peer limits then returns formatted command lines for display / logging. +func PlanCommands(db *sql.DB, wgIface string) ([]string, error) { + m, err := LoadPeerLimits(db) + if err != nil { + return nil, err + } + return FormatPlan(Plan(wgIface, m)), nil +} + +// PlanCommandsForGroup returns formatted tc lines for one group's preset and members only (not merged with other groups). +func PlanCommandsForGroup(db *sql.DB, wgIface, groupID string) ([]string, error) { + var presetCount int + if err := db.QueryRow(`SELECT COUNT(*) FROM vpn_group_pcq WHERE group_id = ?`, groupID).Scan(&presetCount); err != nil { + return nil, err + } + if presetCount == 0 { + return []string{ + "# No saved queue preset for this group yet.", + "# Save Mbps caps below to preview shaping for this group's members only.", + "#", + "# Live apply still merges presets from every group when computing caps per tunnel peer.", + }, nil + } + + m, err := LoadPeerLimitsForGroup(db, groupID) + if err != nil { + return nil, err + } + if len(m) == 0 { + return []string{ + "# This group's preset has no effective shaping lines yet:", + "# set upload/download Mbps or ensure members use WireGuard with IPv4 tunnel addresses.", + }, nil + } + + iface := strings.TrimSpace(wgIface) + if iface == "" { + return []string{"# Configure a WireGuard interface name to preview interface-specific tc lines."}, nil + } + + lines := FormatPlan(PlanForPeers(iface, m)) + if len(lines) == 0 { + return []string{"# (No tc shaping lines for empty interface or limits.)"}, nil + } + return lines, nil +} + +func FormatPlan(cmds [][]string) []string { + lines := make([]string, 0, len(cmds)+2) + for _, c := range cmds { + line := quotedJoin(c) + lines = append(lines, line) + } + return lines +} + +func quotedJoin(args []string) string { + var b strings.Builder + for i, a := range args { + if i > 0 { + b.WriteByte(' ') + } + if strings.ContainsAny(a, " '\"&|;<>`$\n") || a == "" { + b.WriteString(`'`) + b.WriteString(strings.ReplaceAll(a, `'`, `'\''`)) + b.WriteString(`'`) + } else { + b.WriteString(a) + } + } + return b.String() +} diff --git a/internal/pcq/tunnel.go b/internal/pcq/tunnel.go new file mode 100644 index 0000000..349c7a2 --- /dev/null +++ b/internal/pcq/tunnel.go @@ -0,0 +1,38 @@ +package pcq + +import ( + "net" + "strings" +) + +// FirstTunnelIPCIDR mirrors the app's allowed_ips convention: primary tunnel is the segment before first comma. +func FirstTunnelIPCIDR(allowedIPs string) string { + s := strings.TrimSpace(allowedIPs) + if s == "" { + return "" + } + if i := strings.IndexByte(s, ','); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + return s +} + +// TunnelIPv4 returns dotted IPv4 for the primary tunnel segment, or empty. +func TunnelIPv4(allowedIPs string) string { + s := strings.TrimSpace(FirstTunnelIPCIDR(allowedIPs)) + if s == "" { + return "" + } + if strings.Contains(s, "/") { + host, _, err := net.ParseCIDR(s) + if err != nil || host == nil || host.To4() == nil { + return "" + } + return host.To4().String() + } + ip := net.ParseIP(s).To4() + if ip == nil { + return "" + } + return ip.String() +} diff --git a/internal/view/templates/base.tmpl b/internal/view/templates/base.tmpl index 63264e0..7397ff5 100644 --- a/internal/view/templates/base.tmpl +++ b/internal/view/templates/base.tmpl @@ -165,7 +165,7 @@ document.querySelectorAll('#sidebar a.np-spa-link').forEach(function (a) { var href = a.getAttribute('href'); if (!href) return; - var on = href === path; + var on = href === path || (href !== '/ui' && path.startsWith(href + '/')); a.classList.toggle('np-active', on); }); } @@ -346,32 +346,110 @@ var root = document.getElementById('np-toast-root'); if (!root) return; var t = (type || 'success').toLowerCase(); - var isErr = (t === 'error' || t === 'danger' || t === 'fail' || t === 'failed'); + var isErr = (t === 'error' || t === 'danger' || t === 'fail' || t === 'failed'); + var isWarn = (t === 'warning' || t === 'warn'); + + var cfg = isErr ? { + border : 'rgba(185,28,28,0.45)', + bg : 'rgba(69,10,10,0.92)', + accent : '#ef4444', + title : '#fca5a5', + body : 'rgba(252,165,165,0.75)', + label : 'Error', + icon : '', + } : isWarn ? { + border : 'rgba(180,130,0,0.4)', + bg : 'rgba(69,45,0,0.92)', + accent : '#f59e0b', + title : '#fcd34d', + body : 'rgba(253,211,77,0.72)', + label : 'Warning', + icon : '', + } : { + border : 'rgba(16,185,129,0.35)', + bg : 'rgba(6,40,25,0.92)', + accent : '#10b981', + title : '#6ee7b7', + body : 'rgba(110,231,183,0.70)', + label : 'Success', + icon : '', + }; + var el = document.createElement('div'); - el.className = - 'w-[360px] max-w-[calc(100vw-3rem)] rounded-lg border px-4 py-3 shadow-lg shadow-black/40 backdrop-blur ' + - (isErr - ? 'border-red-900/60 bg-red-950/80 text-red-100' - : 'border-emerald-900/60 bg-emerald-950/70 text-emerald-50'); + el.style.cssText = [ + 'display:flex','align-items:stretch', + 'width:360px','max-width:calc(100vw - 3rem)', + 'border-radius:10px', + 'border:1px solid ' + cfg.border, + 'background:' + cfg.bg, + 'box-shadow:0 8px 24px rgba(0,0,0,0.5)', + 'backdrop-filter:blur(12px)', + 'overflow:hidden', + 'opacity:0', + 'transform:translateX(24px)', + 'transition:opacity 220ms ease, transform 220ms ease', + ].join(';'); - var title = document.createElement('div'); - title.className = 'text-sm font-medium'; - title.textContent = isErr ? 'Error' : 'Success'; + // left accent stripe + var stripe = document.createElement('div'); + stripe.style.cssText = 'width:4px;flex-shrink:0;background:' + cfg.accent + ';border-radius:10px 0 0 10px;'; + // body wrapper var body = document.createElement('div'); - body.className = 'mt-1 text-sm opacity-90'; - body.textContent = String(message || ''); + body.style.cssText = 'flex:1;display:flex;align-items:flex-start;gap:10px;padding:12px 14px;min-width:0;'; + + // icon + var iconWrap = document.createElement('div'); + iconWrap.style.cssText = 'flex-shrink:0;margin-top:1px;'; + iconWrap.innerHTML = cfg.icon; + + // text block + var text = document.createElement('div'); + text.style.cssText = 'flex:1;min-width:0;'; + + var titleEl = document.createElement('div'); + titleEl.style.cssText = 'font-size:0.8125rem;font-weight:600;color:' + cfg.title + ';line-height:1.3;'; + titleEl.textContent = cfg.label; + + var msgEl = document.createElement('div'); + msgEl.style.cssText = 'font-size:0.8rem;margin-top:3px;color:' + cfg.body + ';line-height:1.4;word-break:break-word;'; + msgEl.textContent = String(message || ''); - el.appendChild(title); + text.appendChild(titleEl); + if (message) text.appendChild(msgEl); + + // close button + var close = document.createElement('button'); + close.type = 'button'; + close.innerHTML = ''; + close.style.cssText = 'flex-shrink:0;margin-top:1px;padding:2px;color:' + cfg.body + ';opacity:0.6;cursor:pointer;background:none;border:none;line-height:0;'; + close.onmouseover = function() { close.style.opacity = '1'; }; + close.onmouseout = function() { close.style.opacity = '0.6'; }; + close.onclick = function() { dismiss(); }; + + body.appendChild(iconWrap); + body.appendChild(text); + body.appendChild(close); + + el.appendChild(stripe); el.appendChild(body); root.appendChild(el); - setTimeout(function () { - try { el.style.opacity = '0'; el.style.transition = 'opacity 300ms'; } catch (e) {} - }, 3200); - setTimeout(function () { - try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch (e) {} - }, 3600); + // slide in + requestAnimationFrame(function() { + el.style.opacity = '1'; + el.style.transform = 'translateX(0)'; + }); + + function dismiss() { + el.style.opacity = '0'; + el.style.transform = 'translateX(16px)'; + setTimeout(function() { + try { if (el.parentNode) el.parentNode.removeChild(el); } catch(e) {} + }, 260); + } + + setTimeout(dismiss, 4000); } catch (e) {} } diff --git a/internal/view/templates/group_detail.tmpl b/internal/view/templates/group_detail.tmpl new file mode 100644 index 0000000..337156d --- /dev/null +++ b/internal/view/templates/group_detail.tmpl @@ -0,0 +1,125 @@ +{{define "group_detail.tmpl"}} +{{template "base.tmpl" .}} +{{end}} + +{{define "group_detail.content"}} +
+ {{template "page_header.tmpl" .}} +
+
+
+
+

{{.GroupName}}

+

+ {{if .GroupDesc}}{{.GroupDesc}}{{else}}Manage members and queue settings for this group.{{end}} +

+
+
+ + +
+
+ + {{if .FlashMsg}} +
+ {{.FlashMsg}} +
+ {{end}} + + +
+
+

Members ({{len .Members}})

+

Users can belong to multiple groups.

+
+ +
+ + + + + + + + + + {{if not .Members}} + + + + {{end}} + {{range .Members}} + + + + + + {{end}} + +
UserTunnel IPActions
No members yet. Use Add members to invite VPN users.
+
+
+ {{initial .Username}} +
+ {{.Username}} +
+
+ {{if .TunnelIP}} + {{.TunnelIP}} + {{else}} + + {{end}} + +
+ +
+
+
+
+ +
+
+
+{{end}} + +{{define "group_detail.header_right"}}{{end}} diff --git a/internal/view/templates/group_pcq.tmpl b/internal/view/templates/group_pcq.tmpl new file mode 100644 index 0000000..1ae7237 --- /dev/null +++ b/internal/view/templates/group_pcq.tmpl @@ -0,0 +1,155 @@ +{{define "group_pcq.tmpl"}} +{{template "base.tmpl" .}} +{{end}} + +{{define "group_pcq.content"}} +
+ {{template "page_header.tmpl" .}} +
+
+
+
+

Queue — {{.GroupName}}

+

+ Rate limits on {{.WGInterface}} via tc — + {{.MemberTunnelIPsCount}} tunnel peer(s) in this group. + {{if .IsDisabled}}Shaping disabled.{{end}} +

+
+
+ + + Group members + + {{if .HasPreset}} +
+ {{if .IsDisabled}} + + {{else}} + + {{end}} +
+ {{end}} +
+
+ + {{if .PCQDisabled}} +
+ +
+

Queue shaping is disabled

+

+ NETPLUG_PCQ_DISABLE=1 is set — no tc rules are applied. + Presets can still be saved here. +

+
+
+ {{end}} + + {{if .FlashMsg}} +
+ {{.FlashMsg}} +
+ {{end}} + +
+
+

Rates & classifier

+
+
+

+ Mbps per direction; blank means no cap here. Classifier picks which counters this group's preset uses. Effective caps when applying are merged with other groups (minimum wins per tunnel peer). +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+ +
+
+ +
+{{end}} + +{{define "group_pcq.header_right"}}{{end}} diff --git a/internal/view/templates/groups.tmpl b/internal/view/templates/groups.tmpl new file mode 100644 index 0000000..ae22b57 --- /dev/null +++ b/internal/view/templates/groups.tmpl @@ -0,0 +1,156 @@ +{{define "groups.tmpl"}} +{{template "base.tmpl" .}} +{{end}} + +{{define "groups.content"}} +
+ {{template "page_header.tmpl" .}} +
+
+
+
+

VPN Groups

+

+ Organize VPN users into groups for easier access management +

+
+ +
+ + {{if .FlashMsg}} +
+ {{.FlashMsg}} +
+ {{end}} + + {{if .Groups}} +
+
+ + +
+
+ {{end}} + +
+
+

All Groups ({{len .Groups}})

+
+ +
+ + + + + + + + + + {{if not .Groups}} + + + + {{end}} + {{range .Groups}} + + + + + + {{end}} + +
GroupMembersActions
+ No groups yet. Use Add Group to create one. +
+
+
+ +
+
+

{{.Name}}

+ {{if .Description}} +

{{.Description}}

+ {{end}} +
+
+
+ {{.MemberCount}} + +
+ + + +
+
+
+
+
+
+ +
+ +{{end}} + +{{define "groups.header_right"}}{{end}} diff --git a/internal/view/templates/partials/add_group_modal.tmpl b/internal/view/templates/partials/add_group_modal.tmpl new file mode 100644 index 0000000..899e6a0 --- /dev/null +++ b/internal/view/templates/partials/add_group_modal.tmpl @@ -0,0 +1,77 @@ +{{define "partials/add_group_modal.tmpl"}} +
+
+ +
+
+
+
Create New Group
+
Organize VPN users
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+ +
+ + +
+ +
+ + +
+
+
+
+ +{{end}} diff --git a/internal/view/templates/partials/delete_group_modal.tmpl b/internal/view/templates/partials/delete_group_modal.tmpl new file mode 100644 index 0000000..de84194 --- /dev/null +++ b/internal/view/templates/partials/delete_group_modal.tmpl @@ -0,0 +1,65 @@ +{{define "partials/delete_group_modal.tmpl"}} +
+
+ +
+
+
+
Delete Group
+
This action cannot be undone
+
+ +
+ +
+
+
+
+ +
+
+
Are you sure?
+
+ Delete group {{.GroupName}}. All members will be removed and any saved queue preset for this group will be deleted. +
+
+
+
+ +
+ +
+ +
+
+
+
+
+ + +{{end}} diff --git a/internal/view/templates/partials/group_add_members_modal.tmpl b/internal/view/templates/partials/group_add_members_modal.tmpl new file mode 100644 index 0000000..2a09394 --- /dev/null +++ b/internal/view/templates/partials/group_add_members_modal.tmpl @@ -0,0 +1,197 @@ +{{define "partials/group_add_members_modal.tmpl"}} +
+
+ +
+
+
+
+
+ +
+
+

Add members

+

{{.GroupName}}

+
+
+
+ +
+ + {{if .Candidates}} +
+
+
+ + +
+ +
+ +
+
+ {{range .Candidates}} + + {{end}} +
+
+ +
+ + +
+
+ + + + {{else}} +
+
+ +
+

Everyone is already in this group

+

Create more VPN users first, then you can assign them here.

+ +
+ + {{end}} +
+
+{{end}} diff --git a/internal/view/templates/partials/qr_modal.tmpl b/internal/view/templates/partials/qr_modal.tmpl index 3eb2305..82b0da6 100644 --- a/internal/view/templates/partials/qr_modal.tmpl +++ b/internal/view/templates/partials/qr_modal.tmpl @@ -58,16 +58,19 @@ - @@ -91,25 +94,88 @@ try { lucide.createIcons(); } catch (e) {} var textEl = document.getElementById('np-conf-text'); - var confText = textEl ? (textEl.textContent || '') : ''; + var rawText = textEl ? (textEl.textContent || '') : ''; + // Trim trailing blank lines + var confText = rawText.replace(/[\t ]+$/mg, '').replace(/\n+$/, '').trimStart(); + + // QR code var canvas = document.getElementById('np-qr-canvas'); var QR = (window.QRCode && window.QRCode.toCanvas) ? window.QRCode : null; if (canvas && QR && confText) { - try { - QR.toCanvas(canvas, confText, { width: 256, margin: 1 }, function () {}); - } catch (e) {} + try { QR.toCanvas(canvas, confText, { width: 256, margin: 1 }, function () {}); } catch (e) {} + } + + // Syntax-highlight the config and inject into the
+    function highlight(text) {
+      var lines = text.split('\n');
+      return lines.map(function (line) {
+        // Comment lines
+        if (/^\s*#/.test(line)) {
+          return '' + esc(line) + '';
+        }
+        // Section headers [Interface] / [Peer]
+        if (/^\s*\[/.test(line)) {
+          return '' + esc(line) + '';
+        }
+        // Key = Value
+        var m = line.match(/^(\s*)([\w]+)(\s*=\s*)(.*)$/);
+        if (m) {
+          return m[1] +
+            ''  + esc(m[2]) + '' +
+            ''  + esc(m[3]) + '' +
+            ''  + esc(m[4]) + '';
+        }
+        return esc(line);
+      }).join('\n');
+    }
+
+    function esc(s) {
+      return s.replace(/&/g,'&').replace(//g,'>');
+    }
+
+    var pre = document.getElementById('np-conf-pre');
+    if (pre) {
+      pre.innerHTML = highlight(confText);
     }
 
+    // Copy button
     var copyBtn = document.getElementById('np-conf-copy');
+    var feedback = document.getElementById('np-copy-feedback');
+    var feedbackTimer;
     if (copyBtn) {
       copyBtn.addEventListener('click', function () {
-        try { navigator.clipboard.writeText(confText || ''); } catch (e) {}
+        navigator.clipboard.writeText(confText).then(function () {
+          if (feedback) {
+            clearTimeout(feedbackTimer);
+            feedback.textContent = 'Config copied!';
+            feedback.style.display = '';
+            feedbackTimer = setTimeout(function () { feedback.style.display = 'none'; }, 2500);
+          }
+          // Swap icon briefly
+          var icon = copyBtn.querySelector('[data-lucide]');
+          if (icon) {
+            icon.setAttribute('data-lucide', 'check');
+            try { lucide.createIcons(); } catch(e) {}
+            setTimeout(function () {
+              icon.setAttribute('data-lucide', 'copy');
+              try { lucide.createIcons(); } catch(e) {}
+            }, 2500);
+          }
+        }).catch(function () {
+          if (feedback) {
+            feedback.textContent = 'Copy failed';
+            feedback.style.color = '#f87171';
+            feedback.style.display = '';
+            feedbackTimer = setTimeout(function () { feedback.style.display = 'none'; feedback.style.color = ''; }, 2500);
+          }
+        });
       });
     }
 
+    // Show/hide toggle
     var toggle = document.getElementById('np-conf-toggle');
-    var wrap = document.getElementById('np-conf-wrap');
-    var lbl = document.getElementById('np-conf-toggle-label');
+    var wrap   = document.getElementById('np-conf-wrap');
+    var lbl    = document.getElementById('np-conf-toggle-label');
     if (toggle && wrap) {
       function apply() {
         var next = !!toggle.checked;
diff --git a/internal/view/templates/partials/remove_member_modal.tmpl b/internal/view/templates/partials/remove_member_modal.tmpl
new file mode 100644
index 0000000..84c556c
--- /dev/null
+++ b/internal/view/templates/partials/remove_member_modal.tmpl
@@ -0,0 +1,70 @@
+{{define "partials/remove_member_modal.tmpl"}}
+
+
+ +
+
+
+
Remove Member
+
Remove from this group only
+
+ +
+ +
+
+
+
+ +
+
+
Remove {{.Username}}?
+
+ They will be removed from this group but their VPN account will not be affected. +
+
+
+
+ +
+ + +
+
+
+
+ + +{{end}} diff --git a/internal/view/templates/pcq_overview.tmpl b/internal/view/templates/pcq_overview.tmpl new file mode 100644 index 0000000..d9071d3 --- /dev/null +++ b/internal/view/templates/pcq_overview.tmpl @@ -0,0 +1,309 @@ +{{define "pcq_overview.tmpl"}} +{{template "base.tmpl" .}} +{{end}} + +{{define "pcq_overview.content"}} +
+ {{template "page_header.tmpl" .}} +
+
+
+
+

Queues

+

+ Per-group traffic queues: rate limits merged per tunnel IPv4 and applied with Linux tc on the WireGuard interface. + Create presets from Add queue for groups that don’t have one yet. +

+
+ + + Manage groups + +
+ + {{if .PCQDisabled}} +
+ +
+

Queue shaping is disabled

+

+ NETPLUG_PCQ_DISABLE=1 is set. + No tc rules are applied to the WireGuard interface. + Presets are saved and can be configured, but shaping is inactive until the flag is removed. +

+
+
+ {{end}} + + {{if .FlashMsg}} +
+ {{.FlashMsg}} +
+ {{end}} + + {{if or .QueuePresets .GroupsSansQueue}} +
+
+ + +
+
+ {{end}} + + + +
+
+
+

Queue presets ({{len .QueuePresets}})

+

+ Saved limits for groups. Clearing a preset via the detail page removes the group from this list until you add limits again. +

+
+
+ + + + + + + + + + + + + {{if not .QueuePresets}} + + + + {{end}} + {{range .QueuePresets}} + + + + + + + + + {{end}} + +
GroupMembersCapsClassifierStatusActions
+ {{if .GroupsSansQueue}} + No queue presets yet. Add one for a group below, or visit a group via Add queue. + {{else}} + No groups yet. Create a group first under + Groups. + {{end}} +
+
+
+ +
+
+

{{.Name}}

+
+
+
+ {{.Members}} + + {{if ne .PCQSummary "—"}} + {{.PCQSummary}} + {{else}} + Not set + {{end}} + + {{.Classifier}} + + {{if .IsDisabled}} + + + Limits Off + + {{else if .HasLimits}} + + + Limits on + + {{else}} + + + No caps + + {{end}} + +
+
+ + {{if .IsDisabled}} + + {{else}} + + {{end}} +
+ + + + + + +
+
+
+
+ + {{if .GroupsSansQueue}} +
+
+

Groups without a queue preset ({{len .GroupsSansQueue}})

+

+ New groups do not appear in queue limits until you add a preset here. +

+
+
+ + + + + + + + + + {{if not .GroupsSansQueue}} + + + + {{end}} + {{range .GroupsSansQueue}} + + + + + + {{end}} + +
GroupMembersActions
+ Every group either has a saved queue preset above, or there are no groups yet. +
+
+
+ +
+ {{.Name}} +
+
{{.Members}} + +
+
+
+ {{end}} +
+
+
+ +
+ +{{end}} + +{{define "pcq_overview.header_right"}}{{end}} diff --git a/internal/view/templates/sidebar.tmpl b/internal/view/templates/sidebar.tmpl index d914ced..7cdfba9 100644 --- a/internal/view/templates/sidebar.tmpl +++ b/internal/view/templates/sidebar.tmpl @@ -18,6 +18,10 @@ background-color: rgba(16, 185, 129, 0.15); color: rgb(209 250 229); } + + /* Disabled sidebar item tooltip */ + .sb-disabled-item { position: relative; } + .sb-disabled-item .sb-tooltip { display: none; }
@@ -44,6 +48,12 @@ {{template "sidebar_item.tmpl" dict "Href" "/ui" "Label" "Overview" "Icon" "layout-dashboard" "Active" (eq .Path "/ui") }} {{template "sidebar_item.tmpl" dict "Href" "/ui/connections" "Label" "Connections" "Icon" "link-2" "Active" (eq .Path "/ui/connections") }} {{template "sidebar_item.tmpl" dict "Href" "/ui/users" "Label" "Users" "Icon" "users" "Active" (eq .Path "/ui/users") }} + {{template "sidebar_item.tmpl" dict "Href" "/ui/groups" "Label" "Groups" "Icon" "users-round" "Active" (or (eq .Path "/ui/groups") (hasPrefix .Path "/ui/groups/")) }} + {{if .PCQDisabled}} + {{template "sidebar_item_disabled.tmpl" dict "Label" "Queues" "Icon" "sliders-horizontal" "Tooltip" "Queues is disabled"}} + {{else}} + {{template "sidebar_item.tmpl" dict "Href" "/ui/queues" "Label" "Queues" "Icon" "sliders-horizontal" "Active" (or (eq .Path "/ui/queues") (hasPrefix .Path "/ui/queues/")) }} + {{end}} {{template "sidebar_item.tmpl" dict "Href" "/ui/wireguard" "Label" "Wireguard" "Icon" "server" "Active" (eq .Path "/ui/wireguard") }} @@ -80,6 +90,45 @@
+ {{end}} {{define "sidebar_item.tmpl"}} @@ -96,3 +145,14 @@ {{.Label}} {{end}} + +{{define "sidebar_item_disabled.tmpl"}} +
+ + {{.Label}} + {{.Tooltip}} +
+{{end}} diff --git a/internal/view/view.go b/internal/view/view.go index 9993a6b..8013336 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -29,14 +29,21 @@ var ( once sync.Once baseTmpl *template.Template baseErr error + + globalPCQDisabled bool ) +// SetPCQDisabled stores the NETPLUG_PCQ_DISABLE flag so every Render call +// can inject it into templates automatically. +func SetPCQDisabled(v bool) { globalPCQDisabled = v } + func parse() (*template.Template, error) { once.Do(func() { funcMap := template.FuncMap{ "humanBytes": humanBytes, "humanUptime": humanUptime, "lower": strings.ToLower, + "hasPrefix": strings.HasPrefix, "initial": func(s string) string { s = strings.TrimSpace(s) if s == "" { @@ -189,6 +196,9 @@ func Render(w http.ResponseWriter, r *http.Request, name string, data M) { if _, ok := data["GitRevisionShort"]; !ok { data["GitRevisionShort"] = version.Display() } + if _, ok := data["PCQDisabled"]; !ok { + data["PCQDisabled"] = globalPCQDisabled + } // Compose layout by rendering the per-page content into a safe HTML field. htmx := strings.EqualFold(r.Header.Get("HX-Request"), "true") @@ -252,6 +262,9 @@ func RenderPartial(w http.ResponseWriter, r *http.Request, name string, data M) if _, ok := data["GitRevisionShort"]; !ok { data["GitRevisionShort"] = version.Display() } + if _, ok := data["PCQDisabled"]; !ok { + data["PCQDisabled"] = globalPCQDisabled + } if err := t.ExecuteTemplate(w, name, data); err != nil { log.Printf("template execute error (%s): %v", name, err) http.Error(w, "template error", http.StatusInternalServerError)