Changeset 0.33.0 (#207)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-19 21:37:32 +00:00
committed by xcaliber
parent b1266b0d7c
commit ed3e9363f2
42 changed files with 2527 additions and 129 deletions

View File

@@ -43,6 +43,8 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
# BANNER_POSITION=top
# ── Logging ──────────────────────────────────
# LOG_FORMAT: "text" (default, human-readable) or "json" (structured, machine-parseable)
LOG_FORMAT=text
LOG_LEVEL=info
# ── File Storage ─────────────────────────────

View File

@@ -6,7 +6,7 @@
# ==========================================
# Build stage
FROM golang:1.22-bookworm AS builder
FROM golang:1.23-bookworm AS builder
ARG APP_VERSION=dev

View File

@@ -82,6 +82,12 @@ type Config struct {
// hours are marked 'stale'. Default 72 (3 days). Set to 0 to disable.
WorkflowStaleHours int
// Structured logging (v0.33.0)
// LOG_FORMAT: "text" (default, backward-compatible) or "json" (structured).
// LOG_LEVEL: "debug", "info" (default), "warn", "error".
LogFormat string
LogLevel string
// Auth mode (v0.24.0): "builtin" (default) | "mtls" | "oidc"
AuthMode string
@@ -151,6 +157,9 @@ func Load() *Config {
WorkflowStaleHours: getEnvInt("WORKFLOW_STALE_HOURS", 72),
LogFormat: getEnv("LOG_FORMAT", "text"),
LogLevel: getEnv("LOG_LEVEL", "info"),
AuthMode: getEnv("AUTH_MODE", "builtin"),
// mTLS

View File

@@ -10,6 +10,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"chat-switchboard/metrics"
)
const (
@@ -121,6 +123,7 @@ func (h *Hub) HandleWebSocket(c *gin.Context) {
h.mu.Unlock()
log.Printf("[ws] connected: %s (user=%s, total=%d)", connID, userID, h.ConnCount())
metrics.WebSocketConnections.Inc()
// Subscribe this connection to bus events destined for clients
conn.subscribeToBus()
@@ -214,6 +217,7 @@ func (h *Hub) removeConn(conn *Conn) {
}
log.Printf("[ws] disconnected: %s (total=%d)", conn.id, h.ConnCount())
metrics.WebSocketConnections.Dec()
// Publish presence offline (only if no other conns for this user)
if len(h.ConnsByUser(conn.userID)) == 0 {

View File

@@ -1,6 +1,6 @@
module chat-switchboard
go 1.22
go 1.23.0
require (
github.com/gin-gonic/gin v1.9.1
@@ -10,15 +10,18 @@ require (
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/minio/minio-go/v7 v7.0.82
github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron/v3 v3.0.1
go.starlark.net v0.0.0-20260210143700-b62fd896b91b
golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
golang.org/x/crypto v0.41.0
golang.org/x/net v0.43.0
modernc.org/sqlite v1.34.5
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
@@ -29,23 +32,29 @@ require (
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect

View File

@@ -1,9 +1,14 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -29,8 +34,8 @@ github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
@@ -42,12 +47,18 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -63,16 +74,28 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -85,41 +108,44 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.starlark.net v0.0.0-20260210143700-b62fd896b91b h1:mDO9/2PuBcapqFbhiCmFcEQZvlQnk3ILEZR+a8NL1z4=
go.starlark.net v0.0.0-20260210143700-b62fd896b91b/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"log"
"log/slog"
"net/http"
"strings"
"time"
@@ -21,6 +22,7 @@ import (
"chat-switchboard/filters"
"chat-switchboard/health"
"chat-switchboard/knowledge"
"chat-switchboard/metrics"
"chat-switchboard/models"
"chat-switchboard/notifications"
"chat-switchboard/providers"
@@ -224,6 +226,16 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
userID := getUserID(c)
// v0.33.0: Correlation ID — same as request_id, propagated through
// completion chain for cross-referencing logs.
correlationID, _ := c.Get("request_id")
slog.Info("completion.start",
"request_id", correlationID,
"channel_id", channelID,
"user_id", userID,
"model", req.Model,
)
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
@@ -1912,6 +1924,20 @@ func (h *CompletionHandler) logUsage(
if err := h.stores.Usage.Log(c.Request.Context(), entry); err != nil {
log.Printf("⚠ Failed to log usage: %v", err)
}
// v0.33.0: Prometheus token counters
metrics.CompletionTokensTotal.WithLabelValues("prompt", modelID).Add(float64(inputTokens))
metrics.CompletionTokensTotal.WithLabelValues("completion", modelID).Add(float64(outputTokens))
// v0.33.0: Structured usage log for observability correlation
correlationID, _ := c.Get("request_id")
slog.Info("completion.usage",
"request_id", correlationID,
"model_id", modelID,
"provider_config_id", configID,
"input_tokens", inputTokens,
"output_tokens", outputTokens,
)
}
// calcCost computes the cost for a given token count and price-per-million.
@@ -1923,23 +1949,36 @@ func calcCost(tokens int, pricePerM *float64) *float64 {
return &cost
}
// recordHealth records provider call outcome for health tracking (v0.22.0).
// recordHealth records provider call outcome for health tracking (v0.22.0)
// and Prometheus completion metrics (v0.33.0).
func (h *CompletionHandler) recordHealth(configID string, start time.Time, err error) {
duration := time.Since(start)
latencyMs := int(duration.Milliseconds())
// v0.33.0: Prometheus completion duration (always, even on error)
if configID != "" {
metrics.CompletionDuration.WithLabelValues(configID, "").Observe(duration.Seconds())
}
if h.health == nil || configID == "" {
return
}
latencyMs := int(time.Since(start).Milliseconds())
if err != nil {
errMsg := err.Error()
status := "error"
if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") {
h.health.RecordTimeout(configID, latencyMs, errMsg)
status = "timeout"
} else if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
h.health.RecordRateLimit(configID, latencyMs, errMsg)
status = "rate_limited"
} else {
h.health.RecordError(configID, latencyMs, errMsg)
}
metrics.CompletionsTotal.WithLabelValues(configID, "", status).Inc()
} else {
h.health.RecordSuccess(configID, latencyMs)
metrics.CompletionsTotal.WithLabelValues(configID, "", "success").Inc()
}
}

View File

@@ -0,0 +1,118 @@
package handlers
import (
"database/sql"
"net/http"
"runtime"
"time"
"github.com/gin-gonic/gin"
"chat-switchboard/database"
"chat-switchboard/events"
"chat-switchboard/health"
"chat-switchboard/models"
"chat-switchboard/store"
)
// ── Dashboard Admin Handler ─────────────────
// GET /api/v1/admin/dashboard
// Aggregates live operational data for the built-in admin monitoring page.
var processStartTime = time.Now()
type DashboardAdminHandler struct {
stores store.Stores
healthStore health.Store
hub *events.Hub
}
func NewDashboardAdminHandler(stores store.Stores, hs health.Store, hub *events.Hub) *DashboardAdminHandler {
return &DashboardAdminHandler{stores: stores, healthStore: hs, hub: hub}
}
type runtimeStats struct {
Goroutines int `json:"goroutines"`
HeapMB int `json:"heap_mb"`
SysMB int `json:"sys_mb"`
NumGC uint32 `json:"num_gc"`
GoVersion string `json:"go_version"`
}
func (h *DashboardAdminHandler) GetDashboard(c *gin.Context) {
ctx := c.Request.Context()
// Provider health summaries
var providerHealth []models.ProviderHealthSummary
windows, err := h.healthStore.ListAllCurrent(ctx)
if err == nil {
for _, w := range windows {
providerHealth = append(providerHealth, models.ProviderHealthSummary{
ProviderConfigID: w.ProviderConfigID,
Status: health.DeriveStatus(w.ErrorRate(), w.RequestCount),
RequestCount: w.RequestCount,
ErrorRate: w.ErrorRate(),
ErrorCount: w.ErrorCount,
RateLimitCount: w.RateLimitCount,
TimeoutCount: w.TimeoutCount,
AvgLatencyMs: w.AvgLatencyMs(),
MaxLatencyMs: w.MaxLatencyMs,
LastError: w.LastError,
LastErrorAt: w.LastErrorAt,
})
}
}
// 24h usage totals
since := time.Now().Add(-24 * time.Hour)
totals, _ := h.stores.Usage.GetTotals(ctx, store.UsageQueryOptions{
Since: &since,
})
// DB pool stats
var dbPool *sql.DBStats
if database.DB != nil {
stats := database.DB.Stats()
dbPool = &stats
}
// WebSocket connections
wsCount := 0
if h.hub != nil {
wsCount = h.hub.ConnCount()
}
// Recent errors from audit log (last 10 error-related entries)
var recentErrors []models.AuditEntry
entries, _, auditErr := h.stores.Audit.List(ctx, store.AuditListOptions{
ListOptions: store.ListOptions{Limit: 10},
ResourceType: "error",
})
if auditErr == nil {
recentErrors = entries
}
// Go runtime stats
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
rt := runtimeStats{
Goroutines: runtime.NumGoroutine(),
HeapMB: int(mem.HeapAlloc / 1024 / 1024),
SysMB: int(mem.Sys / 1024 / 1024),
NumGC: mem.NumGC,
GoVersion: runtime.Version(),
}
// Uptime
uptime := time.Since(processStartTime).Truncate(time.Second).String()
SafeJSON(c, http.StatusOK, gin.H{
"provider_health": providerHealth,
"usage_24h": totals,
"db_pool": dbPool,
"ws_connections": wsCount,
"recent_errors": recentErrors,
"runtime": rt,
"uptime": uptime,
})
}

View File

@@ -12,27 +12,39 @@ import (
"github.com/gin-gonic/gin"
"chat-switchboard/events"
"chat-switchboard/metrics"
"chat-switchboard/providers"
"chat-switchboard/sandbox"
"chat-switchboard/store"
"chat-switchboard/tools"
)
// recordHealthFn records provider health from standalone streaming functions.
// recordHealthFn records provider health from standalone streaming functions
// and updates Prometheus completion metrics (v0.33.0).
func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err error) {
duration := time.Since(start)
latencyMs := int(duration.Milliseconds())
if configID != "" {
metrics.CompletionDuration.WithLabelValues(configID, "").Observe(duration.Seconds())
}
if hr == nil || configID == "" {
return
}
latencyMs := int(time.Since(start).Milliseconds())
if err != nil {
errMsg := err.Error()
status := "error"
if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
hr.RecordRateLimit(configID, latencyMs, errMsg)
status = "rate_limited"
} else {
hr.RecordError(configID, latencyMs, errMsg)
}
metrics.CompletionsTotal.WithLabelValues(configID, "", status).Inc()
} else {
hr.RecordSuccess(configID, latencyMs)
metrics.CompletionsTotal.WithLabelValues(configID, "", "success").Inc()
}
}

View File

@@ -10,6 +10,7 @@ import (
"sync"
"time"
"chat-switchboard/metrics"
"chat-switchboard/models"
)
@@ -335,6 +336,25 @@ func (a *Accumulator) flush() {
if err := a.store.UpsertWindow(ctx, w); err != nil {
log.Printf("⚠ health: flush failed for provider %s: %v", b.providerConfigID, err)
}
// v0.33.0: Update Prometheus provider status gauge
errorRate := float64(0)
if b.requestCount > 0 {
errorRate = float64(b.errorCount) / float64(b.requestCount)
}
status := DeriveStatus(errorRate, b.requestCount)
var statusVal float64
switch status {
case models.StatusHealthy:
statusVal = 1
case models.StatusDegraded:
statusVal = 2
case models.StatusDown:
statusVal = 3
default:
statusVal = 0
}
metrics.ProviderStatus.WithLabelValues(b.providerConfigID).Set(statusVal)
}
}

39
server/logging/logger.go Normal file
View File

@@ -0,0 +1,39 @@
// Package logging provides structured logging configuration for
// Chat Switchboard using the standard library's log/slog package.
// Configured via LOG_FORMAT ("text"|"json") and LOG_LEVEL env vars.
package logging
import (
"log/slog"
"os"
"strings"
)
// Init configures the global slog logger.
//
// - format: "json" for machine-parseable output, anything else for
// human-readable key=value pairs (backward-compatible default).
// - level: "debug", "info" (default), "warn", "error".
func Init(format, level string) {
var lvl slog.Level
switch strings.ToLower(level) {
case "debug":
lvl = slog.LevelDebug
case "warn", "warning":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: lvl}
var handler slog.Handler
if strings.ToLower(format) == "json" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
slog.SetDefault(slog.New(handler))
}

View File

@@ -1,7 +1,9 @@
package main
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"log"
@@ -12,6 +14,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/prometheus/client_golang/prometheus/promhttp"
"chat-switchboard/auth"
"chat-switchboard/compaction"
@@ -24,6 +27,8 @@ import (
"chat-switchboard/sandbox"
"chat-switchboard/handlers"
"chat-switchboard/health"
"chat-switchboard/logging"
"chat-switchboard/metrics"
"chat-switchboard/knowledge"
"chat-switchboard/memory"
"chat-switchboard/middleware"
@@ -43,6 +48,14 @@ import (
"chat-switchboard/workspace"
)
// v0.33.0: Embedded OpenAPI spec and Swagger UI for /api/docs.
//
//go:embed static/openapi.yaml
var openapiSpec []byte
//go:embed static/swagger.html
var swaggerHTML []byte
func main() {
// ── Subcommand dispatch ──────────────────
// Usage: switchboard vault rekey
@@ -58,6 +71,10 @@ func main() {
// ── Server startup ──────────────────────
cfg := config.Load()
// v0.33.0: Structured logging — must be first so all subsequent
// log output goes through slog.
logging.Init(cfg.LogFormat, cfg.LogLevel)
// Register LLM providers
providers.Init()
@@ -129,6 +146,9 @@ func main() {
healthAccum = health.NewAccumulator(healthStore)
defer healthAccum.Stop()
// v0.33.0: Start Prometheus DB pool collector
metrics.StartDBCollector(database.DB, 15*time.Second)
// Auto-disable: deactivate providers that are "down" for N consecutive
// hourly windows. Configured via PROVIDER_AUTO_DISABLE_THRESHOLD env var.
// Default: 3 (3 consecutive "down" hours triggers deactivation). Set to 0 to disable.
@@ -390,7 +410,11 @@ func main() {
log.Printf(" 🔗 Pre-completion filter chain: %d filters", filterChain.Len())
r := gin.Default()
r := gin.New()
r.Use(middleware.RequestID())
r.Use(middleware.Prometheus())
r.Use(middleware.Logger())
r.Use(middleware.Recovery())
userCache := middleware.NewUserStatusCache()
r.Use(middleware.CORS(cfg))
@@ -478,6 +502,19 @@ func main() {
c.JSON(200, gin.H{"status": "ok"})
})
// v0.33.0: Prometheus metrics endpoint (no auth — Prometheus scrapes directly)
base.GET("/metrics", gin.WrapH(promhttp.Handler()))
// v0.33.0: OpenAPI spec + Swagger UI (no auth — documentation)
base.GET("/api/docs", func(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", swaggerHTML)
})
base.GET("/api/docs/openapi.yaml", func(c *gin.Context) {
// Replace ${VERSION} placeholder with actual version from VERSION file
patched := bytes.Replace(openapiSpec, []byte("${VERSION}"), []byte(Version), 1)
c.Data(http.StatusOK, "application/yaml", patched)
})
// WebSocket endpoint
base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter), hub.HandleWebSocket)
@@ -1207,6 +1244,10 @@ func main() {
admin.PUT("/extensions/:id/secrets", extSecH.SetSecrets)
admin.DELETE("/extensions/:id/secrets", extSecH.DeleteSecrets)
// Admin Dashboard (v0.33.0)
dashAdm := handlers.NewDashboardAdminHandler(stores, healthStore, hub)
admin.GET("/dashboard", dashAdm.GetDashboard)
// Provider Health (admin — v0.22.0)
healthAdm := handlers.NewHealthAdminHandler(healthStore, stores)
admin.GET("/providers/health", healthAdm.GetAllProviderHealth)

View File

@@ -0,0 +1,26 @@
package metrics
import (
"database/sql"
"time"
)
// StartDBCollector begins a background goroutine that reads sql.DBStats
// every interval and updates the Prometheus DB pool gauges.
func StartDBCollector(db *sql.DB, interval time.Duration) {
if db == nil {
return
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
stats := db.Stats()
DBOpenConnections.Set(float64(stats.OpenConnections))
DBInUseConnections.Set(float64(stats.InUse))
DBIdleConnections.Set(float64(stats.Idle))
DBWaitCount.Set(float64(stats.WaitCount))
DBWaitDuration.Set(stats.WaitDuration.Seconds())
}
}()
}

97
server/metrics/metrics.go Normal file
View File

@@ -0,0 +1,97 @@
// Package metrics defines all Prometheus metrics for Chat Switchboard.
// All metrics use the "switchboard_" prefix. Registered via promauto
// so they are available on the default registry's /metrics endpoint.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// ── HTTP Request Metrics ────────────────────
var (
HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "switchboard_http_requests_total",
Help: "Total HTTP requests by method, path pattern, and status code.",
}, []string{"method", "path_pattern", "status"})
HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "switchboard_http_request_duration_seconds",
Help: "HTTP request latency in seconds.",
Buckets: []float64{0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30},
}, []string{"method", "path_pattern"})
)
// ── WebSocket Metrics ───────────────────────
var WebSocketConnections = promauto.NewGauge(prometheus.GaugeOpts{
Name: "switchboard_websocket_connections",
Help: "Current number of active WebSocket connections.",
})
// ── Completion / Token Metrics ──────────────
var (
CompletionTokensTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "switchboard_completion_tokens_total",
Help: "Total tokens processed by direction (prompt|completion) and model.",
}, []string{"direction", "model_id"})
CompletionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "switchboard_completions_total",
Help: "Total completion requests by provider, model, and status.",
}, []string{"provider_config_id", "model_id", "status"})
CompletionDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "switchboard_completion_duration_seconds",
Help: "Completion request latency (wall time including tool loops).",
Buckets: []float64{0.1, 0.5, 1, 2, 5, 10, 30, 60, 120},
}, []string{"provider_config_id", "model_id"})
)
// ── Provider Health ─────────────────────────
var ProviderStatus = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "switchboard_provider_status",
Help: "Provider health status: 0=unknown, 1=healthy, 2=degraded, 3=down.",
}, []string{"provider_config_id"})
// ── Database Pool Metrics ───────────────────
var (
DBOpenConnections = promauto.NewGauge(prometheus.GaugeOpts{
Name: "switchboard_db_open_connections",
Help: "Number of open database connections.",
})
DBInUseConnections = promauto.NewGauge(prometheus.GaugeOpts{
Name: "switchboard_db_in_use_connections",
Help: "Number of database connections currently in use.",
})
DBIdleConnections = promauto.NewGauge(prometheus.GaugeOpts{
Name: "switchboard_db_idle_connections",
Help: "Number of idle database connections.",
})
DBWaitCount = promauto.NewGauge(prometheus.GaugeOpts{
Name: "switchboard_db_wait_count_total",
Help: "Total number of connections waited for.",
})
DBWaitDuration = promauto.NewGauge(prometheus.GaugeOpts{
Name: "switchboard_db_wait_duration_seconds_total",
Help: "Total time blocked waiting for a new connection (seconds).",
})
)
// ── Task Scheduler Metrics ──────────────────
var TaskExecutionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "switchboard_task_executions_total",
Help: "Total task executions by status (success|error).",
}, []string{"status"})
// ── Sandbox Metrics ─────────────────────────
var SandboxExecutionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "switchboard_sandbox_executions_total",
Help: "Total Starlark sandbox executions by entry point and status.",
}, []string{"entry_point", "status"})

