mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
[功能] 添加源站管理功能,包括源站的创建、更新、删除及列表展示
This commit is contained in:
@@ -9,6 +9,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
|
||||
当前稳定能力包括:
|
||||
|
||||
* 反代规则管理
|
||||
* 源站管理与复用
|
||||
* 配置预览、发布、激活与回滚
|
||||
* Agent 注册、心跳、同步、应用结果上报
|
||||
* OpenResty 主配置模板、性能参数与缓存参数托管
|
||||
@@ -40,6 +41,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
|
||||
边界补充:
|
||||
|
||||
* OpenResty 代理缓存只作为当前反代链路优化能力存在,不扩展为独立缓存产品
|
||||
* 源站对象只用于沉淀可复用的回源地址,不扩展为带健康检查、权重、策略编排的 `origin_pool`
|
||||
* 主配置文件由 Server 统一渲染并由 Agent 受控写入,不支持节点侧手工编辑后回传合并
|
||||
* 节点观测聚焦运营与运维排障所需的摘要、趋势和受控窗口数据,不提供长期日志托管
|
||||
|
||||
@@ -105,6 +107,7 @@ Origin
|
||||
当前有效实体:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `node_system_profiles`
|
||||
@@ -120,6 +123,8 @@ Origin
|
||||
稳定约束:
|
||||
|
||||
* 一个域名只对应一条 `proxy_routes` 规则
|
||||
* `origins` 只保存源站地址、展示名与备注,不承载协议、端口、路径、权重或健康检查策略
|
||||
* `proxy_routes` 可选关联一个 `origins` 记录,用于复用源站地址;规则仍保存完整 `origin_url` 快照以参与渲染与版本快照
|
||||
* `proxy_routes` 至少包含一个上游地址;为兼容历史数据保留 `origin_url` 主上游字段,也允许在同一规则内补充多个上游做负载均衡
|
||||
* `proxy_routes` 上游统一渲染为带 keepalive 的 named `upstream`;单上游可附带 base path 或 query 并在 `proxy_pass` 中追加,多上游仍限定为纯 `scheme://host[:port]`
|
||||
* `proxy_routes.origin_host` 为可选字段,用于回源时覆盖 `Host` 请求头;未设置时默认透传访问域名
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
当前有效实体:
|
||||
|
||||
* `proxy_routes`
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `node_system_profiles`
|
||||
@@ -115,7 +116,9 @@
|
||||
通用约束:
|
||||
|
||||
* 不新增平台化对象,除非设计文档明确要求
|
||||
* `origins` 仅作为可复用源站地址目录,字段保持轻量;协议、端口、路径与查询参数继续归属具体 `proxy_routes`
|
||||
* `proxy_routes` 维持一条域名对应一条规则;规则内允许保存一个或多个上游地址用于负载均衡,但不引入独立 `origin_pool`
|
||||
* `proxy_routes` 如关联 `origins`,必须同时保存可直接渲染的 `origin_url`;源站地址变更时,由 service 负责同步更新引用该源站的规则快照
|
||||
* `proxy_routes` 的上游统一使用 named `upstream` + keepalive;单上游如带 base path 或 query,应在 `proxy_pass` 上补回 URI,多上游仅允许纯 `scheme://host[:port]`
|
||||
* `proxy_routes.origin_host` 为可选字段,仅用于覆盖回源 `Host` 请求头,不引入新的平台化对象
|
||||
* `config_versions` 必须保存完整快照与渲染结果
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GetOrigins(c *gin.Context) {
|
||||
origins, err := service.ListOrigins()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": origins,
|
||||
})
|
||||
}
|
||||
|
||||
func GetOrigin(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
origin, err := service.GetOriginDetail(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": origin,
|
||||
})
|
||||
}
|
||||
|
||||
func CreateOrigin(c *gin.Context) {
|
||||
var input service.OriginInput
|
||||
if err := json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
origin, err := service.CreateOrigin(input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": origin,
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateOrigin(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
var input service.OriginInput
|
||||
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
origin, err := service.UpdateOrigin(uint(id), input)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": origin,
|
||||
})
|
||||
}
|
||||
|
||||
func DeleteOrigin(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的参数",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err = service.DeleteOrigin(uint(id)); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
const (
|
||||
legacyDatabaseSchemaVersion = 1
|
||||
currentDatabaseSchemaVersion = 3
|
||||
currentDatabaseSchemaVersion = 4
|
||||
databaseSchemaVersionRowID = 1
|
||||
)
|
||||
|
||||
|
||||
@@ -9,10 +9,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/schema"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"openflare/common"
|
||||
"openflare/utils/security"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -36,6 +39,7 @@ func registeredModels() []any {
|
||||
&File{},
|
||||
&User{},
|
||||
&Option{},
|
||||
&Origin{},
|
||||
&ProxyRoute{},
|
||||
&ConfigVersion{},
|
||||
&Node{},
|
||||
@@ -307,6 +311,19 @@ func validateDatabaseSchemaV3(db *gorm.DB, backend string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV4(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV3(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasTable(&Origin{}) {
|
||||
return fmt.Errorf("table origins is missing")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") {
|
||||
return fmt.Errorf("column proxy_routes.origin_id is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renameLegacyObservabilityShardTables(db *gorm.DB) error {
|
||||
for _, baseTable := range shardedObservabilityBaseTables() {
|
||||
for _, table := range observabilityShardTables(baseTable) {
|
||||
@@ -549,6 +566,91 @@ func migrateObservabilityShardsToID(db *gorm.DB, backend string) error {
|
||||
return dropLegacyObservabilityShardTables(db)
|
||||
}
|
||||
|
||||
func normalizeOriginAddressForMigration(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func extractOriginAddressForMigration(rawURL string) string {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return normalizeOriginAddressForMigration(parsed.Hostname())
|
||||
}
|
||||
|
||||
func backfillOriginsFromProxyRoutes(db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("database handle is nil")
|
||||
}
|
||||
if !db.Migrator().HasTable(&Origin{}) || !db.Migrator().HasTable(&ProxyRoute{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var routes []ProxyRoute
|
||||
if err := db.Order("id asc").Find(&routes).Error; err != nil {
|
||||
return fmt.Errorf("list proxy routes for origin backfill failed: %w", err)
|
||||
}
|
||||
|
||||
type originSeed struct {
|
||||
ID uint
|
||||
Address string
|
||||
}
|
||||
|
||||
originByAddress := make(map[string]originSeed)
|
||||
var origins []Origin
|
||||
if err := db.Order("id asc").Find(&origins).Error; err != nil {
|
||||
return fmt.Errorf("list origins for backfill failed: %w", err)
|
||||
}
|
||||
for _, origin := range origins {
|
||||
address := normalizeOriginAddressForMigration(origin.Address)
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
originByAddress[address] = originSeed{ID: origin.ID, Address: address}
|
||||
}
|
||||
|
||||
for _, route := range routes {
|
||||
address := extractOriginAddressForMigration(route.OriginURL)
|
||||
if address == "" {
|
||||
continue
|
||||
}
|
||||
origin, ok := originByAddress[address]
|
||||
if !ok {
|
||||
name := address
|
||||
if ip := net.ParseIP(address); ip != nil {
|
||||
name = ip.String()
|
||||
}
|
||||
record := Origin{
|
||||
Name: name,
|
||||
Address: address,
|
||||
Remark: "",
|
||||
}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
return fmt.Errorf("create origin for address %s failed: %w", address, err)
|
||||
}
|
||||
origin = originSeed{ID: record.ID, Address: address}
|
||||
originByAddress[address] = origin
|
||||
}
|
||||
if route.OriginID != nil && *route.OriginID == origin.ID {
|
||||
continue
|
||||
}
|
||||
if err := db.Model(&ProxyRoute{}).
|
||||
Where("id = ?", route.ID).
|
||||
Update("origin_id", origin.ID).Error; err != nil {
|
||||
return fmt.Errorf("backfill proxy route %d origin_id failed: %w", route.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateOriginsSchema(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return backfillOriginsFromProxyRoutes(db)
|
||||
}
|
||||
|
||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
return []databaseSchemaMigration{
|
||||
{
|
||||
@@ -563,6 +665,12 @@ func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
migrate: migrateObservabilityShardsToID,
|
||||
validate: validateDatabaseSchemaV3,
|
||||
},
|
||||
{
|
||||
fromVersion: 3,
|
||||
toVersion: 4,
|
||||
migrate: migrateOriginsSchema,
|
||||
validate: validateDatabaseSchemaV4,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,6 +683,19 @@ func databaseSchemaMigrationMap() map[int]databaseSchemaMigration {
|
||||
}
|
||||
|
||||
func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseSchemaMigration) error {
|
||||
if backend == "sqlite" {
|
||||
if err := migration.migrate(db, backend); err != nil {
|
||||
return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err)
|
||||
}
|
||||
if err := migration.validate(db, backend); err != nil {
|
||||
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
|
||||
}
|
||||
if err := saveDatabaseSchemaVersion(db, migration.toVersion); err != nil {
|
||||
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := migration.migrate(tx, backend); err != nil {
|
||||
return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err)
|
||||
@@ -614,10 +735,13 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backfillOriginsFromProxyRoutes(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateSQLiteDataIfNeeded(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDatabaseSchemaV3(db, backend); err != nil {
|
||||
if err := validateDatabaseSchemaV4(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
|
||||
|
||||
@@ -409,6 +409,61 @@ func TestEnsureDatabaseSchemaUpToDateMigratesObservabilityShardsToID(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateOriginsSchemaBackfillsOrigins(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "legacy-origins.db")
|
||||
if err := registerSharding(db, "sqlite"); err != nil {
|
||||
t.Fatalf("register sharding: %v", err)
|
||||
}
|
||||
if err := applyCurrentSchema(db, "sqlite"); err != nil {
|
||||
t.Fatalf("applyCurrentSchema: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
route := &ProxyRoute{
|
||||
Domain: "app.example.com",
|
||||
OriginURL: "https://origin-a.internal:8443/api",
|
||||
Upstreams: `["https://origin-a.internal:8443/api"]`,
|
||||
Enabled: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(route).Error; err != nil {
|
||||
t.Fatalf("seed proxy route: %v", err)
|
||||
}
|
||||
if err := db.Exec(`DELETE FROM origins`).Error; err != nil {
|
||||
t.Fatalf("clear origins: %v", err)
|
||||
}
|
||||
if err := db.Model(&ProxyRoute{}).Where("id = ?", route.ID).Update("origin_id", nil).Error; err != nil {
|
||||
t.Fatalf("clear route origin_id: %v", err)
|
||||
}
|
||||
|
||||
if err := backfillOriginsFromProxyRoutes(db); err != nil {
|
||||
t.Fatalf("backfillOriginsFromProxyRoutes: %v", err)
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&Origin{}) {
|
||||
t.Fatal("expected origins table to exist")
|
||||
}
|
||||
if !db.Migrator().HasColumn(&ProxyRoute{}, "origin_id") {
|
||||
t.Fatal("expected proxy_routes.origin_id column to exist")
|
||||
}
|
||||
|
||||
reloadedRoute := &ProxyRoute{}
|
||||
if err := db.First(reloadedRoute, route.ID).Error; err != nil {
|
||||
t.Fatalf("query proxy route: %v", err)
|
||||
}
|
||||
if reloadedRoute.OriginID == nil || *reloadedRoute.OriginID == 0 {
|
||||
t.Fatal("expected migrated route to be linked to a backfilled origin")
|
||||
}
|
||||
|
||||
origin := &Origin{}
|
||||
if err := db.First(origin, *reloadedRoute.OriginID).Error; err != nil {
|
||||
t.Fatalf("query origin: %v", err)
|
||||
}
|
||||
if origin.Address != "origin-a.internal" {
|
||||
t.Fatalf("unexpected backfilled origin address: %s", origin.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *testing.T) {
|
||||
db := openBareTestSQLiteDB(t, "failed-validation.db")
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Origin struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"size:255;not null"`
|
||||
Address string `json:"address" gorm:"uniqueIndex;size:255;not null"`
|
||||
Remark string `json:"remark" gorm:"size:255"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OriginRouteCount struct {
|
||||
OriginID uint `json:"origin_id"`
|
||||
RouteCount int64 `json:"route_count"`
|
||||
}
|
||||
|
||||
func ListOrigins() (origins []*Origin, err error) {
|
||||
err = DB.Order("id desc").Find(&origins).Error
|
||||
return origins, err
|
||||
}
|
||||
|
||||
func GetOriginByID(id uint) (*Origin, error) {
|
||||
origin := &Origin{}
|
||||
err := DB.First(origin, id).Error
|
||||
return origin, err
|
||||
}
|
||||
|
||||
func GetOriginByAddress(address string) (*Origin, error) {
|
||||
origin := &Origin{}
|
||||
err := DB.Where("address = ?", address).First(origin).Error
|
||||
return origin, err
|
||||
}
|
||||
|
||||
func ListOriginRouteCounts() ([]OriginRouteCount, error) {
|
||||
result := make([]OriginRouteCount, 0)
|
||||
err := DB.Model(&ProxyRoute{}).
|
||||
Select("origin_id, COUNT(*) AS route_count").
|
||||
Where("origin_id IS NOT NULL").
|
||||
Group("origin_id").
|
||||
Scan(&result).Error
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (origin *Origin) Insert() error {
|
||||
return DB.Create(origin).Error
|
||||
}
|
||||
|
||||
func (origin *Origin) Update() error {
|
||||
return DB.Save(origin).Error
|
||||
}
|
||||
|
||||
func (origin *Origin) Delete() error {
|
||||
return DB.Delete(origin).Error
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import "time"
|
||||
type ProxyRoute struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Domain string `json:"domain" gorm:"uniqueIndex;size:255;not null"`
|
||||
OriginID *uint `json:"origin_id" gorm:"index"`
|
||||
OriginURL string `json:"origin_url" gorm:"size:2048;not null"`
|
||||
OriginHost string `json:"origin_host" gorm:"size:255"`
|
||||
Upstreams string `json:"upstreams" gorm:"type:text;not null;default:'[]'"`
|
||||
@@ -37,6 +38,11 @@ func GetProxyRouteByID(id uint) (*ProxyRoute, error) {
|
||||
return route, err
|
||||
}
|
||||
|
||||
func ListProxyRoutesByOriginID(originID uint) (routes []*ProxyRoute, err error) {
|
||||
err = DB.Where("origin_id = ?", originID).Order("id desc").Find(&routes).Error
|
||||
return routes, err
|
||||
}
|
||||
|
||||
func (route *ProxyRoute) Insert() error {
|
||||
return DB.Create(route).Error
|
||||
}
|
||||
@@ -44,6 +50,7 @@ func (route *ProxyRoute) Insert() error {
|
||||
func (route *ProxyRoute) Update() error {
|
||||
return DB.Model(&ProxyRoute{}).Where("id = ?", route.ID).Updates(map[string]any{
|
||||
"domain": route.Domain,
|
||||
"origin_id": route.OriginID,
|
||||
"origin_url": route.OriginURL,
|
||||
"origin_host": route.OriginHost,
|
||||
"upstreams": route.Upstreams,
|
||||
|
||||
@@ -82,6 +82,15 @@ func SetApiRouter(router *gin.Engine) {
|
||||
proxyRoute.POST("/:id/update", controller.UpdateProxyRoute)
|
||||
proxyRoute.POST("/:id/delete", controller.DeleteProxyRoute)
|
||||
}
|
||||
originRoute := apiRouter.Group("/origins")
|
||||
originRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
originRoute.GET("/", controller.GetOrigins)
|
||||
originRoute.GET("/:id", controller.GetOrigin)
|
||||
originRoute.POST("/", controller.CreateOrigin)
|
||||
originRoute.POST("/:id/update", controller.UpdateOrigin)
|
||||
originRoute.POST("/:id/delete", controller.DeleteOrigin)
|
||||
}
|
||||
managedDomainRoute := apiRouter.Group("/managed-domains")
|
||||
managedDomainRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"openflare/model"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OriginInput struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type OriginRouteSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OriginView struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Remark string `json:"remark"`
|
||||
RouteCount int64 `json:"route_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type OriginDetailView struct {
|
||||
OriginView
|
||||
Routes []OriginRouteSummary `json:"routes"`
|
||||
}
|
||||
|
||||
func ListOrigins() ([]OriginView, error) {
|
||||
origins, err := model.ListOrigins()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildOriginViews(origins)
|
||||
}
|
||||
|
||||
func GetOriginDetail(id uint) (*OriginDetailView, error) {
|
||||
origin, err := model.GetOriginByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
views, err := buildOriginViews([]*model.Origin{origin})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routes, err := model.ListProxyRoutesByOriginID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]OriginRouteSummary, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
items = append(items, OriginRouteSummary{
|
||||
ID: route.ID,
|
||||
Domain: route.Domain,
|
||||
OriginURL: route.OriginURL,
|
||||
Enabled: route.Enabled,
|
||||
UpdatedAt: route.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i int, j int) bool {
|
||||
return items[i].Domain < items[j].Domain
|
||||
})
|
||||
detail := &OriginDetailView{
|
||||
OriginView: views[0],
|
||||
Routes: items,
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func CreateOrigin(input OriginInput) (*model.Origin, error) {
|
||||
origin, err := buildOrigin(nil, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = origin.Insert(); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return nil, errors.New("源站地址已存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return origin, nil
|
||||
}
|
||||
|
||||
func UpdateOrigin(id uint, input OriginInput) (*model.Origin, error) {
|
||||
origin, err := model.GetOriginByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
previousAddress := origin.Address
|
||||
nextOrigin, err := buildOrigin(origin, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(nextOrigin).Error; err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return errors.New("源站地址已存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if previousAddress == nextOrigin.Address {
|
||||
return nil
|
||||
}
|
||||
return updateRoutesForOriginAddress(tx, nextOrigin.ID, nextOrigin.Address)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nextOrigin, nil
|
||||
}
|
||||
|
||||
func DeleteOrigin(id uint) error {
|
||||
routes, err := model.ListProxyRoutesByOriginID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(routes) > 0 {
|
||||
return errors.New("该源站仍被规则引用,无法删除")
|
||||
}
|
||||
origin, err := model.GetOriginByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return origin.Delete()
|
||||
}
|
||||
|
||||
func buildOrigin(existing *model.Origin, input OriginInput) (*model.Origin, error) {
|
||||
address := normalizeOriginAddress(input.Address)
|
||||
if err := validateOriginAddress(address); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing == nil {
|
||||
existing = &model.Origin{}
|
||||
}
|
||||
existing.Address = address
|
||||
existing.Name = normalizeOriginName(input.Name, address)
|
||||
existing.Remark = strings.TrimSpace(input.Remark)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func getOrCreateOriginByAddress(address string) (*model.Origin, error) {
|
||||
normalizedAddress := normalizeOriginAddress(address)
|
||||
if err := validateOriginAddress(normalizedAddress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := model.GetOriginByAddress(normalizedAddress)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
origin := &model.Origin{
|
||||
Name: normalizedAddress,
|
||||
Address: normalizedAddress,
|
||||
Remark: "",
|
||||
}
|
||||
if err := origin.Insert(); err != nil {
|
||||
if isUniqueConstraintError(err) {
|
||||
return model.GetOriginByAddress(normalizedAddress)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return origin, nil
|
||||
}
|
||||
|
||||
func updateRoutesForOriginAddress(tx *gorm.DB, originID uint, address string) error {
|
||||
var routes []*model.ProxyRoute
|
||||
if err := tx.Where("origin_id = ?", originID).Order("id asc").Find(&routes).Error; err != nil {
|
||||
return fmt.Errorf("query routes for origin update failed: %w", err)
|
||||
}
|
||||
for _, route := range routes {
|
||||
rewrittenOriginURL, err := rewriteOriginURLAddress(route.OriginURL, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rewrite route %d origin failed: %w", route.ID, err)
|
||||
}
|
||||
upstreams := make([]string, 0)
|
||||
if strings.TrimSpace(route.Upstreams) != "" {
|
||||
if err := json.Unmarshal([]byte(route.Upstreams), &upstreams); err != nil {
|
||||
return fmt.Errorf("decode route %d upstreams failed: %w", route.ID, err)
|
||||
}
|
||||
}
|
||||
if len(upstreams) == 0 {
|
||||
upstreams = append(upstreams, rewrittenOriginURL)
|
||||
} else {
|
||||
upstreams[0] = rewrittenOriginURL
|
||||
}
|
||||
upstreamsJSON, err := json.Marshal(upstreams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode route %d upstreams failed: %w", route.ID, err)
|
||||
}
|
||||
if err := tx.Model(&model.ProxyRoute{}).
|
||||
Where("id = ?", route.ID).
|
||||
Updates(map[string]any{
|
||||
"origin_url": rewrittenOriginURL,
|
||||
"upstreams": string(upstreamsJSON),
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("update route %d origin address failed: %w", route.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildOriginViews(origins []*model.Origin) ([]OriginView, error) {
|
||||
countRows, err := model.ListOriginRouteCounts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countMap := make(map[uint]int64, len(countRows))
|
||||
for _, row := range countRows {
|
||||
countMap[row.OriginID] = row.RouteCount
|
||||
}
|
||||
views := make([]OriginView, 0, len(origins))
|
||||
for _, origin := range origins {
|
||||
views = append(views, OriginView{
|
||||
ID: origin.ID,
|
||||
Name: origin.Name,
|
||||
Address: origin.Address,
|
||||
Remark: origin.Remark,
|
||||
RouteCount: countMap[origin.ID],
|
||||
CreatedAt: origin.CreatedAt,
|
||||
UpdatedAt: origin.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func normalizeOriginAddress(raw string) string {
|
||||
return strings.ToLower(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func validateOriginAddress(address string) error {
|
||||
if address == "" {
|
||||
return errors.New("源站地址不能为空")
|
||||
}
|
||||
if strings.Contains(address, "://") || strings.ContainsAny(address, "/?#") {
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
if strings.HasPrefix(address, "[") || strings.HasSuffix(address, "]") {
|
||||
return errors.New("源站地址无需包含 IPv6 方括号")
|
||||
}
|
||||
if ip := net.ParseIP(address); ip != nil {
|
||||
return nil
|
||||
}
|
||||
if len(address) > 253 {
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
labels := strings.Split(address, ".")
|
||||
for _, label := range labels {
|
||||
if len(label) == 0 || len(label) > 63 {
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
for _, r := range label {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
|
||||
continue
|
||||
}
|
||||
return errors.New("源站地址格式不合法")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeOriginName(name string, address string) string {
|
||||
normalized := strings.TrimSpace(name)
|
||||
if normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func normalizeOriginPort(raw string) (string, error) {
|
||||
port := strings.TrimSpace(raw)
|
||||
if port == "" {
|
||||
return "", errors.New("端口不能为空")
|
||||
}
|
||||
value, err := strconv.Atoi(port)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return "", errors.New("端口格式不合法")
|
||||
}
|
||||
return strconv.Itoa(value), nil
|
||||
}
|
||||
|
||||
func normalizeOriginScheme(raw string) (string, error) {
|
||||
scheme := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch scheme {
|
||||
case "http", "https":
|
||||
return scheme, nil
|
||||
default:
|
||||
return "", errors.New("源站协议仅支持 http 或 https")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOriginURI(raw string) (string, error) {
|
||||
uri := strings.TrimSpace(raw)
|
||||
if uri == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(uri, "://") {
|
||||
return "", errors.New("源站路径不能包含协议")
|
||||
}
|
||||
if !strings.HasPrefix(uri, "/") && !strings.HasPrefix(uri, "?") {
|
||||
return "", errors.New("源站路径需以 / 或 ? 开头")
|
||||
}
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
func formatOriginHost(address string, port string) string {
|
||||
if ip := net.ParseIP(address); ip != nil && strings.Contains(address, ":") {
|
||||
return net.JoinHostPort(address, port)
|
||||
}
|
||||
return net.JoinHostPort(address, port)
|
||||
}
|
||||
|
||||
func buildOriginURLFromParts(
|
||||
scheme string,
|
||||
address string,
|
||||
port string,
|
||||
uri string,
|
||||
) (string, error) {
|
||||
normalizedScheme, err := normalizeOriginScheme(scheme)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
normalizedAddress := normalizeOriginAddress(address)
|
||||
if err := validateOriginAddress(normalizedAddress); err != nil {
|
||||
return "", err
|
||||
}
|
||||
normalizedPort, err := normalizeOriginPort(port)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
normalizedURI, err := normalizeOriginURI(uri)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parsed := &url.URL{
|
||||
Scheme: normalizedScheme,
|
||||
Host: formatOriginHost(normalizedAddress, normalizedPort),
|
||||
}
|
||||
if normalizedURI != "" {
|
||||
if strings.HasPrefix(normalizedURI, "?") {
|
||||
parsed.RawQuery = strings.TrimPrefix(normalizedURI, "?")
|
||||
} else {
|
||||
pathQuery := strings.SplitN(normalizedURI, "?", 2)
|
||||
parsed.Path = pathQuery[0]
|
||||
if len(pathQuery) > 1 {
|
||||
parsed.RawQuery = pathQuery[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func extractOriginAddress(rawURL string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("源站地址格式不合法: %w", err)
|
||||
}
|
||||
address := normalizeOriginAddress(parsed.Hostname())
|
||||
if err := validateOriginAddress(address); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func rewriteOriginURLAddress(rawURL string, newAddress string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("源站地址格式不合法: %w", err)
|
||||
}
|
||||
address := normalizeOriginAddress(newAddress)
|
||||
if err := validateOriginAddress(address); err != nil {
|
||||
return "", err
|
||||
}
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
return "", errors.New("源站地址缺少端口")
|
||||
}
|
||||
parsed.Host = formatOriginHost(address, port)
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func splitOriginURL(rawURL string) (scheme string, address string, port string, uri string, err error) {
|
||||
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
scheme = parsed.Scheme
|
||||
address = normalizeOriginAddress(parsed.Hostname())
|
||||
port = parsed.Port()
|
||||
uri = parsed.EscapedPath()
|
||||
if uri == "" {
|
||||
uri = parsed.Path
|
||||
}
|
||||
if parsed.RawQuery != "" {
|
||||
if uri == "" {
|
||||
uri = "?" + parsed.RawQuery
|
||||
} else {
|
||||
uri = uri + "?" + parsed.RawQuery
|
||||
}
|
||||
}
|
||||
return scheme, address, port, uri, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"openflare/model"
|
||||
)
|
||||
|
||||
func TestCreateProxyRouteStructuredOriginAutoCreatesOrigin(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "app.example.com",
|
||||
OriginScheme: "https",
|
||||
OriginAddress: "origin.internal",
|
||||
OriginPort: "8443",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
if route.OriginID == nil || *route.OriginID == 0 {
|
||||
t.Fatal("expected route to be linked with an auto-created origin")
|
||||
}
|
||||
if route.OriginURL != "https://origin.internal:8443" {
|
||||
t.Fatalf("unexpected route origin url: %s", route.OriginURL)
|
||||
}
|
||||
|
||||
origin, err := model.GetOriginByID(*route.OriginID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOriginByID failed: %v", err)
|
||||
}
|
||||
if origin.Address != "origin.internal" {
|
||||
t.Fatalf("unexpected origin address: %s", origin.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOriginRewritesLinkedRouteOriginURL(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
origin, err := CreateOrigin(OriginInput{
|
||||
Name: "primary-origin",
|
||||
Address: "origin-a.internal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrigin failed: %v", err)
|
||||
}
|
||||
route, err := CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "app.example.com",
|
||||
OriginID: &origin.ID,
|
||||
OriginScheme: "https",
|
||||
OriginPort: "8443",
|
||||
OriginURI: "/api",
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
|
||||
updatedOrigin, err := UpdateOrigin(origin.ID, OriginInput{
|
||||
Name: origin.Name,
|
||||
Address: "origin-c.internal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateOrigin failed: %v", err)
|
||||
}
|
||||
if updatedOrigin.Address != "origin-c.internal" {
|
||||
t.Fatalf("unexpected updated origin address: %s", updatedOrigin.Address)
|
||||
}
|
||||
|
||||
reloadedRoute, err := model.GetProxyRouteByID(route.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProxyRouteByID failed: %v", err)
|
||||
}
|
||||
if reloadedRoute.OriginURL != "https://origin-c.internal:8443/api" {
|
||||
t.Fatalf("expected route origin url to be rewritten, got %s", reloadedRoute.OriginURL)
|
||||
}
|
||||
if reloadedRoute.Upstreams == "" || reloadedRoute.Upstreams == "[]" {
|
||||
t.Fatalf("expected route upstreams to be preserved, got %s", reloadedRoute.Upstreams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteOriginRejectsReferencedOrigin(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
|
||||
origin, err := CreateOrigin(OriginInput{
|
||||
Address: "origin-a.internal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateOrigin failed: %v", err)
|
||||
}
|
||||
if _, err = CreateProxyRoute(ProxyRouteInput{
|
||||
Domain: "app.example.com",
|
||||
OriginID: &origin.ID,
|
||||
OriginScheme: "https",
|
||||
OriginPort: "443",
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateProxyRoute failed: %v", err)
|
||||
}
|
||||
|
||||
if err = DeleteOrigin(origin.ID); err == nil {
|
||||
t.Fatal("expected referenced origin deletion to fail")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"openflare/model"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
@@ -25,7 +27,12 @@ type ProxyRouteCustomHeaderInput struct {
|
||||
|
||||
type ProxyRouteInput struct {
|
||||
Domain string `json:"domain"`
|
||||
OriginID *uint `json:"origin_id"`
|
||||
OriginURL string `json:"origin_url"`
|
||||
OriginScheme string `json:"origin_scheme"`
|
||||
OriginAddress string `json:"origin_address"`
|
||||
OriginPort string `json:"origin_port"`
|
||||
OriginURI string `json:"origin_uri"`
|
||||
OriginHost string `json:"origin_host"`
|
||||
Upstreams []string `json:"upstreams"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -85,7 +92,10 @@ func DeleteProxyRoute(id uint) error {
|
||||
|
||||
func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.ProxyRoute, error) {
|
||||
domain := strings.ToLower(strings.TrimSpace(input.Domain))
|
||||
originURL := strings.TrimSpace(input.OriginURL)
|
||||
originURL, originID, err := resolveProxyRoutePrimaryOrigin(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
originHost := strings.TrimSpace(input.OriginHost)
|
||||
remark := strings.TrimSpace(input.Remark)
|
||||
upstreams, err := normalizeUpstreams(originURL, input.Upstreams)
|
||||
@@ -141,6 +151,7 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
||||
route = &model.ProxyRoute{}
|
||||
}
|
||||
route.Domain = domain
|
||||
route.OriginID = originID
|
||||
route.OriginURL = upstreams[0]
|
||||
route.OriginHost = originHost
|
||||
route.Upstreams = string(upstreamsJSON)
|
||||
@@ -156,6 +167,78 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
|
||||
return route, nil
|
||||
}
|
||||
|
||||
func resolveProxyRoutePrimaryOrigin(input ProxyRouteInput) (string, *uint, error) {
|
||||
if hasStructuredOriginInput(input) {
|
||||
scheme, err := normalizeOriginScheme(input.OriginScheme)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
port, err := normalizeOriginPort(input.OriginPort)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
uri, err := normalizeOriginURI(input.OriginURI)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if input.OriginID != nil && *input.OriginID != 0 {
|
||||
origin, err := model.GetOriginByID(*input.OriginID)
|
||||
if err != nil {
|
||||
return "", nil, errors.New("所选源站不存在")
|
||||
}
|
||||
originURL, err := buildOriginURLFromParts(
|
||||
scheme,
|
||||
origin.Address,
|
||||
port,
|
||||
uri,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return originURL, &origin.ID, nil
|
||||
}
|
||||
|
||||
address := normalizeOriginAddress(input.OriginAddress)
|
||||
if err := validateOriginAddress(address); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
originURL, err := buildOriginURLFromParts(scheme, address, port, uri)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
origin, err := getOrCreateOriginByAddress(address)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return originURL, &origin.ID, nil
|
||||
}
|
||||
|
||||
originURL := strings.TrimSpace(input.OriginURL)
|
||||
if originURL == "" {
|
||||
return "", nil, errors.New("源站地址不能为空")
|
||||
}
|
||||
address, err := extractOriginAddress(originURL)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
origin, findErr := model.GetOriginByAddress(address)
|
||||
if findErr == nil {
|
||||
return originURL, &origin.ID, nil
|
||||
}
|
||||
if !errors.Is(findErr, gorm.ErrRecordNotFound) {
|
||||
return "", nil, findErr
|
||||
}
|
||||
return originURL, nil, nil
|
||||
}
|
||||
|
||||
func hasStructuredOriginInput(input ProxyRouteInput) bool {
|
||||
return (input.OriginID != nil && *input.OriginID != 0) ||
|
||||
strings.TrimSpace(input.OriginScheme) != "" ||
|
||||
strings.TrimSpace(input.OriginAddress) != "" ||
|
||||
strings.TrimSpace(input.OriginPort) != "" ||
|
||||
strings.TrimSpace(input.OriginURI) != ""
|
||||
}
|
||||
|
||||
func normalizeCustomHeaders(headers []ProxyRouteCustomHeaderInput) ([]ProxyRouteCustomHeaderInput, error) {
|
||||
if len(headers) == 0 {
|
||||
return []ProxyRouteCustomHeaderInput{}, nil
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { OriginDetailPage } from '@/features/origins/components/origin-detail-page';
|
||||
|
||||
export default function OriginDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return <OriginDetailPage originId={searchParams.get('id') ?? ''} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OriginsPage } from '@/features/origins/components/origins-page';
|
||||
|
||||
export default function OriginRoute() {
|
||||
return <OriginsPage />;
|
||||
}
|
||||
@@ -47,6 +47,13 @@ function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
|
||||
<path d="M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z" />
|
||||
</svg>
|
||||
);
|
||||
case 'origin':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
<path d="M12 4.5c3.3 0 6 2.5 6 5.6 0 4.4-6 9.4-6 9.4s-6-5-6-9.4c0-3.1 2.7-5.6 6-5.6Z" />
|
||||
<circle cx="12" cy="10.1" r="1.9" />
|
||||
</svg>
|
||||
);
|
||||
case 'domain':
|
||||
return (
|
||||
<svg {...commonProps}>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
OriginDetail,
|
||||
OriginItem,
|
||||
OriginMutationPayload,
|
||||
} from '@/features/origins/types';
|
||||
|
||||
export function getOrigins() {
|
||||
return apiRequest<OriginItem[]>('/origins/');
|
||||
}
|
||||
|
||||
export function getOrigin(id: number) {
|
||||
return apiRequest<OriginDetail>(`/origins/${id}`);
|
||||
}
|
||||
|
||||
export function createOrigin(payload: OriginMutationPayload) {
|
||||
return apiRequest<OriginItem>('/origins/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOrigin(id: number, payload: OriginMutationPayload) {
|
||||
return apiRequest<OriginItem>(`/origins/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteOrigin(id: number) {
|
||||
return apiRequest<void>(`/origins/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { deleteOrigin, getOrigin } from '@/features/origins/api/origins';
|
||||
import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
export function OriginDetailPage({ originId }: { originId: string }) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<{
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const originQuery = useQuery({
|
||||
queryKey: ['origins', originId],
|
||||
queryFn: () => getOrigin(Number(originId)),
|
||||
enabled: originId !== '',
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrigin,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
router.push('/origin');
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message:
|
||||
error instanceof Error ? error.message : '请求失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (originQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (originQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title="源站详情加载失败"
|
||||
description={
|
||||
originQuery.error instanceof Error
|
||||
? originQuery.error.message
|
||||
: '请求失败,请稍后重试。'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const origin = originQuery.data;
|
||||
if (!origin) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="源站不存在"
|
||||
description="该源站可能已被删除,或当前 ID 无法匹配到源站记录。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(origin.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={origin.name}
|
||||
description="源站详情"
|
||||
action={
|
||||
<>
|
||||
<Link
|
||||
href="/origin"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setIsEditorOpen(true)}
|
||||
>
|
||||
编辑源站
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除源站
|
||||
</DangerButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
<AppCard title="源站地址">
|
||||
<p className="text-sm text-[var(--foreground-primary)]">
|
||||
{origin.address}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="绑定规则">
|
||||
<div className="space-y-3">
|
||||
<StatusBadge
|
||||
label={`${origin.route_count} 条规则`}
|
||||
variant={origin.route_count > 0 ? 'success' : 'warning'}
|
||||
/>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
编辑地址后,绑定规则的主源站地址会一起更新。
|
||||
</p>
|
||||
</div>
|
||||
</AppCard>
|
||||
<AppCard title="创建时间">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(origin.created_at)}
|
||||
</p>
|
||||
</AppCard>
|
||||
<AppCard title="更新时间">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(origin.updated_at)}
|
||||
</p>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<AppCard title="备注">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{origin.remark || '暂无备注'}
|
||||
</p>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="关联规则"
|
||||
description="展示当前源站作为主源站绑定的规则。"
|
||||
>
|
||||
{origin.routes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无关联规则"
|
||||
description="当前源站还没有被任何规则引用。"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="text-[var(--foreground-secondary)]">
|
||||
<th className="px-3 py-3 font-medium">域名</th>
|
||||
<th className="px-3 py-3 font-medium">源站地址</th>
|
||||
<th className="px-3 py-3 font-medium">状态</th>
|
||||
<th className="px-3 py-3 font-medium">更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{origin.routes.map((route) => (
|
||||
<tr key={route.id}>
|
||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
||||
{route.domain}
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{route.origin_url}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={route.enabled ? '启用' : '停用'}
|
||||
variant={route.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||
{formatDateTime(route.updated_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<OriginEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
origin={origin}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSaved={() => {
|
||||
setFeedback({ tone: 'success', message: '源站已更新。' });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['origins', origin.id],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { createOrigin, updateOrigin } from '@/features/origins/api/origins';
|
||||
import type {
|
||||
OriginItem,
|
||||
OriginMutationPayload,
|
||||
} from '@/features/origins/types';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceTextarea,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const originSchema = z.object({
|
||||
name: z.string().max(255, '源站名不能超过 255 个字符'),
|
||||
address: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入源站地址')
|
||||
.refine(
|
||||
(value) => !/[/?#]/.test(value) && !value.includes('://'),
|
||||
'源站地址格式不合法',
|
||||
),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
type OriginFormValues = z.infer<typeof originSchema>;
|
||||
|
||||
function toPayload(values: OriginFormValues): OriginMutationPayload {
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
address: values.address.trim(),
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function toFormValues(origin?: OriginItem | null): OriginFormValues {
|
||||
if (!origin) {
|
||||
return {
|
||||
name: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: origin.name,
|
||||
address: origin.address,
|
||||
remark: origin.remark || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function OriginEditorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
origin,
|
||||
onSaved,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
origin?: OriginItem | null;
|
||||
onSaved?: (origin: OriginItem, mode: 'create' | 'update') => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<OriginFormValues>({
|
||||
resolver: zodResolver(originSchema),
|
||||
defaultValues: toFormValues(origin),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(toFormValues(origin));
|
||||
}, [form, origin, isOpen]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: OriginFormValues) => {
|
||||
const payload = toPayload(values);
|
||||
return origin ? updateOrigin(origin.id, payload) : createOrigin(payload);
|
||||
},
|
||||
onSuccess: async (savedOrigin) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['origins'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-routes'] }),
|
||||
]);
|
||||
onSaved?.(savedOrigin, origin ? 'update' : 'create');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={origin ? '编辑源站' : '新增源站'}
|
||||
description="源站会作为规则里的可复用地址目录,协议和端口仍由规则决定。"
|
||||
footer={
|
||||
<div className="flex flex-wrap justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
form="origin-editor-form"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending
|
||||
? '保存中...'
|
||||
: origin
|
||||
? '保存修改'
|
||||
: '新增源站'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id="origin-editor-form"
|
||||
className="space-y-5"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="源站地址"
|
||||
hint="支持 IP、域名或主机名,例如 10.0.0.10、origin.internal。"
|
||||
error={form.formState.errors.address?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="origin.internal"
|
||||
{...form.register('address')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="源站名"
|
||||
hint="可选,留空时默认使用源站地址。"
|
||||
error={form.formState.errors.name?.message}
|
||||
>
|
||||
<ResourceInput placeholder="主站源站" {...form.register('name')} />
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<ResourceField
|
||||
label="备注"
|
||||
error={form.formState.errors.remark?.message}
|
||||
>
|
||||
<ResourceTextarea
|
||||
placeholder="例如:主站内网入口"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
{mutation.isError ? (
|
||||
<p className="text-sm text-[var(--status-danger-foreground)]">
|
||||
{mutation.error instanceof Error
|
||||
? mutation.error.message
|
||||
: '请求失败,请稍后重试。'}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { EmptyState } from '@/components/feedback/empty-state';
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { deleteOrigin, getOrigins } from '@/features/origins/api/origins';
|
||||
import { OriginEditorModal } from '@/features/origins/components/origin-editor-modal';
|
||||
import type { OriginItem } from '@/features/origins/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
type FeedbackState = {
|
||||
tone: 'success' | 'danger';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function OriginsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||
const [editingOrigin, setEditingOrigin] = useState<OriginItem | null>(null);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||
|
||||
const originsQuery = useQuery({
|
||||
queryKey: ['origins'],
|
||||
queryFn: getOrigins,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOrigin,
|
||||
onSuccess: async () => {
|
||||
setFeedback({ tone: 'success', message: '源站已删除。' });
|
||||
await queryClient.invalidateQueries({ queryKey: ['origins'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
setFeedback({
|
||||
tone: 'danger',
|
||||
message:
|
||||
error instanceof Error ? error.message : '请求失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
|
||||
|
||||
const handleDelete = (origin: OriginItem) => {
|
||||
if (!window.confirm(`确认删除源站 ${origin.name} 吗?`)) {
|
||||
return;
|
||||
}
|
||||
setFeedback(null);
|
||||
deleteMutation.mutate(origin.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="源站"
|
||||
description="集中维护规则复用的源站地址,减少批量改地址时的重复操作。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingOrigin(null);
|
||||
setFeedback(null);
|
||||
setIsEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
新增源站
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{feedback ? (
|
||||
<InlineMessage tone={feedback.tone} message={feedback.message} />
|
||||
) : null}
|
||||
|
||||
<AppCard
|
||||
title="源站列表"
|
||||
description="编辑源站地址后,所有引用该源站的规则会同步更新。"
|
||||
>
|
||||
{originsQuery.isLoading ? (
|
||||
<LoadingState />
|
||||
) : originsQuery.isError ? (
|
||||
<ErrorState
|
||||
title="源站列表加载失败"
|
||||
description={
|
||||
originsQuery.error instanceof Error
|
||||
? originsQuery.error.message
|
||||
: '请求失败,请稍后重试。'
|
||||
}
|
||||
/>
|
||||
) : origins.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无源站"
|
||||
description="点击右上角“新增源站”开始录入。后续规则可直接复用这些地址。"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{origins.map((origin) => (
|
||||
<article
|
||||
key={origin.id}
|
||||
className="rounded-[28px] border border-[var(--border-default)] bg-[var(--surface-elevated)] p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-[var(--foreground-primary)]">
|
||||
{origin.name}
|
||||
</h2>
|
||||
<StatusBadge
|
||||
label={`${origin.route_count} 条规则`}
|
||||
variant={
|
||||
origin.route_count > 0 ? 'success' : 'warning'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--foreground-primary)]">
|
||||
{origin.address}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
{origin.remark || '暂无备注'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-panel)] px-4 py-3 text-sm text-[var(--foreground-secondary)]">
|
||||
最后更新:{formatDateTime(origin.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
<Link
|
||||
href={`/origin/detail?id=${origin.id}`}
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
详情
|
||||
</Link>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingOrigin(origin);
|
||||
setFeedback(null);
|
||||
setIsEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => handleDelete(origin)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
{isEditorOpen ? (
|
||||
<OriginEditorModal
|
||||
isOpen={isEditorOpen}
|
||||
origin={editingOrigin}
|
||||
onClose={() => setIsEditorOpen(false)}
|
||||
onSaved={(origin, mode) => {
|
||||
setFeedback({
|
||||
tone: 'success',
|
||||
message: mode === 'create' ? '源站已创建。' : '源站已更新。',
|
||||
});
|
||||
setEditingOrigin(origin);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface OriginItem {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
route_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OriginRouteSummary {
|
||||
id: number;
|
||||
domain: string;
|
||||
origin_url: string;
|
||||
enabled: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OriginDetail extends OriginItem {
|
||||
routes: OriginRouteSummary[];
|
||||
}
|
||||
|
||||
export interface OriginMutationPayload {
|
||||
name: string;
|
||||
address: string;
|
||||
remark: string;
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
} from '@/features/config-versions/api/config-versions';
|
||||
import { getManagedDomains } from '@/features/managed-domains/api/managed-domains';
|
||||
import type { ManagedDomainItem } from '@/features/managed-domains/types';
|
||||
import { getOrigins } from '@/features/origins/api/origins';
|
||||
import type { OriginItem } from '@/features/origins/types';
|
||||
import {
|
||||
createProxyRoute,
|
||||
deleteProxyRoute,
|
||||
@@ -63,27 +65,17 @@ const cachePolicyValues = [
|
||||
'path_prefix',
|
||||
'path_exact',
|
||||
] as const;
|
||||
const originProtocolValues = ['http', 'https'] as const;
|
||||
|
||||
const proxyRouteSchema = z
|
||||
.object({
|
||||
managed_domain_id: z.string().trim().min(1, '请选择网站'),
|
||||
subdomain_label: z.string(),
|
||||
origin_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入源站地址')
|
||||
.refine(
|
||||
(value) => /^https?:\/\//.test(value),
|
||||
'源站地址必须以 http:// 或 https:// 开头',
|
||||
)
|
||||
.refine((value) => {
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, '请输入合法的源站地址'),
|
||||
origin_id: z.string(),
|
||||
origin_scheme: z.enum(originProtocolValues),
|
||||
origin_address: z.string().trim().min(1, '请输入源站地址'),
|
||||
origin_port: z.string().trim().min(1, '请输入端口'),
|
||||
origin_uri: z.string(),
|
||||
origin_host: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -146,14 +138,70 @@ const proxyRouteSchema = z
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedOriginAddress = value.origin_address.trim();
|
||||
if (
|
||||
normalizedOriginAddress &&
|
||||
(/[/?#]/.test(normalizedOriginAddress) ||
|
||||
normalizedOriginAddress.includes('://'))
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_address'],
|
||||
message: '源站地址仅支持 IP、域名或主机名',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedOriginPort = value.origin_port.trim();
|
||||
const portNumber = Number(normalizedOriginPort);
|
||||
if (
|
||||
normalizedOriginPort &&
|
||||
(!/^\d+$/.test(normalizedOriginPort) ||
|
||||
!Number.isInteger(portNumber) ||
|
||||
portNumber < 1 ||
|
||||
portNumber > 65535)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_port'],
|
||||
message: '端口需为 1 到 65535 的整数',
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedOriginURI = value.origin_uri.trim();
|
||||
if (
|
||||
normalizedOriginURI &&
|
||||
!normalizedOriginURI.startsWith('/') &&
|
||||
!normalizedOriginURI.startsWith('?')
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_uri'],
|
||||
message: '源站路径需以 / 或 ? 开头',
|
||||
});
|
||||
}
|
||||
|
||||
const primaryOriginURL = buildOriginUrl(
|
||||
value.origin_scheme,
|
||||
value.origin_address,
|
||||
value.origin_port,
|
||||
value.origin_uri,
|
||||
);
|
||||
if (!primaryOriginURL) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_address'],
|
||||
message: '请输入完整的源站信息',
|
||||
});
|
||||
}
|
||||
|
||||
const upstreams = parseUpstreamsText(
|
||||
value.origin_url,
|
||||
primaryOriginURL,
|
||||
value.upstreams_text,
|
||||
);
|
||||
if (upstreams.length === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['origin_url'],
|
||||
path: ['origin_address'],
|
||||
message: '至少需要一个上游地址',
|
||||
});
|
||||
}
|
||||
@@ -213,7 +261,11 @@ type FeedbackState = {
|
||||
const defaultValues: ProxyRouteFormValues = {
|
||||
managed_domain_id: '',
|
||||
subdomain_label: '',
|
||||
origin_url: '',
|
||||
origin_id: '',
|
||||
origin_scheme: 'https',
|
||||
origin_address: '',
|
||||
origin_port: '',
|
||||
origin_uri: '',
|
||||
origin_host: '',
|
||||
upstreams_text: '',
|
||||
enabled: true,
|
||||
@@ -230,6 +282,7 @@ const defaultValues: ProxyRouteFormValues = {
|
||||
const routesQueryKey = ['proxy-routes'];
|
||||
const certificatesQueryKey = ['tls-certificates'];
|
||||
const managedDomainsQueryKey = ['managed-domains'];
|
||||
const originsQueryKey = ['origins'];
|
||||
const versionsQueryKey = ['config-versions'];
|
||||
|
||||
function hasConfigChanges(diff: {
|
||||
@@ -338,13 +391,66 @@ function buildCertificateLabel(certificate: TlsCertificateItem) {
|
||||
: certificate.name;
|
||||
}
|
||||
|
||||
function buildOriginUrl(
|
||||
scheme: 'http' | 'https',
|
||||
address: string,
|
||||
port: string,
|
||||
uri: string,
|
||||
) {
|
||||
const normalizedAddress = address.trim();
|
||||
const normalizedPort = port.trim();
|
||||
if (!normalizedAddress || !normalizedPort) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const host =
|
||||
normalizedAddress.includes(':') && !normalizedAddress.startsWith('[')
|
||||
? `[${normalizedAddress}]`
|
||||
: normalizedAddress;
|
||||
const normalizedURI = uri.trim();
|
||||
|
||||
return `${scheme}://${host}:${normalizedPort}${normalizedURI}`;
|
||||
}
|
||||
|
||||
function parseOriginUrl(rawValue: string) {
|
||||
try {
|
||||
const parsed = new URL(rawValue);
|
||||
const uri = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
return {
|
||||
scheme: (parsed.protocol.replace(':', '') || 'https') as 'http' | 'https',
|
||||
address: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'http:' ? '80' : '443'),
|
||||
uri: parsed.search ? `${uri}${parsed.search}` || parsed.search : uri,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
scheme: 'https' as const,
|
||||
address: '',
|
||||
port: '',
|
||||
uri: '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function toPayload(values: ProxyRouteFormValues): ProxyRouteMutationPayload {
|
||||
const primaryOriginUrl = buildOriginUrl(
|
||||
values.origin_scheme,
|
||||
values.origin_address,
|
||||
values.origin_port,
|
||||
values.origin_uri,
|
||||
);
|
||||
|
||||
return {
|
||||
domain: buildRouteDomain(values.managed_domain_id, values.subdomain_label),
|
||||
origin_url: values.origin_url.trim(),
|
||||
origin_id: values.origin_id ? Number(values.origin_id) : null,
|
||||
origin_url: primaryOriginUrl,
|
||||
origin_scheme: values.origin_scheme,
|
||||
origin_address: values.origin_address.trim(),
|
||||
origin_port: values.origin_port.trim(),
|
||||
origin_uri: values.origin_uri.trim(),
|
||||
origin_host: values.origin_host.trim(),
|
||||
upstreams: parseUpstreamsText(
|
||||
values.origin_url,
|
||||
primaryOriginUrl,
|
||||
values.upstreams_text,
|
||||
).slice(1),
|
||||
enabled: values.enabled,
|
||||
@@ -382,10 +488,16 @@ function toFormValues(
|
||||
);
|
||||
}
|
||||
|
||||
const parsedOrigin = parseOriginUrl(route.origin_url);
|
||||
|
||||
return {
|
||||
managed_domain_id: managedDomainMatch.managedDomainId,
|
||||
subdomain_label: managedDomainMatch.subdomainLabel,
|
||||
origin_url: route.origin_url,
|
||||
origin_id: route.origin_id ? String(route.origin_id) : '',
|
||||
origin_scheme: parsedOrigin.scheme,
|
||||
origin_address: parsedOrigin.address,
|
||||
origin_port: parsedOrigin.port,
|
||||
origin_uri: parsedOrigin.uri,
|
||||
origin_host: route.origin_host || '',
|
||||
upstreams_text: upstreams.slice(1).join('\n'),
|
||||
enabled: route.enabled,
|
||||
@@ -453,6 +565,22 @@ export function ProxyRoutesPage() {
|
||||
control: form.control,
|
||||
name: 'subdomain_label',
|
||||
});
|
||||
const watchedOriginAddress = useWatch({
|
||||
control: form.control,
|
||||
name: 'origin_address',
|
||||
});
|
||||
const watchedOriginScheme = useWatch({
|
||||
control: form.control,
|
||||
name: 'origin_scheme',
|
||||
});
|
||||
const watchedOriginPort = useWatch({
|
||||
control: form.control,
|
||||
name: 'origin_port',
|
||||
});
|
||||
const watchedOriginURI = useWatch({
|
||||
control: form.control,
|
||||
name: 'origin_uri',
|
||||
});
|
||||
const watchedEnabled = useWatch({ control: form.control, name: 'enabled' });
|
||||
const watchedEnableHttps = useWatch({
|
||||
control: form.control,
|
||||
@@ -487,6 +615,11 @@ export function ProxyRoutesPage() {
|
||||
queryFn: getManagedDomains,
|
||||
});
|
||||
|
||||
const originsQuery = useQuery({
|
||||
queryKey: originsQueryKey,
|
||||
queryFn: getOrigins,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: ProxyRouteFormValues) => {
|
||||
const payload = toPayload(values);
|
||||
@@ -506,6 +639,7 @@ export function ProxyRoutesPage() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: routesQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: managedDomainsQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: originsQueryKey }),
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -561,6 +695,17 @@ export function ProxyRoutesPage() {
|
||||
() => managedDomainsQuery.data ?? [],
|
||||
[managedDomainsQuery.data],
|
||||
);
|
||||
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data]);
|
||||
|
||||
const matchedOrigin = useMemo(
|
||||
() =>
|
||||
origins.find(
|
||||
(item) =>
|
||||
item.address.toLowerCase() ===
|
||||
watchedOriginAddress.trim().toLowerCase(),
|
||||
) ?? null,
|
||||
[origins, watchedOriginAddress],
|
||||
);
|
||||
|
||||
const selectedManagedDomain = useMemo(
|
||||
() =>
|
||||
@@ -578,6 +723,19 @@ export function ProxyRoutesPage() {
|
||||
selectedManagedDomainValue,
|
||||
watchedSubdomainLabel,
|
||||
);
|
||||
const primaryOriginPreview = buildOriginUrl(
|
||||
watchedOriginScheme,
|
||||
watchedOriginAddress,
|
||||
watchedOriginPort,
|
||||
watchedOriginURI,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue('origin_id', matchedOrigin ? String(matchedOrigin.id) : '', {
|
||||
shouldDirty: true,
|
||||
shouldValidate: false,
|
||||
});
|
||||
}, [form, matchedOrigin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!watchedEnableHttps) {
|
||||
@@ -911,13 +1069,57 @@ export function ProxyRoutesPage() {
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="源站地址"
|
||||
hint="示例:https://origin.internal"
|
||||
error={form.formState.errors.origin_url?.message}
|
||||
label="源站协议"
|
||||
hint="先选择回源协议,再选择或输入源站地址。"
|
||||
>
|
||||
<ResourceSelect
|
||||
value={watchedOriginScheme}
|
||||
onChange={(event) =>
|
||||
form.setValue(
|
||||
'origin_scheme',
|
||||
event.target.value as ProxyRouteFormValues['origin_scheme'],
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="https">https</option>
|
||||
<option value="http">http</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1.2fr_0.8fr]">
|
||||
<ResourceField
|
||||
label="源站"
|
||||
hint="可从已有源站中选择,也可以直接输入新地址;保存规则后会自动创建不存在的源站。"
|
||||
error={form.formState.errors.origin_address?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="https://origin.internal"
|
||||
{...form.register('origin_url')}
|
||||
list="proxy-route-origin-addresses"
|
||||
placeholder="origin.internal"
|
||||
{...form.register('origin_address')}
|
||||
/>
|
||||
<datalist id="proxy-route-origin-addresses">
|
||||
{origins.map((origin) => (
|
||||
<option
|
||||
key={origin.id}
|
||||
value={origin.address}
|
||||
label={origin.name}
|
||||
/>
|
||||
))}
|
||||
</datalist>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="端口"
|
||||
hint="规则主源站的访问端口。"
|
||||
error={form.formState.errors.origin_port?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="443"
|
||||
{...form.register('origin_port')}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
@@ -958,6 +1160,32 @@ export function ProxyRoutesPage() {
|
||||
)
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_1fr]">
|
||||
<ResourceField
|
||||
label="源站路径"
|
||||
hint="可选,用于保留已有带路径或查询参数的回源能力,例如 /api 或 ?token=demo。"
|
||||
error={form.formState.errors.origin_uri?.message}
|
||||
>
|
||||
<ResourceInput
|
||||
placeholder="/api"
|
||||
{...form.register('origin_uri')}
|
||||
/>
|
||||
</ResourceField>
|
||||
<AppCard
|
||||
title="主源站预览"
|
||||
description={
|
||||
matchedOrigin
|
||||
? `当前会复用已存在的源站 ${matchedOrigin.name}。`
|
||||
: '如果这里的地址尚未收录,保存规则时会自动创建一个新源站。'
|
||||
}
|
||||
>
|
||||
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
{primaryOriginPreview ||
|
||||
'选择协议、输入源站和端口后,这里会显示最终主源站地址。'}
|
||||
</p>
|
||||
</AppCard>
|
||||
</div>
|
||||
|
||||
<ResourceField
|
||||
label="回源主机名"
|
||||
hint="可选。填写后将覆盖回源请求的 Host,留空则默认使用访问域名 $host"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export interface ProxyRouteCustomHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteCustomHeader {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_host: string;
|
||||
upstreams: string;
|
||||
@@ -21,10 +22,15 @@ export interface ProxyRouteItem {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProxyRouteMutationPayload {
|
||||
domain: string;
|
||||
|
||||
export interface ProxyRouteMutationPayload {
|
||||
domain: string;
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_scheme: 'http' | 'https';
|
||||
origin_address: string;
|
||||
origin_port: string;
|
||||
origin_uri: string;
|
||||
origin_host: string;
|
||||
upstreams: string[];
|
||||
enabled: boolean;
|
||||
@@ -37,24 +43,24 @@ export interface ProxyRouteMutationPayload {
|
||||
custom_headers: ProxyRouteCustomHeader[];
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface TlsCertificateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
not_after?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchCandidate {
|
||||
managed_domain_id: number;
|
||||
domain: string;
|
||||
match_type: 'exact' | 'wildcard';
|
||||
certificate_id: number;
|
||||
certificate_name: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchResult {
|
||||
domain: string;
|
||||
matched: boolean;
|
||||
candidate?: ManagedDomainMatchCandidate;
|
||||
candidates: ManagedDomainMatchCandidate[];
|
||||
}
|
||||
|
||||
export interface TlsCertificateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
not_after?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchCandidate {
|
||||
managed_domain_id: number;
|
||||
domain: string;
|
||||
match_type: 'exact' | 'wildcard';
|
||||
certificate_id: number;
|
||||
certificate_name: string;
|
||||
}
|
||||
|
||||
export interface ManagedDomainMatchResult {
|
||||
domain: string;
|
||||
matched: boolean;
|
||||
candidate?: ManagedDomainMatchCandidate;
|
||||
candidates: ManagedDomainMatchCandidate[];
|
||||
}
|
||||
|
||||
@@ -1,51 +1,56 @@
|
||||
import type {NavigationItem} from '@/types/navigation';
|
||||
import type { NavigationItem } from '@/types/navigation';
|
||||
|
||||
export const dashboardNavigation: NavigationItem[] = [
|
||||
{
|
||||
href: '/',
|
||||
label: '总览',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
href: '/node',
|
||||
label: '节点',
|
||||
icon: 'node',
|
||||
},
|
||||
{
|
||||
href: '/website',
|
||||
label: '网站',
|
||||
icon: 'website',
|
||||
},
|
||||
{
|
||||
href: '/proxy-route',
|
||||
label: '规则',
|
||||
icon: 'proxy',
|
||||
},
|
||||
{
|
||||
href: '/config-version',
|
||||
label: '发布',
|
||||
icon: 'release',
|
||||
},
|
||||
{
|
||||
href: '/access-log',
|
||||
label: '日志',
|
||||
icon: 'log',
|
||||
},
|
||||
{
|
||||
href: '/',
|
||||
label: '总览',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
href: '/node',
|
||||
label: '节点',
|
||||
icon: 'node',
|
||||
},
|
||||
{
|
||||
href: '/website',
|
||||
label: '网站',
|
||||
icon: 'website',
|
||||
},
|
||||
{
|
||||
href: '/origin',
|
||||
label: '源站',
|
||||
icon: 'origin',
|
||||
},
|
||||
{
|
||||
href: '/proxy-route',
|
||||
label: '规则',
|
||||
icon: 'proxy',
|
||||
},
|
||||
{
|
||||
href: '/config-version',
|
||||
label: '发布',
|
||||
icon: 'release',
|
||||
},
|
||||
{
|
||||
href: '/access-log',
|
||||
label: '日志',
|
||||
icon: 'log',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/performance',
|
||||
label: '性能',
|
||||
icon: 'performance',
|
||||
},
|
||||
{
|
||||
href: '/user',
|
||||
label: '用户',
|
||||
icon: 'user',
|
||||
},
|
||||
{
|
||||
href: '/performance',
|
||||
label: '性能',
|
||||
icon: 'performance',
|
||||
},
|
||||
{
|
||||
href: '/user',
|
||||
label: '用户',
|
||||
icon: 'user',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/setting',
|
||||
label: '设置',
|
||||
icon: 'setting',
|
||||
},
|
||||
{
|
||||
href: '/setting',
|
||||
label: '设置',
|
||||
icon: 'setting',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -87,6 +87,18 @@ describe('ProxyRoutesPage', () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/origins/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/tls-certificates/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
@@ -166,6 +178,18 @@ describe('ProxyRoutesPage', () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/origins/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: '',
|
||||
data: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/tls-certificates/')) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
|
||||
@@ -2,6 +2,7 @@ export type NavigationIconKey =
|
||||
| 'home'
|
||||
| 'node'
|
||||
| 'website'
|
||||
| 'origin'
|
||||
| 'domain'
|
||||
| 'certificate'
|
||||
| 'proxy'
|
||||
|
||||
Reference in New Issue
Block a user