This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit 695d9590bed4bd6ce909a29ef7c59555b5c0a208
Author: Yang Luo <[email protected]>
AuthorDate: Fri Aug 21 21:19:05 2026 +0800

    feat(setting): edit the settings in the web UI, not conf/app.conf
---
 README.md                                          |  14 +-
 README_zh.md                                       |  14 +-
 conf/app.conf                                      |   7 +
 conf/conf.go                                       |  36 ++++
 controllers/setting.go                             | 168 +++++++++++++++
 controllers/system.go                              |   7 +-
 embedsupport/conf.go                               |   2 +-
 main.go                                            |   3 +
 object/llm_pricing.go                              |  60 +++++-
 object/ormer.go                                    |   6 +
 object/setting.go                                  | 237 +++++++++++++++++++++
 object/site_cert_account.go                        |   6 +-
 proxy/proxy.go                                     |   4 +-
 routers/router.go                                  |   3 +-
 run/database.go                                    |   4 +-
 run/database_cloud.go                              |  14 +-
 run/run.go                                         |   4 +-
 run/util.go                                        |  29 +--
 run/util_path.go                                   |   8 +-
 service/proxy.go                                   | 209 ++++++++++++++----
 service/startup.go                                 |   9 +-
 web/src/App.tsx                                    |   2 +
 .../system.go => web/src/backend/SettingBackend.ts |  24 +--
 web/src/locales/en/data.json                       |  64 +++++-
 web/src/locales/zh/data.json                       |  64 +++++-
 web/src/nav.ts                                     |   4 +-
 web/src/pages/LlmRecordsPage.tsx                   |  78 ++++++-
 web/src/pages/SettingPage.tsx                      | 217 +++++++++++++++++++
 web/src/pages/SiteListPage.tsx                     |  89 ++++++--
 web/src/types.ts                                   |  50 +++++
 30 files changed, 1282 insertions(+), 154 deletions(-)

diff --git a/README.md b/README.md
index 66310e3..443066b 100644
--- a/README.md
+++ b/README.md
@@ -114,9 +114,9 @@ Podman reads the same file:
 podman compose up -d
 ```
 
-Either way the UI is on http://localhost:17000, the SQLite database lives in a 
named volume that survives `down`, and `conf/app.conf` is mounted from the 
repository, so settings can be changed and the container restarted without 
rebuilding it.
+Either way the UI is on http://localhost:17000, the SQLite database lives in a 
named volume that survives `down`, and `conf/app.conf` is mounted from the 
repository, so the settings it seeds can be edited before the first start 
without rebuilding the image.
 
-To serve the WAF proxy from a container, set `gatewayEnabled = true` in that 
mounted `conf/app.conf` and publish its ports too, by adding them next to 
`17000:17000` in `docker-compose.yml`:
+To serve the WAF proxy from a container, turn the reverse proxy on on the 
**Settings** page and publish its ports too, by adding them next to 
`17000:17000` in `docker-compose.yml`:
 
 ```yaml
     ports:
@@ -127,7 +127,7 @@ To serve the WAF proxy from a container, set 
`gatewayEnabled = true` in that mou
 
 ## Configuration
 
-Everything is optional. Settings live in `conf/app.conf`, next to the 
executable, and each one is explained in the file itself. The ones people 
actually change:
+Everything is optional. Settings are changed on the **Settings** page of the 
web UI and stored in the database, so nothing has to be edited by hand and 
nothing has to be restarted. `conf/app.conf`, next to the executable, seeds 
them on the very first start and explains each one; the one-step install has no 
file beside it and seeds from the copy baked into the binary instead. Editing 
the file after that first start does nothing, except for the keys read before 
the database is open: `httpp [...]
 
 | Setting | Default | What it does |
 | --- | --- | --- |
@@ -146,7 +146,7 @@ Gateway prints what it is actually doing when it starts, so 
the result can be ch
 | Casbin Gateway                                                              |
 +----------------+-----------------------------------------------------------+
 | Management UI  | http://localhost:17000                                     |
-| Settings       | conf/app.conf                                              |
+| Settings       | Settings page, seeded from conf/app.conf                   |
 | Web UI files   | web/build                                                  |
 | Reverse proxy  | enabled                                                    |
 | Gateway HTTP   | :8080                                                      |
@@ -161,7 +161,7 @@ If a port is taken, Gateway says which process holds it and 
stops, rather than s
 
 ### Recording prompts
 
-Nothing about a relayed request is stored until you ask for it, because a 
prompt can carry anything that was pasted into it:
+Nothing about a relayed request is stored until you ask for it, because a 
prompt can carry anything that was pasted into it. **Record metadata** and 
**Record metadata and bodies**, the buttons on the LLM Records page, turn it on 
for the next request; the Settings page holds the same choice and the limits 
around it, seeded from:
 
 ```ini
 ; "off" keeps nothing, "metadata" records who called which model with which
@@ -182,7 +182,7 @@ The cost next to each record uses built-in list prices, 
which vendors change and
 The reverse proxy is off by default, so installing Gateway does not take over 
ports 80 and 443. To use it:
 
 1. **Advanced → Sites → Add**. Set **Domain** to the hostname clients will use 
(`test.example.com`), **Host** and **Port** to where the traffic goes 
(`127.0.0.1` and `8000`), and **Mode** to `HTTP` — `HTTPS Only`, the default, 
redirects plain HTTP away before it reaches the backend.
-2. Set `gatewayEnabled = true` in `conf/app.conf` and restart. Ports 80 and 
443 need root on Linux and macOS, so for a first try set `gatewayHttpPort = 
8080`.
+2. Flip **Reverse proxy**, the switch at the top of the Sites page. It takes 
effect at once and is remembered across restarts. Ports 80 and 443 need root on 
Linux and macOS, so for a first try set the gateway HTTP port to `8080` on the 
**Settings** page.
 3. Start anything on the backend port, e.g. `python -m http.server 8000`, then 
ask for the site by `Host` header — the gateway routes on it, so no DNS or 
`hosts` entry is needed:
 
 ```bash
@@ -193,7 +193,7 @@ You should get your backend's response. A `site not found 
for host` reply means
 
 ### Connecting Casdoor
 
-[Casdoor](https://casdoor.org) is optional and takes over member management. 
Create an organization and an application for Gateway in a Casdoor instance, 
then fill in `casdoorEndpoint`, `clientId`, `clientSecret`, 
`casdoorOrganization` and `casdoorApplication`. Sign-in redirects to Casdoor as 
soon as `casdoorEndpoint` is set, which also enables [OAuth 
logins](https://casdoor.org/docs/provider/oauth/overview), health-check alerts, 
the `CAPTCHA` rule action, per-site SSO and cloud file storage.
+[Casdoor](https://casdoor.org) is optional and takes over member management. 
Create an organization and an application for Gateway in a Casdoor instance, 
then fill in the five fields of **Settings → Sign-in**. Sign-in redirects to 
Casdoor as soon as `casdoorEndpoint` is set, which also enables [OAuth 
logins](https://casdoor.org/docs/provider/oauth/overview), health-check alerts, 
the `CAPTCHA` rule action, per-site SSO and cloud file storage.
 
 ## Development
 
diff --git a/README_zh.md b/README_zh.md
index 7417f28..e7f2bb0 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -114,9 +114,9 @@ Podman 读同一个文件:
 podman compose up -d
 ```
 
-两种方式下管理界面都在 http://localhost:17000,SQLite 数据库存在一个命名卷里,`down` 
之后依然保留;`conf/app.conf` 从仓库挂载进去,改完配置重启容器即可生效,不用重新构建镜像。
+两种方式下管理界面都在 http://localhost:17000,SQLite 数据库存在一个命名卷里,`down` 
之后依然保留;`conf/app.conf` 从仓库挂载进去,首次启动前可以先改它播下的那份初始配置,不用重新构建镜像。
 
-想在容器里用 WAF 反向代理,把挂载的 `conf/app.conf` 里的 `gatewayEnabled` 设为 `true`,并在 
`docker-compose.yml` 里 `17000:17000` 旁边补上它的端口:
+想在容器里用 WAF 反向代理,在 **设置** 页面里打开反向代理,并在 `docker-compose.yml` 里 `17000:17000` 
旁边补上它的端口:
 
 ```yaml
     ports:
@@ -127,7 +127,7 @@ podman compose up -d
 
 ## 配置
 
-所有配置都是可选的。设置位于可执行文件旁边的 `conf/app.conf`,每一项在文件里都有说明。真正常被改动的是这些:
+所有配置都是可选的。设置在 Web UI 的 **设置** 页面里修改,保存在数据库中,既不用手工改文件,也不用重启。可执行文件旁边的 
`conf/app.conf` 
只在第一次启动时播下这些值,每一项在文件里都有说明;一步安装装出来的单个可执行文件旁边没有这个文件,播的是编进二进制里的那一份。第一次启动之后再改这个文件不会有任何效果,只有在数据库打开之前就要读的那几项例外:`httpport`、`driverName`、`dataSourceName`、`dbName`
 和 `redisEndpoint`。真正常被改动的是这些:
 
 | 配置项 | 默认值 | 作用 |
 | --- | --- | --- |
@@ -146,7 +146,7 @@ Gateway 启动时会打印它实际在做什么,所以可以直接看结果而
 | Casbin Gateway                                                              |
 +----------------+-----------------------------------------------------------+
 | Management UI  | http://localhost:17000                                     |
-| Settings       | conf/app.conf                                              |
+| Settings       | Settings page, seeded from conf/app.conf                   |
 | Web UI files   | web/build                                                  |
 | Reverse proxy  | enabled                                                    |
 | Gateway HTTP   | :8080                                                      |
@@ -161,7 +161,7 @@ Gateway 启动时会打印它实际在做什么,所以可以直接看结果而
 
 ### 记录提示词
 
-在你主动要求之前,转发请求的内容一概不存储,因为提示词里可能粘进任何东西:
+在你主动要求之前,转发请求的内容一概不存储,因为提示词里可能粘进任何东西。LLM Records 页面上的 **只记录元数据** 和 
**记录元数据和正文** 两个按钮会从下一次请求起打开记录;设置页面里有同样的选项和相关的上限,它们的初始值来自:
 
 ```ini
 ; "off" 什么都不留,"metadata" 记录谁调用了哪个模型、结果如何,
@@ -182,7 +182,7 @@ llmRecordMaxPayloadBytes = 1048576
 反向代理默认关闭,所以安装 Gateway 不会占用 80 和 443 端口。要使用它:
 
 1. **Advanced → Sites → Add**。**Domain** 
填客户端会使用的主机名(`test.example.com`),**Host** 和 **Port** 填流量的去向(`127.0.0.1` 和 
`8000`),**Mode** 选 `HTTP` —— 默认的 `HTTPS Only` 会在请求到达后端之前把明文 HTTP 重定向走。
-2. 在 `conf/app.conf` 里设 `gatewayEnabled = true` 并重启。Linux 和 macOS 上 80/443 
端口需要 root,所以第一次尝试可以设 `gatewayHttpPort = 8080`。
+2. 打开 Sites 页面顶部的 **反向代理** 开关。它立即生效,重启后依然保持。Linux 和 macOS 上 80/443 端口需要 
root,所以第一次尝试可以在 **设置** 页面里把网关 HTTP 端口改成 `8080`。
 3. 在后端端口上起点什么,例如 `python -m http.server 8000`,然后用 `Host` 头请求这个站点 —— 