View File

@@ -1,130 +1,112 @@
package middleware
import (
"log"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// RequestLoggerConfig holds the configuration for the request logger
// RequestLoggerConfig holds the configuration for the request logger.
type RequestLoggerConfig struct {
SkipPaths []string
Logger *log.Logger
TimeFormat string
TimeZone string
SkipPaths []string
}
// DefaultRequestLoggerConfig returns the default request logger configuration
// DefaultRequestLoggerConfig returns the default request logger configuration.
func DefaultRequestLoggerConfig() *RequestLoggerConfig {
return &RequestLoggerConfig{
SkipPaths: []string{"/health", "/ready", "/metrics"},
Logger: log.Default(),
TimeFormat: "2006/01/02 - 15:04:05",
TimeZone: "UTC",
SkipPaths: []string{"/health", "/ready", "/metrics"},
}
}
// Logger returns a Gin middleware handler for logging requests
// Logger returns a Gin middleware handler for logging requests.
func Logger() gin.HandlerFunc {
return LoggerWithConfig(DefaultRequestLoggerConfig())
}
// LoggerWithConfig returns a Gin middleware handler for logging requests with custom configuration
// LoggerWithConfig returns a Gin middleware handler for structured
// request logging via slog. When LOG_FORMAT=json the output is
// machine-parseable JSON lines; otherwise human-readable key=value.
func LoggerWithConfig(config *RequestLoggerConfig) gin.HandlerFunc {
skip := make(map[string]bool, len(config.SkipPaths))
for _, p := range config.SkipPaths {
skip[p] = true
}
return func(c *gin.Context) {
// Skip logging for certain paths
path := c.Request.URL.Path
skip := false
for _, skipPath := range config.SkipPaths {
if path == skipPath {
skip = true
break
}
}
start := time.Now()
raw := c.Request.URL.RawQuery
c.Next()
if skip {
if skip[path] {
c.Next()
return
}
// Calculate latency
latency := time.Since(start)
// Get the client IP
clientIP := c.ClientIP()
// Get the request method
method := c.Request.Method
// Get the status code
statusCode := c.Writer.Status()
// Log the request
if raw != "" {
raw = "?" + raw
start := time.Now()
c.Next()
reqID, _ := c.Get(RequestIDKey)
attrs := []any{
"method", c.Request.Method,
"path", path,
"status", c.Writer.Status(),
"latency_ms", time.Since(start).Milliseconds(),
"client_ip", c.ClientIP(),
}
config.Logger.Printf(
"[%s] %s %s%s | %d | %v | %s",
clientIP,
method,
path,
raw,
statusCode,
latency,
c.Errors.ByType(gin.ErrorTypePrivate).String(),
)
if id, ok := reqID.(string); ok && id != "" {
attrs = append(attrs, "request_id", id)
}
if uid := c.GetString("user_id"); uid != "" {
attrs = append(attrs, "user_id", uid)
}
if errs := c.Errors.ByType(gin.ErrorTypePrivate).String(); errs != "" {
attrs = append(attrs, "errors", errs)
}
slog.Info("request", attrs...)
}
}
// Recovery returns a Gin middleware handler for recovering from panics
// Recovery returns a Gin middleware handler for recovering from panics.
func Recovery() gin.HandlerFunc {
return RecoveryWithConfig(DefaultRecoveryConfig())
}
// RecoveryConfig holds the configuration for the recovery middleware
// RecoveryConfig holds the configuration for the recovery middleware.
type RecoveryConfig struct {
Logger *log.Logger
TimeFormat string
TimeZone string
SkipStackTrace bool
StackTraceSize int
StackAll bool
}
// DefaultRecoveryConfig returns the default recovery configuration
// DefaultRecoveryConfig returns the default recovery configuration.
func DefaultRecoveryConfig() *RecoveryConfig {
return &RecoveryConfig{
Logger: log.Default(),
TimeFormat: "2006/01/02 - 15:04:05",
TimeZone: "UTC",
SkipStackTrace: false,
StackTraceSize: 1024,
StackAll: false,
}
}
// RecoveryWithConfig returns a Gin middleware handler for recovering from panics with custom configuration
// RecoveryWithConfig returns a Gin middleware handler for recovering
// from panics with custom configuration.
func RecoveryWithConfig(config *RecoveryConfig) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// Log the panic
config.Logger.Printf("[PANIC] %v", err)
// Abort with a 500 error
reqID, _ := c.Get(RequestIDKey)
slog.Error("panic",
"error", err,
"method", c.Request.Method,
"path", c.Request.URL.Path,
"request_id", reqID,
)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": "internal server error",
})
}
}()
c.Next()
}
}
}

