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
The following commit(s) were added to refs/heads/master by this push:
new e7a78f8 feat(setting): export, import and back up the configuration
e7a78f8 is described below
commit e7a78f86526bdbcb753778d2910dccc182d0c8df
Author: Yang Luo <[email protected]>
AuthorDate: Fri Sep 4 00:55:06 2026 +0800
feat(setting): export, import and back up the configuration
---
conf/conf.go | 32 +
controllers/backup.go | 230 +++++++
main.go | 3 +
object/backup.go | 378 +++++++++++
object/setting.go | 28 +
object/snapshot.go | 699 +++++++++++++++++++++
routers/router.go | 9 +
web/src/backend/BackupBackend.ts | 64 ++
web/src/components/settings/backup-panel.tsx | 361 +++++++++++
.../components/settings/import-export-panel.tsx | 330 ++++++++++
web/src/components/settings/snapshot.tsx | 239 +++++++
web/src/locales/en/data.json | 72 ++-
web/src/locales/zh/data.json | 72 ++-
web/src/pages/SettingPage.tsx | 12 +-
web/src/types.ts | 89 +++
15 files changed, 2615 insertions(+), 3 deletions(-)
diff --git a/conf/conf.go b/conf/conf.go
index bc62ea1..dce2299 100644
--- a/conf/conf.go
+++ b/conf/conf.go
@@ -270,6 +270,38 @@ func GetConfigBatchSize() int {
return res
}
+// GetBackupDir is the local directory that holds configuration snapshots. They
+// sit beside the database rather than inside it, so a backup survives the
+// database it was taken from.
+func GetBackupDir() string {
+ dir := GetConfigString("backupDir")
+ if dir == "" {
+ return "./data/backups"
+ }
+ return dir
+}
+
+// GetBackupIntervalHours is how often an automatic backup is taken. The
+// configuration changes when somebody changes it, so once a day already keeps
+// more copies than most machines have edits.
+func GetBackupIntervalHours() int {
+ res, err := strconv.Atoi(GetConfigString("backupIntervalHours"))
+ if err != nil || res <= 0 {
+ res = 24
+ }
+ return res
+}
+
+// GetBackupRetention is how many snapshots are kept before the oldest is
+// dropped. Snapshots are small, but a directory nobody prunes is not a backup.
+func GetBackupRetention() int {
+ res, err := strconv.Atoi(GetConfigString("backupRetention"))
+ if err != nil || res <= 0 {
+ res = 10
+ }
+ return res
+}
+
// GetAgentPatchStateDir is the local directory that holds agent patch
// manifests, backups, and monitor cursors. It is operational state only; agent
// behaviour records remain in memory.
diff --git a/controllers/backup.go b/controllers/backup.go
new file mode 100644
index 0000000..d727515
--- /dev/null
+++ b/controllers/backup.go
@@ -0,0 +1,230 @@
+// 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"
+
+ "github.com/apache/casbin-gateway/object"
+)
+
+// ExportSnapshot answers with the configuration as one document, which the
page
+// saves as a file. Everything here is admin-only: a snapshot carries the API
+// keys of every provider on the machine.
+func (c *ApiController) ExportSnapshot() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var scope object.SnapshotScope
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &scope); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ snapshot, err := object.BuildSnapshot(scope, object.BackupManual)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(snapshot)
+}
+
+// ImportSnapshot writes a snapshot back. A dry run decides everything the real
+// one would and writes nothing, so the page can show what an import will do
+// before it does it.
+func (c *ApiController) ImportSnapshot() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var request struct {
+ Snapshot *object.Snapshot `json:"snapshot"`
+ Scope object.SnapshotScope `json:"scope"`
+ Mode string `json:"mode"`
+ DryRun bool `json:"dryRun"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil
{
+ c.ResponseError(err.Error())
+ return
+ }
+ if request.Snapshot == nil {
+ c.ResponseError("this file is not a Gateway snapshot")
+ return
+ }
+
+ // Taken before anything is written, so an import that turns out to be
the
+ // wrong one is a restore away rather than a loss.
+ backup := ""
+ if !request.DryRun {
+ backup = object.BackupBeforeWrite()
+ }
+
+ report, err := object.ImportSnapshot(request.Snapshot, request.Scope,
request.Mode, request.DryRun)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ report.Backup = backup
+
+ c.ResponseOk(report)
+}
+
+// GetBackupState is the schedule, the last run and the files themselves.
+func (c *ApiController) GetBackupState() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ state, err := object.GetBackupState()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(state)
+}
+
+func (c *ApiController) CreateBackup() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ if _, err := object.CreateBackup(object.BackupManual); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.GetBackupState()
+}
+
+// GetBackup returns one stored snapshot in full, which is what downloading a
+// backup from the list needs.
+func (c *ApiController) GetBackup() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ snapshot, err := object.ReadBackup(c.Input().Get("name"))
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(snapshot)
+}
+
+// RestoreBackup puts one snapshot back exactly as it was taken. Like an import
+// it can be asked what it would do first.
+func (c *ApiController) RestoreBackup() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var request struct {
+ Name string `json:"name"`
+ DryRun bool `json:"dryRun"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil
{
+ c.ResponseError(err.Error())
+ return
+ }
+
+ backup := ""
+ if !request.DryRun {
+ backup = object.BackupBeforeWrite()
+ }
+
+ report, err := object.RestoreBackup(request.Name, request.DryRun)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ report.Backup = backup
+
+ c.ResponseOk(report)
+}
+
+func (c *ApiController) DeleteBackup() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var request struct {
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil
{
+ c.ResponseError(err.Error())
+ return
+ }
+
+ if err := object.DeleteBackup(request.Name); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.GetBackupState()
+}
+
+// UpdateBackupSchedule stores the schedule on the built-in setting row, the
+// same place the Settings page saves everything else.
+func (c *ApiController) UpdateBackupSchedule() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var request struct {
+ Mode string `json:"mode"`
+ IntervalHours int `json:"intervalHours"`
+ Retention int `json:"retention"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil
{
+ c.ResponseError(err.Error())
+ return
+ }
+ if request.Mode != object.BackupAuto && request.Mode !=
object.BackupOff {
+ c.ResponseError("backupMode must be \"auto\" or \"off\"")
+ return
+ }
+ if request.IntervalHours <= 0 {
+ c.ResponseError("the backup interval must be at least one hour")
+ return
+ }
+ if request.Retention <= 0 {
+ c.ResponseError("at least one backup has to be kept")
+ return
+ }
+
+ setting, err := object.GetBuiltInSetting()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if setting == nil {
+ c.ResponseError("the built-in setting does not exist")
+ return
+ }
+
+ setting.BackupMode = request.Mode
+ setting.BackupIntervalHours = request.IntervalHours
+ setting.BackupRetention = request.Retention
+ if _, err := object.UpdateSetting(object.BuiltInSettingId, setting);
err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.GetBackupState()
+}
diff --git a/main.go b/main.go
index 32aee7c..6940d22 100644
--- a/main.go
+++ b/main.go
@@ -99,6 +99,9 @@ func main() {
object.StartProviderProbeSweep()
// Prices are read over the same transport, and only when the sync is
on.
object.StartModelsDevSync()
+ // A snapshot of the configuration, taken on a schedule and beside the
+ // database rather than inside it.
+ object.StartBackupSchedule()
agentmonitor.Configure(
conf.GetAgentPatchStateDir(),
diff --git a/object/backup.go b/object/backup.go
new file mode 100644
index 0000000..4fc6ad1
--- /dev/null
+++ b/object/backup.go
@@ -0,0 +1,378 @@
+// 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 (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/conf"
+ "github.com/apache/casbin-gateway/util"
+ "github.com/beego/beego"
+)
+
+// Whether a backup is taken on a schedule. It is on by default: a snapshot of
+// the configuration is a few kilobytes, and the copy nobody took is the one
+// that is missed.
+const (
+ BackupAuto = "auto"
+ BackupOff = "off"
+)
+
+// What took a backup, which is also the second half of its file name.
+const (
+ BackupManual = "manual"
+ BackupSchedule = "schedule"
+ // BackupBeforeImport is taken in front of anything that writes a
snapshot
+ // back, so an import that turns out to be the wrong one is one restore
away.
+ BackupBeforeImport = "before-import"
+)
+
+// backupDelay lets the process finish starting before it writes anything, and
+// backupTick is how often the schedule is reconsidered - turning it on does
not
+// wait out a whole interval to take effect.
+const (
+ backupDelay = 120 * time.Second
+ backupTick = 10 * time.Minute
+)
+
+// backupNamePattern is what a file in the backup directory has to look like to
+// be one of ours. Names arrive from the browser, so nothing else is opened.
+var backupNamePattern =
regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[a-z0-9-]+)?\.json$`)
+
+var (
+ backupMutex sync.Mutex
+ backupTaken time.Time
+ backupError string
+ backupLatest string
+)
+
+// Backup is one snapshot on disk, described without reading the whole of it
+// back to the browser.
+type Backup struct {
+ Name string `json:"name"`
+ CreatedTime string `json:"createdTime"`
+ Reason string `json:"reason"`
+ Gateway string `json:"gateway"`
+ Host string `json:"host"`
+ Size int64 `json:"size"`
+ // Secrets is whether the API keys are in it, which decides whether
restoring
+ // it leaves a working Gateway or one with the keys to type back in.
+ Secrets bool `json:"secrets"`
+ Counts SnapshotCounts `json:"counts"`
+}
+
+// BackupState is the schedule and the last run, which is what the Settings
page
+// reports. Like ProviderHealth it lives in memory: it describes what this
+// process has done, not a stored setting.
+type BackupState struct {
+ Mode string `json:"mode"`
+ IntervalHours int `json:"intervalHours"`
+ Retention int `json:"retention"`
+ Dir string `json:"dir"`
+ // NextTime is when the schedule runs again, empty when nothing is
scheduled.
+ TakenTime string `json:"takenTime"`
+ NextTime string `json:"nextTime"`
+ Latest string `json:"latest"`
+ Error string `json:"error"`
+ Backups []*Backup `json:"backups"`
+}
+
+func GetBackupMode() string {
+ if strings.EqualFold(conf.GetConfigStringUnquoted("backupMode"),
BackupOff) {
+ return BackupOff
+ }
+ return BackupAuto
+}
+
+func backupDir() string {
+ return conf.GetBackupDir()
+}
+
+// backupPath resolves one name inside the backup directory. A name that is not
+// one of ours never becomes a path, so nothing outside that directory is read,
+// written or deleted.
+func backupPath(name string) (string, error) {
+ if !backupNamePattern.MatchString(name) {
+ return "", fmt.Errorf("this is not the name of a backup: %s",
name)
+ }
+ return filepath.Join(backupDir(), name), nil
+}
+
+func backupFileName(at time.Time, reason string) string {
+ if reason == "" {
+ reason = BackupManual
+ }
+ return fmt.Sprintf("%s-%s.json", at.Format("20060102-150405"), reason)
+}
+
+// CreateBackup writes the whole configuration, secrets included, to a file
+// beside the database, then drops the oldest files past the retention.
+func CreateBackup(reason string) (*Backup, error) {
+ snapshot, err := BuildSnapshot(FullScope(), reason)
+ if err != nil {
+ return nil, err
+ }
+
+ data, err := json.MarshalIndent(snapshot, "", " ")
+ if err != nil {
+ return nil, err
+ }
+
+ dir := backupDir()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, err
+ }
+
+ at := time.Now()
+ name := backupFileName(at, reason)
+ path := filepath.Join(dir, name)
+ // A second backup within the same second would otherwise overwrite the
+ // first, which is the one case where two snapshots differ by an import.
+ for suffix := 2; util.FileExist(path) && suffix < 100; suffix++ {
+ name = fmt.Sprintf("%s-%d.json",
strings.TrimSuffix(backupFileName(at, reason), ".json"), suffix)
+ path = filepath.Join(dir, name)
+ }
+
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ return nil, err
+ }
+
+ backupMutex.Lock()
+ backupTaken = time.Now()
+ backupError = ""
+ backupLatest = name
+ backupMutex.Unlock()
+
+ if err := pruneBackups(); err != nil {
+ beego.Error("the old backups could not be pruned:", err)
+ }
+
+ return describeBackup(name, int64(len(data)), snapshot), nil
+}
+
+func describeBackup(name string, size int64, snapshot *Snapshot) *Backup {
+ return &Backup{
+ Name: name,
+ CreatedTime: snapshot.CreatedTime,
+ Reason: snapshot.Reason,
+ Gateway: snapshot.Gateway,
+ Host: snapshot.Host,
+ Size: size,
+ Secrets: snapshot.Scope.Secrets,
+ Counts: snapshot.Counts(),
+ }
+}
+
+// ListBackups describes every snapshot in the directory, newest first. A file
+// that will not parse is left out rather than failing the listing: the
+// directory is on disk, and anything can put a file there.
+func ListBackups() ([]*Backup, error) {
+ entries, err := os.ReadDir(backupDir())
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []*Backup{}, nil
+ }
+ return nil, err
+ }
+
+ backups := []*Backup{}
+ for _, entry := range entries {
+ if entry.IsDir() ||
!backupNamePattern.MatchString(entry.Name()) {
+ continue
+ }
+
+ snapshot, size, err := readBackupFile(entry.Name())
+ if err != nil {
+ beego.Error("a backup could not be read:",
entry.Name(), err)
+ continue
+ }
+ backups = append(backups, describeBackup(entry.Name(), size,
snapshot))
+ }
+
+ // The name starts with the timestamp, so it sorts the same way the
contents
+ // would and needs nothing parsed to do it.
+ sort.Slice(backups, func(i, j int) bool { return backups[i].Name >
backups[j].Name })
+ return backups, nil
+}
+
+func readBackupFile(name string) (*Snapshot, int64, error) {
+ path, err := backupPath(name)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ snapshot := &Snapshot{}
+ if err := json.Unmarshal(data, snapshot); err != nil {
+ return nil, 0, err
+ }
+ return snapshot, int64(len(data)), nil
+}
+
+// ReadBackup returns one snapshot in full, which is what downloading a backup
+// and restoring one both need.
+func ReadBackup(name string) (*Snapshot, error) {
+ snapshot, _, err := readBackupFile(name)
+ return snapshot, err
+}
+
+func DeleteBackup(name string) error {
+ path, err := backupPath(name)
+ if err != nil {
+ return err
+ }
+ return os.Remove(path)
+}
+
+// RestoreBackup puts one snapshot back exactly as it was taken, so a section
it
+// covers ends up with the rows it held and nothing else.
+func RestoreBackup(name string, dryRun bool) (*ImportReport, error) {
+ snapshot, err := ReadBackup(name)
+ if err != nil {
+ return nil, err
+ }
+ return ImportSnapshot(snapshot, snapshot.Scope, ImportReplace, dryRun)
+}
+
+// pruneBackups keeps the newest few. A backup taken in front of an import is
+// counted with the rest: it is only worth keeping until the import it guards
+// has been lived with.
+func pruneBackups() error {
+ backups, err := ListBackups()
+ if err != nil {
+ return err
+ }
+
+ retention := conf.GetBackupRetention()
+ if len(backups) <= retention {
+ return nil
+ }
+
+ for _, backup := range backups[retention:] {
+ if err := DeleteBackup(backup.Name); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// GetBackupState answers the Settings page: the schedule, the last run, and
the
+// files themselves.
+func GetBackupState() (*BackupState, error) {
+ backups, err := ListBackups()
+ if err != nil {
+ return nil, err
+ }
+
+ backupMutex.Lock()
+ defer backupMutex.Unlock()
+
+ state := &BackupState{
+ Mode: GetBackupMode(),
+ IntervalHours: conf.GetBackupIntervalHours(),
+ Retention: conf.GetBackupRetention(),
+ Dir: backupDir(),
+ Latest: backupLatest,
+ Error: backupError,
+ Backups: backups,
+ }
+
+ // The files outlive the process, so the last backup is the newest of
them
+ // rather than only the one this process took.
+ taken := backupTaken
+ if len(backups) > 0 {
+ state.Latest = backups[0].Name
+ // The name was written from the wall clock, so it is read back
in the
+ // same zone rather than as UTC.
+ if from, err := time.ParseInLocation("20060102-150405",
backups[0].Name[:15], time.Local); err == nil && from.After(taken) {
+ taken = from
+ }
+ }
+ if !taken.IsZero() {
+ state.TakenTime = util.FormatTime(taken)
+ if state.Mode == BackupAuto {
+ state.NextTime =
util.FormatTime(taken.Add(backupInterval()))
+ }
+ }
+ return state, nil
+}
+
+// StartBackupSchedule takes a snapshot of the configuration every so often.
The
+// schedule is read each time round rather than held, so turning it off stops
it.
+func StartBackupSchedule() {
+ go func() {
+ time.Sleep(backupDelay)
+ for {
+ if isBackupDue() {
+ if _, err := CreateBackup(BackupSchedule); err
!= nil {
+ beego.Error("the scheduled backup
failed:", err)
+ backupMutex.Lock()
+ backupError = err.Error()
+ backupMutex.Unlock()
+ }
+ }
+ time.Sleep(backupTick)
+ }
+ }()
+}
+
+func backupInterval() time.Duration {
+ return time.Duration(conf.GetBackupIntervalHours()) * time.Hour
+}
+
+func isBackupDue() bool {
+ if GetBackupMode() != BackupAuto {
+ return false
+ }
+
+ state, err := GetBackupState()
+ if err != nil {
+ beego.Error("the backups could not be listed:", err)
+ return false
+ }
+ if state.TakenTime == "" {
+ return true
+ }
+
+ taken, err := time.Parse(time.RFC3339, state.TakenTime)
+ if err != nil {
+ return true
+ }
+ return time.Since(taken) >= backupInterval()
+}
+
+// BackupBeforeWrite is the copy taken in front of an import or a restore. It
+// never stops one: a Gateway whose backup directory cannot be written is still
+// a Gateway somebody is allowed to import into.
+func BackupBeforeWrite() string {
+ backup, err := CreateBackup(BackupBeforeImport)
+ if err != nil {
+ beego.Error("the backup before an import could not be taken:",
err)
+ return ""
+ }
+ return backup.Name
+}
diff --git a/object/setting.go b/object/setting.go
index b2695cd..48e85b9 100644
--- a/object/setting.go
+++ b/object/setting.go
@@ -54,6 +54,13 @@ type Setting struct {
ModelsDevSyncMode string `xorm:"varchar(20)"
json:"modelsDevSyncMode"`
ModelsDevSyncIntervalHours int `xorm:"int"
json:"modelsDevSyncIntervalHours"`
+ // BackupMode is "auto" or "off", the interval is how often a snapshot
of the
+ // configuration is taken, and the retention is how many are kept.
+ BackupMode string `xorm:"varchar(20)" json:"backupMode"`
+ BackupIntervalHours int `xorm:"int" json:"backupIntervalHours"`
+ BackupRetention int `xorm:"int" json:"backupRetention"`
+ BackupDir string `xorm:"varchar(500)" json:"backupDir"`
+
AgentPatchStateDir string `xorm:"varchar(500)"
json:"agentPatchStateDir"`
AgentRecordCapacity int `xorm:"int" json:"agentRecordCapacity"`
AgentMonitorPollSeconds int `xorm:"int"
json:"agentMonitorPollSeconds"`
@@ -93,6 +100,11 @@ func SyncSettingToConf(setting *Setting) {
"modelsDevSyncMode": setting.ModelsDevSyncMode,
"modelsDevSyncIntervalHours":
strconv.Itoa(setting.ModelsDevSyncIntervalHours),
+ "backupMode": setting.BackupMode,
+ "backupIntervalHours":
strconv.Itoa(setting.BackupIntervalHours),
+ "backupRetention": strconv.Itoa(setting.BackupRetention),
+ "backupDir": setting.BackupDir,
+
"agentPatchStateDir": setting.AgentPatchStateDir,
"agentRecordCapacity":
strconv.Itoa(setting.AgentRecordCapacity),
"agentMonitorPollSeconds":
strconv.Itoa(setting.AgentMonitorPollSeconds),
@@ -214,6 +226,17 @@ func InitBuiltInSetting() {
}
}
+ if setting.BackupMode == "" {
+ setting.BackupMode = GetBackupMode()
+ setting.BackupIntervalHours = conf.GetBackupIntervalHours()
+ setting.BackupRetention = conf.GetBackupRetention()
+ setting.BackupDir = conf.GetBackupDir()
+ if _, err = ormer.Engine.ID(core.PK{setting.Owner,
setting.Name}).
+ Cols("backup_mode", "backup_interval_hours",
"backup_retention", "backup_dir").Update(setting); err != nil {
+ panic(err)
+ }
+ }
+
SyncSettingToConf(setting)
}
@@ -240,6 +263,11 @@ func newSettingFromConf() *Setting {
ModelsDevSyncMode: GetModelsDevSyncMode(),
ModelsDevSyncIntervalHours:
conf.GetModelsDevSyncIntervalHours(),
+ BackupMode: GetBackupMode(),
+ BackupIntervalHours: conf.GetBackupIntervalHours(),
+ BackupRetention: conf.GetBackupRetention(),
+ BackupDir: conf.GetBackupDir(),
+
AgentPatchStateDir: conf.GetAgentPatchStateDir(),
AgentRecordCapacity: conf.GetAgentRecordCapacity(),
AgentMonitorPollSeconds: conf.GetAgentMonitorPollSeconds(),
diff --git a/object/snapshot.go b/object/snapshot.go
new file mode 100644
index 0000000..4e7ff35
--- /dev/null
+++ b/object/snapshot.go
@@ -0,0 +1,699 @@
+// 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 (
+ "errors"
+ "fmt"
+ "sort"
+
+ "github.com/apache/casbin-gateway/util"
+ "github.com/apache/casbin-gateway/version"
+)
+
+// SnapshotVersion is the format of the document below. A snapshot written by a
+// later Gateway is refused rather than half-read.
+const SnapshotVersion = 1
+
+// The sections a snapshot is made of. They are named rather than positional
+// because a snapshot is often taken, and restored, one section at a time.
+const (
+ SectionProviders = "providers"
+ SectionAgents = "agents"
+ SectionProbeCases = "probeCases"
+ SectionLlmPrices = "llmPrices"
+ SectionSetting = "setting"
+)
+
+// How an import treats a row that is already there.
+const (
+ // ImportMerge leaves every existing row alone and adds the rest, which
is
+ // what merging two machines' configurations means.
+ ImportMerge = "merge"
+ // ImportOverwrite rewrites the rows the snapshot names and leaves the
rest.
+ ImportOverwrite = "overwrite"
+ // ImportReplace additionally deletes what the snapshot does not name,
so the
+ // sections it covers end up exactly as they were when it was taken.
This is
+ // what restoring a backup means.
+ ImportReplace = "replace"
+)
+
+// What one row of an import did, for the report the page shows before and
after.
+const (
+ ChangeAdd = "add"
+ ChangeUpdate = "update"
+ ChangeDelete = "delete"
+ ChangeSkip = "skip"
+ ChangeFail = "fail"
+)
+
+// SnapshotScope is which sections a snapshot carries, and whether the
+// credentials inside them come with it.
+type SnapshotScope struct {
+ Providers bool `json:"providers"`
+ Agents bool `json:"agents"`
+ ProbeCases bool `json:"probeCases"`
+ LlmPrices bool `json:"llmPrices"`
+ Setting bool `json:"setting"`
+ // Secrets carries the provider API keys and the secret half of the
settings.
+ // A snapshot without them describes a configuration that cannot yet
serve
+ // traffic: every key has to be typed again on the machine it lands on.
+ Secrets bool `json:"secrets"`
+}
+
+// FullScope is everything, which is what a backup takes: a backup that leaves
+// the keys out is not one anybody can restore from.
+func FullScope() SnapshotScope {
+ return SnapshotScope{Providers: true, Agents: true, ProbeCases: true,
LlmPrices: true, Setting: true, Secrets: true}
+}
+
+func (scope SnapshotScope) isEmpty() bool {
+ return !scope.Providers && !scope.Agents && !scope.ProbeCases &&
!scope.LlmPrices && !scope.Setting
+}
+
+// Snapshot is the whole document, and the file an export downloads. It holds
+// the configuration only: the records, the probe reports and the usage history
+// describe what happened on one machine rather than how it is set up, and they
+// are what makes a database too big to move around.
+type Snapshot struct {
+ Version int `json:"version"`
+ CreatedTime string `json:"createdTime"`
+ // Gateway and Host say where the snapshot came from, which is the first
+ // thing anybody restoring one wants to know.
+ Gateway string `json:"gateway"`
+ Host string `json:"host"`
+ // Reason is what took it: "manual", "schedule", or what an automatic
+ // snapshot was taken in front of.
+ Reason string `json:"reason"`
+ Scope SnapshotScope `json:"scope"`
+ Setting *Setting `json:"setting,omitempty"`
+
+ Providers []*Provider `json:"providers,omitempty"`
+ Agents []*Agent `json:"agents,omitempty"`
+ AgentInstances []*AgentInstance `json:"agentInstances,omitempty"`
+ ProbeCases []*ProbeCase `json:"probeCases,omitempty"`
+ LlmPrices []*LlmPriceEntry `json:"llmPrices,omitempty"`
+}
+
+// SnapshotCounts is how much a snapshot holds, which is what a listing shows
+// without reading the whole file back to the browser.
+type SnapshotCounts struct {
+ Providers int `json:"providers"`
+ Agents int `json:"agents"`
+ AgentInstances int `json:"agentInstances"`
+ ProbeCases int `json:"probeCases"`
+ LlmPrices int `json:"llmPrices"`
+ Setting int `json:"setting"`
+}
+
+func (snapshot *Snapshot) Counts() SnapshotCounts {
+ counts := SnapshotCounts{
+ Providers: len(snapshot.Providers),
+ Agents: len(snapshot.Agents),
+ AgentInstances: len(snapshot.AgentInstances),
+ ProbeCases: len(snapshot.ProbeCases),
+ LlmPrices: len(snapshot.LlmPrices),
+ }
+ if snapshot.Setting != nil {
+ counts.Setting = 1
+ }
+ return counts
+}
+
+// ImportChange is one row an import touched, or refused to.
+type ImportChange struct {
+ Section string `json:"section"`
+ Id string `json:"id"`
+ Action string `json:"action"`
+ // Detail is why a row was skipped or how it failed. Empty otherwise.
+ Detail string `json:"detail"`
+}
+
+// ImportReport is what an import did, and what a dry run says it would do. The
+// two are the same document so the confirmation and the result read alike.
+type ImportReport struct {
+ DryRun bool `json:"dryRun"`
+ Mode string `json:"mode"`
+ Added int `json:"added"`
+ Updated int `json:"updated"`
+ Deleted int `json:"deleted"`
+ Skipped int `json:"skipped"`
+ Failed int `json:"failed"`
+ Changes []*ImportChange `json:"changes"`
+ // Backup is the snapshot taken before anything was written, empty on a
dry
+ // run and when the backup itself could not be taken.
+ Backup string `json:"backup"`
+}
+
+func (report *ImportReport) record(section string, id string, action string,
detail string) {
+ report.Changes = append(report.Changes, &ImportChange{Section: section,
Id: id, Action: action, Detail: detail})
+ switch action {
+ case ChangeAdd:
+ report.Added++
+ case ChangeUpdate:
+ report.Updated++
+ case ChangeDelete:
+ report.Deleted++
+ case ChangeSkip:
+ report.Skipped++
+ case ChangeFail:
+ report.Failed++
+ }
+}
+
+// BuildSnapshot reads the configuration out of the database. Provider keys
come
+// back decrypted, so the snapshot is readable on a machine whose encryption
key
+// is a different one - or none at all.
+func BuildSnapshot(scope SnapshotScope, reason string) (*Snapshot, error) {
+ if scope.isEmpty() {
+ return nil, errors.New("a snapshot has to carry at least one
section")
+ }
+
+ snapshot := &Snapshot{
+ Version: SnapshotVersion,
+ CreatedTime: util.GetCurrentTime(),
+ Gateway: version.Current().Version,
+ Host: util.GetHostname(),
+ Reason: reason,
+ Scope: scope,
+ }
+
+ if scope.Providers {
+ providers, err := GetProviders("")
+ if err != nil {
+ return nil, err
+ }
+ snapshot.Providers = providers
+ }
+
+ if scope.Agents {
+ agents, err := GetAgents()
+ if err != nil {
+ return nil, err
+ }
+ for _, agent := range agents {
+ snapshot.Agents = append(snapshot.Agents, agent)
+ }
+ sort.Slice(snapshot.Agents, func(i, j int) bool { return
snapshot.Agents[i].Name < snapshot.Agents[j].Name })
+
+ instances, err := GetAgentInstances("")
+ if err != nil {
+ return nil, err
+ }
+ snapshot.AgentInstances = instances
+ }
+
+ if scope.ProbeCases {
+ cases, err := GetProbeCases()
+ if err != nil {
+ return nil, err
+ }
+ snapshot.ProbeCases = cases
+ }
+
+ if scope.LlmPrices {
+ prices, err := GetLlmPriceEntries()
+ if err != nil {
+ return nil, err
+ }
+ snapshot.LlmPrices = prices
+ }
+
+ if scope.Setting {
+ setting, err := GetBuiltInSetting()
+ if err != nil {
+ return nil, err
+ }
+ snapshot.Setting = setting
+ }
+
+ if !scope.Secrets {
+ redactSnapshot(snapshot)
+ }
+ return snapshot, nil
+}
+
+// redactSnapshot empties every credential in place. The rows are copies read
+// for this snapshot alone, so nothing stored is touched.
+func redactSnapshot(snapshot *Snapshot) {
+ for _, provider := range snapshot.Providers {
+ provider.ApiKey = ""
+ if provider.Quota != nil {
+ provider.Quota.Token = ""
+ }
+ }
+
+ if snapshot.Setting != nil {
+ snapshot.Setting.ApiKeyEncryptionKey = ""
+ snapshot.Setting.ClientSecret = ""
+ snapshot.Setting.RelayToken = ""
+ }
+}
+
+// ImportSnapshot writes a snapshot back. Only the sections the caller asks for
+// are touched, and only the ones the snapshot actually carries: importing the
+// providers out of a full backup leaves everything else where it is.
+//
+// A dry run walks the same path and decides the same things without writing,
+// which is what the page shows before anybody presses the button.
+func ImportSnapshot(snapshot *Snapshot, scope SnapshotScope, mode string,
dryRun bool) (*ImportReport, error) {
+ if snapshot == nil {
+ return nil, errors.New("there is no snapshot to import")
+ }
+ if snapshot.Version > SnapshotVersion {
+ return nil, fmt.Errorf("this snapshot was written by a later
Gateway (format %d, this one reads %d)", snapshot.Version, SnapshotVersion)
+ }
+ switch mode {
+ case ImportMerge, ImportOverwrite, ImportReplace:
+ default:
+ return nil, fmt.Errorf("the import mode must be %q, %q or %q",
ImportMerge, ImportOverwrite, ImportReplace)
+ }
+ if scope.isEmpty() {
+ return nil, errors.New("an import has to cover at least one
section")
+ }
+
+ report := &ImportReport{DryRun: dryRun, Mode: mode, Changes:
[]*ImportChange{}}
+
+ if scope.Providers && snapshot.Scope.Providers {
+ if err := importProviders(snapshot, mode, dryRun, report); err
!= nil {
+ return nil, err
+ }
+ }
+ // Agent routing names providers, so it is written after them: a
routing that
+ // arrives with its provider in the same snapshot resolves on the first
pass.
+ if scope.Agents && snapshot.Scope.Agents {
+ if err := importAgents(snapshot, mode, dryRun, report); err !=
nil {
+ return nil, err
+ }
+ }
+ if scope.ProbeCases && snapshot.Scope.ProbeCases {
+ if err := importProbeCases(snapshot, mode, dryRun, report); err
!= nil {
+ return nil, err
+ }
+ }
+ if scope.LlmPrices && snapshot.Scope.LlmPrices {
+ if err := importLlmPrices(snapshot, mode, dryRun, report); err
!= nil {
+ return nil, err
+ }
+ }
+ if scope.Setting && snapshot.Scope.Setting {
+ importSetting(snapshot, mode, dryRun, report)
+ }
+
+ return report, nil
+}
+
+func importProviders(snapshot *Snapshot, mode string, dryRun bool, report
*ImportReport) error {
+ stored, err := GetProviders("")
+ if err != nil {
+ return err
+ }
+
+ existing := map[string]*Provider{}
+ for _, provider := range stored {
+ existing[provider.GetId()] = provider
+ }
+
+ named := map[string]bool{}
+ for _, incoming := range snapshot.Providers {
+ provider := *incoming
+ id := provider.GetId()
+ named[id] = true
+
+ current, found := existing[id]
+ if found && mode == ImportMerge {
+ report.record(SectionProviders, id, ChangeSkip, "a
provider with this name is already here")
+ continue
+ }
+
+ if dryRun {
+ if found {
+ report.record(SectionProviders, id,
ChangeUpdate, "")
+ } else {
+ report.record(SectionProviders, id, ChangeAdd,
keylessDetail(&provider))
+ }
+ continue
+ }
+
+ if found {
+ // An empty key means the snapshot was taken without
secrets, which
+ // is "leave the key alone" rather than "clear it" -
the same thing
+ // the mask means when the form sends it back untouched.
+ if provider.ApiKey == "" && current.ApiKey != "" {
+ provider.ApiKey = ApiKeyMask
+ }
+ if provider.Quota != nil && provider.Quota.Token == "" {
+ provider.Quota.Token = ApiKeyMask
+ }
+ if _, err := UpdateProvider(id, &provider); err != nil {
+ report.record(SectionProviders, id, ChangeFail,
err.Error())
+ continue
+ }
+ report.record(SectionProviders, id, ChangeUpdate, "")
+ continue
+ }
+
+ detail := keylessDetail(&provider)
+ if _, err := AddProvider(&provider); err != nil {
+ report.record(SectionProviders, id, ChangeFail,
err.Error())
+ continue
+ }
+ report.record(SectionProviders, id, ChangeAdd, detail)
+ }
+
+ if mode != ImportReplace {
+ return nil
+ }
+
+ for _, provider := range stored {
+ id := provider.GetId()
+ if named[id] {
+ continue
+ }
+ if dryRun {
+ report.record(SectionProviders, id, ChangeDelete, "")
+ continue
+ }
+ if _, err := DeleteProvider(provider); err != nil {
+ report.record(SectionProviders, id, ChangeFail,
err.Error())
+ continue
+ }
+ report.record(SectionProviders, id, ChangeDelete, "")
+ }
+ return nil
+}
+
+// keylessDetail marks a provider that arrives with no key, which is the one
+// thing somebody has to finish by hand after importing a redacted snapshot.
+func keylessDetail(provider *Provider) string {
+ if provider.ApiKey == "" && provider.AuthMode != ProviderAuthClient {
+ return "the snapshot carries no API key for it"
+ }
+ return ""
+}
+
+func importAgents(snapshot *Snapshot, mode string, dryRun bool, report
*ImportReport) error {
+ stored, err := GetAgents()
+ if err != nil {
+ return err
+ }
+
+ named := map[string]bool{}
+ for _, agent := range snapshot.Agents {
+ named[agent.Name] = true
+ _, found := stored[agent.Name]
+ if found && mode == ImportMerge {
+ report.record(SectionAgents, agent.Name, ChangeSkip,
"this agent is already routed")
+ continue
+ }
+
+ action := ChangeAdd
+ if found {
+ action = ChangeUpdate
+ }
+ if dryRun {
+ report.record(SectionAgents, agent.Name, action, "")
+ continue
+ }
+
+ // A routing whose provider is not on this machine is refused by
+ // SetAgentRouting, which is what the report says rather than
the import
+ // failing over one agent.
+ if err := SetAgentRouting(agent.Name, agent.Provider,
agent.Fallbacks, agent.Mode); err != nil {
+ report.record(SectionAgents, agent.Name, ChangeFail,
err.Error())
+ continue
+ }
+ report.record(SectionAgents, agent.Name, action, "")
+ }
+
+ if err := importAgentInstances(snapshot, mode, dryRun, report); err !=
nil {
+ return err
+ }
+
+ if mode != ImportReplace {
+ return nil
+ }
+
+ for name := range stored {
+ if named[name] {
+ continue
+ }
+ if dryRun {
+ report.record(SectionAgents, name, ChangeDelete, "")
+ continue
+ }
+ if err := SetAgentRouting(name, "", nil, ""); err != nil {
+ report.record(SectionAgents, name, ChangeFail,
err.Error())
+ continue
+ }
+ report.record(SectionAgents, name, ChangeDelete, "")
+ }
+ return nil
+}
+
+// importAgentInstances restores the extra copies of an agent. An instance
names
+// a state directory on the machine it was created on, so one imported from
+// elsewhere is a row that only makes sense once that agent is installed here.
+func importAgentInstances(snapshot *Snapshot, mode string, dryRun bool, report
*ImportReport) error {
+ stored, err := GetAgentInstances("")
+ if err != nil {
+ return err
+ }
+
+ existing := map[string]*AgentInstance{}
+ for _, instance := range stored {
+ existing[instance.Name] = instance
+ }
+
+ named := map[string]bool{}
+ for _, incoming := range snapshot.AgentInstances {
+ instance := *incoming
+ named[instance.Name] = true
+
+ if _, found := existing[instance.Name]; found {
+ if mode == ImportMerge {
+ report.record(SectionAgents, instance.Name,
ChangeSkip, "this instance is already here")
+ continue
+ }
+ if dryRun {
+ report.record(SectionAgents, instance.Name,
ChangeUpdate, "")
+ continue
+ }
+ if err := SetAgentInstanceDisplayName(instance.Name,
instance.DisplayName); err != nil {
+ report.record(SectionAgents, instance.Name,
ChangeFail, err.Error())
+ continue
+ }
+ report.record(SectionAgents, instance.Name,
ChangeUpdate, "")
+ continue
+ }
+
+ if dryRun {
+ report.record(SectionAgents, instance.Name, ChangeAdd,
"")
+ continue
+ }
+ if err := AddAgentInstance(&instance); err != nil {
+ report.record(SectionAgents, instance.Name, ChangeFail,
err.Error())
+ continue
+ }
+ report.record(SectionAgents, instance.Name, ChangeAdd, "")
+ }
+
+ if mode != ImportReplace {
+ return nil
+ }
+
+ for _, instance := range stored {
+ if named[instance.Name] {
+ continue
+ }
+ if dryRun {
+ report.record(SectionAgents, instance.Name,
ChangeDelete, "")
+ continue
+ }
+ if err := DeleteAgentInstance(instance.Name); err != nil {
+ report.record(SectionAgents, instance.Name, ChangeFail,
err.Error())
+ continue
+ }
+ report.record(SectionAgents, instance.Name, ChangeDelete, "")
+ }
+ return nil
+}
+
+func importProbeCases(snapshot *Snapshot, mode string, dryRun bool, report
*ImportReport) error {
+ stored, err := GetProbeCases()
+ if err != nil {
+ return err
+ }
+
+ existing := map[string]*ProbeCase{}
+ for _, probeCase := range stored {
+ existing[probeCase.Name] = probeCase
+ }
+
+ named := map[string]bool{}
+ for _, incoming := range snapshot.ProbeCases {
+ probeCase := *incoming
+ named[probeCase.Name] = true
+
+ if _, found := existing[probeCase.Name]; found {
+ if mode == ImportMerge {
+ report.record(SectionProbeCases,
probeCase.Name, ChangeSkip, "this case is already here")
+ continue
+ }
+ if dryRun {
+ report.record(SectionProbeCases,
probeCase.Name, ChangeUpdate, "")
+ continue
+ }
+ if err := UpdateProbeCase(probeCase.Name, &probeCase);
err != nil {
+ report.record(SectionProbeCases,
probeCase.Name, ChangeFail, err.Error())
+ continue
+ }
+ report.record(SectionProbeCases, probeCase.Name,
ChangeUpdate, "")
+ continue
+ }
+
+ if dryRun {
+ report.record(SectionProbeCases, probeCase.Name,
ChangeAdd, "")
+ continue
+ }
+ if err := AddProbeCase(&probeCase); err != nil {
+ report.record(SectionProbeCases, probeCase.Name,
ChangeFail, err.Error())
+ continue
+ }
+ report.record(SectionProbeCases, probeCase.Name, ChangeAdd, "")
+ }
+
+ if mode != ImportReplace {
+ return nil
+ }
+
+ for _, probeCase := range stored {
+ if named[probeCase.Name] {
+ continue
+ }
+ if dryRun {
+ report.record(SectionProbeCases, probeCase.Name,
ChangeDelete, "")
+ continue
+ }
+ if err := DeleteProbeCase(probeCase.Name); err != nil {
+ report.record(SectionProbeCases, probeCase.Name,
ChangeFail, err.Error())
+ continue
+ }
+ report.record(SectionProbeCases, probeCase.Name, ChangeDelete,
"")
+ }
+ return nil
+}
+
+func importLlmPrices(snapshot *Snapshot, mode string, dryRun bool, report
*ImportReport) error {
+ stored, err := GetLlmPriceEntries()
+ if err != nil {
+ return err
+ }
+
+ existing := map[string]*LlmPriceEntry{}
+ for _, entry := range stored {
+ existing[entry.Model] = entry
+ }
+
+ named := map[string]bool{}
+ for _, incoming := range snapshot.LlmPrices {
+ entry := *incoming
+ named[entry.Model] = true
+
+ _, found := existing[entry.Model]
+ if found && mode == ImportMerge {
+ report.record(SectionLlmPrices, entry.Model,
ChangeSkip, "this model is already priced")
+ continue
+ }
+
+ action := ChangeAdd
+ if found {
+ action = ChangeUpdate
+ }
+ if dryRun {
+ report.record(SectionLlmPrices, entry.Model, action, "")
+ continue
+ }
+ if err := SetLlmPriceEntry(&entry); err != nil {
+ report.record(SectionLlmPrices, entry.Model,
ChangeFail, err.Error())
+ continue
+ }
+ report.record(SectionLlmPrices, entry.Model, action, "")
+ }
+
+ if mode != ImportReplace {
+ return nil
+ }
+
+ for _, entry := range stored {
+ if named[entry.Model] {
+ continue
+ }
+ if dryRun {
+ report.record(SectionLlmPrices, entry.Model,
ChangeDelete, "")
+ continue
+ }
+ if err := DeleteLlmPriceEntry(entry.Model); err != nil {
+ report.record(SectionLlmPrices, entry.Model,
ChangeFail, err.Error())
+ continue
+ }
+ report.record(SectionLlmPrices, entry.Model, ChangeDelete, "")
+ }
+ return nil
+}
+
+// importSetting writes the one settings row. Merge leaves it alone: there is
+// always a row, so merging a second one in would mean overwriting the first.
+func importSetting(snapshot *Snapshot, mode string, dryRun bool, report
*ImportReport) {
+ id := BuiltInSettingId
+ if mode == ImportMerge {
+ report.record(SectionSetting, id, ChangeSkip, "the settings of
this Gateway are already set")
+ return
+ }
+ if dryRun {
+ report.record(SectionSetting, id, ChangeUpdate, "")
+ return
+ }
+
+ current, err := GetBuiltInSetting()
+ if err != nil {
+ report.record(SectionSetting, id, ChangeFail, err.Error())
+ return
+ }
+ if current == nil {
+ report.record(SectionSetting, id, ChangeFail, "the built-in
setting does not exist")
+ return
+ }
+
+ setting := *snapshot.Setting
+ // A redacted snapshot leaves the secrets behind; the ones already here
are
+ // kept rather than cleared, which is what makes a keyless snapshot
safe to
+ // restore onto a working Gateway.
+ if setting.ApiKeyEncryptionKey == "" {
+ setting.ApiKeyEncryptionKey = current.ApiKeyEncryptionKey
+ }
+ if setting.ClientSecret == "" {
+ setting.ClientSecret = current.ClientSecret
+ }
+ if setting.RelayToken == "" {
+ setting.RelayToken = current.RelayToken
+ }
+
+ if _, err := UpdateSetting(id, &setting); err != nil {
+ report.record(SectionSetting, id, ChangeFail, err.Error())
+ return
+ }
+ report.record(SectionSetting, id, ChangeUpdate, "")
+}
diff --git a/routers/router.go b/routers/router.go
index f2b0ee8..f2498f6 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -44,6 +44,15 @@ func initAPI() {
beego.Router("/api/get-setting", &controllers.ApiController{},
"GET:GetSetting")
beego.Router("/api/update-setting", &controllers.ApiController{},
"POST:UpdateSetting")
+ beego.Router("/api/export-snapshot", &controllers.ApiController{},
"POST:ExportSnapshot")
+ beego.Router("/api/import-snapshot", &controllers.ApiController{},
"POST:ImportSnapshot")
+ beego.Router("/api/get-backup-state", &controllers.ApiController{},
"GET:GetBackupState")
+ beego.Router("/api/get-backup", &controllers.ApiController{},
"GET:GetBackup")
+ beego.Router("/api/create-backup", &controllers.ApiController{},
"POST:CreateBackup")
+ beego.Router("/api/restore-backup", &controllers.ApiController{},
"POST:RestoreBackup")
+ beego.Router("/api/delete-backup", &controllers.ApiController{},
"POST:DeleteBackup")
+ beego.Router("/api/update-backup-schedule",
&controllers.ApiController{}, "POST:UpdateBackupSchedule")
+
beego.Router("/api/get-agents", &controllers.ApiController{},
"GET:GetAgents")
beego.Router("/api/get-agent-catalog", &controllers.ApiController{},
"GET:GetAgentCatalog")
beego.Router("/api/get-agent-install-jobs",
&controllers.ApiController{}, "GET:GetAgentInstallJobs")
diff --git a/web/src/backend/BackupBackend.ts b/web/src/backend/BackupBackend.ts
new file mode 100644
index 0000000..c8d4470
--- /dev/null
+++ b/web/src/backend/BackupBackend.ts
@@ -0,0 +1,64 @@
+// 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 {query, request} from "@/backend/request";
+import type {BackupMode, BackupState, ImportMode, ImportReport, Snapshot,
SnapshotScope} from "@/types";
+
+/** The configuration as one document, which the page then saves as a file. */
+export function exportSnapshot(scope: SnapshotScope) {
+ return request<Snapshot>("/api/export-snapshot", "POST", scope);
+}
+
+/**
+ * Writes a snapshot back. A dry run decides everything the real one would and
+ * writes nothing, which is what the confirmation shows.
+ */
+export function importSnapshot(snapshot: Snapshot, scope: SnapshotScope, mode:
ImportMode, dryRun: boolean) {
+ return request<ImportReport>("/api/import-snapshot", "POST", {
+ snapshot: snapshot,
+ scope: scope,
+ mode: mode,
+ dryRun: dryRun,
+ });
+}
+
+/** The schedule automatic backups are on, and the files themselves. */
+export function getBackupState() {
+ return request<BackupState>("/api/get-backup-state");
+}
+
+/** One stored snapshot in full, for downloading it. */
+export function getBackup(name: string) {
+ return request<Snapshot>(`/api/get-backup${query({name: name})}`);
+}
+
+export function createBackup() {
+ return request<BackupState>("/api/create-backup", "POST");
+}
+
+export function restoreBackup(name: string, dryRun: boolean) {
+ return request<ImportReport>("/api/restore-backup", "POST", {name: name,
dryRun: dryRun});
+}
+
+export function deleteBackup(name: string) {
+ return request<BackupState>("/api/delete-backup", "POST", {name: name});
+}
+
+export function updateBackupSchedule(mode: BackupMode, intervalHours: number,
retention: number) {
+ return request<BackupState>("/api/update-backup-schedule", "POST", {
+ mode: mode,
+ intervalHours: intervalHours,
+ retention: retention,
+ });
+}
diff --git a/web/src/components/settings/backup-panel.tsx
b/web/src/components/settings/backup-panel.tsx
new file mode 100644
index 0000000..89a87e8
--- /dev/null
+++ b/web/src/components/settings/backup-panel.tsx
@@ -0,0 +1,361 @@
+// 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 {Archive, Download, History, Trash2} from "lucide-react";
+import i18next from "i18next";
+
+import * as BackupBackend from "@/backend/BackupBackend";
+import * as Setting from "@/Setting";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {CountsBadges, downloadSnapshot, ImportReportView} from
"@/components/settings/snapshot";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from
"@/components/ui/card";
+import {Switch} from "@/components/ui/switch";
+import type {Backup, BackupState, ImportReport} from "@/types";
+
+/** How often a backup is taken. The configuration only changes when somebody
+ * changes it, so even the shortest of these is often. */
+const INTERVALS = [6, 24, 168];
+const RETENTIONS = [5, 10, 20, 50];
+
+function intervalLabel(hours: number) {
+ if (hours === 24) {
+ return i18next.t("setting:Once a day");
+ }
+ if (hours === 168) {
+ return i18next.t("setting:Once a week");
+ }
+ return i18next.t("setting:Every {count} hours").replace("{count}",
String(hours));
+}
+
+/** The presets plus whatever is stored, so a value set in the configuration
+ * file rather than here still shows in the dropdown instead of leaving it
blank. */
+function withCurrent(presets: number[], current: number, label: (value:
number) => string) {
+ const values = presets.includes(current) ? presets : [...presets,
current].sort((a, b) => a - b);
+ return values.map(value => ({label: label(value), value: String(value)}));
+}
+
+function reasonLabel(reason: string) {
+ switch (reason) {
+ case "schedule":
+ return i18next.t("setting:On a schedule");
+ case "before-import":
+ return i18next.t("setting:Before an import");
+ default:
+ return i18next.t("setting:By hand");
+ }
+}
+
+function formatSize(bytes: number) {
+ if (bytes < 1024) {
+ return `${bytes} B`;
+ }
+ if (bytes < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(1)} KB`;
+ }
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+/** One file, with everything that decides whether it is the one to restore. */
+function BackupRow({
+ backup,
+ busy,
+ onDownload,
+ onRestore,
+ onDelete,
+ preview,
+}: {
+ backup: Backup;
+ busy: boolean;
+ onDownload: (backup: Backup) => void;
+ onRestore: (backup: Backup) => void | Promise<unknown>;
+ onDelete: (backup: Backup) => void | Promise<unknown>;
+ preview: (backup: Backup) => void;
+}) {
+ return (
+ <div className="flex flex-wrap items-start justify-between gap-3 border-b
px-3 py-2.5 last:border-b-0">
+ <div className="flex min-w-0 flex-col gap-1">
+ <div className="flex flex-wrap items-center gap-2">
+ <span className="font-mono text-xs">{backup.name}</span>
+ <Badge variant="muted" className="font-normal">
+ {reasonLabel(backup.reason)}
+ </Badge>
+ {backup.secrets ? null : (
+ <Badge variant="warning" className="font-normal">
+ {i18next.t("setting:No keys")}
+ </Badge>
+ )}
+ </div>
+ <div className="text-muted-foreground text-xs">
+ {Setting.getFormattedDate(backup.createdTime)} ·
{formatSize(backup.size)}
+ {backup.gateway === "" ? "" : ` · ${backup.gateway}`}
+ </div>
+ <CountsBadges counts={backup.counts} />
+ </div>
+
+ <div className="flex shrink-0 items-center gap-1.5">
+ <Button variant="outline" size="xs" disabled={busy} onClick={() =>
onDownload(backup)}>
+ <Download />
+ {i18next.t("setting:Download")}
+ </Button>
+ <ConfirmDialog
+ title={i18next.t("setting:Restore this backup?")}
+ description={i18next.t("setting:Restore warning")}
+ confirmText={i18next.t("setting:Restore")}
+ onConfirm={() => onRestore(backup)}
+ onOpenChange={open => {
+ if (open) {
+ preview(backup);
+ }
+ }}
+ disabled={busy}
+ >
+ <Button variant="outline" size="xs" disabled={busy}>
+ <History />
+ {i18next.t("setting:Restore")}
+ </Button>
+ </ConfirmDialog>
+ <ConfirmDialog
+ title={i18next.t("setting:Delete this backup?")}
+ confirmText={i18next.t("general:Delete")}
+ onConfirm={() => onDelete(backup)}
+ disabled={busy}
+ >
+ <Button variant="ghost" size="icon-xs" disabled={busy}
aria-label={i18next.t("general:Delete")}>
+ <Trash2 />
+ </Button>
+ </ConfirmDialog>
+ </div>
+ </div>
+ );
+}
+
+/**
+ * The snapshots of the configuration Gateway keeps on this machine: whether it
+ * takes them on its own, how many it keeps, and the files themselves. A backup
+ * is also taken in front of every import, so the restore below is what undoes
+ * one that turned out to be the wrong file.
+ */
+export function BackupPanel({onSettingChanged}: {onSettingChanged: () =>
void}) {
+ const [state, setState] = React.useState<BackupState | null>(null);
+ const [busy, setBusy] = React.useState(false);
+ const [report, setReport] = React.useState<ImportReport | null>(null);
+
+ const load = React.useCallback(() => {
+ BackupBackend.getBackupState()
+ .then(res => {
+ if (res.status === "ok") {
+ setState(res.data);
+ }
+ })
+ .catch(() => undefined);
+ }, []);
+
+ React.useEffect(load, [load]);
+
+ if (state === null) {
+ return null;
+ }
+
+ const auto = state.mode === "auto";
+
+ // Every one of these answers with the state, so the list and the schedule
are
+ // never a reload behind what was just done.
+ const apply = (promise: Promise<{status: string; msg: string; data?:
BackupState}>) => {
+ setBusy(true);
+ return promise
+ .then(res => {
+ if (res.status === "ok" && res.data) {
+ setState(res.data);
+ // The schedule is stored on the same row the form above edits, so
the
+ // form is told rather than left holding what it read on load.
+ onSettingChanged();
+ } else {
+ Setting.showMessage("error", `${i18next.t("general:Failed to
save")}: ${res.msg}`);
+ }
+ })
+ .catch(failure => Setting.showMessage("error", failure.message ||
String(failure)))
+ .then(() => setBusy(false));
+ };
+
+ const schedule = (mode: BackupState["mode"], intervalHours: number,
retention: number) =>
+ apply(BackupBackend.updateBackupSchedule(mode, intervalHours, retention));
+
+ const backUpNow = () => {
+ setReport(null);
+ return apply(BackupBackend.createBackup());
+ };
+
+ const download = (backup: Backup) => {
+ BackupBackend.getBackup(backup.name)
+ .then(res => {
+ if (res.status === "ok" && res.data) {
+ downloadSnapshot(res.data, backup.name);
+ } else {
+ Setting.showMessage("error", `${i18next.t("general:Failed to get
data")}: ${res.msg}`);
+ }
+ })
+ .catch(failure => Setting.showMessage("error", failure.message ||
String(failure)));
+ };
+
+ // Asked as the confirmation opens, so what the dialog is agreeing to is on
+ // the page behind it rather than described in the abstract.
+ const previewRestore = (backup: Backup) => {
+ setReport(null);
+ BackupBackend.restoreBackup(backup.name, true)
+ .then(res => {
+ if (res.status === "ok" && res.data) {
+ setReport(res.data);
+ }
+ })
+ .catch(() => undefined);
+ };
+
+ const restore = (backup: Backup) => {
+ setBusy(true);
+ return BackupBackend.restoreBackup(backup.name, false)
+ .then(res => {
+ if (res.status === "ok" && res.data) {
+ setReport(res.data);
+ Setting.showMessage("success", i18next.t("setting:The backup was
restored"));
+ onSettingChanged();
+ load();
+ } else {
+ Setting.showMessage("error", `${i18next.t("general:Failed to
save")}: ${res.msg}`);
+ }
+ })
+ .catch(failure => Setting.showMessage("error", failure.message ||
String(failure)))
+ .then(() => setBusy(false));
+ };
+
+ const remove = (backup: Backup) =>
apply(BackupBackend.deleteBackup(backup.name));
+
+ const fact = (label: string, value: React.ReactNode) => (
+ <div className="flex flex-col gap-0.5">
+ <span className="text-muted-foreground text-xs">{i18next.t(label)}</span>
+ <span className="text-sm">{value}</span>
+ </div>
+ );
+
+ return (
+ <Card className="gap-4 py-5">
+ <CardHeader className="px-5">
+ <div className="flex flex-wrap items-start justify-between gap-3">
+ <div className="min-w-0">
+ <CardTitle
className="text-[15px]">{i18next.t("setting:Backups")}</CardTitle>
+ <CardDescription>{i18next.t("setting:Backups
description")}</CardDescription>
+ </div>
+ <div className="flex shrink-0 items-center gap-2">
+ <span className="text-muted-foreground text-xs">
+ {i18next.t(auto ? "setting:Backups are on" : "setting:Backups
are off")}
+ </span>
+ <Switch
+ checked={auto}
+ disabled={busy}
+ aria-label={i18next.t("setting:Backups")}
+ onCheckedChange={checked =>
+ schedule(checked ? "auto" : "off", state.intervalHours,
state.retention)
+ }
+ />
+ </div>
+ </div>
+ </CardHeader>
+
+ <CardContent className="flex flex-col gap-4 px-5">
+ <div className="grid gap-3 sm:grid-cols-4">
+ {fact(
+ "setting:Last backup",
+ state.takenTime === "" ? i18next.t("setting:Never") :
Setting.getFormattedDate(state.takenTime),
+ )}
+ {fact(
+ "setting:Next backup",
+ auto
+ ? state.nextTime === ""
+ ? i18next.t("setting:Due now")
+ : Setting.getFormattedDate(state.nextTime)
+ : i18next.t("setting:Nothing is scheduled"),
+ )}
+ <div className="flex flex-col gap-1">
+ <span className="text-muted-foreground
text-xs">{i18next.t("setting:How often")}</span>
+ <SimpleSelect
+ size="sm"
+ disabled={!auto || busy}
+ value={String(state.intervalHours)}
+ onChange={value => schedule(state.mode, Number(value),
state.retention)}
+ options={withCurrent(INTERVALS, state.intervalHours,
intervalLabel)}
+ />
+ </div>
+ <div className="flex flex-col gap-1">
+ <span className="text-muted-foreground
text-xs">{i18next.t("setting:How many to keep")}</span>
+ <SimpleSelect
+ size="sm"
+ disabled={busy}
+ value={String(state.retention)}
+ onChange={value => schedule(state.mode, state.intervalHours,
Number(value))}
+ options={withCurrent(RETENTIONS, state.retention, String)}
+ />
+ </div>
+ </div>
+
+ <div className="text-muted-foreground text-xs">
+ {i18next.t("setting:Backups live in {dir}").replace("{dir}",
state.dir)}
+ </div>
+
+ {state.error === "" ? null : (
+ <MessageAlert title={i18next.t("setting:The last backup failed")}
description={state.error} />
+ )}
+
+ {report === null ? null : (
+ <div className="flex flex-col gap-2">
+ <span className="text-muted-foreground text-xs">
+ {i18next.t(report.dryRun ? "setting:What this restore would do"
: "setting:What this restore did")}
+ </span>
+ <ImportReportView report={report} />
+ </div>
+ )}
+
+ <div className="rounded-md border">
+ {state.backups.length === 0 ? (
+ <div className="text-muted-foreground px-3 py-6 text-center
text-sm">
+ {i18next.t("setting:No backups yet")}
+ </div>
+ ) : (
+ state.backups.map(backup => (
+ <BackupRow
+ key={backup.name}
+ backup={backup}
+ busy={busy}
+ onDownload={download}
+ onRestore={restore}
+ onDelete={remove}
+ preview={previewRestore}
+ />
+ ))
+ )}
+ </div>
+
+ <div>
+ <Button variant="outline" onClick={backUpNow} loading={busy}>
+ <Archive />
+ {i18next.t("setting:Back up now")}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/web/src/components/settings/import-export-panel.tsx
b/web/src/components/settings/import-export-panel.tsx
new file mode 100644
index 0000000..28ea463
--- /dev/null
+++ b/web/src/components/settings/import-export-panel.tsx
@@ -0,0 +1,330 @@
+// 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 {Download, Upload, X} from "lucide-react";
+import i18next from "i18next";
+
+import * as BackupBackend from "@/backend/BackupBackend";
+import * as Setting from "@/Setting";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {
+ CountsBadges,
+ countsOf,
+ downloadSnapshot,
+ FULL_SCOPE,
+ ImportReportView,
+ isScopeEmpty,
+ ScopePicker,
+} from "@/components/settings/snapshot";
+import {MessageAlert} from "@/components/ui/alert";
+import {Button} from "@/components/ui/button";
+import {Card, CardContent, CardDescription, CardHeader, CardTitle} from
"@/components/ui/card";
+import type {ImportMode, ImportReport, Snapshot, SnapshotScope} from "@/types";
+
+/** The file name an export downloads under: the host it came from and the
day. */
+function exportFileName(snapshot: Snapshot) {
+ const day = (snapshot.createdTime ?? "").slice(0, 10).replace(/-/g, "") ||
"export";
+ const host = (snapshot.host ?? "gateway").replace(/[^A-Za-z0-9_-]/g, "-");
+ return `casbin-gateway-${host}-${day}.json`;
+}
+
+/** A file is only a snapshot if it says which version and which sections it
is. */
+function readSnapshot(text: string) {
+ const parsed = JSON.parse(text) as Snapshot;
+ if (typeof parsed?.version !== "number" || parsed?.scope === undefined) {
+ throw new Error(i18next.t("setting:This file is not a Gateway snapshot"));
+ }
+ return parsed;
+}
+
+/** The sections a file actually carries, so nothing else can be asked for. */
+function scopeOfFile(snapshot: Snapshot): SnapshotScope {
+ return {...snapshot.scope, secrets: snapshot.scope.secrets};
+}
+
+export function ImportExportPanel({onSettingChanged}: {onSettingChanged: () =>
void}) {
+ const [exportScope, setExportScope] =
React.useState<SnapshotScope>(FULL_SCOPE);
+ const [exporting, setExporting] = React.useState(false);
+
+ const [fileName, setFileName] = React.useState("");
+ const [snapshot, setSnapshot] = React.useState<Snapshot | null>(null);
+ const [importScope, setImportScope] =
React.useState<SnapshotScope>(FULL_SCOPE);
+ const [mode, setMode] = React.useState<ImportMode>("merge");
+ const [preview, setPreview] = React.useState<ImportReport | null>(null);
+ const [result, setResult] = React.useState<ImportReport | null>(null);
+ const [error, setError] = React.useState("");
+ const [importing, setImporting] = React.useState(false);
+ const fileInput = React.useRef<HTMLInputElement>(null);
+
+ const runExport = () => {
+ setExporting(true);
+ BackupBackend.exportSnapshot(exportScope)
+ .then(res => {
+ if (res.status === "ok" && res.data) {
+ downloadSnapshot(res.data, exportFileName(res.data));
+ Setting.showMessage("success", i18next.t("setting:The snapshot was
saved"));
+ } else {
+ Setting.showMessage("error", `${i18next.t("general:Failed to get
data")}: ${res.msg}`);
+ }
+ })
+ .catch(failure => Setting.showMessage("error", failure.message ||
String(failure)))
+ .then(() => setExporting(false));
+ };
+
+ const clearFile = () => {
+ setFileName("");
+ setSnapshot(null);
+ setPreview(null);
+ setResult(null);
+ setError("");
+ if (fileInput.current !== null) {
+ fileInput.current.value = "";
+ }
+ };
+
+ const pickFile = (file: File | undefined) => {
+ if (file === undefined) {
+ return;
+ }
+ setResult(null);
+ setError("");
+ file
+ .text()
+ .then(text => {
+ const parsed = readSnapshot(text);
+ setSnapshot(parsed);
+ setFileName(file.name);
+ setImportScope(scopeOfFile(parsed));
+ setMode("merge");
+ })
+ .catch(failure => {
+ clearFile();
+ setError(failure.message || String(failure));
+ });
+ };
+
+ // The preview is the same walk the import makes, so what the page shows is
+ // what will happen rather than a guess at it.
+ React.useEffect(() => {
+ if (snapshot === null || isScopeEmpty(importScope)) {
+ setPreview(null);
+ return;
+ }
+
+ let current = true;
+ BackupBackend.importSnapshot(snapshot, importScope, mode, true)
+ .then(res => {
+ if (!current) {
+ return;
+ }
+ if (res.status === "ok" && res.data) {
+ setPreview(res.data);
+ setError("");
+ } else {
+ setPreview(null);
+ setError(res.msg);
+ }
+ })
+ .catch(failure => {
+ if (current) {
+ setPreview(null);
+ setError(failure.message || String(failure));
+ }
+ });
+ return () => {
+ current = false;
+ };
+ }, [snapshot, importScope, mode]);
+
+ const runImport = () => {
+ if (snapshot === null) {
+ return Promise.resolve();
+ }
+ setImporting(true);
+ return BackupBackend.importSnapshot(snapshot, importScope, mode, false)
+ .then(res => {
+ if (res.status === "ok" && res.data) {
+ setResult(res.data);
+ Setting.showMessage("success", i18next.t("setting:The snapshot was
imported"));
+ onSettingChanged();
+ } else {
+ Setting.showMessage("error", `${i18next.t("general:Failed to
save")}: ${res.msg}`);
+ }
+ })
+ .catch(failure => Setting.showMessage("error", failure.message ||
String(failure)))
+ .then(() => setImporting(false));
+ };
+
+ const counts = countsOf(snapshot);
+ const deletes = mode === "replace" && (preview?.deleted ?? 0) > 0;
+
+ return (
+ <Card className="gap-4 py-5">
+ <CardHeader className="px-5">
+ <CardTitle className="text-[15px]">{i18next.t("setting:Import and
export")}</CardTitle>
+ <CardDescription>{i18next.t("setting:Import and export
description")}</CardDescription>
+ </CardHeader>
+
+ <CardContent className="grid gap-6 px-5 md:grid-cols-2">
+ <div className="flex flex-col gap-3">
+ <div className="text-sm
font-medium">{i18next.t("setting:Export")}</div>
+ <ScopePicker
+ scope={exportScope}
+ onChange={setExportScope}
+ disabled={exporting}
+ secretsLabel={i18next.t("setting:Include the API keys")}
+ secretsHint={i18next.t("setting:Include the API keys hint")}
+ />
+ {exportScope.secrets ? (
+ <MessageAlert
+ variant="warning"
+ title={i18next.t("setting:The file carries every key")}
+ description={i18next.t("setting:The file carries every key
hint")}
+ />
+ ) : null}
+ <div>
+ <Button onClick={runExport} loading={exporting}
disabled={isScopeEmpty(exportScope)}>
+ <Download />
+ {i18next.t("setting:Export to a file")}
+ </Button>
+ </div>
+ </div>
+
+ <div className="flex flex-col gap-3">
+ <div className="text-sm
font-medium">{i18next.t("setting:Import")}</div>
+
+ <input
+ ref={fileInput}
+ type="file"
+ accept="application/json,.json"
+ className="hidden"
+ onChange={event => pickFile(event.target.files?.[0])}
+ />
+
+ {snapshot === null ? (
+ <>
+ <p className="text-muted-foreground
text-sm">{i18next.t("setting:Import hint")}</p>
+ <div>
+ <Button variant="outline" onClick={() =>
fileInput.current?.click()}>
+ <Upload />
+ {i18next.t("setting:Choose a file")}
+ </Button>
+ </div>
+ </>
+ ) : (
+ <>
+ <div className="flex flex-col gap-2 rounded-md border p-3">
+ <div className="flex items-start justify-between gap-2">
+ <div className="min-w-0">
+ <div className="truncate font-mono
text-xs">{fileName}</div>
+ <div className="text-muted-foreground text-xs">
+ {i18next
+ .t("setting:Taken on {host} at {time}")
+ .replace("{host}", snapshot.host || "?")
+ .replace("{time}",
Setting.getFormattedDate(snapshot.createdTime) ?? "?")}
+ </div>
+ </div>
+ <Button variant="ghost" size="icon-xs" onClick={clearFile}
aria-label={i18next.t("general:Cancel")}>
+ <X />
+ </Button>
+ </div>
+ <CountsBadges counts={counts} />
+ {snapshot.scope.secrets ? null : (
+ <span className="text-muted-foreground text-xs">
+ {i18next.t("setting:This file carries no keys")}
+ </span>
+ )}
+ </div>
+
+ <ScopePicker
+ scope={importScope}
+ onChange={setImportScope}
+ available={counts}
+ disabled={importing}
+ secretsLabel={i18next.t("setting:Include the API keys")}
+ secretsHint={i18next.t("setting:Keys are taken from the file")}
+ />
+
+ <div className="grid gap-1.5">
+ <span className="text-muted-foreground
text-xs">{i18next.t("setting:What to do with what is already here")}</span>
+ <SimpleSelect
+ size="sm"
+ value={mode}
+ disabled={importing}
+ onChange={value => setMode(value as ImportMode)}
+ options={[
+ {label: i18next.t("setting:Keep what is here"), value:
"merge"},
+ {label: i18next.t("setting:Overwrite what the file
names"), value: "overwrite"},
+ {label: i18next.t("setting:Replace it entirely"), value:
"replace"},
+ ]}
+ />
+ </div>
+
+ {error === "" ? null : <MessageAlert title={error} />}
+
+ {result === null && preview !== null ? (
+ <div className="flex flex-col gap-2">
+ <span className="text-muted-foreground
text-xs">{i18next.t("setting:What this import would do")}</span>
+ <ImportReportView report={preview} />
+ </div>
+ ) : null}
+
+ {result === null ? null : (
+ <div className="flex flex-col gap-2">
+ <MessageAlert
+ variant="success"
+ title={i18next.t("setting:The snapshot was imported")}
+ description={
+ result.backup === ""
+ ? undefined
+ : i18next.t("setting:A backup was taken
first").replace("{name}", result.backup)
+ }
+ />
+ <ImportReportView report={result} />
+ </div>
+ )}
+
+ <div className="flex gap-2">
+ <ConfirmDialog
+ title={i18next.t("setting:Import this snapshot?")}
+ description={
+ deletes
+ ? i18next
+ .t("setting:Replace warning")
+ .replace("{count}", String(preview?.deleted ?? 0))
+ : i18next.t("setting:Import warning")
+ }
+ confirmText={i18next.t("setting:Import")}
+ variant={deletes ? "destructive" : "default"}
+ onConfirm={runImport}
+ disabled={importing || preview === null}
+ >
+ <Button loading={importing} disabled={preview === null}>
+ <Upload />
+ {i18next.t("setting:Import")}
+ </Button>
+ </ConfirmDialog>
+ <Button variant="outline" onClick={() =>
fileInput.current?.click()} disabled={importing}>
+ {i18next.t("setting:Choose another file")}
+ </Button>
+ </div>
+ </>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/web/src/components/settings/snapshot.tsx
b/web/src/components/settings/snapshot.tsx
new file mode 100644
index 0000000..9cae4ed
--- /dev/null
+++ b/web/src/components/settings/snapshot.tsx
@@ -0,0 +1,239 @@
+// 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 {Badge, type BadgeVariant} from "@/components/ui/badge";
+import {Switch} from "@/components/ui/switch";
+import type {ImportChange, ImportReport, Snapshot, SnapshotCounts,
SnapshotScope} from "@/types";
+
+/** The sections a snapshot is made of, in the order they are worth reading. */
+export const SNAPSHOT_SECTIONS = [
+ {key: "providers", label: "setting:Providers"},
+ {key: "agents", label: "setting:Agent routing"},
+ {key: "probeCases", label: "setting:Probe cases"},
+ {key: "llmPrices", label: "setting:Model prices"},
+ {key: "setting", label: "setting:Settings"},
+] as const;
+
+export type SnapshotSection = (typeof SNAPSHOT_SECTIONS)[number]["key"];
+
+export const FULL_SCOPE: SnapshotScope = {
+ providers: true,
+ agents: true,
+ probeCases: true,
+ llmPrices: true,
+ setting: true,
+ secrets: true,
+};
+
+export function sectionLabel(key: string) {
+ const section = SNAPSHOT_SECTIONS.find(entry => entry.key === key);
+ return section ? i18next.t(section.label) : key;
+}
+
+/** Whether a scope would carry anything at all. */
+export function isScopeEmpty(scope: SnapshotScope) {
+ return SNAPSHOT_SECTIONS.every(section => !scope[section.key]);
+}
+
+/**
+ * The section toggles both the export and the import are driven by. The import
+ * passes `available` so a section the file does not carry is shown as off and
+ * cannot be turned on: what is not in the file cannot be imported from it.
+ */
+export function ScopePicker({
+ scope,
+ onChange,
+ available,
+ disabled = false,
+ secretsLabel,
+ secretsHint,
+}: {
+ scope: SnapshotScope;
+ onChange: (scope: SnapshotScope) => void;
+ available?: SnapshotCounts;
+ disabled?: boolean;
+ secretsLabel: string;
+ secretsHint: string;
+}) {
+ const countOf = (key: SnapshotSection) => {
+ if (available === undefined) {
+ return undefined;
+ }
+ return key === "agents" ? available.agents + available.agentInstances :
available[key];
+ };
+
+ const row = (
+ key: SnapshotSection | "secrets",
+ label: React.ReactNode,
+ hint: React.ReactNode,
+ off: boolean,
+ ) => (
+ <div key={key} className="flex items-start justify-between gap-3 py-2">
+ <div className="min-w-0">
+ <div className="text-sm">{label}</div>
+ {hint ? <div className="text-muted-foreground text-xs">{hint}</div> :
null}
+ </div>
+ <Switch
+ className="mt-0.5 shrink-0"
+ checked={scope[key] && !off}
+ disabled={disabled || off}
+ aria-label={typeof label === "string" ? label : key}
+ onCheckedChange={checked => onChange({...scope, [key]: checked})}
+ />
+ </div>
+ );
+
+ return (
+ <div className="divide-y">
+ {SNAPSHOT_SECTIONS.map(section => {
+ const count = countOf(section.key);
+ const off = count === 0;
+ return row(
+ section.key,
+ i18next.t(section.label),
+ count === undefined
+ ? null
+ : off
+ ? i18next.t("setting:Not in this file")
+ : i18next.t("setting:{count} in this file").replace("{count}",
String(count)),
+ off,
+ );
+ })}
+ {row("secrets", secretsLabel, secretsHint, false)}
+ </div>
+ );
+}
+
+/** How much a snapshot file holds, counted in the browser: a file that arrived
+ * from somewhere else has no counts of its own. */
+export function countsOf(snapshot: Snapshot | null): SnapshotCounts {
+ const length = (section: string) => (snapshot?.[section] as unknown[] |
undefined)?.length ?? 0;
+ return {
+ providers: length("providers"),
+ agents: length("agents"),
+ agentInstances: length("agentInstances"),
+ probeCases: length("probeCases"),
+ llmPrices: length("llmPrices"),
+ setting: snapshot?.setting === undefined || snapshot.setting === null ? 0
: 1,
+ };
+}
+
+/** What one snapshot holds, as the row of badges a listing has room for. */
+export function CountsBadges({counts}: {counts: SnapshotCounts}) {
+ const entries = SNAPSHOT_SECTIONS.map(section => ({
+ key: section.key,
+ label: i18next.t(section.label),
+ count: section.key === "agents" ? counts.agents + counts.agentInstances :
counts[section.key],
+ })).filter(entry => entry.count > 0);
+
+ if (entries.length === 0) {
+ return <span className="text-muted-foreground
text-xs">{i18next.t("setting:Nothing in it")}</span>;
+ }
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {entries.map(entry => (
+ <Badge key={entry.key} variant="muted" className="font-normal">
+ {entry.label} {entry.count}
+ </Badge>
+ ))}
+ </div>
+ );
+}
+
+const ACTION_TONES: Record<ImportChange["action"], BadgeVariant> = {
+ add: "success",
+ update: "info",
+ delete: "warning",
+ skip: "muted",
+ fail: "danger",
+};
+
+function actionLabel(action: ImportChange["action"]) {
+ switch (action) {
+ case "add":
+ return i18next.t("setting:Added");
+ case "update":
+ return i18next.t("setting:Updated");
+ case "delete":
+ return i18next.t("setting:Deleted");
+ case "skip":
+ return i18next.t("setting:Skipped");
+ default:
+ return i18next.t("setting:Failed");
+ }
+}
+
+/**
+ * What an import did, or what a dry run says it would do. The counts are the
+ * summary; the rows underneath are what makes a "replace" reviewable, since
+ * that is the mode that deletes.
+ */
+export function ImportReportView({report}: {report: ImportReport}) {
+ const totals: {action: ImportChange["action"]; count: number}[] = [
+ {action: "add", count: report.added},
+ {action: "update", count: report.updated},
+ {action: "delete", count: report.deleted},
+ {action: "skip", count: report.skipped},
+ {action: "fail", count: report.failed},
+ ];
+
+ return (
+ <div className="flex flex-col gap-2">
+ <div className="flex flex-wrap gap-1.5">
+ {totals
+ .filter(total => total.count > 0)
+ .map(total => (
+ <Badge key={total.action} variant={ACTION_TONES[total.action]}
className="font-normal">
+ {actionLabel(total.action)} {total.count}
+ </Badge>
+ ))}
+ {report.changes.length === 0 ? (
+ <span className="text-muted-foreground
text-xs">{i18next.t("setting:Nothing would change")}</span>
+ ) : null}
+ </div>
+
+ {report.changes.length === 0 ? null : (
+ <div className="max-h-56 overflow-y-auto rounded-md border">
+ {report.changes.map((change, index) => (
+ <div
+ key={`${change.section}-${change.id}-${index}`}
+ className="flex items-start gap-2 border-b px-3 py-1.5 text-xs
last:border-b-0"
+ >
+ <Badge variant={ACTION_TONES[change.action]} className="shrink-0
font-normal">
+ {actionLabel(change.action)}
+ </Badge>
+ <span className="text-muted-foreground
shrink-0">{sectionLabel(change.section)}</span>
+ <span className="min-w-0 flex-1 truncate
font-mono">{change.id}</span>
+ {change.detail ? <span className="text-muted-foreground min-w-0
flex-1">{change.detail}</span> : null}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+/** Saves a snapshot as a file, which is the whole of what "export" means
here. */
+export function downloadSnapshot(snapshot: Snapshot, name: string) {
+ const url = URL.createObjectURL(new Blob([JSON.stringify(snapshot, null,
2)], {type: "application/json"}));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = name;
+ link.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 657f3cd..a5cf468 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -856,45 +856,115 @@
"Used path": "Used path"
},
"setting": {
+ "A backup was taken first": "A backup was taken first, as {name}.",
"API key encryption key": "API key encryption key",
"API key encryption key hint": "Encrypts the provider API keys at rest,
any length. Back it up with the database: losing it makes already-encrypted
keys unreadable.",
+ "Added": "Added",
"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 routing": "Agent routing",
"Agent state dir": "Agent state directory",
"Agent state dir hint": "Patch manifests, config backups, ingest tokens
and file-tail cursors.",
"Agents": "Agents",
+ "Back up now": "Back up now",
+ "Backups": "Backups",
+ "Backups are off": "Backups are off",
+ "Backups are on": "Backups are on",
+ "Backups description": "A snapshot of the configuration, written beside
the database rather than inside it, so it survives the database it was taken
from. One is also taken in front of every import.",
+ "Backups live in {dir}": "Backups live in {dir}",
+ "Before an import": "Before an import",
+ "By hand": "By hand",
"Casdoor application": "Casdoor application",
"Casdoor endpoint": "Casdoor endpoint",
"Casdoor organization": "Casdoor organization",
"Channel probes": "Channel probes",
"Channel probes description": "A probe sends a few short requests to a
provider's upstream and checks the answers against what the vendor's API
documents: the model name reported, the vendor headers carried, the shape of
the event stream, whether prompt caching is really accounted for, and whether
two identical requests are billed the same. What it asks is the suite on the
Authenticity page, where every case can be reweighted, turned off or rewritten.
It spends a few cents of that pr [...]
+ "Choose a file": "Choose a file",
+ "Choose another file": "Choose another file",
"Client ID": "Client ID",
"Client secret": "Client secret",
+ "Delete this backup?": "Delete this backup?",
+ "Deleted": "Deleted",
+ "Download": "Download",
+ "Due now": "Due now",
+ "Every {count} hours": "Every {count} hours",
+ "Export": "Export",
+ "Export to a file": "Export to a file",
+ "Failed": "Failed",
+ "How many to keep": "How many to keep",
+ "How often": "How often",
+ "Import": "Import",
+ "Import and export": "Import and export",
+ "Import and export description": "The configuration as one file: what to
move to another machine, and what to read back from one.",
+ "Import hint": "Choose a file exported from Gateway, or a backup
downloaded from the list above.",
+ "Import this snapshot?": "Import this snapshot?",
+ "Import warning": "A backup of what is here now is taken first, so this
can be undone from the list above.",
+ "Include the API keys": "Include the API keys",
+ "Include the API keys hint": "Without them the file describes the
configuration but cannot serve traffic.",
+ "Keep what is here": "Keep what is here",
+ "Keys are taken from the file": "A provider the file has no key for keeps
the key it already has here.",
"LLM records": "LLM records",
"LLM records description": "Prompts can carry sensitive data, so anything
beyond metadata is opt-in.",
+ "Last backup": "Last backup",
"Max payload bytes": "Max stored body, in bytes",
"Max records": "Max records",
+ "Model prices": "Model prices",
"Network": "Network",
+ "Never": "Never",
+ "Next backup": "Next backup",
+ "No backups yet": "No backups yet",
+ "No keys": "No keys",
+ "Not in this file": "Not in this file",
+ "Nothing in it": "Nothing in it",
+ "Nothing is scheduled": "Nothing is scheduled",
+ "Nothing would change": "Nothing would change",
+ "On a schedule": "On a schedule",
+ "Once a day": "Once a day",
+ "Once a week": "Once a week",
"Outbound SOCKS5 proxy": "Outbound SOCKS5 proxy",
"Outbound SOCKS5 proxy hint": "\"host:port\", used for outbound traffic
such as update downloads.",
+ "Overwrite what the file names": "Overwrite what the file names",
"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.",
+ "Probe cases": "Probe cases",
"Probe interval hours": "Re-probe after",
"Probe interval hours hint": "How long an authenticity report stays
current. A provider whose report is older than this is probed again, which is
what notices a backend that was swapped since.",
"Probe mode": "When to probe",
"Probe mode hint": "Automatic probing runs when a provider is added, when
its endpoint, type or key changes, and once for any provider that has never
been probed.",
+ "Providers": "Providers",
"Queue capacity": "Write queue capacity",
"Queue capacity hint": "A full queue drops records rather than slowing the
proxy down.",
"Record mode": "Record mode",
"Relay token": "Relay token",
"Relay token hint": "What an agent sends to /v1. Requests from this
machine never need it. Clear the field and save to issue a new one.",
+ "Replace it entirely": "Replace it entirely",
+ "Replace warning": "{count} of the things here are not in this file and
will be deleted. A backup of what is here now is taken first.",
+ "Restore": "Restore",
+ "Restore this backup?": "Restore this backup?",
+ "Restore warning": "The sections this backup covers end up exactly as they
were when it was taken: what it does not name is deleted. A backup of what is
here now is taken first.",
"Retention days": "Retention days",
"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."
+ "Sign-in description": "Casdoor is optional: leave the endpoint empty to
sign in against Gateway's own user table.",
+ "Skipped": "Skipped",
+ "Taken on {host} at {time}": "Taken on {host} at {time}",
+ "The backup was restored": "The backup was restored",
+ "The file carries every key": "The file carries every key",
+ "The file carries every key hint": "Anyone who opens it can read every
provider API key on this machine. Keep it where the keys themselves belong.",
+ "The last backup failed": "The last backup failed",
+ "The snapshot was imported": "The snapshot was imported",
+ "The snapshot was saved": "The snapshot was saved",
+ "This file carries no keys": "This file carries no API keys, so every
provider in it needs its key typed again.",
+ "This file is not a Gateway snapshot": "This file is not a Gateway
snapshot",
+ "Updated": "Updated",
+ "What this import would do": "What this import would do",
+ "What this restore did": "What this restore did",
+ "What this restore would do": "What this restore would do",
+ "What to do with what is already here": "What to do with what is already
here",
+ "{count} in this file": "{count} in this file"
},
"usage": {
"Add a price": "Add a price",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 6e807c1..08d9a71 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -877,45 +877,115 @@
"Used path": "已用字段路径"
},
"setting": {
+ "A backup was taken first": "导入前已先备份为 {name}。",
"API key encryption key": "API Key 加密密钥",
"API key encryption key hint": "用于加密存储的供应商 API
Key,长度不限。请与数据库一起备份:一旦丢失,已加密的密钥将无法读取。",
+ "Added": "新增",
"Agent poll seconds": "Agent 轮询间隔(秒)",
"Agent record capacity": "Agent 记录条数",
"Agent record capacity hint": "Agent 记录不会落盘,因此这个值也决定了它们占用的内存上限。",
+ "Agent routing": "代理路由",
"Agent state dir": "Agent 状态目录",
"Agent state dir hint": "补丁清单、配置备份、上报令牌和文件读取位置。",
"Agents": "Agent",
+ "Back up now": "立即备份",
+ "Backups": "备份",
+ "Backups are off": "自动备份已关闭",
+ "Backups are on": "自动备份已开启",
+ "Backups description": "配置的快照,写在数据库旁边而不是里面,所以它比数据库本身活得更久。每次导入之前也会自动备份一份。",
+ "Backups live in {dir}": "备份保存在 {dir}",
+ "Before an import": "导入前",
+ "By hand": "手动",
"Casdoor application": "Casdoor 应用",
"Casdoor endpoint": "Casdoor 地址",
"Casdoor organization": "Casdoor 组织",
"Channel probes": "渠道探测",
"Channel probes description": "探测会向供应商的上游发几个很短的请求,再拿返回和厂商 API
文档对照:自报的模型名、带的厂商响应头、事件流的结构、提示词缓存是否真的入账、两个相同请求计费是否一致。具体问什么由“真伪检测”页上的用例决定,每条都可以改权重、停用或重写。它会花掉该供应商一点点额度。",
+ "Choose a file": "选择文件",
+ "Choose another file": "换一个文件",
"Client ID": "Client ID",
"Client secret": "Client Secret",
+ "Delete this backup?": "删除这个备份?",
+ "Deleted": "删除",
+ "Download": "下载",
+ "Due now": "现在就该备份",
+ "Every {count} hours": "每 {count} 小时一次",
+ "Export": "导出",
+ "Export to a file": "导出为文件",
+ "Failed": "失败",
+ "How many to keep": "保留数量",
+ "How often": "备份频率",
+ "Import": "导入",
+ "Import and export": "导入与导出",
+ "Import and export description": "把配置装进一个文件:既是搬到另一台机器上的东西,也是从另一台机器读回来的东西。",
+ "Import hint": "选择一个从 Gateway 导出的文件,或者从上面的列表里下载的备份。",
+ "Import this snapshot?": "导入这个快照?",
+ "Import warning": "导入前会先为当前配置备份一份,所以可以从上面的列表里撤销。",
+ "Include the API keys": "包含 API 密钥",
+ "Include the API keys hint": "不包含时,文件只描述配置,落地后还不能真正转发请求。",
+ "Keep what is here": "保留现有的,只添加新的",
+ "Keys are taken from the file": "文件里没有密钥的供应商,会保留这台机器上已有的密钥。",
"LLM records": "LLM 记录",
"LLM records description": "提示词可能包含敏感数据,因此元数据之外的内容都需要主动开启。",
+ "Last backup": "上次备份",
"Max payload bytes": "单条正文上限(字节)",
"Max records": "最大记录条数",
+ "Model prices": "模型价格",
"Network": "网络",
+ "Never": "从未",
+ "Next backup": "下次备份",
+ "No backups yet": "还没有备份",
+ "No keys": "不含密钥",
+ "Not in this file": "文件里没有",
+ "Nothing in it": "里面什么都没有",
+ "Nothing is scheduled": "没有排期",
+ "Nothing would change": "不会有任何改动",
+ "On a schedule": "按计划",
+ "Once a day": "每天一次",
+ "Once a week": "每周一次",
"Outbound SOCKS5 proxy": "出站 SOCKS5 代理",
"Outbound SOCKS5 proxy hint": "形如 \"host:port\",用于升级下载等出站流量。",
+ "Overwrite what the file names": "覆盖文件里提到的",
"Page description": "Casbin Gateway 的全部配置项。修改会保存到数据库并立即生效。",
"Pricing file": "价格文件",
"Pricing file hint": "可选的 JSON 文件,按每百万 Token 的美元价格覆盖内置价目表。",
+ "Probe cases": "探测用例",
"Probe interval hours": "多久重测一次",
"Probe interval hours hint":
"一份真伪报告多久算过期。超过这个时间的供应商会被再探测一次——后台悄悄换了供应商,只有这样才发现得了。",
"Probe mode": "何时探测",
"Probe mode hint": "自动探测会在新增渠道时、改了地址/类型/密钥后、以及对从未探测过的渠道各跑一次。",
+ "Providers": "供应商",
"Queue capacity": "写入队列容量",
"Queue capacity hint": "队列写满时会丢弃记录,而不是拖慢代理。",
"Record mode": "记录模式",
"Relay token": "中继令牌",
"Relay token hint": "智能体访问 /v1 时携带的令牌。来自本机的请求不需要它。清空后保存即可重新生成。",
+ "Replace it entirely": "完全替换",
+ "Replace warning": "当前有 {count} 项不在这个文件里,将被删除。导入前会先为当前配置备份一份。",
+ "Restore": "恢复",
+ "Restore this backup?": "恢复这个备份?",
+ "Restore warning": "这个备份覆盖的部分会完全回到备份当时的样子:备份里没有的会被删除。恢复前会先为当前配置备份一份。",
"Retention days": "保留天数",
"Security": "安全",
"Settings": "设置",
"Sign-in": "登录",
- "Sign-in description": "Casdoor 是可选的:地址留空则使用 Gateway 自带的用户表登录。"
+ "Sign-in description": "Casdoor 是可选的:地址留空则使用 Gateway 自带的用户表登录。",
+ "Skipped": "跳过",
+ "Taken on {host} at {time}": "{time} 于 {host} 导出",
+ "The backup was restored": "备份已恢复",
+ "The file carries every key": "这个文件会带上全部密钥",
+ "The file carries every key hint": "任何打开它的人都能看到这台机器上所有供应商的 API
密钥,请按密钥本身的标准保管。",
+ "The last backup failed": "上次备份失败",
+ "The snapshot was imported": "快照已导入",
+ "The snapshot was saved": "快照已保存",
+ "This file carries no keys": "这个文件里没有 API 密钥,里面每个供应商的密钥都要重新填。",
+ "This file is not a Gateway snapshot": "这个文件不是 Gateway 的配置快照",
+ "Updated": "更新",
+ "What this import would do": "这次导入将会做什么",
+ "What this restore did": "这次恢复做了什么",
+ "What this restore would do": "这次恢复将会做什么",
+ "What to do with what is already here": "已经存在的怎么办",
+ "{count} in this file": "文件里有 {count} 项"
},
"usage": {
"Add a price": "新增价格",
diff --git a/web/src/pages/SettingPage.tsx b/web/src/pages/SettingPage.tsx
index 5130c8e..93ba034 100644
--- a/web/src/pages/SettingPage.tsx
+++ b/web/src/pages/SettingPage.tsx
@@ -17,6 +17,8 @@ import i18next from "i18next";
import * as SettingBackend from "@/backend/SettingBackend";
import * as Setting from "@/Setting";
+import {BackupPanel} from "@/components/settings/backup-panel";
+import {ImportExportPanel} from "@/components/settings/import-export-panel";
import {Field} from "@/components/shared/form-dialog";
import {Loading} from "@/components/shared/loading";
import {UnauthorizedResult} from "@/components/shared/misc";
@@ -37,7 +39,9 @@ export default function SettingPage({account}: {account:
Account}) {
const [setting, setSetting] = React.useState<SettingType | null>(null);
const [saving, setSaving] = React.useState(false);
- React.useEffect(() => {
+ // The panels below store parts of this same row, so the form is reloaded
+ // after one of them writes rather than left to save what it read on load.
+ const load = React.useCallback(() => {
if (!isAdmin) {
return;
}
@@ -51,6 +55,8 @@ export default function SettingPage({account}: {account:
Account}) {
});
}, [isAdmin]);
+ React.useEffect(load, [load]);
+
if (!isAdmin) {
return <UnauthorizedResult />;
}
@@ -193,6 +199,10 @@ export default function SettingPage({account}: {account:
Account}) {
<Section columns={2} collapsible title={i18next.t("setting:Network")}>
{textField("httpProxy", i18next.t("setting:Outbound SOCKS5 proxy"),
i18next.t("setting:Outbound SOCKS5 proxy hint"))}
</Section>
+
+ <BackupPanel onSettingChanged={load} />
+
+ <ImportExportPanel onSettingChanged={load} />
</PageContainer>
);
}
diff --git a/web/src/types.ts b/web/src/types.ts
index 9b5a477..48b42ce 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -755,6 +755,95 @@ export interface Setting {
relayToken: string;
httpProxy: string;
+
+ backupMode: BackupMode;
+ backupIntervalHours: number;
+ backupRetention: number;
+ backupDir: string;
+}
+
+export type BackupMode = "auto" | "off";
+
+/** Which sections a snapshot carries, and whether the credentials inside them
+ * come with it. */
+export interface SnapshotScope {
+ providers: boolean;
+ agents: boolean;
+ probeCases: boolean;
+ llmPrices: boolean;
+ setting: boolean;
+ secrets: boolean;
+}
+
+export interface SnapshotCounts {
+ providers: number;
+ agents: number;
+ agentInstances: number;
+ probeCases: number;
+ llmPrices: number;
+ setting: number;
+}
+
+/** The exported document itself. It is only ever handed straight back to the
+ * server or written to a file, so the sections stay opaque here. */
+export interface Snapshot {
+ version: number;
+ createdTime: string;
+ gateway: string;
+ host: string;
+ reason: string;
+ scope: SnapshotScope;
+ [section: string]: unknown;
+}
+
+export type ImportMode = "merge" | "overwrite" | "replace";
+
+export interface ImportChange {
+ section: string;
+ id: string;
+ action: "add" | "update" | "delete" | "skip" | "fail";
+ detail: string;
+}
+
+/** What an import did, and what a dry run says it would do: the two are the
+ * same document, so the confirmation and the result read alike. */
+export interface ImportReport {
+ dryRun: boolean;
+ mode: ImportMode;
+ added: number;
+ updated: number;
+ deleted: number;
+ skipped: number;
+ failed: number;
+ changes: ImportChange[];
+ /** The snapshot taken before anything was written, empty on a dry run. */
+ backup: string;
+}
+
+/** One snapshot on disk. */
+export interface Backup {
+ name: string;
+ createdTime: string;
+ reason: string;
+ gateway: string;
+ host: string;
+ size: number;
+ secrets: boolean;
+ counts: SnapshotCounts;
+}
+
+/** The schedule automatic backups are on, and the files themselves. */
+export interface BackupState {
+ mode: BackupMode;
+ intervalHours: number;
+ retention: number;
+ dir: string;
+ takenTime: string;
+ /** When the schedule runs again, empty when nothing is scheduled. */
+ nextTime: string;
+ latest: string;
+ error: string;
+ backups: Backup[];
}
/** The kinds of configuration the Skills, MCP & Prompts page manages. */
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]