网关就是按它路由的,所以不需要 DNS 或 `hosts` 记录:
 
 ```bash
@@ -193,7 +193,7 @@ curl -H "Host: test.example.com" http://127.0.0.1:8080/
 
 ### 接入 Casdoor
 
-[Casdoor](https://casdoor.org) 是可选的,接管成员管理。在一个 Casdoor 实例里为 Gateway 
创建组织和应用,然后填好 `casdoorEndpoint`、`clientId`、`clientSecret`、`casdoorOrganization` 
和 `casdoorApplication`。只要设置了 `casdoorEndpoint`,登录就会跳转到 Casdoor,同时还会启用 [OAuth 
登录](https://casdoor.org/docs/provider/oauth/overview)、健康检查告警、`CAPTCHA` 
规则动作、按站点的 SSO 以及云端文件存储。
+[Casdoor](https://casdoor.org) 是可选的,接管成员管理。在一个 Casdoor 实例里为 Gateway 
创建组织和应用,然后在 **设置 → 登录** 里填好那五项。只要设置了 `casdoorEndpoint`,登录就会跳转到 Casdoor,同时还会启用 
[OAuth 登录](https://casdoor.org/docs/provider/oauth/overview)、健康检查告警、`CAPTCHA` 
规则动作、按站点的 SSO 以及云端文件存储。
 
 ## 开发
 
diff --git a/conf/app.conf b/conf/app.conf
index e51360b..69b7234 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -1,3 +1,10 @@
+; This file seeds the settings on the very first start. Casbin Gateway then
+; keeps them in its database and reads them from there, so a line changed below
+; afterwards has no effect — use the Settings page of the web UI instead. The
+; exceptions are the keys read before the database is open: "appname",
+; "httpport", "runmode", "driverName", "dataSourceName", "dbName" and
+; "redisEndpoint". Those are only ever read from this file.
+
 appname = casbin-gateway
 ; Port of the management UI and the REST API. Whatever is already listening on
 ; it is killed on startup, so a previous run does not have to be stopped by 
hand.
diff --git a/conf/conf.go b/conf/conf.go
index fc09bef..7974809 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -21,6 +21,7 @@ import (
        "runtime"
        "strconv"
        "strings"
+       "sync"
 
        "github.com/beego/beego"
 )
@@ -48,11 +49,40 @@ func ApplyEnvOverrides() {
        }
 }
 
+var (
+       settingOverrides = map[string]string{}
+       settingMutex     sync.RWMutex
+)
+
+// SetSettingOverrides hands over the settings held in the database. The
+// built-in Setting row is the source of truth for everything the web UI can
+// change, so a key it carries wins over conf/app.conf even when it is empty:
+// the row was seeded from the file, and clearing a value in the UI has to
+// clear it for good rather than fall back to what the file still says.
+func SetSettingOverrides(overrides map[string]string) {
+       settingMutex.Lock()
+       defer settingMutex.Unlock()
+
+       settingOverrides = overrides
+}
+
+func getSettingOverride(key string) (string, bool) {
+       settingMutex.RLock()
+       defer settingMutex.RUnlock()
+
+       value, ok := settingOverrides[key]
+       return value, ok
+}
+
 func GetConfigString(key string) string {
        if value, ok := os.LookupEnv(key); ok {
                return value
        }
 
+       if value, ok := getSettingOverride(key); ok {
+               return value
+       }
+
        res := beego.AppConfig.String(key)
        if res == "" {
                if key == "staticBaseUrl" {
@@ -65,6 +95,12 @@ func GetConfigString(key string) string {
        return res
 }
 
+// GetConfigStringUnquoted drops the quotes conf/app.conf writes around a 
value.
+// Settings stored in the database never carry them.
+func GetConfigStringUnquoted(key string) string {
+       return strings.Trim(GetConfigString(key), `"' `)
+}
+
 func GetConfigBool(key string) bool {
        value := GetConfigString(key)
        if value == "true" {
diff --git a/controllers/setting.go b/controllers/setting.go
new file mode 100644
index 0000000..62f7961
--- /dev/null
+++ b/controllers/setting.go
@@ -0,0 +1,168 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+       "encoding/json"
+       "fmt"
+       "strings"
+       "time"
+
+       "github.com/apache/casbin-gateway/agentmonitor"
+       "github.com/apache/casbin-gateway/casdoor"
+       "github.com/apache/casbin-gateway/conf"
+       "github.com/apache/casbin-gateway/object"
+       "github.com/apache/casbin-gateway/proxy"
+       "github.com/apache/casbin-gateway/run"
+       "github.com/apache/casbin-gateway/service"
+)
+
+func (c *ApiController) GetSetting() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       setting, err := object.GetBuiltInSetting()
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       c.ResponseOk(setting)
+}
+
+// UpdateSetting stores the settings and applies them to the running Gateway, 
so
+// the Settings page is all there is to it: no file to edit, and nothing to
+// restart afterwards.
+func (c *ApiController) UpdateSetting() {
+       if c.RequireAdmin() {
+               return
+       }
+
+       var setting object.Setting
+       if err := json.Unmarshal(c.Ctx.Input.RequestBody, &setting); err != nil 
{
+               c.ResponseError(err.Error())
+               return
+       }
+
+       if err := validateSetting(&setting); err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+
+       previous, err := object.GetBuiltInSetting()
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if previous == nil {
+               c.ResponseError("the built-in setting does not exist")
+               return
+       }
+
+       affected, err := object.UpdateSetting(object.BuiltInSettingId, &setting)
+       if err != nil {
+               c.ResponseError(err.Error())
+               return
+       }
+       if !affected {
+               c.ResponseError("the built-in setting could not be updated")
+               return
+       }
+
+       // Everything is saved by now, so a subsystem that refuses the new 
setting is
+       // reported without losing it: the admin can correct the value, or start
+       // Gateway again with the privileges it turned out to need.
+       if err = applySetting(previous, &setting); err != nil {
+               c.ResponseError(fmt.Sprintf("the settings were saved, but they 
could not all be applied: %s", err.Error()))
+               return
+       }
+
+       c.ResponseOk(setting)
+}
+
+func validateSetting(setting *object.Setting) error {
+       switch setting.LlmRecordMode {
+       case conf.LlmRecordOff, conf.LlmRecordMetadata, conf.LlmRecordFull:
+       default:
+               return fmt.Errorf("llmRecordMode must be one of \"off\", 
\"metadata\" or \"full\"")
+       }
+
+       for _, port := range []int{setting.GatewayHttpPort, 
setting.GatewayHttpsPort} {
+               if port < 1 || port > 65535 {
+                       return fmt.Errorf("the gateway ports must be between 1 
and 65535")
+               }
+       }
+       if setting.GatewayHttpPort == setting.GatewayHttpsPort {
+               return fmt.Errorf("the two gateway ports must differ")
+       }
+
+       // run.InitAppMap() panics on a malformed value, so it is rejected here
+       // rather than taking the process down after it has been stored.
+       if appMap := strings.TrimSpace(setting.AppMap); appMap != "" {
+               if err := json.Unmarshal([]byte(appMap), &map[string]string{}); 
err != nil {
+                       return fmt.Errorf("appMap must be a JSON object, e.g. 
{\"casdoor\": \"cc\"}: %s", err.Error())
+               }
+       }
+
+       return nil
+}
+
+// applySetting hands the stored settings to the subsystems that hold on to
+// them. conf already answers with the new values, so each call below is the
+// same one main() makes at startup.
+func applySetting(previous *object.Setting, setting *object.Setting) error {
+       casdoor.InitCasdoorConfig()
+       proxy.InitHttpClient()
+       run.InitAppMap()
+       object.ReloadLlmPrices()
+       agentmonitor.Configure(
+               conf.GetAgentPatchStateDir(),
+               time.Duration(conf.GetAgentMonitorPollSeconds())*time.Second,
+               conf.GetAgentRecordCapacity(),
+       )
+
+       if setting.LlmRecordMode == conf.LlmRecordOff {
+               object.StopLlmRecordWriter()
+       } else {
+               // The queue is sized when the writer starts, so a new capacity 
only
+               // takes hold once it has been through a stop.
+               if previous.LlmRecordQueueCapacity != 
setting.LlmRecordQueueCapacity {
+                       object.StopLlmRecordWriter()
+               }
+               object.StartLlmRecordWriter()
+       }
+
+       return applyGatewaySetting(previous, setting)
+}
+
+func applyGatewaySetting(previous *object.Setting, setting *object.Setting) 
error {
+       if !setting.GatewayEnabled {
+               service.StopGateway()
+               return nil
+       }
+
+       portsChanged := previous.GatewayHttpPort != setting.GatewayHttpPort ||
+               previous.GatewayHttpsPort != setting.GatewayHttpsPort
+       if portsChanged && service.IsGatewayRunning() {
+               service.StopGateway()
+       }
+
+       if err := service.StartGateway(); err != nil {
+               return fmt.Errorf("the reverse proxy could not start: %s", 
err.Error())
+       }
+
+       return nil
+}
diff --git a/controllers/system.go b/controllers/system.go
index 1ccfde1..28896a8 100644
--- a/controllers/system.go
+++ b/controllers/system.go
@@ -16,11 +16,12 @@ package controllers
 
 import (
        "github.com/apache/casbin-gateway/conf"
+       "github.com/apache/casbin-gateway/service"
 )
 
 // GetGatewayStatus tells the web UI whether the reverse-proxy gateway is
-// actually running. Without it, a Gateway started with "gatewayEnabled = 
false"
-// accepts every site and rule and silently proxies nothing.
+// actually running. Without it, a Gateway whose reverse proxy is off accepts
+// every site and rule and silently proxies nothing.
 func (c *ApiController) GetGatewayStatus() {
        if c.RequireSignedIn() {
                return
@@ -28,6 +29,8 @@ func (c *ApiController) GetGatewayStatus() {
 
        c.ResponseOk(map[string]interface{}{
                "gatewayEnabled": conf.IsGatewayEnabled(),
+               "gatewayRunning": service.IsGatewayRunning(),
+               "gatewayError":   service.GatewayError(),
                "httpPort":       conf.GetGatewayHttpPort(),
                "httpsPort":      conf.GetGatewayHttpsPort(),
        })
diff --git a/embedsupport/conf.go b/embedsupport/conf.go
index a779f4e..83bc9cb 100644
--- a/embedsupport/conf.go
+++ b/embedsupport/conf.go
@@ -65,7 +65,7 @@ func setupConf(appConf string) {
        conf.ApplyEnvOverrides()
 
        usingEmbeddedConf = true
-       fmt.Println("Using the conf/app.conf embedded in this binary. To change 
any setting, put your own conf/app.conf next to the executable.")
+       fmt.Println("Using the conf/app.conf embedded in this binary. Settings 
can be changed on the Settings page of the web UI.")
 }
 
 // onDiskConfPath returns the config file beego found on disk, or "" when there
diff --git a/main.go b/main.go
index e445e90..fd44b2f 100644
--- a/main.go
+++ b/main.go
@@ -45,6 +45,9 @@ func main() {
        object.InitFlag()
        object.InitAdapter()
        object.CreateTables()
+       // The built-in Setting row answers conf from here on, so it has to be 
loaded
+       // before anything reads a setting out of conf.
+       object.InitBuiltInSetting()
        object.StartLlmRecordWriter()
        defer object.StopLlmRecordWriter()
        casdoor.InitCasdoorConfig()
diff --git a/object/llm_pricing.go b/object/llm_pricing.go
index b671f6d..4937c0c 100644
--- a/object/llm_pricing.go
+++ b/object/llm_pricing.go
@@ -35,9 +35,9 @@ type LlmPrice struct {
        CacheRead  float64 `json:"cacheRead"`
 }
 
-// llmPrices are published list prices, keyed by the part of a model name that
+// builtInLlmPrices are published list prices, keyed by the part of a model 
name that
 // identifies the model. They go stale, so llmPricingFile overrides them.
-var llmPrices = map[string]LlmPrice{
+var builtInLlmPrices = map[string]LlmPrice{
        "claude-opus-4":     {15, 75, 18.75, 1.5},
        "claude-3-opus":     {15, 75, 18.75, 1.5},
        "claude-sonnet-4":   {3, 15, 3.75, 0.3},
@@ -61,13 +61,46 @@ var llmPrices = map[string]LlmPrice{
 }
 
 var (
-       pricingOnce sync.Once
-       pricingKeys []string
+       pricingMutex  sync.RWMutex
+       pricingLoaded bool
+       pricingKeys   []string
+       llmPrices     map[string]LlmPrice
 )
 
+// ReloadLlmPrices re-reads the override file, so changing "llmPricingFile" on
+// the Settings page costs the next request rather than the next restart.
+func ReloadLlmPrices() {
+       pricingMutex.Lock()
+       defer pricingMutex.Unlock()
+
+       loadLlmPrices()
+}
+
+func ensureLlmPrices() {
+       pricingMutex.RLock()
+       loaded := pricingLoaded
+       pricingMutex.RUnlock()
+       if loaded {
+               return
+       }
+
+       pricingMutex.Lock()
+       defer pricingMutex.Unlock()
+       if !pricingLoaded {
+               loadLlmPrices()
+       }
+}
+
 // loadLlmPrices merges the override file over the built-in table, longest key
-// first so a specific model wins over the family it belongs to.
+// first so a specific model wins over the family it belongs to. It rebuilds 
the
+// table from the built-in one, so a price dropped from the file is dropped 
here
+// too. The caller holds the write lock.
 func loadLlmPrices() {
+       prices := make(map[string]LlmPrice, len(builtInLlmPrices))
+       for model, price := range builtInLlmPrices {
+               prices[model] = price
+       }
+
        path := conf.GetLlmPricingFile()
        if path != "" {
                if data, err := os.ReadFile(path); err != nil {
@@ -80,24 +113,29 @@ func loadLlmPrices() {
                                beego.Error("LLM pricing file is not valid 
JSON:", err)
                        } else {
                                for model, price := range overrides {
-                                       llmPrices[strings.ToLower(model)] = 
price
+                                       prices[strings.ToLower(model)] = price
                                }
                        }
                }
        }
 
-       pricingKeys = make([]string, 0, len(llmPrices))
-       for key := range llmPrices {
-               pricingKeys = append(pricingKeys, key)
+       keys := make([]string, 0, len(prices))
+       for key := range prices {
+               keys = append(keys, key)
        }
-       sort.Slice(pricingKeys, func(i, j int) bool { return 
len(pricingKeys[i]) > len(pricingKeys[j]) })
+       sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > 
len(keys[j]) })
+
+       llmPrices, pricingKeys, pricingLoaded = prices, keys, true
 }
 
 // GetLlmPrice matches a model name in any of the shapes it arrives in, e.g.
 // "claude-sonnet-4-20250514" or "us.anthropic.claude-sonnet-4-v1:0". It 
reports
 // false when nothing matches, so an unpriced model is not costed at zero.
 func GetLlmPrice(model string) (LlmPrice, bool) {
-       pricingOnce.Do(loadLlmPrices)
+       ensureLlmPrices()
+
+       pricingMutex.RLock()
+       defer pricingMutex.RUnlock()
 
        name := strings.ToLower(strings.TrimSpace(model))
        for _, key := range pricingKeys {
diff --git a/object/ormer.go b/object/ormer.go
index 1801e1b..f5e56f5 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -251,6 +251,12 @@ func (a *Ormer) createTable() {
                panic(err)
        }
 
+       // The settings the web UI can change, held in one built-in row.
+       err = a.Engine.Sync2(new(Setting))
+       if err != nil {
+               panic(err)
+       }
+
        // Local users, used for sign-in when no Casdoor is configured.
        err = a.Engine.Sync2(new(User))
        if err != nil {
diff --git a/object/setting.go b/object/setting.go
new file mode 100644
index 0000000..e453b4c
--- /dev/null
+++ b/object/setting.go
@@ -0,0 +1,237 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+       "strconv"
+
+       "github.com/apache/casbin-gateway/conf"
+       "github.com/apache/casbin-gateway/util"
+       "github.com/xorm-io/core"
+)
+
+// BuiltInSettingId is the one row this table holds. Settings are global, so
+// there is nothing to add or delete: the row is created on first start from
+// conf/app.conf and edited from the Settings page afterwards.
+const BuiltInSettingId = "admin/setting-built-in"
+
+type Setting struct {
+       Owner       string `xorm:"varchar(100) notnull pk" json:"owner"`
+       Name        string `xorm:"varchar(100) notnull pk" json:"name"`
+       CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+       DisplayName string `xorm:"varchar(100)" json:"displayName"`
+
+       GatewayEnabled   bool `xorm:"bool" json:"gatewayEnabled"`
+       GatewayHttpPort  int  `xorm:"int" json:"gatewayHttpPort"`
+       GatewayHttpsPort int  `xorm:"int" json:"gatewayHttpsPort"`
+
+       LlmRecordMode            string `xorm:"varchar(100)" 
json:"llmRecordMode"`
+       LlmRecordQueueCapacity   int    `xorm:"int" 
json:"llmRecordQueueCapacity"`
+       LlmRecordRetentionDays   int    `xorm:"int" 
json:"llmRecordRetentionDays"`
+       LlmRecordMaxRecords      int    `xorm:"int" json:"llmRecordMaxRecords"`
+       LlmRecordMaxPayloadBytes int    `xorm:"int" 
json:"llmRecordMaxPayloadBytes"`
+       LlmPricingFile           string `xorm:"varchar(500)" 
json:"llmPricingFile"`
+
+       AgentPatchStateDir      string `xorm:"varchar(500)" 
json:"agentPatchStateDir"`
+       AgentRecordCapacity     int    `xorm:"int" json:"agentRecordCapacity"`
+       AgentMonitorPollSeconds int    `xorm:"int" 
json:"agentMonitorPollSeconds"`
+
+       CasdoorEndpoint     string `xorm:"varchar(500)" json:"casdoorEndpoint"`
+       ClientId            string `xorm:"varchar(100)" json:"clientId"`
+       ClientSecret        string `xorm:"varchar(200)" json:"clientSecret"`
+       CasdoorOrganization string `xorm:"varchar(100)" 
json:"casdoorOrganization"`
+       CasdoorApplication  string `xorm:"varchar(100)" 
json:"casdoorApplication"`
+
+       ApiKeyEncryptionKey string `xorm:"varchar(200)" 
json:"apiKeyEncryptionKey"`
+
+       HttpProxy      string `xorm:"varchar(200)" json:"httpProxy"`
+       AcmeEmail      string `xorm:"varchar(200)" json:"acmeEmail"`
+       AcmePrivateKey string `xorm:"mediumtext" json:"acmePrivateKey"`
+
+       AppDir             string `xorm:"varchar(500)" json:"appDir"`
+       Language           string `xorm:"varchar(100)" json:"language"`
+       AppMap             string `xorm:"varchar(1000)" json:"appMap"`
+       ClientIdPrefix     string `xorm:"varchar(100)" json:"clientIdPrefix"`
+       ClientSecretPrefix string `xorm:"varchar(100)" 
json:"clientSecretPrefix"`
+       DbRegionId         string `xorm:"varchar(100)" json:"dbRegionId"`
+       DbAccessKeyId      string `xorm:"varchar(200)" json:"dbAccessKeyId"`
+       DbAccessKeySecret  string `xorm:"varchar(200)" json:"dbAccessKeySecret"`
+       DbInstanceId       string `xorm:"varchar(100)" json:"dbInstanceId"`
+       DbHost             string `xorm:"varchar(200)" json:"dbHost"`
+       DbUser             string `xorm:"varchar(100)" json:"dbUser"`
+       DbPass             string `xorm:"varchar(200)" json:"dbPass"`
+}
+
+// SyncSettingToConf makes the row the answer conf.GetConfigString() gives, so
+// every existing caller of conf keeps working and none of them has to know 
that
+// the value now comes from the database. Keys not listed here stay in
+// conf/app.conf: the database connection and the management port are read
+// before this row can be, and "isDemoMode" turns the API read-only, which 
would
+// take the Settings page that turns it off down with it.
+func SyncSettingToConf(setting *Setting) {
+       conf.SetSettingOverrides(map[string]string{
+               "gatewayEnabled":   strconv.FormatBool(setting.GatewayEnabled),
+               "gatewayHttpPort":  strconv.Itoa(setting.GatewayHttpPort),
+               "gatewayHttpsPort": strconv.Itoa(setting.GatewayHttpsPort),
+
+               "llmRecordMode":            setting.LlmRecordMode,
+               "llmRecordQueueCapacity":   
strconv.Itoa(setting.LlmRecordQueueCapacity),
+               "llmRecordRetentionDays":   
strconv.Itoa(setting.LlmRecordRetentionDays),
+               "llmRecordMaxRecords":      
strconv.Itoa(setting.LlmRecordMaxRecords),
+               "llmRecordMaxPayloadBytes": 
strconv.Itoa(setting.LlmRecordMaxPayloadBytes),
+               "llmPricingFile":           setting.LlmPricingFile,
+
+               "agentPatchStateDir":      setting.AgentPatchStateDir,
+               "agentRecordCapacity":     
strconv.Itoa(setting.AgentRecordCapacity),
+               "agentMonitorPollSeconds": 
strconv.Itoa(setting.AgentMonitorPollSeconds),
+
+               "casdoorEndpoint":     setting.CasdoorEndpoint,
+               "clientId":            setting.ClientId,
+               "clientSecret":        setting.ClientSecret,
+               "casdoorOrganization": setting.CasdoorOrganization,
+               "casdoorApplication":  setting.CasdoorApplication,
+
+               "apiKeyEncryptionKey": setting.ApiKeyEncryptionKey,
+
+               "httpProxy":      setting.HttpProxy,
+               "acmeEmail":      setting.AcmeEmail,
+               "acmePrivateKey": setting.AcmePrivateKey,
+
+               "appDir":             setting.AppDir,
+               "language":           setting.Language,
+               "appMap":             setting.AppMap,
+               "clientIdPrefix":     setting.ClientIdPrefix,
+               "clientSecretPrefix": setting.ClientSecretPrefix,
+               "dbRegionId":         setting.DbRegionId,
+               "dbAccessKeyId":      setting.DbAccessKeyId,
+               "dbAccessKeySecret":  setting.DbAccessKeySecret,
+               "dbInstanceId":       setting.DbInstanceId,
+               "dbHost":             setting.DbHost,
+               "dbUser":             setting.DbUser,
+               "dbPass":             setting.DbPass,
+       })
+}
+
+func getSetting(owner string, name string) (*Setting, error) {
+       setting := Setting{Owner: owner, Name: name}
+       existed, err := ormer.Engine.Get(&setting)
+       if err != nil {
+               return nil, err
+       }
+
+       if existed {
+               return &setting, nil
+       }
+       return nil, nil
+}
+
+func GetSetting(id string) (*Setting, error) {
+       owner, name := util.GetOwnerAndNameFromId(id)
+       return getSetting(owner, name)
+}
+
+func GetBuiltInSetting() (*Setting, error) {
+       return GetSetting(BuiltInSettingId)
+}
+
+func UpdateSetting(id string, setting *Setting) (bool, error) {
+       owner, name := util.GetOwnerAndNameFromId(id)
+       if s, err := getSetting(owner, name); err != nil {
+               return false, err
+       } else if s == nil {
+               return false, nil
+       }
+
+       setting.Owner, setting.Name = owner, name
+       _, err := ormer.Engine.ID(core.PK{owner, 
name}).AllCols().Update(setting)
+       if err != nil {
+               return false, err
+       }
+
+       SyncSettingToConf(setting)
+       return true, nil
+}
+
+// InitBuiltInSetting creates the row on first start, seeded from conf/app.conf
+// so an existing installation keeps the settings it already had, and loads it
+// into conf. It runs before anything else reads conf, because from here on the
+// row is what conf answers with.
+func InitBuiltInSetting() {
+       setting, err := GetBuiltInSetting()
+       if err != nil {
+               panic(err)
+       }
+
+       if setting == nil {
+               setting = newSettingFromConf()
+               if _, err = ormer.Engine.Insert(setting); err != nil {
+                       panic(err)
+               }
+       }
+
+       SyncSettingToConf(setting)
+}
+
+// newSettingFromConf reads the defaults through conf's own getters, so a key
+// that conf/app.conf never mentioned is stored as the default the code would
+// have used anyway rather than as an empty value.
+func newSettingFromConf() *Setting {
+       return &Setting{
+               Owner:       "admin",
+               Name:        "setting-built-in",
+               CreatedTime: util.GetCurrentTime(),
+               DisplayName: "Built-in Setting",
+
+               GatewayEnabled:   conf.IsGatewayEnabled(),
+               GatewayHttpPort:  conf.GetGatewayHttpPort(),
+               GatewayHttpsPort: conf.GetGatewayHttpsPort(),
+
+               LlmRecordMode:            conf.GetLlmRecordMode(),
+               LlmRecordQueueCapacity:   conf.GetLlmRecordQueueCapacity(),
+               LlmRecordRetentionDays:   conf.GetLlmRecordRetentionDays(),
+               LlmRecordMaxRecords:      conf.GetLlmRecordMaxRecords(),
+               LlmRecordMaxPayloadBytes: conf.GetLlmRecordMaxPayloadBytes(),
+               LlmPricingFile:           conf.GetLlmPricingFile(),
+
+               AgentPatchStateDir:      conf.GetAgentPatchStateDir(),
+               AgentRecordCapacity:     conf.GetAgentRecordCapacity(),
+               AgentMonitorPollSeconds: conf.GetAgentMonitorPollSeconds(),
+
+               CasdoorEndpoint:     
conf.GetConfigStringUnquoted("casdoorEndpoint"),
+               ClientId:            conf.GetConfigStringUnquoted("clientId"),
+               ClientSecret:        
conf.GetConfigStringUnquoted("clientSecret"),
+               CasdoorOrganization: 
conf.GetConfigStringUnquoted("casdoorOrganization"),
+               CasdoorApplication:  
conf.GetConfigStringUnquoted("casdoorApplication"),
+
+               ApiKeyEncryptionKey: 
conf.GetConfigStringUnquoted("apiKeyEncryptionKey"),
+
+               HttpProxy:      conf.GetConfigStringUnquoted("httpProxy"),
+               AcmeEmail:      conf.GetConfigStringUnquoted("acmeEmail"),
+               AcmePrivateKey: conf.GetConfigStringUnquoted("acmePrivateKey"),
+
+               AppDir:             conf.GetConfigStringUnquoted("appDir"),
+               Language:           conf.GetConfigStringUnquoted("language"),
+               AppMap:             conf.GetConfigStringUnquoted("appMap"),
+               ClientIdPrefix:     
conf.GetConfigStringUnquoted("clientIdPrefix"),
+               ClientSecretPrefix: 
conf.GetConfigStringUnquoted("clientSecretPrefix"),
+               DbRegionId:         conf.GetConfigStringUnquoted("dbRegionId"),
+               DbAccessKeyId:      
conf.GetConfigStringUnquoted("dbAccessKeyId"),
+               DbAccessKeySecret:  
conf.GetConfigStringUnquoted("dbAccessKeySecret"),
+               DbInstanceId:       
conf.GetConfigStringUnquoted("dbInstanceId"),
+               DbHost:             conf.GetConfigStringUnquoted("dbHost"),
+               DbUser:             conf.GetConfigStringUnquoted("dbUser"),
+               DbPass:             conf.GetConfigStringUnquoted("dbPass"),
+       }
+}
diff --git a/object/site_cert_account.go b/object/site_cert_account.go
index 5a1f546..a5125a7 100644
--- a/object/site_cert_account.go
+++ b/object/site_cert_account.go
@@ -19,8 +19,8 @@ import (
        "fmt"
        "net/http"
 
+       "github.com/apache/casbin-gateway/conf"
        "github.com/apache/casbin-gateway/proxy"
-       "github.com/beego/beego"
        "github.com/casbin/lego/v4/acme"
        "github.com/casbin/lego/v4/certcrypto"
        "github.com/casbin/lego/v4/lego"
@@ -112,8 +112,8 @@ func getAcmeClient(email string, privateKey string, devMode 
bool, useProxy bool)
 }
 
 func GetAcmeClient(useProxy bool) (*lego.Client, error) {
-       acmeEmail := beego.AppConfig.String("acmeEmail")
-       acmePrivateKey := beego.AppConfig.String("acmePrivateKey")
+       acmeEmail := conf.GetConfigStringUnquoted("acmeEmail")
+       acmePrivateKey := conf.GetConfigStringUnquoted("acmePrivateKey")
        if acmeEmail == "" {
                return nil, fmt.Errorf("acmeEmail should not be empty")
        }
diff --git a/proxy/proxy.go b/proxy/proxy.go
index ff955fb..10cfbfa 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -24,7 +24,7 @@ import (
        "strings"
        "sync"
 
-       "github.com/beego/beego"
+       "github.com/apache/casbin-gateway/conf"
 )
 
 // ProxyHttpClient sends its requests through the configured proxy.
@@ -71,7 +71,7 @@ func Transport() *http.Transport {
 // "socks5h://", "http://"; and "https://"; addresses are honoured as written, 
and
 // may carry credentials.
 func initProxyUrl() {
-       httpProxy := strings.TrimSpace(beego.AppConfig.String("httpProxy"))
+       httpProxy := 
strings.TrimSpace(conf.GetConfigStringUnquoted("httpProxy"))
        if httpProxy == "" {
                return
        }
diff --git a/routers/router.go b/routers/router.go
index 4ca6fe7..2b99a8d 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -39,7 +39,8 @@ func initAPI() {
        beego.Router("/api/update-account", &controllers.ApiController{}, 
"POST:UpdateAccount")
        beego.Router("/api/get-providers", &controllers.ApiController{}, 
"GET:GetProviders")
        beego.Router("/api/get-gateway-status", &controllers.ApiController{}, 
"GET:GetGatewayStatus")
-       beego.Router("/api/update-system-settings", 
&controllers.ApiController{}, "POST:UpdateSystemSettings")
+       beego.Router("/api/get-setting", &controllers.ApiController{}, 
"GET:GetSetting")
+       beego.Router("/api/update-setting", &controllers.ApiController{}, 
"POST:UpdateSetting")
 
        beego.Router("/api/get-global-nodes", &controllers.ApiController{}, 
"GET:GetGlobalNodes")
        beego.Router("/api/get-nodes", &controllers.ApiController{}, 
"GET:GetNodes")
diff --git a/run/database.go b/run/database.go
index 4d6b10a..b93d0cb 100644
--- a/run/database.go
+++ b/run/database.go
@@ -18,7 +18,7 @@ import (
        "fmt"
        "strings"
 
-       "github.com/beego/beego"
+       "github.com/apache/casbin-gateway/conf"
        "github.com/xorm-io/xorm"
 )
 
@@ -27,7 +27,7 @@ func gitCreateDatabase(name string) (bool, error) {
        name = strings.Replace(name, "_00", "_", 1)
 
        driverName := "mysql"
-       dataSourceName := fmt.Sprintf("root:%s@tcp(localhost:3306)/", 
beego.AppConfig.String("dbPass"))
+       dataSourceName := fmt.Sprintf("root:%s@tcp(localhost:3306)/", 
conf.GetConfigStringUnquoted("dbPass"))
        engine, err := xorm.NewEngine(driverName, dataSourceName)
        if err != nil {
                return false, err
diff --git a/run/database_cloud.go b/run/database_cloud.go
index 8aa3e1f..dfa50c6 100644
--- a/run/database_cloud.go
+++ b/run/database_cloud.go
@@ -21,15 +21,15 @@ import (
        "github.com/aliyun/alibaba-cloud-sdk-go/sdk"
        "github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials"
        "github.com/aliyun/alibaba-cloud-sdk-go/services/rds"
-       "github.com/beego/beego"
+       "github.com/apache/casbin-gateway/conf"
 )
 
 var rdsClient *rds.Client
 
 func InitRdsClient() {
-       dbRegionId := beego.AppConfig.String("dbRegionId")
-       dbAccessKeyId := beego.AppConfig.String("dbAccessKeyId")
-       dbAccessKeySecret := beego.AppConfig.String("dbAccessKeySecret")
+       dbRegionId := conf.GetConfigStringUnquoted("dbRegionId")
+       dbAccessKeyId := conf.GetConfigStringUnquoted("dbAccessKeyId")
+       dbAccessKeySecret := conf.GetConfigStringUnquoted("dbAccessKeySecret")
 
        if dbRegionId == "" || dbAccessKeyId == "" || dbAccessKeySecret == "" {
                return
@@ -48,7 +48,7 @@ func InitRdsClient() {
 func gitCreateDatabaseCloud(name string) (bool, error) {
        fmt.Printf("gitCreateDatabaseCloud(): [%s]\n", name)
 
-       dbInstanceId := beego.AppConfig.String("dbInstanceId")
+       dbInstanceId := conf.GetConfigStringUnquoted("dbInstanceId")
 
        // https://help.aliyun.com/document_detail/26258.htm
        r := rds.CreateCreateDatabaseRequest()
@@ -72,8 +72,8 @@ func gitCreateDatabaseCloud(name string) (bool, error) {
 }
 
 func addDatabaseUser(dbName string) error {
-       dbInstanceId := beego.AppConfig.String("dbInstanceId")
-       dbUser := beego.AppConfig.String("dbUser")
+       dbInstanceId := conf.GetConfigStringUnquoted("dbInstanceId")
+       dbUser := conf.GetConfigStringUnquoted("dbUser")
 
        // https://help.aliyun.com/document_detail/26266.html
        r := rds.CreateGrantAccountPrivilegeRequest()
diff --git a/run/run.go b/run/run.go
index b0abbd0..37064e1 100644
--- a/run/run.go
+++ b/run/run.go
@@ -19,8 +19,8 @@ import (
        "path/filepath"
        "strings"
 
+       "github.com/apache/casbin-gateway/conf"
        "github.com/apache/casbin-gateway/util"
-       "github.com/beego/beego"
 )
 
 func isTargetRepo(siteName string) bool {
@@ -52,7 +52,7 @@ func CreateRepo(siteName string, needStart bool, diff string, 
providerName strin
                        return wrapRepoError("gitClone", path, err)
                }
 
-               dbInstanceId := beego.AppConfig.String("dbInstanceId")
+               dbInstanceId := conf.GetConfigStringUnquoted("dbInstanceId")
                if dbInstanceId == "" {
                        _, err = gitCreateDatabase(siteName)
                        if err != nil {
diff --git a/run/util.go b/run/util.go
index b6bf752..4319861 100644
--- a/run/util.go
+++ b/run/util.go
@@ -21,6 +21,7 @@ import (
        "path/filepath"
        "strings"
 
+       "github.com/apache/casbin-gateway/conf"
        "github.com/apache/casbin-gateway/util"
        "github.com/beego/beego"
 )
@@ -65,7 +66,7 @@ func getRepoUrl(name string) string {
 
 func getShortcut() string {
        res := "Shortcut"
-       language := beego.AppConfig.String("language")
+       language := conf.GetConfigStringUnquoted("language")
        if language != "en" {
                res = "快捷方式"
        }
@@ -91,15 +92,15 @@ func updateAppConfFile(name string, i int, orgName string) {
                }
 
                content = strings.ReplaceAll(content, "httpport = 14000", 
fmt.Sprintf("httpport = %d", 40000+i))
-               content = strings.ReplaceAll(content, "root", 
beego.AppConfig.String("dbUser"))
-               content = strings.ReplaceAll(content, "123456", 
beego.AppConfig.String("dbPass"))
-               content = strings.ReplaceAll(content, "localhost:3306", 
fmt.Sprintf("%s:3306", beego.AppConfig.String("dbHost")))
+               content = strings.ReplaceAll(content, "root", 
conf.GetConfigStringUnquoted("dbUser"))
+               content = strings.ReplaceAll(content, "123456", 
conf.GetConfigStringUnquoted("dbPass"))
+               content = strings.ReplaceAll(content, "localhost:3306", 
fmt.Sprintf("%s:3306", conf.GetConfigStringUnquoted("dbHost")))
                content = strings.ReplaceAll(content, "dbName = casibase", 
fmt.Sprintf("dbName = %s", name))
                content = strings.ReplaceAll(content, "redisEndpoint =", 
fmt.Sprintf("redisEndpoint = \"%s\"", beego.AppConfig.String("redisEndpoint")))
                content = strings.ReplaceAll(content, "disablePreviewMode = 
false", "disablePreviewMode = true")
-               content = strings.ReplaceAll(content, "casdoorEndpoint = 
https://door.casdoor.com";, fmt.Sprintf("casdoorEndpoint = %s", 
strings.ReplaceAll(beego.AppConfig.String("casdoorEndpoint"), "my.", "cbc.")))
-               content = strings.ReplaceAll(content, "clientId = 
af6b5aa958822fb9dc33", fmt.Sprintf("clientId = %s", 
beego.AppConfig.String("clientIdPrefix")+shortName))
-               content = strings.ReplaceAll(content, "clientSecret = 
8bc3010c1c951c8d876b1f311a901ff8deeb93bc", fmt.Sprintf("clientSecret = %s", 
beego.AppConfig.String("clientSecretPrefix")+shortName))
+               content = strings.ReplaceAll(content, "casdoorEndpoint = 
https://door.casdoor.com";, fmt.Sprintf("casdoorEndpoint = %s", 
strings.ReplaceAll(conf.GetConfigStringUnquoted("casdoorEndpoint"), "my.", 
"cbc.")))
+               content = strings.ReplaceAll(content, "clientId = 
af6b5aa958822fb9dc33", fmt.Sprintf("clientId = %s", 
conf.GetConfigStringUnquoted("clientIdPrefix")+shortName))
+               content = strings.ReplaceAll(content, "clientSecret = 
8bc3010c1c951c8d876b1f311a901ff8deeb93bc", fmt.Sprintf("clientSecret = %s", 
conf.GetConfigStringUnquoted("clientSecretPrefix")+shortName))
                content = strings.ReplaceAll(content, "casdoorOrganization = 
\"casbin\"", fmt.Sprintf("casdoorOrganization = \"%s\"", shortName))
                content = strings.ReplaceAll(content, "casdoorApplication = 
\"app-casibase\"", fmt.Sprintf("casdoorApplication = \"%s\"", 
fmt.Sprintf("app-%s", shortName)))
                content = strings.ReplaceAll(content, "isLocalIpDb = false", 
"isLocalIpDb = true")
@@ -111,19 +112,19 @@ func updateAppConfFile(name string, i int, orgName 
string) {
                }
 
                content = strings.ReplaceAll(content, "httpport = 14000", 
fmt.Sprintf("httpport = %d", 50000+i))
-               content = strings.ReplaceAll(content, "root", 
beego.AppConfig.String("dbUser"))
-               content = strings.ReplaceAll(content, "123456", 
beego.AppConfig.String("dbPass"))
-               content = strings.ReplaceAll(content, "localhost:3306", 
fmt.Sprintf("%s:3306", beego.AppConfig.String("dbHost")))
+               content = strings.ReplaceAll(content, "root", 
conf.GetConfigStringUnquoted("dbUser"))
+               content = strings.ReplaceAll(content, "123456", 
conf.GetConfigStringUnquoted("dbPass"))
+               content = strings.ReplaceAll(content, "localhost:3306", 
fmt.Sprintf("%s:3306", conf.GetConfigStringUnquoted("dbHost")))
                content = strings.ReplaceAll(content, "dbName = opendata", 
fmt.Sprintf("dbName = %s", name))
                content = strings.ReplaceAll(content, "redisEndpoint =", 
fmt.Sprintf("redisEndpoint = \"%s\"", beego.AppConfig.String("redisEndpoint")))
-               content = strings.ReplaceAll(content, "casdoorEndpoint = 
https://door.casdoor.com";, fmt.Sprintf("casdoorEndpoint = %s", 
strings.ReplaceAll(beego.AppConfig.String("casdoorEndpoint"), "my.", "od.")))
-               content = strings.ReplaceAll(content, "clientId = 
af6b5aa958822fb9dc33", fmt.Sprintf("clientId = %s", 
beego.AppConfig.String("clientIdPrefix")+shortName))
-               content = strings.ReplaceAll(content, "clientSecret = 
8bc3010c1c951c8d876b1f311a901ff8deeb93bc", fmt.Sprintf("clientSecret = %s", 
beego.AppConfig.String("clientSecretPrefix")+shortName))
+               content = strings.ReplaceAll(content, "casdoorEndpoint = 
https://door.casdoor.com";, fmt.Sprintf("casdoorEndpoint = %s", 
strings.ReplaceAll(conf.GetConfigStringUnquoted("casdoorEndpoint"), "my.", 
"od.")))
+               content = strings.ReplaceAll(content, "clientId = 
af6b5aa958822fb9dc33", fmt.Sprintf("clientId = %s", 
conf.GetConfigStringUnquoted("clientIdPrefix")+shortName))
+               content = strings.ReplaceAll(content, "clientSecret = 
8bc3010c1c951c8d876b1f311a901ff8deeb93bc", fmt.Sprintf("clientSecret = %s", 
conf.GetConfigStringUnquoted("clientSecretPrefix")+shortName))
                content = strings.ReplaceAll(content, "casdoorOrganization = 
\"casbin\"", fmt.Sprintf("casdoorOrganization = \"%s\"", shortName))
                content = strings.ReplaceAll(content, "casdoorApplication = 
\"app-opendata\"", fmt.Sprintf("casdoorApplication = \"%s\"", 
fmt.Sprintf("app-%s", shortName)))
        } else {
                content = strings.ReplaceAll(content, "httpport = 8000", 
fmt.Sprintf("httpport = %d", 30000+i))
-               content = strings.ReplaceAll(content, "123456", 
beego.AppConfig.String("dbPass"))
+               content = strings.ReplaceAll(content, "123456", 
conf.GetConfigStringUnquoted("dbPass"))
                content = strings.ReplaceAll(content, "dbName = casdoor", 
fmt.Sprintf("dbName = %s", strings.Replace(name, "_00", "_", 1)))
                content = strings.ReplaceAll(content, "redisEndpoint =", 
"redisEndpoint = \"localhost:6379\"")
                content = strings.ReplaceAll(content, "socks5Proxy = 
\"127.0.0.1:10808\"", "socks5Proxy =")
diff --git a/run/util_path.go b/run/util_path.go
index d1be391..9934cab 100644
--- a/run/util_path.go
+++ b/run/util_path.go
@@ -21,7 +21,7 @@ import (
        "path/filepath"
        "strings"
 
-       "github.com/beego/beego"
+       "github.com/apache/casbin-gateway/conf"
 )
 
 var username string
@@ -41,7 +41,7 @@ func init() {
 }
 
 func InitAppMap() {
-       res := beego.AppConfig.String("appMap")
+       res := conf.GetConfigStringUnquoted("appMap")
        if res != "" {
                err := json.Unmarshal([]byte(res), &appMap)
                if err != nil {
@@ -70,14 +70,14 @@ func getMappedName(name string) string {
 
 func GetRepoPath(name string) string {
        name = getMappedName(name)
-       appDir := beego.AppConfig.String("appDir")
+       appDir := conf.GetConfigStringUnquoted("appDir")
        res := filepath.Join(appDir, name)
        return res
 }
 
 func getCodeAppConfPath(name string) string {
        name = getMappedName(name)
-       appDir := beego.AppConfig.String("appDir")
+       appDir := conf.GetConfigStringUnquoted("appDir")
        return fmt.Sprintf("%s/%s/conf/app.conf", appDir, name)
 }
 
diff --git a/service/proxy.go b/service/proxy.go
index f43bfed..4cc5b8e 100644
--- a/service/proxy.go
+++ b/service/proxy.go
@@ -16,13 +16,16 @@ package service
 
 import (
        "crypto/tls"
+       "errors"
        "fmt"
        "net"
        "net/http"
        "net/http/httputil"
        "net/url"
        "path/filepath"
+       "runtime"
        "strings"
+       "sync"
 
        "github.com/apache/casbin-gateway/conf"
        "github.com/apache/casbin-gateway/object"
@@ -320,85 +323,197 @@ func nextHandle(w http.ResponseWriter, r *http.Request) {
        }
 }
 
-func Start() {
+// The reverse proxy can be switched on and off while Gateway runs, so nobody
+// has to edit conf/app.conf and restart to get their sites proxied. Both
+// servers are held here; an empty slice means the proxy is off.
+var (
+       gatewayMutex   sync.Mutex
+       gatewayServers []*http.Server
+       gatewayError   string
+)
+
+// ListenError names the port that could not be bound. It is reported to the
+// browser rather than being fatal, so a port conflict leaves the management UI
+// reachable and the proxy can be turned off there.
+type ListenError struct {
+       Port int
+       Err  error
+}
+
+func (e *ListenError) Error() string {
+       if holder := util.DescribePortHolder(e.Port); holder != "" {
+               return fmt.Sprintf("port %d is already in use by %s", e.Port, 
holder)
+       }
+
+       if e.Port < 1024 && runtime.GOOS != "windows" {
+               return fmt.Sprintf("cannot listen on port %d: %v (ports below 
1024 need root, so Gateway has to be started with sudo)", e.Port, e.Err)
+       }
+
+       return fmt.Sprintf("cannot listen on port %d: %v", e.Port, e.Err)
+}
+
+func newGatewayMux() *http.ServeMux {
        serverMux := http.NewServeMux()
        serverMux.HandleFunc("/", handleRequest)
        serverMux.HandleFunc("/_sso-callback", handleAuthCallback)
        serverMux.HandleFunc("/_captcha-verify", handleCaptchaCallback)
+       return serverMux
+}
 
+func Start() {
        if !conf.IsGatewayEnabled() {
                fmt.Printf("Casbin Gateway reverse proxy is not enabled 
(gatewayEnabled = false), so site configurations will not take effect\n")
                return
        }
 
+       // A port conflict here does not end the process: the management UI is 
what
+       // the reverse proxy is turned off in, so exiting would leave editing
+       // conf/app.conf by hand as the only way out.
+       if err := StartGateway(); err != nil {
+               var listenErr *ListenError
+               if !errors.As(err, &listenErr) {
+                       panic(err)
+               }
+
+               fmt.Printf("Casbin Gateway reverse proxy could not start: 
%s\n", listenErr.Error())
+               fmt.Printf("  Free the port, or turn the reverse proxy off on 
the Sites page to run the management UI only.\n")
+       }
+}
+
+// GatewayError explains why the proxy is not running although it is enabled.
+// It is empty when there is nothing to explain.
+func GatewayError() string {
+       gatewayMutex.Lock()
+       defer gatewayMutex.Unlock()
+
+       return gatewayError
+}
+
+// IsGatewayRunning reports whether the proxy is serving right now, which is 
not
+// the same as "gatewayEnabled = true": the ports may have refused to bind.
+func IsGatewayRunning() bool {
+       gatewayMutex.Lock()
+       defer gatewayMutex.Unlock()
+
+       return len(gatewayServers) > 0
+}
+
+// StartGateway binds the two gateway ports and serves on them. It is a no-op
+// when the proxy already runs, and leaves nothing bound when it fails.
+func StartGateway() error {
+       gatewayMutex.Lock()
+       defer gatewayMutex.Unlock()
+
+       if len(gatewayServers) > 0 {
+               return nil
+       }
+
+       gatewayError = ""
        gatewayHttpPort := conf.GetGatewayHttpPort()
        gatewayHttpsPort := conf.GetGatewayHttpsPort()
 
        // Both ports are bound up front and the listeners are handed to the 
servers
-       // below. Binding here turns a port conflict into one readable line 
before
-       // anything else starts, and holding on to the listeners means nothing 
can
-       // take the port between this check and the server that serves on it.
+       // below. Binding here turns a port conflict into one readable error 
before
+       // anything serves, and holding on to the listeners means nothing can 
take
+       // the port between this check and the server that serves on it.
        httpListener, err := util.ListenTcp(gatewayHttpPort)
        if err != nil {
-               util.FatalListenError(gatewayHttpPort, `set "gatewayEnabled = 
false" in conf/app.conf to run the management UI only`, err)
+               return failedToListen(gatewayHttpPort, err)
        }
 
        httpsListener, err := util.ListenTcp(gatewayHttpsPort)
        if err != nil {
                httpListener.Close()
-               util.FatalListenError(gatewayHttpsPort, `set "gatewayEnabled = 
false" in conf/app.conf to run the management UI only`, err)
+               return failedToListen(gatewayHttpsPort, err)
        }
 
+       serverMux := newGatewayMux()
+       httpServer := &http.Server{Handler: serverMux}
+       httpsServer := newHttpsServer(serverMux)
+       gatewayServers = []*http.Server{httpServer, httpsServer}
+
        go func() {
                fmt.Printf("Casbin Gateway running on: http://127.0.0.1:%d\n";, 
gatewayHttpPort)
-               err := http.Serve(httpListener, serverMux)
-               if err != nil {
-                       panic(err)
-               }
+               serveGateway(httpServer.Serve(httpListener))
        }()
 
        go func() {
                fmt.Printf("Casbin Gateway running on: https://127.0.0.1:%d\n";, 
gatewayHttpsPort)
-               server := &http.Server{
-                       Handler: serverMux,
-                       TLSConfig: &tls.Config{
-                               // Minimum TLS version 1.2, TLS 1.3 is 
automatically supported
-                               MinVersion: tls.VersionTLS12,
-                               // Prefer server's cipher suite order for 
better security
-                               PreferServerCipherSuites: true,
-                               // Secure cipher suites for TLS 1.2 (excluding 
3DES to prevent Sweet32 attack)
-                               // TLS 1.3 cipher suites are automatically 
configured by Go
-                               CipherSuites: []uint16{
-                                       
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
-                                       
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
-                                       
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
-                                       
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
-                                       
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
-                                       
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
-                               },
-                               // Prefer strong elliptic curves
-                               CurvePreferences: []tls.CurveID{
-                                       tls.X25519,
-                                       tls.CurveP256,
-                                       tls.CurveP384,
-                               },
-                       },
-               }
+               serveGateway(httpsServer.ServeTLS(httpsListener, "", ""))
+       }()
 
-               // start https server and set how to get certificate
-               server.TLSConfig.GetCertificate = func(info 
*tls.ClientHelloInfo) (*tls.Certificate, error) {
-                       domain := info.ServerName
-                       cert, err := getX509CertByDomain(domain)
-                       if err != nil {
-                               return nil, err
-                       }
+       return nil
+}
 
-                       return cert, nil
-               }
+// failedToListen remembers why the proxy is not running, so the Sites page can
+// say so instead of only showing it to whoever pressed the button.
+func failedToListen(port int, err error) error {
+       listenErr := &ListenError{Port: port, Err: err}
+       gatewayError = listenErr.Error()
+       return listenErr
+}
+
+// StopGateway releases the two gateway ports. Sites stay configured; nothing 
is
+// proxied until StartGateway() is called again.
+func StopGateway() {
+       gatewayMutex.Lock()
+       defer gatewayMutex.Unlock()
 
-               err := server.ServeTLS(httpsListener, "", "")
+       for _, server := range gatewayServers {
+               server.Close()
+       }
+       gatewayServers = nil
+       gatewayError = ""
+
+       fmt.Printf("Casbin Gateway reverse proxy stopped, site configurations 
no longer take effect\n")
+}
+
+// serveGateway handles what a gateway server returned. StopGateway() closing 
it
+// is the normal way out; anything else is a failure worth crashing on, so the
+// supervisor restarts a proxy that died on its own.
+func serveGateway(err error) {
+       if err != nil && !errors.Is(err, http.ErrServerClosed) {
+               panic(err)
+       }
+}
+
+func newHttpsServer(serverMux *http.ServeMux) *http.Server {
+       server := &http.Server{
+               Handler: serverMux,
+               TLSConfig: &tls.Config{
+                       // Minimum TLS version 1.2, TLS 1.3 is automatically 
supported
+                       MinVersion: tls.VersionTLS12,
+                       // Prefer server's cipher suite order for better 
security
+                       PreferServerCipherSuites: true,
+                       // Secure cipher suites for TLS 1.2 (excluding 3DES to 
prevent Sweet32 attack)
+                       // TLS 1.3 cipher suites are automatically configured 
by Go
+                       CipherSuites: []uint16{
+                               tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+                               tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+                               tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+                               tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+                               tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
+                               tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
+                       },
+                       // Prefer strong elliptic curves
+                       CurvePreferences: []tls.CurveID{
+                               tls.X25519,
+                               tls.CurveP256,
+                               tls.CurveP384,
+                       },
+               },
+       }
+
+       // start https server and set how to get certificate
+       server.TLSConfig.GetCertificate = func(info *tls.ClientHelloInfo) 
(*tls.Certificate, error) {
+               domain := info.ServerName
+               cert, err := getX509CertByDomain(domain)
                if err != nil {
-                       panic(err)
+                       return nil, err
                }
-       }()
+
+               return cert, nil
+       }
+
+       return server
 }
diff --git a/service/startup.go b/service/startup.go
index e02b14e..1c52770 100644
--- a/service/startup.go
+++ b/service/startup.go
@@ -49,12 +49,15 @@ func PrintStartupSummary() {
        printSummaryTable("Casbin Gateway", rows)
 }
 
+// describeConf names where the settings were seeded from. They live in the
+// database from the first start onwards, so this is only the origin of the
+// values the Settings page now owns.
 func describeConf() string {
        if embedsupport.IsEmbeddedConf() {
-               return "embedded in the binary (put your own conf/app.conf next 
to it to override)"
+               return "Settings page, seeded from the conf/app.conf embedded 
in this binary"
        }
 
-       return "conf/app.conf"
+       return "Settings page, seeded from conf/app.conf"
 }
 
 func describeWebBuild() string {
@@ -76,7 +79,7 @@ func describeGateway() string {
 
        // Start() prints the consequence right after this table, so the row 
itself
        // only has to say how to turn the proxy on.
-       return "disabled (set \"gatewayEnabled = true\" in conf/app.conf)"
+       return "disabled (turn it on on the Sites or Settings page of the web 
UI)"
 }
 
 func describeGatewayPort(port int) string {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index c65cfaf..60e8f2d 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -45,6 +45,7 @@ import RecordEditPage from "@/pages/RecordEditPage";
 import RecordListPage from "@/pages/RecordListPage";
 import RuleEditPage from "@/pages/RuleEditPage";
 import RuleListPage from "@/pages/RuleListPage";
+import SettingPage from "@/pages/SettingPage";
 import SigninPage from "@/pages/SigninPage";
 import SiteEditPage from "@/pages/SiteEditPage";
 import SiteListPage from "@/pages/SiteListPage";
@@ -274,6 +275,7 @@ export default function App() {
                 />
                 <Route path="/nodes" element={requireSignin(user => 
<NodeListPage account={user} />)} />
                 <Route path="/nodes/:owner/:nodeName" 
element={requireSignin(() => <NodeEditPage />)} />
+                <Route path="/settings" element={requireSignin(user => 
<SettingPage account={user} />)} />
                 <Route path="/sites" element={requireSignin(user => 
<SiteListPage account={user} />)} />
                 <Route
                   path="/sites/:owner/:siteName"
diff --git a/controllers/system.go b/web/src/backend/SettingBackend.ts
similarity index 52%
copy from controllers/system.go
copy to web/src/backend/SettingBackend.ts
index 1ccfde1..e8a153a 100644
--- a/controllers/system.go
+++ b/web/src/backend/SettingBackend.ts
@@ -12,23 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package controllers
+import {request} from "@/backend/request";
+import type {Setting} from "@/types";
 
-import (
-       "github.com/apache/casbin-gateway/conf"
-)
-
-// GetGatewayStatus tells the web UI whether the reverse-proxy gateway is
-// actually running. Without it, a Gateway started with "gatewayEnabled = 
false"
-// accepts every site and rule and silently proxies nothing.
-func (c *ApiController) GetGatewayStatus() {
-       if c.RequireSignedIn() {
-               return
-       }
+export function getSetting() {
+  return request<Setting>("/api/get-setting");
+}
 
-       c.ResponseOk(map[string]interface{}{
-               "gatewayEnabled": conf.IsGatewayEnabled(),
-               "httpPort":       conf.GetGatewayHttpPort(),
-               "httpsPort":      conf.GetGatewayHttpsPort(),
-       })
+export function updateSetting(setting: Setting) {
+  return request<Setting>("/api/update-setting", "POST", setting);
 }
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index f2bb7af..1525353 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -458,6 +458,7 @@
     "Recording is off": "Recording is off",
     "Recording is off detail": "Nothing is recorded until you ask for it, 
because prompts can carry sensitive data. Metadata is who called which model 
with which outcome; bodies are the prompts themselves.",
     "Recording is on": "Recording is on",
+    "Recording off": "Recording off",
     "Records were dropped": "{count} records were dropped because the write 
queue was full. Raise llmRecordQueueCapacity if this keeps happening.",
     "Redacted values": "Redacted values",
     "Request": "Request",
@@ -534,6 +535,62 @@
     "or": "or",
     "regex match": "regex match"
   },
+  "setting": {
+    "ACME email": "ACME email",
+    "ACME email hint": "The Let's Encrypt account that issues the certificates 
for your sites.",
+    "ACME private key": "ACME private key",
+    "API key encryption key": "API key encryption key",
+    "API key encryption key hint": "Encrypts the channel API keys at rest, any 
length. Back it up with the database: losing it makes already-encrypted keys 
unreadable.",
+    "Agent poll seconds": "Agent poll interval, in seconds",
+    "Agent record capacity": "Agent records kept",
+    "Agent record capacity hint": "Agent records are never written to disk, so 
this also bounds the memory they use.",
+    "Agent state dir": "Agent state directory",
+    "Agent state dir hint": "Patch manifests, config backups, ingest tokens 
and file-tail cursors.",
+    "Agents": "Agents",
+    "App dir": "Application directory",
+    "App map": "Application name map",
+    "App map hint": "Optional JSON object renaming deployed applications, e.g. 
{\"casdoor\": \"cc\"}.",
+    "Application deployment": "Application deployment",
+    "Application deployment description": "Used only by the feature that 
deploys and restarts application source trees.",
+    "Casdoor application": "Casdoor application",
+    "Casdoor endpoint": "Casdoor endpoint",
+    "Casdoor organization": "Casdoor organization",
+    "Client ID": "Client ID",
+    "Client ID prefix": "Client ID prefix",
+    "Client secret": "Client secret",
+    "Client secret prefix": "Client secret prefix",
+    "Database host": "Database host",
+    "Database password": "Database password",
+    "Database user": "Database user",
+    "Deployed app language": "Deployed application language",
+    "Enable the reverse proxy": "Enable the reverse proxy",
+    "Gateway HTTP port": "Gateway HTTP port",
+    "Gateway HTTPS port": "Gateway HTTPS port",
+    "LLM records": "LLM records",
+    "LLM records description": "Prompts can carry sensitive data, so anything 
beyond metadata is opt-in.",
+    "Max payload bytes": "Max stored body, in bytes",
+    "Max records": "Max records",
+    "Network and certificates": "Network and certificates",
+    "Outbound SOCKS5 proxy": "Outbound SOCKS5 proxy",
+    "Outbound SOCKS5 proxy hint": "\"host:port\", used for outbound traffic 
such as ACME.",
+    "Page description": "Everything Casbin Gateway reads out of its 
configuration. Changes are stored in the database and applied at once.",
+    "Pricing file": "Pricing file",
+    "Pricing file hint": "Optional JSON file of token prices in US dollars per 
million, overriding the built-in list prices.",
+    "Queue capacity": "Write queue capacity",
+    "Queue capacity hint": "A full queue drops records rather than slowing the 
proxy down.",
+    "RDS access key ID": "RDS access key ID",
+    "RDS access key secret": "RDS access key secret",
+    "RDS instance": "RDS instance",
+    "RDS region": "RDS region",
+    "Record mode": "Record mode",
+    "Retention days": "Retention days",
+    "Reverse proxy": "Reverse proxy",
+    "Reverse proxy description": "The WAF itself. Ports 80 and 443 usually 
belong to something else on the host, and binding them needs root on Linux and 
macOS.",
+    "Security": "Security",
+    "Settings": "Settings",
+    "Sign-in": "Sign-in",
+    "Sign-in description": "Casdoor is optional: leave the endpoint empty to 
sign in against Gateway's own user table."
+  },
   "site": {
     "Alert": "Alert",
     "Alert interval": "Alert interval",
@@ -545,6 +602,7 @@
     "Domain": "Domain",
     "Edit Site": "Edit Site",
     "Enable alert": "Enable alert",
+    "Free the port and switch it on again, or leave it off to run the 
management UI only.": "Free the port and switch it on again, or leave it off to 
run the management UI only.",
     "Host": "Host",
     "Hosts": "Hosts",
     "Mode": "Mode",
@@ -555,14 +613,16 @@
     "Other domains": "Other domains",
     "Port": "Port",
     "Public IP": "Public IP",
+    "Reverse proxy": "Reverse proxy",
     "Rules": "Rules",
     "SSL cert": "SSL cert",
     "Site": "Site",
     "Status": "Status",
+    "The reverse proxy could not start": "The reverse proxy could not start",
     "The reverse proxy is not enabled": "The reverse proxy is not enabled",
     "The reverse proxy is now running": "The reverse proxy is now running",
-    "The sites below will not be proxied until it is turned on.": "The sites 
below will not be proxied until it is turned on.",
-    "Turn on the reverse proxy": "Turn on the reverse proxy"
+    "The reverse proxy is now stopped": "The reverse proxy is now stopped",
+    "The sites below will not be proxied until it is turned on.": "The sites 
below will not be proxied until it is turned on."
   },
   "usage": {
     "All": "All",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 3443e31..a2e35b4 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -458,6 +458,7 @@
     "Recording is off": "记录功能已关闭",
     "Recording is off detail": 
"除非你主动开启,否则不会记录任何内容,因为提示词可能包含敏感数据。元数据是谁调用了哪个模型、结果如何;正文则是提示词本身。",
     "Recording is on": "已开启记录",
+    "Recording off": "关闭记录",
     "Records were dropped": "有 {count} 条记录因写入队列已满而被丢弃。如持续出现,请调大 
llmRecordQueueCapacity。",
     "Redacted values": "已脱敏字段数",
     "Request": "请求内容",
@@ -534,6 +535,62 @@
     "or": "或",
     "regex match": "正则表达式匹配"
   },
+  "setting": {
+    "ACME email": "ACME 邮箱",
+    "ACME email hint": "用于为站点签发 Let's Encrypt 证书的账号。",
+    "ACME private key": "ACME 私钥",
+    "API key encryption key": "API Key 加密密钥",
+    "API key encryption key hint": "用于加密存储的通道 API 
Key,长度不限。请与数据库一起备份:一旦丢失,已加密的密钥将无法读取。",
+    "Agent poll seconds": "Agent 轮询间隔(秒)",
+    "Agent record capacity": "Agent 记录条数",
+    "Agent record capacity hint": "Agent 记录不会落盘,因此这个值也决定了它们占用的内存上限。",
+    "Agent state dir": "Agent 状态目录",
+    "Agent state dir hint": "补丁清单、配置备份、上报令牌和文件读取位置。",
+    "Agents": "Agent",
+    "App dir": "应用目录",
+    "App map": "应用名映射",
+    "App map hint": "可选的 JSON 对象,用于重命名部署的应用,例如 {\"casdoor\": \"cc\"}。",
+    "Application deployment": "应用部署",
+    "Application deployment description": "仅供部署并重启应用源码目录的功能使用。",
+    "Casdoor application": "Casdoor 应用",
+    "Casdoor endpoint": "Casdoor 地址",
+    "Casdoor organization": "Casdoor 组织",
+    "Client ID": "Client ID",
+    "Client ID prefix": "Client ID 前缀",
+    "Client secret": "Client Secret",
+    "Client secret prefix": "Client Secret 前缀",
+    "Database host": "数据库主机",
+    "Database password": "数据库密码",
+    "Database user": "数据库用户",
+    "Deployed app language": "部署应用的语言",
+    "Enable the reverse proxy": "启用反向代理",
+    "Gateway HTTP port": "网关 HTTP 端口",
+    "Gateway HTTPS port": "网关 HTTPS 端口",
+    "LLM records": "LLM 记录",
+    "LLM records description": "提示词可能包含敏感数据,因此元数据之外的内容都需要主动开启。",
+    "Max payload bytes": "单条正文上限(字节)",
+    "Max records": "最大记录条数",
+    "Network and certificates": "网络与证书",
+    "Outbound SOCKS5 proxy": "出站 SOCKS5 代理",
+    "Outbound SOCKS5 proxy hint": "形如 \"host:port\",用于 ACME 等出站流量。",
+    "Page description": "Casbin Gateway 的全部配置项。修改会保存到数据库并立即生效。",
+    "Pricing file": "价格文件",
+    "Pricing file hint": "可选的 JSON 文件,按每百万 Token 的美元价格覆盖内置价目表。",
+    "Queue capacity": "写入队列容量",
+    "Queue capacity hint": "队列写满时会丢弃记录,而不是拖慢代理。",
+    "RDS access key ID": "RDS AccessKey ID",
+    "RDS access key secret": "RDS AccessKey Secret",
+    "RDS instance": "RDS 实例",
+    "RDS region": "RDS 地域",
+    "Record mode": "记录模式",
+    "Retention days": "保留天数",
+    "Reverse proxy": "反向代理",
+    "Reverse proxy description": "即 WAF 本身。80 和 443 端口通常已被主机上的其他程序占用,在 Linux 和 
macOS 上绑定它们还需要 root 权限。",
+    "Security": "安全",
+    "Settings": "设置",
+    "Sign-in": "登录",
+    "Sign-in description": "Casdoor 是可选的:地址留空则使用 Gateway 自带的用户表登录。"
+  },
   "site": {
     "Alert": "告警",
     "Alert interval": "告警间隔",
@@ -545,6 +602,7 @@
     "Domain": "域名",
     "Edit Site": "编辑站点",
     "Enable alert": "启用告警",
+    "Free the port and switch it on again, or leave it off to run the 
management UI only.": "请释放该端口后重新开启;也可以保持关闭,只运行管理界面。",
     "Host": "主机",
     "Hosts": "主机列表",
     "Mode": "模式",
@@ -555,14 +613,16 @@
     "Other domains": "其他域名",
     "Port": "端口",
     "Public IP": "公网IP",
+    "Reverse proxy": "反向代理",
     "Rules": "规则",
     "SSL cert": "SSL证书",
     "Site": "站点",
     "Status": "状态",
+    "The reverse proxy could not start": "反向代理启动失败",
     "The reverse proxy is not enabled": "反向代理未启用",
     "The reverse proxy is now running": "反向代理已启动",
-    "The sites below will not be proxied until it is turned on.": 
"在开启之前,下面的站点不会被代理。",
-    "Turn on the reverse proxy": "开启反向代理"
+    "The reverse proxy is now stopped": "反向代理已停止",
+    "The sites below will not be proxied until it is turned on.": 
"在开启之前,下面的站点不会被代理。"
   },
   "usage": {
     "All": "全部",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index cf7e07a..d6aa2fa 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -26,6 +26,7 @@ import {
   ScrollText,
   Server,
   Settings,
+  SlidersHorizontal,
   ShieldCheck,
   type LucideIcon,
 } from "lucide-react";
@@ -80,7 +81,7 @@ export const navGroups: NavGroup[] = [
   {
     key: "/advanced",
     label: "general:Advanced",
-    icon: Settings,
+    icon: SlidersHorizontal,
     children: [
       {key: "/sites", label: "general:Sites", path: "/sites", icon: Globe},
       {key: "/certs", label: "general:Certs", path: "/certs", icon: 
ShieldCheck},
@@ -90,6 +91,7 @@ export const navGroups: NavGroup[] = [
       {key: "/dashboard", label: "general:Gateway Analytics", path: 
"/dashboard", icon: ChartColumn},
     ],
   },
+  {key: "/settings", label: "setting:Settings", icon: Settings, path: 
"/settings", adminOnly: true},
 ];
 
 /** All leaf entries, flattened, for lookups by first path segment. */
diff --git a/web/src/pages/LlmRecordsPage.tsx b/web/src/pages/LlmRecordsPage.tsx
index fd5e7ac..f5747e0 100644
--- a/web/src/pages/LlmRecordsPage.tsx
+++ b/web/src/pages/LlmRecordsPage.tsx
@@ -13,10 +13,11 @@
 // limitations under the License.
 
 import * as React from "react";
-import {Activity, CircleDollarSign, Database, Hash, Logs, RefreshCw, Trash2} 
from "lucide-react";
+import {Activity, CircleDollarSign, Database, Hash, Logs, Play, RefreshCw, 
Trash2} from "lucide-react";
 import i18next from "i18next";
 
 import * as LlmRecordBackend from "@/backend/LlmRecordBackend";
+import * as SettingBackend from "@/backend/SettingBackend";
 import * as Setting from "@/Setting";
 import {RequestInspector} from "@/components/llm/request-inspector";
 import {DataTable, type Column} from "@/components/shared/data-table";
@@ -151,6 +152,7 @@ export default function LlmRecordsPage({account}: {account: 
Account}) {
   const isAdmin = Setting.isAdminUser(account);
   const [records, setRecords] = React.useState<LlmRecord[]>([]);
   const [status, setStatus] = React.useState<LlmRecordStatus | null>(null);
+  const [changingMode, setChangingMode] = React.useState(false);
   const [stats, setStats] = React.useState<LlmRecordStats | null>(null);
   const [total, setTotal] = React.useState(0);
   const [page, setPage] = React.useState(1);
@@ -228,6 +230,33 @@ export default function LlmRecordsPage({account}: 
{account: Account}) {
     });
   }, [isAdmin]);
 
+  // Recording is off until someone asks for it. The Settings page holds the
+  // same field; changing it here is the shortcut, and it applies to the next
+  // request rather than the next restart.
+  const setRecordMode = (mode: string) => {
+    setChangingMode(true);
+    SettingBackend.getSetting()
+      .then(res => {
+        if (res.status !== "ok") {
+          throw new Error(res.msg);
+        }
+        return SettingBackend.updateSetting({...res.data, llmRecordMode: mode 
as LlmRecordStatus["mode"]});
+      })
+      .then(res => {
+        setChangingMode(false);
+        if (res.status === "ok") {
+          loadStatus();
+          Setting.showMessage("success", mode === "off" ? 
i18next.t("llm:Recording is off") : i18next.t("llm:Recording is on"));
+        } else {
+          Setting.showMessage("error", res.msg);
+        }
+      })
+      .catch(error => {
+        setChangingMode(false);
+        Setting.showMessage("error", `${error}`);
+      });
+  };
+
   React.useEffect(() => {
     load(1, pageSize);
     loadStats();
@@ -443,6 +472,19 @@ export default function LlmRecordsPage({account}: 
{account: Account}) {
         description={description}
         actions={
           <>
+            {isAdmin && status ? (
+              <SimpleSelect
+                className="w-[220px]"
+                value={status.mode}
+                disabled={changingMode}
+                onChange={setRecordMode}
+                options={[
+                  {label: i18next.t("llm:Recording off"), value: "off"},
+                  {label: i18next.t("llm:Record metadata"), value: "metadata"},
+                  {label: i18next.t("llm:Record metadata and bodies"), value: 
"full"},
+                ]}
+              />
+            ) : null}
             <SimpleSelect
               className="w-[150px]"
               value={windowHours}
@@ -480,8 +522,38 @@ export default function LlmRecordsPage({account}: 
{account: Account}) {
         }
       />
 
-      {modeOff ? <MessageAlert variant="info" title={i18next.t("llm:Recording 
is off")} description={i18next.t("llm:Recording is off detail")} /> : null}
-      {modeMetadata ? <MessageAlert variant="info" 
title={i18next.t("llm:Bodies are not stored")} 
description={i18next.t("llm:Bodies are not stored detail")} /> : null}
+      {modeOff ? (
+        <MessageAlert
+          variant="info"
+          title={i18next.t("llm:Recording is off")}
+          description={i18next.t("llm:Recording is off detail")}
+          action={
+            <div className="flex gap-2">
+              <Button size="sm" variant="outline" onClick={() => 
setRecordMode("metadata")} loading={changingMode}>
+                <Play />
+                {i18next.t("llm:Record metadata")}
+              </Button>
+              <Button size="sm" variant="outline" onClick={() => 
setRecordMode("full")} loading={changingMode}>
+                <Play />
+                {i18next.t("llm:Record metadata and bodies")}
+              </Button>
+            </div>
+          }
+        />
+      ) : null}
+      {modeMetadata ? (
+        <MessageAlert
+          variant="info"
+          title={i18next.t("llm:Bodies are not stored")}
+          description={i18next.t("llm:Bodies are not stored detail")}
+          action={
+            <Button size="sm" variant="outline" onClick={() => 
setRecordMode("full")} loading={changingMode}>
+              <Play />
+              {i18next.t("llm:Record metadata and bodies")}
+            </Button>
+          }
+        />
+      ) : null}
       {status && status.dropped > 0 ? (
         <MessageAlert
           variant="warning"
diff --git a/web/src/pages/SettingPage.tsx b/web/src/pages/SettingPage.tsx
new file mode 100644
index 0000000..5228f59
--- /dev/null
+++ b/web/src/pages/SettingPage.tsx
@@ -0,0 +1,217 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import i18next from "i18next";
+
+import * as SettingBackend from "@/backend/SettingBackend";
+import * as Setting from "@/Setting";
+import {Field} from "@/components/shared/form-dialog";
+import {Loading} from "@/components/shared/loading";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {NumberInput} from "@/components/shared/number-input";
+import {PageContainer, PageHeader, Section} from 
"@/components/shared/page-header";
+import {PasswordInput} from "@/components/shared/password-input";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {Switch} from "@/components/ui/switch";
+import {Textarea} from "@/components/ui/textarea";
+import type {Account, Setting as SettingType} from "@/types";
+
+type KeysOfType<T> = {[K in keyof SettingType]: SettingType[K] extends T ? K : 
never}[keyof SettingType];
+type StringKey = KeysOfType<string>;
+type NumberKey = KeysOfType<number>;
+type BooleanKey = KeysOfType<boolean>;
+
+export default function SettingPage({account}: {account: Account}) {
+  const isAdmin = Setting.isAdminUser(account);
+  const [setting, setSetting] = React.useState<SettingType | null>(null);
+  const [saving, setSaving] = React.useState(false);
+
+  React.useEffect(() => {
+    if (!isAdmin) {
+      return;
+    }
+
+    SettingBackend.getSetting().then(res => {
+      if (res.status === "ok") {
+        setSetting(res.data);
+      } else {
+        Setting.showMessage("error", `${i18next.t("general:Failed to get 
data")}: ${res.msg}`);
+      }
+    });
+  }, [isAdmin]);
+
+  if (!isAdmin) {
+    return <UnauthorizedResult />;
+  }
+
+  if (setting === null) {
+    return <Loading type="page" />;
+  }
+
+  const updateField = <K extends keyof SettingType>(key: K, value: 
SettingType[K]) => {
+    setSetting(current => (current === null ? current : {...current, [key]: 
value}));
+  };
+
+  // The backend applies what it stores, so a port it cannot bind or a value a
+  // subsystem refuses comes back as an error while the setting stays saved.
+  const save = () => {
+    setSaving(true);
+    SettingBackend.updateSetting(Setting.deepCopy(setting))
+      .then(res => {
+        setSaving(false);
+        if (res.status === "ok") {
+          setSetting(res.data);
+          Setting.showMessage("success", i18next.t("general:Successfully 
saved"));
+        } else {
+          Setting.showMessage("error", `${i18next.t("general:Failed to 
save")}: ${res.msg}`);
+        }
+      })
+      .catch(error => {
+        setSaving(false);
+        Setting.showMessage("error", `${i18next.t("general:Failed to save")}: 
${error}`);
+      });
+  };
+
+  const textField = (key: StringKey, label: string, hint?: string) => (
+    <Field label={label} htmlFor={`setting-${key}`} hint={hint}>
+      <Input
+        id={`setting-${key}`}
+        value={setting[key]}
+        onChange={event => updateField(key, event.target.value)}
+      />
+    </Field>
+  );
+
+  const secretField = (key: StringKey, label: string, hint?: string) => (
+    <Field label={label} htmlFor={`setting-${key}`} hint={hint}>
+      <PasswordInput
+        id={`setting-${key}`}
+        value={setting[key]}
+        onChange={event => updateField(key, event.target.value)}
+      />
+    </Field>
+  );
+
+  const numberField = (key: NumberKey, label: string, hint?: string, min = 0, 
max?: number) => (
+    <Field label={label} htmlFor={`setting-${key}`} hint={hint}>
+      <NumberInput
+        id={`setting-${key}`}
+        value={setting[key]}
+        onChange={value => updateField(key, value)}
+        min={min}
+        max={max}
+      />
+    </Field>
+  );
+
+  const switchField = (key: BooleanKey, label: string, hint?: string) => (
+    <Field label={label} hint={hint}>
+      <div className="flex h-9 items-center">
+        <Switch checked={setting[key]} onCheckedChange={value => 
updateField(key, value)} />
+      </div>
+    </Field>
+  );
+
+  return (
+    <PageContainer>
+      <PageHeader
+        title={i18next.t("setting:Settings")}
+        description={i18next.t("setting:Page description")}
+        actions={
+          <Button onClick={save} loading={saving}>
+            {i18next.t("general:Save")}
+          </Button>
+        }
+      />
+
+      <Section columns={2} title={i18next.t("setting:Reverse proxy")} 
description={i18next.t("setting:Reverse proxy description")}>
+        {switchField("gatewayEnabled", i18next.t("setting:Enable the reverse 
proxy"))}
+        {numberField("gatewayHttpPort", i18next.t("setting:Gateway HTTP 
port"), undefined, 1, 65535)}
+        {numberField("gatewayHttpsPort", i18next.t("setting:Gateway HTTPS 
port"), undefined, 1, 65535)}
+      </Section>
+
+      <Section columns={2} title={i18next.t("setting:LLM records")} 
description={i18next.t("setting:LLM records description")}>
+        <Field label={i18next.t("setting:Record mode")}>
+          <SimpleSelect
+            value={setting.llmRecordMode}
+            onChange={value => updateField("llmRecordMode", value as 
SettingType["llmRecordMode"])}
+            options={[
+              {label: i18next.t("llm:Recording off"), value: "off"},
+              {label: i18next.t("llm:Record metadata"), value: "metadata"},
+              {label: i18next.t("llm:Record metadata and bodies"), value: 
"full"},
+            ]}
+          />
+        </Field>
+        {numberField("llmRecordRetentionDays", i18next.t("setting:Retention 
days"), undefined, 1)}
+        {numberField("llmRecordMaxRecords", i18next.t("setting:Max records"), 
undefined, 1)}
+        {numberField("llmRecordQueueCapacity", i18next.t("setting:Queue 
capacity"), i18next.t("setting:Queue capacity hint"), 1)}
+        {numberField("llmRecordMaxPayloadBytes", i18next.t("setting:Max 
payload bytes"), undefined, 65536, 33554432)}
+        {textField("llmPricingFile", i18next.t("setting:Pricing file"), 
i18next.t("setting:Pricing file hint"))}
+      </Section>
+
+      <Section columns={2} title={i18next.t("setting:Agents")}>
+        {textField("agentPatchStateDir", i18next.t("setting:Agent state dir"), 
i18next.t("setting:Agent state dir hint"))}
+        {numberField("agentRecordCapacity", i18next.t("setting:Agent record 
capacity"), i18next.t("setting:Agent record capacity hint"), 1)}
+        {numberField("agentMonitorPollSeconds", i18next.t("setting:Agent poll 
seconds"), undefined, 1)}
+      </Section>
+
+      <Section columns={2} title={i18next.t("setting:Sign-in")} 
description={i18next.t("setting:Sign-in description")}>
+        {textField("casdoorEndpoint", i18next.t("setting:Casdoor endpoint"))}
+        {textField("clientId", i18next.t("setting:Client ID"))}
+        {secretField("clientSecret", i18next.t("setting:Client secret"))}
+        {textField("casdoorOrganization", i18next.t("setting:Casdoor 
organization"))}
+        {textField("casdoorApplication", i18next.t("setting:Casdoor 
application"))}
+      </Section>
+
+      <Section columns={2} title={i18next.t("setting:Security")}>
+        {secretField("apiKeyEncryptionKey", i18next.t("setting:API key 
encryption key"), i18next.t("setting:API key encryption key hint"))}
+      </Section>
+
+      <Section columns={2} title={i18next.t("setting:Network and 
certificates")}>
+        {textField("httpProxy", i18next.t("setting:Outbound SOCKS5 proxy"), 
i18next.t("setting:Outbound SOCKS5 proxy hint"))}
+        {textField("acmeEmail", i18next.t("setting:ACME email"), 
i18next.t("setting:ACME email hint"))}
+        <Field label={i18next.t("setting:ACME private key")} 
htmlFor="setting-acmePrivateKey" className="md:col-span-2">
+          <Textarea
+            id="setting-acmePrivateKey"
+            rows={4}
+            value={setting.acmePrivateKey}
+            onChange={event => updateField("acmePrivateKey", 
event.target.value)}
+          />
+        </Field>
+      </Section>
+
+      <Section
+        columns={2}
+        title={i18next.t("setting:Application deployment")}
+        description={i18next.t("setting:Application deployment description")}
+      >
+        {textField("appDir", i18next.t("setting:App dir"))}
+        {textField("language", i18next.t("setting:Deployed app language"))}
+        {textField("appMap", i18next.t("setting:App map"), 
i18next.t("setting:App map hint"))}
+        {textField("clientIdPrefix", i18next.t("setting:Client ID prefix"))}
+        {textField("clientSecretPrefix", i18next.t("setting:Client secret 
prefix"))}
+        {textField("dbRegionId", i18next.t("setting:RDS region"))}
+        {textField("dbAccessKeyId", i18next.t("setting:RDS access key ID"))}
+        {secretField("dbAccessKeySecret", i18next.t("setting:RDS access key 
secret"))}
+        {textField("dbInstanceId", i18next.t("setting:RDS instance"))}
+        {textField("dbHost", i18next.t("setting:Database host"))}
+        {textField("dbUser", i18next.t("setting:Database user"))}
+        {secretField("dbPass", i18next.t("setting:Database password"))}
+      </Section>
+    </PageContainer>
+  );
+}
diff --git a/web/src/pages/SiteListPage.tsx b/web/src/pages/SiteListPage.tsx
index bc17915..5f4dca2 100644
--- a/web/src/pages/SiteListPage.tsx
+++ b/web/src/pages/SiteListPage.tsx
@@ -18,6 +18,7 @@ import {Globe, Pencil, Plus, RefreshCw, Trash2} from 
"lucide-react";
 import i18next from "i18next";
 
 import * as MiscBackend from "@/backend/MiscBackend";
+import * as SettingBackend from "@/backend/SettingBackend";
 import * as SiteBackend from "@/backend/SiteBackend";
 import * as Setting from "@/Setting";
 import {DataTable, type Column} from "@/components/shared/data-table";
@@ -27,10 +28,12 @@ import {Badge} from "@/components/ui/badge";
 import {Button} from "@/components/ui/button";
 import {ConfirmDialog} from "@/components/shared/confirm-dialog";
 import {Input} from "@/components/ui/input";
+import {Label} from "@/components/ui/label";
 import {NumberInput} from "@/components/shared/number-input";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
 import {SimpleTooltip} from "@/components/ui/tooltip";
-import type {Account, Site} from "@/types";
+import {Switch} from "@/components/ui/switch";
+import type {Account, GatewayStatus, Site} from "@/types";
 
 function newSite(owner: string): Site {
   const randomName = Setting.getRandomName();
@@ -67,7 +70,8 @@ export default function SiteListPage({account}: {account: 
Account}) {
   const navigate = useNavigate();
   const [data, setData] = React.useState<Site[]>([]);
   const [loading, setLoading] = React.useState(false);
-  const [gatewayEnabled, setGatewayEnabled] = React.useState<boolean | 
null>(null);
+  const [gatewayStatus, setGatewayStatus] = React.useState<GatewayStatus | 
null>(null);
+  const [switchingGateway, setSwitchingGateway] = React.useState(false);
   const [addOpen, setAddOpen] = React.useState(false);
   const [adding, setAdding] = React.useState(false);
   const [form, setForm] = React.useState<Site>(() => newSite(account.name));
@@ -85,17 +89,54 @@ export default function SiteListPage({account}: {account: 
Account}) {
     });
   }, [account.name]);
 
+  const loadGatewayStatus = React.useCallback(() => {
+    MiscBackend.getGatewayStatus().then(res => {
+      if (res.status === "ok") {
+        setGatewayStatus(res.data);
+      }
+    });
+  }, []);
+
   React.useEffect(() => {
     fetchSites();
     // The sites below only do something when the reverse proxy is running, so
     // the page has to say when it is off. Otherwise every site here looks
     // configured and nothing is actually proxied.
-    MiscBackend.getGatewayStatus().then(res => {
-      if (res.status === "ok") {
-        setGatewayEnabled(res.data.gatewayEnabled);
-      }
-    });
-  }, [fetchSites]);
+    loadGatewayStatus();
+  }, [fetchSites, loadGatewayStatus]);
+
+  // A shortcut for the one setting this page is about: the Settings page holds
+  // the same field, and the backend stores it and takes the two gateway ports
+  // straight away, or gives them back.
+  const switchGateway = (enabled: boolean) => {
+    setSwitchingGateway(true);
+    SettingBackend.getSetting()
+      .then(res => {
+        if (res.status !== "ok") {
+          throw new Error(res.msg);
+        }
+        return SettingBackend.updateSetting({...res.data, gatewayEnabled: 
enabled});
+      })
+      .then(res => {
+        setSwitchingGateway(false);
+        // A port that refuses to bind still leaves the setting saved, so the
+        // banner below is told what the state really is either way.
+        loadGatewayStatus();
+        if (res.status === "ok") {
+          Setting.showMessage(
+            "success",
+            enabled ? i18next.t("site:The reverse proxy is now running") : 
i18next.t("site:The reverse proxy is now stopped"),
+          );
+        } else {
+          Setting.showMessage("error", res.msg);
+        }
+      })
+      .catch(error => {
+        setSwitchingGateway(false);
+        loadGatewayStatus();
+        Setting.showMessage("error", `${error}`);
+      });
+  };
 
   const openAddDialog = () => {
     setForm(newSite(account.name));
@@ -333,20 +374,36 @@ export default function SiteListPage({account}: {account: 
Account}) {
       <PageHeader
         title={i18next.t("general:Sites")}
         actions={
-          <Button onClick={openAddDialog}>
-            <Plus />
-            {i18next.t("general:Add")}
-          </Button>
+          <>
+            {account.isAdmin && gatewayStatus !== null ? (
+              <Label className="text-sm font-normal">
+                <Switch checked={gatewayStatus.gatewayEnabled} 
disabled={switchingGateway} onCheckedChange={switchGateway} />
+                {i18next.t("site:Reverse proxy")}
+              </Label>
+            ) : null}
+            <Button onClick={openAddDialog}>
+              <Plus />
+              {i18next.t("general:Add")}
+            </Button>
+          </>
         }
       />
 
-      {gatewayEnabled === false && (
+      {gatewayStatus?.gatewayEnabled === false && (
         <MessageAlert
           variant="warning"
           title={i18next.t("site:The reverse proxy is not enabled")}
-          description={i18next.t(
-            "site:The sites below will not be proxied. Set gatewayEnabled = 
true in conf/app.conf and restart Casbin Gateway to enable it.",
-          )}
+          description={i18next.t("site:The sites below will not be proxied 
until it is turned on.")}
+        />
+      )}
+
+      {gatewayStatus?.gatewayEnabled && !gatewayStatus.gatewayRunning && (
+        <MessageAlert
+          variant="destructive"
+          title={i18next.t("site:The reverse proxy could not start")}
+          description={[gatewayStatus.gatewayError, i18next.t("site:Free the 
port and switch it on again, or leave it off to run the management UI only.")]
+            .filter(Boolean)
+            .join(" ")}
         />
       )}
 
diff --git a/web/src/types.ts b/web/src/types.ts
index 745c45a..4cb8adc 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -350,7 +350,57 @@ export interface GatewayStatus {
   /** False while enabled means the ports refused to bind; "gatewayError" says 
why. */
   gatewayRunning: boolean;
   gatewayError: string;
+}
+
+/**
+ * The one built-in row of the Setting table, holding everything that used to 
be
+ * hand-edited in conf/app.conf. The file only seeds it on the first start.
+ */
+export interface Setting {
+  owner: string;
+  name: string;
+  createdTime: string;
+  displayName: string;
+
+  gatewayEnabled: boolean;
+  gatewayHttpPort: number;
+  gatewayHttpsPort: number;
+
   llmRecordMode: "off" | "metadata" | "full";
+  llmRecordQueueCapacity: number;
+  llmRecordRetentionDays: number;
+  llmRecordMaxRecords: number;
+  llmRecordMaxPayloadBytes: number;
+  llmPricingFile: string;
+
+  agentPatchStateDir: string;
+  agentRecordCapacity: number;
+  agentMonitorPollSeconds: number;
+
+  casdoorEndpoint: string;
+  clientId: string;
+  clientSecret: string;
+  casdoorOrganization: string;
+  casdoorApplication: string;
+
+  apiKeyEncryptionKey: string;
+
+  httpProxy: string;
+  acmeEmail: string;
+  acmePrivateKey: string;
+
+  appDir: string;
+  language: string;
+  appMap: string;
+  clientIdPrefix: string;
+  clientSecretPrefix: string;
+  dbRegionId: string;
+  dbAccessKeyId: string;
+  dbAccessKeySecret: string;
+  dbInstanceId: string;
+  dbHost: string;
+  dbUser: string;
+  dbPass: string;
 }
 
 /** The two kinds of configuration the Skills & MCP page manages. */


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to