View File

@@ -0,0 +1,30 @@
package middleware
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"chat-switchboard/metrics"
)
// Prometheus returns a Gin middleware that records HTTP request metrics.
// Uses c.FullPath() (Gin's registered route pattern, e.g.
// "/api/v1/channels/:id") to avoid label cardinality explosion from
// path parameters.
func Prometheus() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
pattern := c.FullPath()
if pattern == "" {
pattern = "unmatched"
}
status := strconv.Itoa(c.Writer.Status())
metrics.HTTPRequestsTotal.WithLabelValues(c.Request.Method, pattern, status).Inc()
metrics.HTTPRequestDuration.WithLabelValues(c.Request.Method, pattern).Observe(time.Since(start).Seconds())
}
}

View File

@@ -0,0 +1,28 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const (
// RequestIDKey is the Gin context key for the request ID.
RequestIDKey = "request_id"
// RequestIDHeader is the HTTP header used to propagate request IDs.
RequestIDHeader = "X-Request-Id"
)
// RequestID generates a UUID per request and sets it in the Gin context
// and response header. If the incoming request already carries an
// X-Request-Id header, it is preserved (useful for tracing across proxies).
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader(RequestIDHeader)
if id == "" {
id = uuid.New().String()
}
c.Set(RequestIDKey, id)
c.Header(RequestIDHeader, id)
c.Next()
}
}

