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
+ strong in description) */ +.swagger-ui .info .description .renderedMarkdown hr, +.swagger-ui .info .description .markdown hr { + border: none; + border-top: 1px dashed rgba(34, 211, 166, 0.35); + margin: 14px 0; +} +.swagger-ui .info .description .renderedMarkdown strong, +.swagger-ui .info .description .markdown strong { + color: var(--cu-accent-hot) !important; + font-family: var(--cu-mono); + font-weight: 700; +} +.swagger-ui .info .description .renderedMarkdown code, +.swagger-ui .info .description .markdown code { + color: var(--cu-accent-hot) !important; + background: rgba(34, 211, 166, 0.1) !important; +} +.swagger-ui .info .title { + color: var(--cu-text) !important; + font-family: var(--cu-font) !important; + font-weight: 700 !important; + font-size: 2rem !important; + letter-spacing: -0.02em; + text-shadow: 0 0 40px rgba(110, 168, 255, 0.2); +} +/* blinking terminal caret after title */ +.swagger-ui .info .title small.version-stamp { + background: linear-gradient(135deg, var(--cu-accent-hot), var(--cu-accent)) !important; + border-radius: 999px !important; + padding: 4px 10px !important; + font-family: var(--cu-mono) !important; + font-weight: 700 !important; + color: #04120e !important; + box-shadow: 0 0 16px rgba(34, 211, 166, 0.35); +} +.swagger-ui .info .title small { + top: -4px !important; +} +.swagger-ui .info .title::after { + content: "▌"; + color: var(--cu-accent-hot); + font-weight: 400; + margin-left: 4px; + animation: cu-blink 1.05s step-end infinite; + text-shadow: 0 0 8px var(--cu-accent-hot); + font-size: 0.85em; + vertical-align: baseline; +} +.swagger-ui .info p, +.swagger-ui .info li, +.swagger-ui .info table, +.swagger-ui .info .base-url { + color: var(--cu-text-dim) !important; + font-size: 15px !important; + line-height: 1.55 !important; +} +.swagger-ui .info .base-url { + font-family: var(--cu-mono) !important; + color: var(--cu-accent-hot) !important; + font-size: 13px !important; +} +.swagger-ui .info a { + color: var(--cu-accent) !important; + text-decoration: none !important; + border-bottom: 1px dashed rgba(110, 168, 255, 0.35); +} +.swagger-ui .info a:hover { + color: #b3d4ff !important; + border-bottom-color: var(--cu-accent); +} + +/* ── Authorize strip ─────────────────────────────────────── */ +.swagger-ui .scheme-container { + background: rgba(18, 24, 38, 0.92) !important; + box-shadow: none !important; + border: 1px solid var(--cu-border); + border-radius: var(--cu-radius); + margin: 0 0 20px !important; + padding: 14px 18px !important; + backdrop-filter: blur(8px); + position: relative; +} +.swagger-ui .scheme-container::before { + content: "[ auth ]"; + font-family: var(--cu-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + color: var(--cu-text-mute); + margin-right: 10px; + text-transform: uppercase; +} +.swagger-ui .btn.authorize { + background: linear-gradient(135deg, rgba(34, 211, 166, 0.12), rgba(110, 168, 255, 0.12)) !important; + border: 1px solid var(--cu-accent-hot) !important; + color: var(--cu-accent-hot) !important; + border-radius: 10px !important; + font-family: var(--cu-mono) !important; + font-weight: 700 !important; + letter-spacing: 0.06em; + text-transform: uppercase; + font-size: 12px !important; + transition: transform 0.12s ease, box-shadow 0.12s ease; + box-shadow: 0 0 18px rgba(34, 211, 166, 0.12); +} +.swagger-ui .btn.authorize:hover { + transform: translateY(-1px); + box-shadow: 0 6px 22px rgba(34, 211, 166, 0.3); +} +.swagger-ui .btn.authorize svg { + fill: var(--cu-accent-hot) !important; +} +.swagger-ui .authorization__btn.locked { + opacity: 1; +} + +/* ── Tags ────────────────────────────────────────────────── */ +.swagger-ui .opblock-tag { + color: var(--cu-text) !important; + border-bottom: 1px solid var(--cu-border) !important; + font-family: var(--cu-font) !important; + font-weight: 600 !important; + font-size: 1.15rem !important; + margin: 18px 0 8px !important; + padding: 10px 4px !important; +} +.swagger-ui .opblock-tag small { + color: var(--cu-text-mute) !important; + font-family: var(--cu-mono) !important; + font-size: 12px !important; +} +.swagger-ui .opblock-tag-section h3.opblock-tag span:first-child::before { + content: "> "; + color: var(--cu-accent-hot); + font-family: var(--cu-mono); + font-weight: 700; +} + +/* ── Operation blocks ────────────────────────────────────── */ +.swagger-ui .opblock { + border-radius: 12px !important; + margin: 0 0 12px !important; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25) !important; + border: 1px solid var(--cu-border) !important; + overflow: hidden; + background: var(--cu-bg-panel) !important; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +.swagger-ui .opblock:hover { + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35) !important; +} +.swagger-ui .opblock .opblock-summary { + border: none !important; + padding: 10px 12px !important; +} +.swagger-ui .opblock .opblock-summary-method { + border-radius: 8px !important; + font-family: var(--cu-mono) !important; + font-weight: 700 !important; + min-width: 72px !important; + text-align: center; + letter-spacing: 0.06em; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.25); +} +.swagger-ui .opblock .opblock-summary-path, +.swagger-ui .opblock .opblock-summary-path__deprecated { + color: var(--cu-text) !important; + font-family: var(--cu-mono) !important; + font-size: 14px !important; + font-weight: 600 !important; +} +.swagger-ui .opblock .opblock-summary-path::before { + content: "› "; + color: var(--cu-accent-hot); + opacity: 0.7; +} +.swagger-ui .opblock .opblock-summary-description { + color: var(--cu-text-dim) !important; + font-family: var(--cu-font) !important; +} + +.swagger-ui .opblock.opblock-get { + background: linear-gradient(90deg, rgba(56, 189, 248, 0.10), var(--cu-bg-panel) 40%) !important; + border-color: rgba(56, 189, 248, 0.35) !important; +} +.swagger-ui .opblock.opblock-get .opblock-summary-method { + background: var(--cu-get) !important; + color: #041018 !important; +} +.swagger-ui .opblock.opblock-post { + background: linear-gradient(90deg, rgba(52, 211, 153, 0.10), var(--cu-bg-panel) 40%) !important; + border-color: rgba(52, 211, 153, 0.35) !important; +} +.swagger-ui .opblock.opblock-post .opblock-summary-method { + background: var(--cu-post) !important; + color: #052e1c !important; +} +.swagger-ui .opblock.opblock-put { + background: linear-gradient(90deg, rgba(251, 191, 36, 0.10), var(--cu-bg-panel) 40%) !important; + border-color: rgba(251, 191, 36, 0.35) !important; +} +.swagger-ui .opblock.opblock-put .opblock-summary-method { + background: var(--cu-put) !important; + color: #422006 !important; +} +.swagger-ui .opblock.opblock-delete { + background: linear-gradient(90deg, rgba(248, 113, 113, 0.10), var(--cu-bg-panel) 40%) !important; + border-color: rgba(248, 113, 113, 0.35) !important; +} +.swagger-ui .opblock.opblock-delete .opblock-summary-method { + background: var(--cu-delete) !important; +} +.swagger-ui .opblock.opblock-patch { + background: linear-gradient(90deg, rgba(192, 132, 252, 0.10), var(--cu-bg-panel) 40%) !important; + border-color: rgba(192, 132, 252, 0.35) !important; +} +.swagger-ui .opblock.opblock-patch .opblock-summary-method { + background: var(--cu-patch) !important; + color: #2e1065 !important; +} + +.swagger-ui .opblock-body, +.swagger-ui .opblock-section-header { + background: var(--cu-bg-elevated) !important; + color: var(--cu-text) !important; + box-shadow: none !important; + border-color: var(--cu-border) !important; +} +.swagger-ui .opblock-section-header h4, +.swagger-ui .opblock-section-header label, +.swagger-ui .opblock-description-wrapper p, +.swagger-ui .opblock-title_normal p, +.swagger-ui .parameter__name, +.swagger-ui .parameter__type, +.swagger-ui .parameter__in, +.swagger-ui .parameter__deprecated, +.swagger-ui table thead tr td, +.swagger-ui table thead tr th, +.swagger-ui .response-col_status, +.swagger-ui .response-col_description, +.swagger-ui .tab li button, +.swagger-ui .opblock-title, +.swagger-ui label, +.swagger-ui .response-control-media-type__accept-message { + color: var(--cu-text-dim) !important; + font-family: var(--cu-font) !important; +} +.swagger-ui .parameter__name { + font-family: var(--cu-mono) !important; + color: var(--cu-text) !important; + font-size: 13px !important; +} +.swagger-ui .parameter__name.required:after { + color: var(--cu-danger) !important; +} + +/* ── Models ──────────────────────────────────────────────── */ +.swagger-ui section.models { + border: 1px solid var(--cu-border) !important; + background: var(--cu-bg-panel) !important; + border-radius: var(--cu-radius) !important; + box-shadow: var(--cu-glow); +} +.swagger-ui section.models h4 { + color: var(--cu-text) !important; + border-bottom: 1px solid var(--cu-border) !important; + background: transparent !important; + font-family: var(--cu-mono) !important; + letter-spacing: 0.04em; +} +.swagger-ui section.models h4 span::before { + content: "struct "; + color: var(--cu-accent-hot); + font-size: 0.85em; + opacity: 0.8; +} +.swagger-ui .model-box, +.swagger-ui .model, +.swagger-ui .model-title, +.swagger-ui .prop-type, +.swagger-ui .prop-format { + background: transparent !important; + color: var(--cu-text-dim) !important; + font-family: var(--cu-mono) !important; +} +.swagger-ui .prop-type { + color: var(--cu-accent-hot) !important; +} + +/* ── Inputs ──────────────────────────────────────────────── */ +.swagger-ui input[type="text"], +.swagger-ui input[type="password"], +.swagger-ui input[type="search"], +.swagger-ui input[type="email"], +.swagger-ui input[type="file"], +.swagger-ui textarea, +.swagger-ui select { + background: var(--cu-bg) !important; + color: var(--cu-text) !important; + border: 1px solid var(--cu-border-strong) !important; + border-radius: 8px !important; + outline: none !important; + box-shadow: none !important; + font-family: var(--cu-mono) !important; + font-size: 13px !important; +} +.swagger-ui input:focus, +.swagger-ui textarea:focus, +.swagger-ui select:focus { + border-color: var(--cu-accent-hot) !important; + box-shadow: 0 0 0 3px rgba(34, 211, 166, 0.16) !important; +} +.swagger-ui input::placeholder, +.swagger-ui textarea::placeholder { + color: var(--cu-text-mute) !important; +} + +/* ── Buttons ─────────────────────────────────────────────── */ +.swagger-ui .btn { + background: var(--cu-bg-soft) !important; + color: var(--cu-text) !important; + border: 1px solid var(--cu-border-strong) !important; + border-radius: 10px !important; + box-shadow: none !important; + font-family: var(--cu-font) !important; + font-weight: 600 !important; +} +/* Execute = "run the payload" */ +.swagger-ui .btn.execute { + background: linear-gradient(135deg, #059669, #0d9488 55%, #2563eb) !important; + border: none !important; + color: #fff !important; + box-shadow: 0 8px 24px rgba(16, 185, 129, 0.35) !important; + font-family: var(--cu-mono) !important; + font-weight: 700 !important; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 12px !important; +} +.swagger-ui .btn.execute:hover { + filter: brightness(1.1); + box-shadow: 0 10px 28px rgba(34, 211, 166, 0.45) !important; +} +.swagger-ui .btn.cancel { + background: transparent !important; + border-color: var(--cu-text-mute) !important; + color: var(--cu-text-dim) !important; +} +.swagger-ui .try-out__btn { + border-color: var(--cu-accent-hot) !important; + color: var(--cu-accent-hot) !important; + background: rgba(34, 211, 166, 0.06) !important; + font-family: var(--cu-mono) !important; + font-size: 12px !important; + letter-spacing: 0.04em; +} + +/* ── Code / responses (terminal phosphor) ────────────────── */ +.swagger-ui .highlight-code, +.swagger-ui .microlight { + background: #030508 !important; + color: #9ef0c8 !important; + border: 1px solid rgba(34, 211, 166, 0.2) !important; + border-radius: 10px !important; + font-family: var(--cu-mono) !important; + font-size: 12.5px !important; + line-height: 1.5 !important; + box-shadow: inset 0 0 40px rgba(34, 211, 166, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.4); + text-shadow: 0 0 8px rgba(34, 211, 166, 0.15); +} +.swagger-ui .responses-inner h4, +.swagger-ui .responses-inner h5, +.swagger-ui .response-col_description__inner div.markdown, +.swagger-ui .markdown p, +.swagger-ui .renderedMarkdown p, +.swagger-ui .markdown code, +.swagger-ui .renderedMarkdown code { + color: var(--cu-text-dim) !important; +} +.swagger-ui .markdown code, +.swagger-ui .renderedMarkdown code { + font-family: var(--cu-mono) !important; + background: rgba(34, 211, 166, 0.08) !important; + color: var(--cu-accent-hot) !important; + border-radius: 4px; + padding: 1px 5px; +} +.swagger-ui table tbody tr td { + color: var(--cu-text-dim) !important; + border-color: var(--cu-border) !important; +} +.swagger-ui .response-control-media-type__title { + color: var(--cu-text-mute) !important; +} +/* status codes get a bit of matrix love */ +.swagger-ui .response-col_status { + font-family: var(--cu-mono) !important; + font-weight: 700 !important; +} + +/* Curl */ +.swagger-ui .curl-command, +.swagger-ui .curl { + background: #030508 !important; + color: #9ef0c8 !important; + border: 1px solid rgba(34, 211, 166, 0.22) !important; + border-radius: 10px !important; + font-family: var(--cu-mono) !important; + box-shadow: inset 0 0 30px rgba(34, 211, 166, 0.05); +} + +/* Copy buttons etc */ +.swagger-ui .copy-to-clipboard { + background: var(--cu-bg-soft) !important; +} + +/* ── Authorize modal ─────────────────────────────────────── */ +.swagger-ui .dialog-ux .modal-ux { + background: var(--cu-bg-panel) !important; + border: 1px solid var(--cu-border) !important; + border-top: 2px solid var(--cu-accent-hot) !important; + border-radius: 16px !important; + color: var(--cu-text) !important; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.65), 0 0 40px rgba(34, 211, 166, 0.08) !important; +} +.swagger-ui .dialog-ux .modal-ux-header { + border-bottom: 1px solid var(--cu-border) !important; +} +.swagger-ui .dialog-ux .modal-ux-header h3 { + font-family: var(--cu-mono) !important; + letter-spacing: 0.04em; +} +.swagger-ui .dialog-ux .modal-ux-header h3::before { + content: "🔑 "; +} +.swagger-ui .dialog-ux .modal-ux-header h3, +.swagger-ui .dialog-ux .modal-ux-content h4, +.swagger-ui .dialog-ux .modal-ux-content p, +.swagger-ui .dialog-ux .modal-ux-content label { + color: var(--cu-text) !important; + font-family: var(--cu-font) !important; +} +.swagger-ui .auth-container { + border-color: var(--cu-border) !important; + border-radius: 10px !important; + background: rgba(3, 5, 8, 0.5) !important; +} +.swagger-ui .auth-btn-wrapper .btn-done, +.swagger-ui .btn.modal-btn.auth.authorize { + background: linear-gradient(135deg, #059669, #0d9488) !important; + border: none !important; + color: #fff !important; + font-family: var(--cu-mono) !important; + letter-spacing: 0.06em; + text-transform: uppercase; + font-size: 12px !important; + box-shadow: 0 6px 20px rgba(16, 185, 129, 0.35) !important; +} + +/* Filter */ +.swagger-ui .filter .operation-filter-input { + background: var(--cu-bg) !important; + border: 1px solid var(--cu-border-strong) !important; + color: var(--cu-accent-hot) !important; + border-radius: 10px !important; + font-family: var(--cu-mono) !important; +} +.swagger-ui .filter .operation-filter-input::placeholder { + color: var(--cu-text-mute) !important; +} + +/* Loading spinner */ +.swagger-ui .loading-container .loading::after { + border-color: var(--cu-accent-hot) transparent transparent !important; +} + +/* Scrollbars */ +.swagger-ui ::-webkit-scrollbar { + width: 10px; + height: 10px; +} +.swagger-ui ::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #1a9b74, #2a354d); + border-radius: 8px; +} +.swagger-ui ::-webkit-scrollbar-track { + background: var(--cu-bg); +} + +/* Markdown lists in description */ +.swagger-ui .markdown li, +.swagger-ui .renderedMarkdown li { + color: var(--cu-text-dim) !important; +} + +/* Prefer reduced motion: kill blink/scan for a11y */ +@media (prefers-reduced-motion: reduce) { + .swagger-ui .info .title::after, + .swagger-ui .topbar { + animation: none !important; + } + body::before { + opacity: 0.35; + } +}