diff --git a/README.md b/README.md index 4721d43..4d2a53a 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,42 @@ If still 401: hard-refresh swagger (stale token), confirm `curl -sS -o /dev/null -w '%{http_code}\n' -H 'Authorization: Bearer dev' http://127.0.0.1:18080/a/env` is `200`, or check `podman logs cu-api | grep '"token"'`. -**Host / port:** UI uses the host you opened. Override with -`?host=127.0.0.1:18080&scheme=http` if needed. +#### Change the Try-it-out API host (query params — most common) + +By default Swagger calls **whatever host you opened the docs on**. +If that is wrong (port-map, ingress, in-cluster Service, tunnel), **pass the host on the docs URL** — you will do this a lot: + +```text +# point Try-it-out at a different host:port +/api-docs/index.html?host=127.0.0.1:18080&scheme=http + +# in-cluster Service from a port-forwarded docs UI +/api-docs/index.html?host=cluster-utils-api.default.svc.cluster.local:8080&scheme=http + +# public ingress +/api-docs/index.html?host=api.example.com&scheme=https +``` + +| Query | Example | What it does | +|-------|---------|----------------| +| `host` | `my-svc.ns.svc:8080` | Host:port **Execute** / Try-it-out uses | +| `scheme` | `http` or `https` | Scheme for those calls | +| `theme` | `light` | Stock bright Swagger (dark is default) | + +Bookmark the full URL once; every open keeps that target. + +#### Or set it once at process start (env) + +| Env | Example | Purpose | +|-----|---------|---------| +| `SWAGGER_HOST` | `api.example.com` or `cluster-utils-api.ns.svc:8080` | Default host:port for Try-it-out | +| `SWAGGER_SCHEME` | `https` or `http` | Default scheme | + +**Priority:** query `?host=&scheme=` → env `SWAGGER_*` → request Host / `X-Forwarded-Proto`. +(`PORT` is only the process listen port; it does **not** set the public URL.) + +**Theme:** dark by default (custom CSS skin — stock Swagger has no real dark mode). +Light: `?theme=light`. --- diff --git a/RELEASE-v2.5.2.md b/RELEASE-v2.5.2.md new file mode 100644 index 0000000..48a2209 --- /dev/null +++ b/RELEASE-v2.5.2.md @@ -0,0 +1,35 @@ +## cluster-utils-api v2.5.2 — Swagger dark skin + Try-it-out host + +Polish release: usable dark Swagger UI and clear control over which host “Try it out” hits. + +### Swagger UI skin +- Custom **dark theme by default** (stock Swagger has no first-class dark mode — we inject CSS) +- Donkeyx / terminal flair: shell-prompt topbar, phosphor code, scan grid +- `?theme=light` restores stock bright UI + +### Try-it-out host / scheme +Swagger defaults to the host you opened. When that is wrong (port-map, ingress, in-cluster Service): + +**Query params (most common — bookmark the URL):** +```text +/api-docs/index.html?host=my-svc.ns.svc:8080&scheme=http +/api-docs/index.html?host=api.example.com&scheme=https +``` + +**Or once at startup:** +| Env | Purpose | +|-----|---------| +| `SWAGGER_HOST` | Default host:port for Try-it-out | +| `SWAGGER_SCHEME` | `http` or `https` | + +**Priority:** query → env → request Host / `X-Forwarded-Proto` + +The Swagger **info panel shows the live target** (`scheme://host/`), where it came from, and how to override it. + +### Docs +- Links to **this repo** (docs/source) and **cluster-utils** (pair toolkit) +- README + k8s sample updated for host override and image `2.5.2` + +### Images +- `ghcr.io/donkeyx/cluster-utils-api:2.5.2` / `:latest` +- `donkeyx/cluster-utils-api:2.5.2` / `:latest` diff --git a/docs/docs.go b/docs/docs.go index e2c6a18..b5f1a18 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -847,12 +847,12 @@ const docTemplate = `{ // SwaggerInfo holds exported Swagger Info so clients can modify it var SwaggerInfo = &swag.Spec{ - Version: "2.5.0", + Version: "2.5.2", Host: "", BasePath: "/", Schemes: []string{}, Title: "Cluster Utils API", - Description: "HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops.\n\n• Authorize (top-right lock): Bearer <token> e.g. local podman → Bearer dev\n• Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes\n• Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)\n• Pair: https://github.com/donkeyx/cluster-utils", + Description: "HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops.\n\n• Authorize (top-right lock): Bearer <token> e.g. local podman → Bearer dev\n• Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes\n• Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)\n• Docs / source: https://github.com/donkeyx/cluster-utils-api\n• Pair (shell toolkit): https://github.com/donkeyx/cluster-utils", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index d7209c9..25658b9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,10 +1,10 @@ { "swagger": "2.0", "info": { - "description": "HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops.\n\n• Authorize (top-right lock): Bearer \u0026lt;token\u0026gt; e.g. local podman → Bearer dev\n• Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes\n• Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)\n• Pair: https://github.com/donkeyx/cluster-utils", + "description": "HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops.\n\n• Authorize (top-right lock): Bearer \u0026lt;token\u0026gt; e.g. local podman → Bearer dev\n• Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes\n• Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body)\n• Docs / source: https://github.com/donkeyx/cluster-utils-api\n• Pair (shell toolkit): https://github.com/donkeyx/cluster-utils", "title": "Cluster Utils API", "contact": {}, - "version": "2.5.0" + "version": "2.5.2" }, "basePath": "/", "paths": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1f6c9ce..8e9218e 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -135,9 +135,10 @@ info: • Authorize (top-right lock): Bearer <token> e.g. local podman → Bearer dev • Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes • Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body) - • Pair: https://github.com/donkeyx/cluster-utils + • Docs / source: https://github.com/donkeyx/cluster-utils-api + • Pair (shell toolkit): https://github.com/donkeyx/cluster-utils title: Cluster Utils API - version: 2.5.0 + version: 2.5.2 paths: /a/control/probes: get: diff --git a/k8s-cluster-util-apis.yml b/k8s-cluster-util-apis.yml index 0de1b4b..ccdbf7c 100644 --- a/k8s-cluster-util-apis.yml +++ b/k8s-cluster-util-apis.yml @@ -21,8 +21,8 @@ spec: - name: cluster-utils-api # GHCR primary (Hub still works as mirror). Version tag → default pull IfNotPresent. # :latest still forces Always in kube even without imagePullPolicy. - image: ghcr.io/donkeyx/cluster-utils-api:2.5.0 - # Alternative: docker.io/donkeyx/cluster-utils-api:2.5.0 + image: ghcr.io/donkeyx/cluster-utils-api:2.5.2 + # Alternative: docker.io/donkeyx/cluster-utils-api:2.5.2 ports: - name: http containerPort: 8080 @@ -88,6 +88,11 @@ spec: # value: "10" # - name: AUTH_TOKEN # value: "fixed-token-for-tests" + # Swagger Try-it-out public URL (optional; default = request Host) + # - name: SWAGGER_HOST + # value: cluster-utils-api.default.svc.cluster.local:8080 + # - name: SWAGGER_SCHEME + # value: http # --- OTEL traces (OTLP *push* to Alloy → Tempo; not scraped) --- # - name: OTEL_SERVICE_NAME # value: cluster-utils-api diff --git a/main.go b/main.go index aa08650..67c43ae 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,12 @@ // @title Cluster Utils API -// @version 2.5.0 +// @version 2.5.2 // @description HTTP sidekick for cluster-utils — drop into a namespace and poke probes, ingress headers, env, and east-west hops. // @description // @description • Authorize (top-right lock): Bearer <token> e.g. local podman → Bearer dev // @description • Probes: /startupz /livez /readyz · Control: GET|PUT /a/control/probes // @description • Proxy: GET /a/proxy?url=https://httpbin.org/get (or POST JSON body) -// @description • Pair: https://github.com/donkeyx/cluster-utils +// @description • Docs / source: https://github.com/donkeyx/cluster-utils-api +// @description • Pair (shell toolkit): https://github.com/donkeyx/cluster-utils // @BasePath / // @securityDefinitions.apikey BearerAuth // @in header @@ -98,6 +99,14 @@ func main() { zap.String("env", getCurlCommand(port, securityToken)), zap.String("probes", fmt.Sprintf("curl -sS -H 'Authorization: Bearer %s' http://localhost:%d/a/control/probes | jq", securityToken, port)), ) + // Optional fixed Try-it-out target (Swagger host/scheme). Empty = use request Host. + if h, s := os.Getenv("SWAGGER_HOST"), os.Getenv("SWAGGER_SCHEME"); h != "" || s != "" { + logger.Info("swagger public URL override (Try-it-out)", + zap.String("SWAGGER_HOST", h), + zap.String("SWAGGER_SCHEME", s), + zap.String("note", "query ?host=&scheme= still wins per request"), + ) + } r.Run(fmt.Sprintf(":%d", port)) } diff --git a/routes/routes.go b/routes/routes.go index 93f984f..a8a5e02 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -1,13 +1,18 @@ package routes import ( - "github.com/donkeyx/cluster-utils-api/docs" - "github.com/donkeyx/cluster-utils-api/handlers" - "github.com/donkeyx/cluster-utils-api/middleware" + "bytes" + _ "embed" + "fmt" "net/http" + "os" "strings" "sync" + "github.com/donkeyx/cluster-utils-api/docs" + "github.com/donkeyx/cluster-utils-api/handlers" + "github.com/donkeyx/cluster-utils-api/middleware" + "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -15,9 +20,20 @@ import ( ginSwagger "github.com/swaggo/gin-swagger" ) -// swaggerInfoMu guards docs.SwaggerInfo Host/Schemes when serving the UI for different origins. +// swaggerInfoMu guards docs.SwaggerInfo Host/Schemes/Description when serving the UI. var swaggerInfoMu sync.Mutex +// swaggerBaseDescription is the static @description from swag (no per-request target line). +var ( + swaggerBaseDescription string + swaggerBaseDescriptionOnce sync.Once +) + +// darkCSS is injected into Swagger UI (gin-swagger has no first-class dark mode). +// +//go:embed swagger-dark.css +var darkCSS []byte + func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { // Middleware (otel / metrics / log / recover) is registered in main before this. @@ -26,9 +42,9 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { c.Redirect(http.StatusFound, "/api-docs/index.html") }) - // Swagger UI: persist Authorize token in the browser; host/scheme follow where you opened - // the page (so port-forward / docker / cluster DNS all work). Optional overrides: - // /api-docs/index.html?host=my-svc:8080&scheme=http + // Swagger UI: persist Authorize token; host/scheme for Try-it-out. + // Priority: ?host=&scheme= query > SWAGGER_HOST / SWAGGER_SCHEME env > request Host / X-Forwarded-Proto. + // Dark theme by default; ?theme=light for stock Swagger look. r.GET("/api-docs/*any", swaggerHandler()) r.GET("/help", handlers.HelpHandler) @@ -36,9 +52,6 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { r.GET("/metrics", handlers.PrometheusMetricsHandler()) // Kube-style probes (plus older aliases) - // live = liveness → restart on fail - // ready = readiness → leave Service endpoints on fail - // startup = cold start latch → kube only until first success r.GET("/livez", handlers.LiveHandler) r.GET("/healthz", handlers.HealthzHandler) r.GET("/health", handlers.HealthHandler) @@ -63,42 +76,134 @@ func SetupRouter(logger *zap.Logger, st string, r *gin.Engine) { authGroup.GET("/env", handlers.EnvHandler) authGroup.GET("/control/probes", handlers.GetProbesHandler) authGroup.PUT("/control/probes", handlers.PutProbesHandler) - // open /proxy would be SSRF (scan cluster, hit metadata, etc.) - // Separate handlers so Swagger GET has no body (browsers reject GET+body). authGroup.GET("/proxy", handlers.ProxyGetHandler) authGroup.POST("/proxy", handlers.ProxyPostHandler) } func swaggerHandler() gin.HandlerFunc { - // Empty host in the generated spec would also work; we set Host from the request - // so the Swagger top bar shows a real target and Try it out hits the right place. handler := ginSwagger.WrapHandler( swaggerFiles.Handler, ginSwagger.PersistAuthorization(true), ginSwagger.DefaultModelsExpandDepth(-1), + ginSwagger.DocExpansion("list"), ) return func(c *gin.Context) { + // Serve our dark stylesheet (relative to /api-docs/) + any := c.Param("any") + if any == "/swagger-dark.css" || any == "swagger-dark.css" || strings.HasSuffix(any, "swagger-dark.css") { + c.Data(http.StatusOK, "text/css; charset=utf-8", darkCSS) + return + } + + // Resolve Try-it-out server URL (not the listen bind — that's PORT). + // 1) query 2) SWAGGER_* env 3) request + hostSource := "request Host" host := strings.TrimSpace(c.Query("host")) - if host == "" { - host = c.Request.Host + if host != "" { + hostSource = "URL ?host=" + } else { + host = strings.TrimSpace(os.Getenv("SWAGGER_HOST")) + if host != "" { + hostSource = "SWAGGER_HOST env" + } else { + host = c.Request.Host + } } + schemeSource := "request / X-Forwarded-Proto" scheme := strings.ToLower(strings.TrimSpace(c.Query("scheme"))) - if scheme != "http" && scheme != "https" { - if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { - scheme = "https" + if scheme == "http" || scheme == "https" { + schemeSource = "URL ?scheme=" + } else { + scheme = strings.ToLower(strings.TrimSpace(os.Getenv("SWAGGER_SCHEME"))) + if scheme == "http" || scheme == "https" { + schemeSource = "SWAGGER_SCHEME env" } else { - scheme = "http" + if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") { + scheme = "https" + } else { + scheme = "http" + } } } + light := strings.EqualFold(c.Query("theme"), "light") + // Serialize updates to the global SwaggerInfo used when doc.json is generated. swaggerInfoMu.Lock() + swaggerBaseDescriptionOnce.Do(func() { + swaggerBaseDescription = docs.SwaggerInfo.Description + }) docs.SwaggerInfo.Host = host docs.SwaggerInfo.Schemes = []string{scheme} docs.SwaggerInfo.BasePath = "/" + // Live target + how to change it (shows in the Swagger info panel). + docs.SwaggerInfo.Description = swaggerBaseDescription + fmt.Sprintf( + "\n\n---\n\n**Try-it-out target (now):** `%s://%s/` \n"+ + "_host from %s · scheme from %s_ \n\n"+ + "Change for this tab: append query params, e.g. \n"+ + "`/api-docs/index.html?host=my-svc.ns.svc:8080&scheme=http` \n"+ + "Or set env `SWAGGER_HOST` / `SWAGGER_SCHEME` once at startup. \n"+ + "Priority: **query → env → request Host**.", + scheme, host, hostSource, schemeSource, + ) + + // Capture HTML for index so we can inject dark CSS (stock swagger is bright white). + if !light && isSwaggerIndex(any) { + buf := &responseCapture{ResponseWriter: c.Writer, body: &bytes.Buffer{}, status: http.StatusOK} + c.Writer = buf + handler(c) + html := buf.body.String() + inject := `` + + `` + if strings.Contains(html, "") { + html = strings.Replace(html, "", inject+"", 1) + } else { + html = inject + html + } + c.Writer = buf.ResponseWriter + // Drop content-length from capture; write fresh body + for k, vv := range buf.Header() { + if strings.EqualFold(k, "Content-Length") { + continue + } + for _, v := range vv { + c.Writer.Header().Add(k, v) + } + } + c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") + c.Writer.WriteHeader(buf.status) + _, _ = c.Writer.Write([]byte(html)) + swaggerInfoMu.Unlock() + return + } + handler(c) swaggerInfoMu.Unlock() } } + +func isSwaggerIndex(any string) bool { + any = strings.TrimPrefix(any, "/") + return any == "" || any == "index.html" || strings.HasSuffix(any, "/index.html") +} + +// responseCapture buffers the handler response so we can rewrite HTML. +type responseCapture struct { + gin.ResponseWriter + body *bytes.Buffer + status int +} + +func (w *responseCapture) Write(b []byte) (int, error) { + return w.body.Write(b) +} + +func (w *responseCapture) WriteString(s string) (int, error) { + return w.body.WriteString(s) +} + +func (w *responseCapture) WriteHeader(statusCode int) { + w.status = statusCode +} diff --git a/routes/swagger-dark.css b/routes/swagger-dark.css new file mode 100644 index 0000000..d62b723 --- /dev/null +++ b/routes/swagger-dark.css @@ -0,0 +1,745 @@ +/* + * Cluster Utils API — Swagger UI skin + * Stock Swagger has no themes; this is a full restyle with a little + * donkeyx / terminal flair (CSS-only). + */ + +@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Outfit:wght@400;500;600;700&display=swap"); + +:root { + color-scheme: dark; + --cu-bg: #07090f; + --cu-bg-elevated: #0e121b; + --cu-bg-panel: #121826; + --cu-bg-soft: #182033; + --cu-border: #243049; + --cu-border-strong: #33415f; + --cu-text: #eef1f8; + --cu-text-dim: #9aa6c0; + --cu-text-mute: #6d7a96; + --cu-accent: #6ea8ff; + --cu-accent-2: #a78bfa; + --cu-accent-hot: #22d3a6; /* terminal phosphor */ + --cu-danger: #f87171; + --cu-warn: #fbbf24; + --cu-get: #38bdf8; + --cu-post: #34d399; + --cu-put: #fbbf24; + --cu-delete: #f87171; + --cu-patch: #c084fc; + --cu-glow: 0 0 0 1px rgba(110, 168, 255, 0.15), 0 12px 40px rgba(0, 0, 0, 0.45); + --cu-glow-hot: 0 0 0 1px rgba(34, 211, 166, 0.2), 0 0 28px rgba(34, 211, 166, 0.08); + --cu-radius: 12px; + --cu-font: "Outfit", "Segoe UI", system-ui, sans-serif; + --cu-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +@keyframes cu-blink { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} + +@keyframes cu-scan { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(100vh); } +} + +@keyframes cu-glow-pulse { + 0%, 100% { box-shadow: 0 0 0 1px rgba(34, 211, 166, 0.15), 0 8px 32px rgba(0, 0, 0, 0.35); } + 50% { box-shadow: 0 0 0 1px rgba(34, 211, 166, 0.35), 0 8px 36px rgba(34, 211, 166, 0.12); } +} + +::selection { + background: rgba(34, 211, 166, 0.28); + color: #f0fff8; +} + +html { + background: var(--cu-bg) !important; +} + +body { + margin: 0; + background-color: var(--cu-bg) !important; + background-image: + /* faint circuit grid */ + linear-gradient(rgba(110, 168, 255, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(110, 168, 255, 0.035) 1px, transparent 1px), + /* color washes */ + radial-gradient(1200px 600px at 10% -10%, rgba(110, 168, 255, 0.12), transparent 55%), + radial-gradient(900px 500px at 100% 0%, rgba(167, 139, 250, 0.10), transparent 50%), + radial-gradient(800px 400px at 50% 100%, rgba(34, 211, 166, 0.07), transparent 50%) !important; + background-size: + 48px 48px, + 48px 48px, + auto, + auto, + auto !important; + color: var(--cu-text) !important; + font-family: var(--cu-font) !important; + min-height: 100vh; +} + +/* CRT scanlines + soft vignette — pointer-events none so UI stays usable */ +body::before { + content: ""; + pointer-events: none; + position: fixed; + inset: 0; + z-index: 9998; + background: + repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 0, 0, 0.045) 2px, + rgba(0, 0, 0, 0.045) 4px + ), + radial-gradient(ellipse at center, transparent 55%, rgba(0, 0, 0, 0.35) 100%); + opacity: 0.7; +} + +/* donkeyx corner tag */ +body::after { + content: "// donkeyx · 0xCU · poke with care"; + pointer-events: none; + position: fixed; + bottom: 14px; + right: 18px; + z-index: 9999; + font-family: var(--cu-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--cu-accent-hot); + opacity: 0.55; + text-shadow: 0 0 12px rgba(34, 211, 166, 0.35); +} + +/* ── Top bar ─────────────────────────────────────────────── */ +.swagger-ui .topbar { + background: rgba(10, 14, 22, 0.92) !important; + backdrop-filter: blur(14px); + border-bottom: 1px solid var(--cu-border); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + padding: 12px 0; + position: relative; + animation: cu-glow-pulse 6s ease-in-out infinite; +} +/* thin phosphor scan line under topbar */ +.swagger-ui .topbar::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + var(--cu-accent-hot) 20%, + var(--cu-accent) 50%, + var(--cu-accent-2) 80%, + transparent 100% + ); + opacity: 0.7; +} +.swagger-ui .topbar .download-url-wrapper .select-label { + color: var(--cu-text-dim) !important; + font-family: var(--cu-mono) !important; + font-size: 12px !important; + text-transform: lowercase; + letter-spacing: 0.04em; +} +.swagger-ui .topbar .download-url-wrapper .select-label span:before { + content: "$ "; + color: var(--cu-accent-hot); +} +.swagger-ui .topbar .download-url-wrapper input[type="text"] { + background: var(--cu-bg) !important; + border: 1px solid var(--cu-border-strong) !important; + color: var(--cu-accent-hot) !important; + border-radius: 8px !important; + font-family: var(--cu-mono) !important; + font-size: 13px !important; + box-shadow: inset 0 0 0 1px rgba(34, 211, 166, 0.06) !important; +} +.swagger-ui .topbar-wrapper img, +.swagger-ui .topbar .link span { + display: none !important; +} +/* Brand: donkeyx shell prompt */ +.swagger-ui .topbar .link:after { + content: "🐴 donkeyx@cluster-utils:~$"; + color: var(--cu-text) !important; + font-family: var(--cu-mono) !important; + font-weight: 700; + font-size: 0.95rem; + letter-spacing: 0.01em; + text-shadow: 0 0 18px rgba(34, 211, 166, 0.25); +} +.swagger-ui .topbar a { + color: var(--cu-accent-hot) !important; +} +/* tiny ops badge on the right of topbar wrapper */ +.swagger-ui .topbar .topbar-wrapper::after { + content: "TRACE · PING · PROXY"; + margin-left: auto; + font-family: var(--cu-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; + color: var(--cu-text-mute); + border: 1px solid var(--cu-border); + border-radius: 999px; + padding: 4px 10px; + background: rgba(34, 211, 166, 0.06); + color: var(--cu-accent-hot); + opacity: 0.85; +} +.swagger-ui .topbar .topbar-wrapper { + display: flex !important; + align-items: center; + gap: 16px; +} + +/* Wrapper breathing room */ +.swagger-ui .wrapper { + max-width: 1200px; + padding: 0 20px; +} + +/* ── Info / hero ─────────────────────────────────────────── */ +.swagger-ui .info { + margin: 28px 0 20px !important; + padding: 22px 24px 22px 28px !important; + background: + linear-gradient(145deg, rgba(24, 32, 51, 0.95), rgba(10, 14, 22, 0.98)) !important; + border: 1px solid var(--cu-border) !important; + border-left: 3px solid var(--cu-accent-hot) !important; + border-radius: var(--cu-radius) !important; + box-shadow: var(--cu-glow), var(--cu-glow-hot); + position: relative; + overflow: hidden; +} +/* terminal session banner — live host is in the description body (server-injected) */ +.swagger-ui .info::before { + content: "$> session opened · Try-it-out host is below · override with ?host=&scheme= · /a/* needs Bearer AUTH_TOKEN"; + display: block; + font-family: var(--cu-mono); + font-size: 11.5px; + font-weight: 600; + color: var(--cu-accent-hot); + letter-spacing: 0.02em; + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px dashed rgba(34, 211, 166, 0.25); + text-shadow: 0 0 10px rgba(34, 211, 166, 0.2); + opacity: 0.9; +} +/* highlight the live target block (markdown