View File

@@ -40,7 +40,7 @@
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09"/></svg>
System
</a>
<a href="{{$base}}/admin/usage" class="admin-cat-btn{{if eq $section "usage"}} active{{else if eq $section "audit"}} active{{else if eq $section "stats"}} active{{end}}" data-cat="monitoring">
<a href="{{$base}}/admin/dashboard" class="admin-cat-btn{{if eq $section "dashboard"}} active{{else if eq $section "usage"}} active{{else if eq $section "audit"}} active{{else if eq $section "stats"}} active{{end}}" data-cat="monitoring">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
Monitoring
</a>

643
server/static/openapi.yaml Normal file
View File

@@ -0,0 +1,643 @@
openapi: 3.0.3
info:
title: Chat Switchboard API
description: |
Multi-provider AI chat platform with team workspaces, workflow automation,
and extensibility via Starlark packages.
**Authentication:** Most endpoints require a Bearer JWT token obtained via
`POST /api/v1/auth/login`. Include it as `Authorization: Bearer <token>`.
**WebSocket:** Real-time events are delivered over WebSocket at `/ws`.
Obtain a ticket via `POST /api/v1/ws/ticket` and connect with `?ticket=<id>`.
version: "${VERSION}"
contact:
name: Chat Switchboard
servers:
- url: /
description: Current instance
tags:
- name: Auth
description: Authentication and session management
- name: Channels
description: Chat channel CRUD and messaging
- name: Completions
description: AI completion requests (streaming and non-streaming)
- name: Health
description: System health and readiness probes
- name: Admin
description: Platform administration (requires admin role)
- name: Usage
description: Token usage and cost tracking
- name: WebSocket
description: Real-time event delivery
paths:
# ── Auth ───────────────────────────────────
/api/v1/auth/register:
post:
tags: [Auth]
summary: Register a new user
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, password]
properties:
username:
type: string
minLength: 3
password:
type: string
minLength: 8
email:
type: string
format: email
display_name:
type: string
responses:
"201":
description: User created
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"400":
$ref: "#/components/responses/BadRequest"
"409":
description: Username already taken
/api/v1/auth/login:
post:
tags: [Auth]
summary: Authenticate and obtain JWT tokens
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [username, password]
properties:
username:
type: string
password:
type: string
responses:
"200":
description: Login successful
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"401":
description: Invalid credentials
"429":
description: Rate limited
/api/v1/auth/refresh:
post:
tags: [Auth]
summary: Refresh an expired access token
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [refresh_token]
properties:
refresh_token:
type: string
responses:
"200":
description: New token pair
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"401":
description: Invalid or expired refresh token
/api/v1/auth/logout:
post:
tags: [Auth]
summary: Invalidate the current session
security:
- bearerAuth: []
responses:
"200":
description: Logged out
# ── Channels ───────────────────────────────
/api/v1/channels:
get:
tags: [Channels]
summary: List channels accessible to the current user
security:
- bearerAuth: []
parameters:
- name: type
in: query
schema:
type: string
enum: [direct, group, workflow]
- name: page
in: query
schema:
type: integer
default: 1
- name: per_page
in: query
schema:
type: integer
default: 50
responses:
"200":
description: Channel list
content:
application/json:
schema:
type: object
properties:
channels:
type: array
items:
$ref: "#/components/schemas/Channel"
total:
type: integer
post:
tags: [Channels]
summary: Create a new channel
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
type:
type: string
enum: [direct, group]
default: direct
system_prompt:
type: string
responses:
"201":
description: Channel created
content:
application/json:
schema:
$ref: "#/components/schemas/Channel"
/api/v1/channels/{id}:
get:
tags: [Channels]
summary: Get channel details
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/ChannelID"
responses:
"200":
description: Channel details
content:
application/json:
schema:
$ref: "#/components/schemas/Channel"
"404":
$ref: "#/components/responses/NotFound"
put:
tags: [Channels]
summary: Update channel settings
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/ChannelID"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
system_prompt:
type: string
ai_mode:
type: string
enum: [auto, off, mention_only]
responses:
"200":
description: Updated
delete:
tags: [Channels]
summary: Archive a channel
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/ChannelID"
responses:
"200":
description: Archived
# ── Completions ────────────────────────────
/api/v1/chat/completions:
post:
tags: [Completions]
summary: Send a message and get an AI completion
description: |
Streams SSE events by default. Set `stream: false` for a single JSON response.
Supports tool calling, @mentions, multi-model routing, and workflow integration.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [content, channel_id]
properties:
channel_id:
type: string
format: uuid
content:
type: string
model:
type: string
description: Override the default model
persona_id:
type: string
format: uuid
provider_config_id:
type: string
format: uuid
max_tokens:
type: integer
temperature:
type: number
minimum: 0
maximum: 2
top_p:
type: number
minimum: 0
maximum: 1
stream:
type: boolean
default: true
file_ids:
type: array
items:
type: string
format: uuid
disabled_tools:
type: array
items:
type: string
responses:
"200":
description: |
**Streaming:** SSE events (`data: {"content":"..."}`, `data: [DONE]`).
**Non-streaming:** OpenAI-compatible JSON response.
content:
text/event-stream:
schema:
type: string
application/json:
schema:
type: object
properties:
model:
type: string
choices:
type: array
items:
type: object
properties:
message:
type: object
properties:
role:
type: string
content:
type: string
finish_reason:
type: string
usage:
type: object
properties:
prompt_tokens:
type: integer
completion_tokens:
type: integer
total_tokens:
type: integer
"400":
$ref: "#/components/responses/BadRequest"
"429":
description: Token budget exceeded
# ── Health ─────────────────────────────────
/health:
get:
tags: [Health]
summary: Basic health check
responses:
"200":
description: System status
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
version:
type: string
database:
type: boolean
schema_version:
type: integer
/healthz/live:
get:
tags: [Health]
summary: Kubernetes liveness probe
responses:
"200":
description: Process alive
/healthz/ready:
get:
tags: [Health]
summary: Kubernetes readiness probe
description: Returns 503 if the database is unreachable (2s timeout).
responses:
"200":
description: Ready to serve traffic
"503":
description: Database unavailable
/metrics:
get:
tags: [Health]
summary: Prometheus metrics endpoint
description: |
Returns all `switchboard_*` metrics in Prometheus text exposition format.
Includes HTTP request latency, WebSocket connections, completion tokens,
DB pool stats, and provider health gauges.
responses:
"200":
description: Prometheus text format
content:
text/plain:
schema:
type: string
# ── WebSocket ──────────────────────────────
/api/v1/ws/ticket:
post:
tags: [WebSocket]
summary: Obtain a WebSocket connection ticket
description: |
Returns a single-use ticket (30s TTL) for authenticating the WebSocket
upgrade at `/ws?ticket=<id>`. Cross-pod safe (v0.32.0).
security:
- bearerAuth: []
responses:
"200":
description: Ticket issued
content:
application/json:
schema:
type: object
properties:
ticket:
type: string
# ── Admin: Stats ───────────────────────────
/api/v1/admin/stats:
get:
tags: [Admin]
summary: Platform statistics
security:
- bearerAuth: []
responses:
"200":
description: Aggregate counts
content:
application/json:
schema:
type: object
properties:
users:
type: integer
channels:
type: integer
messages:
type: integer
# ── Admin: Provider Health ─────────────────
/api/v1/admin/providers/health:
get:
tags: [Admin]
summary: Current health status for all providers
security:
- bearerAuth: []
responses:
"200":
description: Provider health summaries
content:
application/json:
schema:
type: object
properties:
providers:
type: array
items:
$ref: "#/components/schemas/ProviderHealth"
# ── Admin: Usage ───────────────────────────
/api/v1/admin/usage:
get:
tags: [Admin, Usage]
summary: Aggregated token usage
security:
- bearerAuth: []
parameters:
- name: since
in: query
schema:
type: string
format: date-time
- name: until
in: query
schema:
type: string
format: date-time
- name: period
in: query
schema:
type: string
enum: [7d, 30d, 90d]
- name: group_by
in: query
schema:
type: string
enum: [model, user, provider, day]
responses:
"200":
description: Usage data
# ── Admin: Dashboard ───────────────────────
/api/v1/admin/dashboard:
get:
tags: [Admin]
summary: Real-time observability dashboard data
description: |
Aggregates provider health, 24h usage, DB pool stats, WebSocket count,
recent errors, and uptime into a single response. Designed for the
built-in admin dashboard (v0.33.0).
security:
- bearerAuth: []
responses:
"200":
description: Dashboard snapshot
content:
application/json:
schema:
type: object
properties:
providers:
type: array
items:
$ref: "#/components/schemas/ProviderHealth"
usage_24h:
type: object
db_pool:
type: object
properties:
open_connections:
type: integer
in_use:
type: integer
idle:
type: integer
ws_connections:
type: integer
uptime_seconds:
type: number
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
ChannelID:
name: id
in: path
required: true
schema:
type: string
format: uuid
responses:
BadRequest:
description: Invalid request
content:
application/json:
schema:
type: object
properties:
error:
type: string
NotFound:
description: Resource not found
content:
application/json:
schema:
type: object
properties:
error:
type: string
schemas:
AuthResponse:
type: object
properties:
token:
type: string
refresh_token:
type: string
user:
type: object
properties:
id:
type: string
format: uuid
username:
type: string
role:
type: string
enum: [admin, user]
Channel:
type: object
properties:
id:
type: string
format: uuid
title:
type: string
type:
type: string
enum: [direct, group, workflow]
user_id:
type: string
format: uuid
team_id:
type: string
format: uuid
ai_mode:
type: string
enum: [auto, off, mention_only]
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
ProviderHealth:
type: object
properties:
provider_config_id:
type: string
format: uuid
status:
type: string
enum: [healthy, degraded, down, unknown]
request_count:
type: integer
error_count:
type: integer
error_rate:
type: number
avg_latency_ms:
type: number
max_latency_ms:
type: integer

267
server/static/swagger.html Normal file
View File

@@ -0,0 +1,267 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Chat Switchboard — API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
/* ── Palette — WCAG AA 4.5:1 minimum contrast ────────────────── */
:root {
--sw-bg: #ffffff;
--sw-bg-secondary: #f5f5f7;
--sw-bg-section: #fafafa;
--sw-text: #1b1b1b; /* 17.8:1 on #fff */
--sw-text-secondary:#4e4e4e; /* 8.5:1 on #fff */
--sw-text-muted: #636363; /* 6.1:1 on #fff */
--sw-border: #d5d5d5;
--sw-link: #1a56a8; /* 7.8:1 on #fff */
--sw-code-bg: #f0f0f2;
--sw-code-text: #1b1b1b;
--sw-input-bg: #ffffff;
--sw-input-border: #b0b0b0;
}
@media (prefers-color-scheme: dark) {
:root {
--sw-bg: #161625;
--sw-bg-secondary: #1e1e30;
--sw-bg-section: #222236;
--sw-text: #dcdce4; /* 13.5:1 on #161625 */
--sw-text-secondary:#a8a8b8; /* 8.0:1 on #161625 */
--sw-text-muted: #8e8ea0; /* 5.7:1 on #161625 */
--sw-border: #363648;
--sw-link: #7cacf8; /* 8.0:1 on #161625 */
--sw-code-bg: #1c1c2e;
--sw-code-text: #dcdce4;
--sw-input-bg: #1e1e30;
--sw-input-border: #484860;
}
}
/* ── Global ──────────────────────────────────────────────────── */
body { margin: 0; background: var(--sw-bg); color: var(--sw-text); }
.swagger-ui { background: var(--sw-bg); color: var(--sw-text); }
.swagger-ui .wrapper { background: var(--sw-bg); }
/* Hide default Swagger branding bar */
.swagger-ui .topbar { display: none; }
/* ── Override low-contrast utility classes ────────────────────── */
.swagger-ui .silver,
.swagger-ui .light-silver { color: var(--sw-text-muted); }
.swagger-ui .moon-gray,
.swagger-ui .gray { color: var(--sw-text-secondary); }
/* ── Info header ─────────────────────────────────────────────── */
.swagger-ui .info .title,
.swagger-ui .info .title small pre { color: var(--sw-text); }
.swagger-ui .info .base-url { color: var(--sw-text-secondary); }
.swagger-ui .info p,
.swagger-ui .info li,
.swagger-ui .info table td,
.swagger-ui .info table th,
.swagger-ui .renderedMarkdown p,
.swagger-ui .markdown p { color: var(--sw-text-secondary); }
.swagger-ui .info a,
.swagger-ui .renderedMarkdown a,
.swagger-ui .markdown a { color: var(--sw-link); }
.swagger-ui .info h1,
.swagger-ui .info h2,
.swagger-ui .info h3 { color: var(--sw-text); }
/* ── Scheme / server selector ────────────────────────────────── */
.swagger-ui .scheme-container {
background: var(--sw-bg-secondary);
box-shadow: none;
border-bottom: 1px solid var(--sw-border);
}
.swagger-ui .scheme-container .schemes > label { color: var(--sw-text); }
/* ── Tag groups ──────────────────────────────────────────────── */
.swagger-ui .opblock-tag {
color: var(--sw-text);
border-bottom-color: var(--sw-border);
}
.swagger-ui .opblock-tag small { color: var(--sw-text-muted); }
.swagger-ui .opblock-tag a { color: var(--sw-text-secondary); }
/* ── Operation blocks ────────────────────────────────────────── */
.swagger-ui .opblock .opblock-summary-description { color: var(--sw-text-secondary); }
.swagger-ui .opblock .opblock-summary-path,
.swagger-ui .opblock .opblock-summary-path__deprecated { color: var(--sw-text); }
.swagger-ui .opblock-description-wrapper p,
.swagger-ui .opblock-external-docs-wrapper p { color: var(--sw-text-secondary); }
/* Operation block section headers */
.swagger-ui .opblock .opblock-section-header {
background: var(--sw-bg-section);
border-bottom: 1px solid var(--sw-border);
box-shadow: none;
}
.swagger-ui .opblock .opblock-section-header h4 { color: var(--sw-text); }
.swagger-ui .opblock .opblock-section-header label { color: var(--sw-text-secondary); }
/* Operation block body (expanded) */
.swagger-ui .opblock-body { background: var(--sw-bg); }
.swagger-ui .opblock-body pre.microlight { background: var(--sw-code-bg); color: var(--sw-code-text); }
/* ── Parameters table ────────────────────────────────────────── */
.swagger-ui .parameters-col_name,
.swagger-ui .parameter__name { color: var(--sw-text); }
.swagger-ui .parameter__type { color: var(--sw-text-muted); }
.swagger-ui .parameter__deprecated { color: #dc3545; }
.swagger-ui .parameter__name.required::after { color: #dc3545; }
.swagger-ui .parameter__in { color: var(--sw-text-muted); }
.swagger-ui table thead tr td,
.swagger-ui table thead tr th {
color: var(--sw-text);
border-bottom-color: var(--sw-border);
}
.swagger-ui table tbody tr td { color: var(--sw-text-secondary); }
.swagger-ui .parameters-container,
.swagger-ui .responses-wrapper { background: var(--sw-bg); }
/* ── Responses ───────────────────────────────────────────────── */
.swagger-ui .response-col_status { color: var(--sw-text); }
.swagger-ui .response-col_description { color: var(--sw-text-secondary); }
.swagger-ui .response-col_links { color: var(--sw-text-muted); }
.swagger-ui .response-control-media-type__accept-message { color: var(--sw-text-muted); }
.swagger-ui .responses-inner { background: var(--sw-bg); }
.swagger-ui .responses-inner h4,
.swagger-ui .responses-inner h5 { color: var(--sw-text); }
.swagger-ui .response .response-col_description__inner p { color: var(--sw-text-secondary); }
/* ── Models / Schemas ────────────────────────────────────────── */
.swagger-ui .model-title { color: var(--sw-text); }
.swagger-ui section.models { border-color: var(--sw-border); background: var(--sw-bg); }
.swagger-ui section.models h4 { color: var(--sw-text); }
.swagger-ui section.models .model-container { background: var(--sw-bg); border-bottom-color: var(--sw-border); }
.swagger-ui .model { color: var(--sw-text-secondary); }
.swagger-ui .model .property { color: var(--sw-text-secondary); }
.swagger-ui .model .property.primitive { color: var(--sw-text-muted); }
.swagger-ui .model-box { background: var(--sw-bg-secondary); }
.swagger-ui .model-toggle::after { color: var(--sw-text-secondary); }
.swagger-ui span.model-toggle { color: var(--sw-text-secondary); }
/* ── Code / pre blocks ───────────────────────────────────────── */
.swagger-ui pre,
.swagger-ui pre.microlight {
background: var(--sw-code-bg);
color: var(--sw-code-text);
border: 1px solid var(--sw-border);
border-radius: 4px;
}
.swagger-ui .highlight-code > .microlight code { color: var(--sw-code-text); }
.swagger-ui .example code,
.swagger-ui .model code {
background: var(--sw-code-bg);
color: var(--sw-code-text);
}
.swagger-ui .copy-to-clipboard { background: var(--sw-bg-section); }
/* ── Form controls ───────────────────────────────────────────── */
.swagger-ui select {
background: var(--sw-input-bg);
color: var(--sw-text);
border-color: var(--sw-input-border);
}
.swagger-ui input[type=text],
.swagger-ui input[type=search],
.swagger-ui input[type=email],
.swagger-ui input[type=password],
.swagger-ui input[type=file] {
background: var(--sw-input-bg);
color: var(--sw-text);
border-color: var(--sw-input-border);
}
.swagger-ui textarea {
background: var(--sw-input-bg);
color: var(--sw-text);
border-color: var(--sw-input-border);
}
.swagger-ui .body-param textarea {
background: var(--sw-input-bg);
color: var(--sw-text);
}
/* ── Buttons ──────────────────────────────────────────────────── */
.swagger-ui .btn {
color: var(--sw-text);
border-color: var(--sw-input-border);
background: transparent;
}
.swagger-ui .btn:hover { background: var(--sw-bg-secondary); }
.swagger-ui .btn.authorize { color: #49cc90; border-color: #49cc90; }
.swagger-ui .btn.cancel { color: #dc3545; border-color: #dc3545; }
.swagger-ui .btn.execute { color: #fff; background: #4990e2; border-color: #4990e2; }
.swagger-ui .try-out__btn { color: var(--sw-text-secondary); border-color: var(--sw-input-border); }
/* ── Tabs ─────────────────────────────────────────────────────── */
.swagger-ui .tab li { color: var(--sw-text-secondary); }
.swagger-ui .tab li.active { color: var(--sw-text); }
.swagger-ui .tab li button.tablinks { color: inherit; }
.swagger-ui .opblock .tab-header .tab-item { color: var(--sw-text-secondary); }
.swagger-ui .opblock .tab-header .tab-item.active h4 { color: var(--sw-text); }
/* ── Auth dialog ─────────────────────────────────────────────── */
.swagger-ui .dialog-ux .modal-ux {
background: var(--sw-bg);
border: 1px solid var(--sw-border);
}
.swagger-ui .dialog-ux .modal-ux-header { border-bottom-color: var(--sw-border); }
.swagger-ui .dialog-ux .modal-ux-header h3 { color: var(--sw-text); }
.swagger-ui .dialog-ux .modal-ux-content p { color: var(--sw-text-secondary); }
.swagger-ui .dialog-ux .modal-ux-content h4 { color: var(--sw-text); }
.swagger-ui .dialog-ux .modal-ux-content label { color: var(--sw-text); }
.swagger-ui .dialog-ux .backdrop-ux {
background: rgba(0,0,0,0.5);
}
/* ── Auth wrapper ────────────────────────────────────────────── */
.swagger-ui .auth-wrapper { background: var(--sw-bg); }
.swagger-ui .auth-wrapper .auth-container h4 { color: var(--sw-text); }
.swagger-ui .auth-wrapper .auth-container p { color: var(--sw-text-secondary); }
/* ── Filter / search ─────────────────────────────────────────── */
.swagger-ui .filter-container { background: var(--sw-bg-secondary); }
.swagger-ui .filter-container .operation-filter-input {
background: var(--sw-input-bg);
color: var(--sw-text);
border-color: var(--sw-input-border);
}
/* ── Loading ──────────────────────────────────────────────────── */
.swagger-ui .loading-container .loading::after { color: var(--sw-text-muted); }
/* ── Arrows / toggles ────────────────────────────────────────── */
.swagger-ui .expand-operation svg { fill: var(--sw-text-secondary); }
.swagger-ui .models-control svg { fill: var(--sw-text-secondary); }
.swagger-ui .expand-methods svg { fill: var(--sw-text-secondary); }
/* ── Markdown content inside descriptions ─────────────────────── */
.swagger-ui .renderedMarkdown code {
background: var(--sw-code-bg);
color: var(--sw-code-text);
padding: 2px 4px;
border-radius: 3px;
}
.swagger-ui .renderedMarkdown pre code {
padding: 0;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/api/docs/openapi.yaml',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: 'BaseLayout',
});
</script>
</body>
</html>