优化结构
This commit is contained in:
parent
690a2d71ca
commit
a2ce3ae218
1
go.mod
1
go.mod
|
|
@ -143,6 +143,7 @@ require (
|
|||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -331,6 +331,8 @@ github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdk
|
|||
github.com/qiniu/go-sdk/v7 v7.25.2 h1:URwgZpxySdiwu2yQpHk93X4LXWHyFRp1x3Vmlk/YWvo=
|
||||
github.com/qiniu/go-sdk/v7 v7.25.2/go.mod h1:dmKtJ2ahhPWFVi9o1D5GemmWoh/ctuB9peqTowyTO8o=
|
||||
github.com/qiniu/x v1.10.5/go.mod h1:03Ni9tj+N2h2aKnAz+6N0Xfl8FwMEDRC2PAlxekASDs=
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek=
|
||||
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
const IntegrationKindPayment = "payment"
|
||||
const (
|
||||
IntegrationKindPayment = "payment"
|
||||
IntegrationKindMQ = "mq"
|
||||
IntegrationKindWebSocket = "websocket"
|
||||
)
|
||||
|
||||
type IntegrationConfig struct {
|
||||
Kind string
|
||||
|
|
@ -150,10 +154,79 @@ func mergeIntegrationDefaults(defaults, values map[string]any) map[string]any {
|
|||
}
|
||||
|
||||
func ValidateIntegrationConfig(kind, provider string, values map[string]any) error {
|
||||
if normalizeIntegrationPart(kind) != IntegrationKindPayment {
|
||||
kind = normalizeIntegrationPart(kind)
|
||||
provider = normalizeIntegrationPart(provider)
|
||||
switch kind {
|
||||
case IntegrationKindPayment:
|
||||
return validatePaymentIntegrationConfig(provider, values)
|
||||
case IntegrationKindMQ, IntegrationKindWebSocket:
|
||||
return validateCommunicationIntegrationConfig(kind, provider, values)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return validatePaymentIntegrationConfig(normalizeIntegrationPart(provider), values)
|
||||
}
|
||||
|
||||
func validateCommunicationIntegrationConfig(kind, provider string, values map[string]any) error {
|
||||
definition, ok := IntegrationDefinition(kind, provider)
|
||||
if !ok {
|
||||
return errors.New("不支持的通信集成")
|
||||
}
|
||||
for _, field := range definition.Fields {
|
||||
if field.Required && integrationText(values, field.Key) == "" {
|
||||
return fmt.Errorf("%s 缺少配置字段 %s", provider, field.Key)
|
||||
}
|
||||
}
|
||||
|
||||
switch kind + "/" + provider {
|
||||
case IntegrationKindMQ + "/emqx":
|
||||
broker := strings.ToLower(integrationText(values, "broker"))
|
||||
if !strings.HasPrefix(broker, "tcp://") && !strings.HasPrefix(broker, "ssl://") && !strings.HasPrefix(broker, "ws://") && !strings.HasPrefix(broker, "wss://") && !strings.HasPrefix(broker, "mqtt://") {
|
||||
return errors.New("emqx broker 必须使用 tcp、ssl、ws、wss 或 mqtt 协议")
|
||||
}
|
||||
if keepAlive := integrationInt64(values, "keep_alive", 0); keepAlive <= 0 {
|
||||
return errors.New("emqx keep_alive 必须大于 0")
|
||||
}
|
||||
if timeout := integrationInt64(values, "connect_timeout", 0); timeout <= 0 {
|
||||
return errors.New("emqx connect_timeout 必须大于 0")
|
||||
}
|
||||
case IntegrationKindMQ + "/rabbitmq":
|
||||
port := integrationInt64(values, "port", 0)
|
||||
if port < 1 || port > 65535 {
|
||||
return errors.New("rabbitmq port 必须在 1-65535 之间")
|
||||
}
|
||||
exchangeType := strings.ToLower(integrationText(values, "exchange_type"))
|
||||
if exchangeType != "direct" && exchangeType != "fanout" && exchangeType != "topic" && exchangeType != "headers" {
|
||||
return errors.New("rabbitmq exchange_type 必须是 direct、fanout、topic 或 headers")
|
||||
}
|
||||
if integrationInt64(values, "prefetch_count", -1) < 0 {
|
||||
return errors.New("rabbitmq prefetch_count 不能小于 0")
|
||||
}
|
||||
if integrationInt64(values, "heartbeat", -1) < 0 {
|
||||
return errors.New("rabbitmq heartbeat 不能小于 0")
|
||||
}
|
||||
if integrationInt64(values, "connect_timeout", 0) <= 0 {
|
||||
return errors.New("rabbitmq connect_timeout 必须大于 0")
|
||||
}
|
||||
case IntegrationKindWebSocket + "/melody":
|
||||
path := integrationText(values, "path")
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return errors.New("websocket path 必须以 / 开头")
|
||||
}
|
||||
for _, key := range []string{"write_wait", "pong_wait", "ping_period"} {
|
||||
value := integrationText(values, key)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil || duration <= 0 {
|
||||
return fmt.Errorf("websocket %s 必须是大于 0 的时长", key)
|
||||
}
|
||||
}
|
||||
if integrationInt64(values, "max_message_size", -1) < 0 || integrationInt64(values, "message_buffer_size", -1) < 0 {
|
||||
return errors.New("websocket 消息大小和缓冲区不能小于 0")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePaymentIntegrationConfig(provider string, values map[string]any) error {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,58 @@ func genericPaymentDefinition(provider, name, description string) IntegrationCon
|
|||
}
|
||||
|
||||
var integrationDefinitions = map[string][]IntegrationConfigDefinition{
|
||||
IntegrationKindMQ: {
|
||||
{
|
||||
Kind: IntegrationKindMQ, Provider: "emqx", Name: "EMQX", Description: "EMQX MQTT 消息服务",
|
||||
Defaults: map[string]any{"broker": "tcp://127.0.0.1:1883", "client_id": "kra", "username": "", "password": "", "keep_alive": 30, "clean_session": true, "connect_timeout": 10},
|
||||
Fields: []IntegrationConfigField{
|
||||
{Key: "broker", Label: "Broker 地址", Type: "text", Required: true, Placeholder: "tcp://127.0.0.1:1883"},
|
||||
{Key: "client_id", Label: "客户端 ID", Type: "text", Required: true, Placeholder: "kra"},
|
||||
{Key: "username", Label: "用户名", Type: "text"},
|
||||
{Key: "password", Label: "密码", Type: "password", Secret: true},
|
||||
{Key: "keep_alive", Label: "心跳间隔(秒)", Type: "number", Required: true},
|
||||
{Key: "clean_session", Label: "清理会话", Type: "switch", Description: "连接时不恢复 Broker 端保存的旧会话。"},
|
||||
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Kind: IntegrationKindMQ, Provider: "rabbitmq", Name: "RabbitMQ", Description: "RabbitMQ AMQP 消息队列",
|
||||
Defaults: map[string]any{"host": "127.0.0.1", "port": 5672, "username": "guest", "password": "guest", "vhost": "/", "exchange": "kra", "exchange_type": "topic", "queue": "kra", "routing_key": "#", "durable": true, "auto_delete": false, "prefetch_count": 10, "heartbeat": 10, "connect_timeout": 10, "tls": false},
|
||||
Fields: []IntegrationConfigField{
|
||||
{Key: "host", Label: "主机", Type: "text", Required: true, Placeholder: "127.0.0.1"},
|
||||
{Key: "port", Label: "端口", Type: "number", Required: true},
|
||||
{Key: "username", Label: "用户名", Type: "text", Required: true},
|
||||
{Key: "password", Label: "密码", Type: "password", Required: true, Secret: true},
|
||||
{Key: "vhost", Label: "Virtual Host", Type: "text", Required: true, Placeholder: "/"},
|
||||
{Key: "exchange", Label: "Exchange", Type: "text", Required: true, Placeholder: "kra"},
|
||||
{Key: "exchange_type", Label: "Exchange 类型", Type: "select", Required: true, Options: []IntegrationConfigOption{{Label: "topic", Value: "topic"}, {Label: "direct", Value: "direct"}, {Label: "fanout", Value: "fanout"}, {Label: "headers", Value: "headers"}}},
|
||||
{Key: "queue", Label: "Queue", Type: "text", Required: true, Placeholder: "kra"},
|
||||
{Key: "routing_key", Label: "默认 Routing Key", Type: "text", Required: true, Placeholder: "#", Description: "业务未指定订阅键时使用;topic 类型支持 * 和 #。"},
|
||||
{Key: "durable", Label: "持久化", Type: "switch"},
|
||||
{Key: "auto_delete", Label: "自动删除", Type: "switch"},
|
||||
{Key: "prefetch_count", Label: "预取数量", Type: "number"},
|
||||
{Key: "heartbeat", Label: "心跳间隔(秒)", Type: "number"},
|
||||
{Key: "connect_timeout", Label: "连接超时(秒)", Type: "number", Required: true},
|
||||
{Key: "tls", Label: "启用 TLS", Type: "switch"},
|
||||
},
|
||||
},
|
||||
},
|
||||
IntegrationKindWebSocket: {
|
||||
{
|
||||
Kind: IntegrationKindWebSocket, Provider: "melody", Name: "WebSocket", Description: "WebSocket 实时连接服务",
|
||||
Defaults: map[string]any{"path": "/ws", "allow_origins": []string{}, "max_message_size": 0, "write_wait": "10s", "pong_wait": "60s", "ping_period": "54s", "message_buffer_size": 0, "concurrent_message_handling": false},
|
||||
Fields: []IntegrationConfigField{
|
||||
{Key: "path", Label: "访问路径", Type: "text", Required: true, Placeholder: "/ws"},
|
||||
{Key: "allow_origins", Label: "允许的来源", Type: "string-list", Placeholder: "https://admin.example.com", Description: "每行一个 Origin;留空时沿用 WebSocket 组件默认策略。"},
|
||||
{Key: "max_message_size", Label: "最大消息字节数", Type: "number", Description: "0 表示使用组件默认值。"},
|
||||
{Key: "write_wait", Label: "写入超时", Type: "text", Required: true, Placeholder: "10s"},
|
||||
{Key: "pong_wait", Label: "Pong 等待时间", Type: "text", Required: true, Placeholder: "60s"},
|
||||
{Key: "ping_period", Label: "Ping 间隔", Type: "text", Required: true, Placeholder: "54s"},
|
||||
{Key: "message_buffer_size", Label: "消息缓冲区", Type: "number", Description: "0 表示不额外缓冲。"},
|
||||
{Key: "concurrent_message_handling", Label: "并发处理消息", Type: "switch"},
|
||||
},
|
||||
},
|
||||
},
|
||||
IntegrationKindPayment: {
|
||||
paymentDefinition(PaymentAlipay, "支付宝", "支付宝 OpenAPI RSA2 支付", map[string]any{"app_id": "", "private_key": "", "public_key": "", "environment": "production", "sign_type": "RSA2", "gateway_url": "https://openapi.alipay.com/gateway.do", "method": "alipay.trade.create"},
|
||||
integrationField("app_id", "应用 ID", true, false, "text"), integrationField("private_key", "应用私钥", true, true, "textarea"), integrationField("public_key", "支付宝公钥", true, true, "textarea"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -93,8 +93,6 @@ message AdminBackend {
|
|||
Zap zap = 11;
|
||||
CORS cors = 12;
|
||||
App app = 13;
|
||||
WebSocket websocket = 14;
|
||||
MQ mq = 15;
|
||||
|
||||
message JWT {
|
||||
string signing_key = 1;
|
||||
|
|
@ -181,31 +179,6 @@ message AdminBackend {
|
|||
string env = 3;
|
||||
}
|
||||
|
||||
message WebSocket {
|
||||
bool enabled = 1;
|
||||
string path = 2;
|
||||
repeated string allow_origins = 3;
|
||||
int64 max_message_size = 4;
|
||||
google.protobuf.Duration write_wait = 5;
|
||||
google.protobuf.Duration pong_wait = 6;
|
||||
google.protobuf.Duration ping_period = 7;
|
||||
int32 message_buffer_size = 8;
|
||||
bool concurrent_message_handling = 9;
|
||||
}
|
||||
|
||||
// MQ config is persisted in sys_integration_configs (kind=mq/provider=emqx).
|
||||
// The bootstrap fields are retained as a one-time migration source.
|
||||
message MQ {
|
||||
bool enabled = 1;
|
||||
string broker = 2;
|
||||
string client_id = 3;
|
||||
string username = 4;
|
||||
string password = 5;
|
||||
int32 keep_alive = 6;
|
||||
bool clean_session = 7;
|
||||
int32 connect_timeout = 8;
|
||||
}
|
||||
|
||||
message Storage {
|
||||
string type = 1;
|
||||
Qiniu qiniu = 2;
|
||||
|
|
|
|||
|
|
@ -182,8 +182,6 @@ func (d *Data) persistConfigValuesLocked(dataConfig *conf.Data, adminConfig *con
|
|||
fileAdmin := cloneAdminConfig(adminConfig)
|
||||
fileAdmin.Storage = nil
|
||||
fileAdmin.Email = nil
|
||||
fileAdmin.Websocket = nil
|
||||
fileAdmin.Mq = nil
|
||||
adminValue, err := protoMap(fileAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -365,8 +363,6 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
|||
}
|
||||
legacyStorage := next.Admin.Storage
|
||||
legacyEmail := next.Admin.Email
|
||||
legacyWebSocket := next.Admin.Websocket
|
||||
legacyMQ := next.Admin.Mq
|
||||
currentAdmin := d.runtime.Admin()
|
||||
if legacyStorage == nil {
|
||||
if currentAdmin != nil {
|
||||
|
|
@ -376,12 +372,6 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
|||
if legacyEmail == nil && currentAdmin != nil {
|
||||
legacyEmail = currentAdmin.Email
|
||||
}
|
||||
if legacyWebSocket == nil && currentAdmin != nil {
|
||||
legacyWebSocket = currentAdmin.Websocket
|
||||
}
|
||||
if legacyMQ == nil && currentAdmin != nil {
|
||||
legacyMQ = currentAdmin.Mq
|
||||
}
|
||||
storageConfig, err := resolveStorageIntegrationConfig(candidateDB.WithContext(ctx), legacyStorage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload storage configuration: %w", err)
|
||||
|
|
@ -392,16 +382,6 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
|||
return fmt.Errorf("reload email configuration: %w", err)
|
||||
}
|
||||
next.Admin.Email = emailConfig
|
||||
websocketConfig, err := resolveWebSocketIntegrationConfig(candidateDB.WithContext(ctx), legacyWebSocket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload websocket configuration: %w", err)
|
||||
}
|
||||
next.Admin.Websocket = websocketConfig
|
||||
mqConfig, err := resolveMQIntegrationConfig(candidateDB.WithContext(ctx), legacyMQ)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload mq configuration: %w", err)
|
||||
}
|
||||
next.Admin.Mq = mqConfig
|
||||
candidateStorage, err := storage.New(next.Admin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reload storage: %w", err)
|
||||
|
|
@ -436,6 +416,9 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
|||
mongoAccepted = true
|
||||
}
|
||||
d.runtime.Replace(next.Data, next.Admin)
|
||||
if err = d.loadIntegrationRuntime(candidateDB); err != nil {
|
||||
return fmt.Errorf("reload integration runtime: %w", err)
|
||||
}
|
||||
if d.storage != nil {
|
||||
d.storage.Replace(candidateStorage)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,6 @@ func (d *Data) watchConfig() func() {
|
|||
if current := d.runtime.Admin(); current != nil {
|
||||
next.Admin.Storage = current.Storage
|
||||
next.Admin.Email = current.Email
|
||||
next.Admin.Mq = current.Mq
|
||||
next.Admin.Websocket = current.Websocket
|
||||
}
|
||||
next.Admin.ConfigPath = absolute
|
||||
d.runtime.Replace(next.Data, next.Admin)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import (
|
|||
datapayment "kra/internal/data/payment"
|
||||
datasystem "kra/internal/data/repository"
|
||||
"kra/internal/integration/storage"
|
||||
"kra/internal/integrationruntime"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ type Data struct {
|
|||
redis *reloadableRedis
|
||||
mongo *reloadableMongo
|
||||
runtime *conf.Runtime
|
||||
integrations *integrationruntime.Store
|
||||
storage *storage.Reloadable
|
||||
dbListMu sync.RWMutex
|
||||
dbList map[string]*gorm.DB
|
||||
|
|
@ -73,6 +75,15 @@ func (d *Data) Runtime() *conf.Runtime {
|
|||
return d.runtime
|
||||
}
|
||||
|
||||
// IntegrationRuntime exposes database-backed integration configuration to
|
||||
// long-lived adapters without making config.yaml part of their lifecycle.
|
||||
func (d *Data) IntegrationRuntime() *integrationruntime.Store {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return d.integrations
|
||||
}
|
||||
|
||||
// Database resolves the primary or a named database for repositories such as
|
||||
// the system export module.
|
||||
func (d *Data) Database(name string) (*gorm.DB, error) {
|
||||
|
|
@ -156,7 +167,7 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
|
|||
// and /init/initdb remain available.
|
||||
c.Database = &conf.Data_Database{}
|
||||
}
|
||||
d := &Data{runtime: runtime, appLogger: appLogger, storage: storageManager, catalog: catalog}
|
||||
d := &Data{runtime: runtime, integrations: integrationruntime.NewStore(), appLogger: appLogger, storage: storageManager, catalog: catalog}
|
||||
usingFallback := !databaseConnectionConfigured(c.Database)
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
|
|
@ -208,17 +219,10 @@ func NewData(runtime *conf.Runtime, appLogger *slog.Logger, storageManager *stor
|
|||
}
|
||||
admin.Storage = storageConfig
|
||||
admin.Email = emailConfig
|
||||
websocketConfig, websocketErr := resolveWebSocketIntegrationConfig(db, admin.Websocket)
|
||||
if websocketErr != nil {
|
||||
return nil, nil, fmt.Errorf("load websocket integration configuration: %w", websocketErr)
|
||||
}
|
||||
admin.Websocket = websocketConfig
|
||||
mqConfig, mqErr := resolveMQIntegrationConfig(db, admin.Mq)
|
||||
if mqErr != nil {
|
||||
return nil, nil, fmt.Errorf("load mq integration configuration: %w", mqErr)
|
||||
}
|
||||
admin.Mq = mqConfig
|
||||
runtime.Replace(c, admin)
|
||||
if err = d.loadIntegrationRuntime(db); err != nil {
|
||||
return nil, nil, fmt.Errorf("load integration runtime: %w", err)
|
||||
}
|
||||
activeStorage, storageErr := storage.New(admin)
|
||||
if storageErr != nil {
|
||||
return nil, nil, fmt.Errorf("initialize storage: %w", storageErr)
|
||||
|
|
|
|||
|
|
@ -69,12 +69,6 @@ func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error {
|
|||
if next.Email == nil {
|
||||
next.Email = currentAdmin.Email
|
||||
}
|
||||
if next.Websocket == nil {
|
||||
next.Websocket = currentAdmin.Websocket
|
||||
}
|
||||
if next.Mq == nil {
|
||||
next.Mq = currentAdmin.Mq
|
||||
}
|
||||
next.ConfigPath = currentAdmin.ConfigPath
|
||||
candidateStorage, err := storage.New(next)
|
||||
if err != nil {
|
||||
|
|
@ -86,12 +80,6 @@ func (d *Data) PersistAdminConfig(ctx context.Context, raw []byte) error {
|
|||
if err := d.persistEmailIntegrationConfig(ctx, next.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.PersistWebSocketConfig(ctx, next.Websocket); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistMQIntegrationConfig(ctx, next.Mq); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistConfigValues(currentData, next); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -120,12 +108,6 @@ func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byt
|
|||
if nextAdmin.Email == nil {
|
||||
nextAdmin.Email = currentAdmin.Email
|
||||
}
|
||||
if nextAdmin.Websocket == nil {
|
||||
nextAdmin.Websocket = currentAdmin.Websocket
|
||||
}
|
||||
if nextAdmin.Mq == nil {
|
||||
nextAdmin.Mq = currentAdmin.Mq
|
||||
}
|
||||
nextAdmin.ConfigPath = currentAdmin.ConfigPath
|
||||
candidateStorage, err := storage.New(nextAdmin)
|
||||
if err != nil {
|
||||
|
|
@ -137,12 +119,6 @@ func (d *Data) PersistRuntimeConfig(ctx context.Context, dataRaw, adminRaw []byt
|
|||
if err := d.persistEmailIntegrationConfig(ctx, nextAdmin.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.PersistWebSocketConfig(ctx, nextAdmin.Websocket); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistMQIntegrationConfig(ctx, nextAdmin.Mq); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.persistConfigValues(nextData, nextAdmin); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -229,14 +205,6 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig
|
|||
if err != nil {
|
||||
return fmt.Errorf("initialize email integration configuration: %w", err)
|
||||
}
|
||||
var legacyWebSocket *conf.AdminBackend_WebSocket
|
||||
if currentAdmin != nil {
|
||||
legacyWebSocket = currentAdmin.Websocket
|
||||
}
|
||||
websocketConfig, err := resolveWebSocketIntegrationConfig(candidate.WithContext(ctx), legacyWebSocket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize websocket integration configuration: %w", err)
|
||||
}
|
||||
signingKey := uuid.NewString()
|
||||
if err := d.persistDatabaseConfig(config, signingKey); err != nil {
|
||||
return fmt.Errorf("persist database configuration: %w", err)
|
||||
|
|
@ -251,14 +219,11 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *biz.DatabaseConfig
|
|||
}
|
||||
currentAdmin.Jwt.SigningKey = signingKey
|
||||
currentAdmin.Storage = storageConfig
|
||||
currentAdmin.Websocket = websocketConfig
|
||||
currentAdmin.Email = emailConfig
|
||||
mqConfig, err := resolveMQIntegrationConfig(candidate.WithContext(ctx), currentAdmin.Mq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize mq integration configuration: %w", err)
|
||||
}
|
||||
currentAdmin.Mq = mqConfig
|
||||
d.runtime.Replace(currentData, currentAdmin)
|
||||
if err = d.loadIntegrationRuntime(candidate); err != nil {
|
||||
return fmt.Errorf("initialize integration runtime: %w", err)
|
||||
}
|
||||
activated = true
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ const (
|
|||
integrationKindStorage = "storage"
|
||||
integrationKindEmail = "email"
|
||||
integrationKindPayment = "payment"
|
||||
integrationKindMQ = "mq"
|
||||
)
|
||||
|
||||
// integrationConfigPO stores credentials and provider-specific options for
|
||||
|
|
@ -35,88 +34,6 @@ type integrationConfigPO struct {
|
|||
Config string `gorm:"type:text;not null"`
|
||||
}
|
||||
|
||||
func defaultMQIntegrationConfig() *conf.AdminBackend_MQ {
|
||||
return &conf.AdminBackend_MQ{CleanSession: true, KeepAlive: 30, ConnectTimeout: 10}
|
||||
}
|
||||
|
||||
func saveMQIntegrationConfig(db *gorm.DB, config *conf.AdminBackend_MQ) error {
|
||||
if config == nil {
|
||||
config = defaultMQIntegrationConfig()
|
||||
}
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode emqx integration configuration: %w", err)
|
||||
}
|
||||
enabled := config.Enabled && strings.TrimSpace(config.Broker) != ""
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
var row integrationConfigPO
|
||||
err = clean.Where("kind = ? AND provider = ?", integrationKindMQ, "emqx").First(&row).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return clean.Create(&integrationConfigPO{Kind: integrationKindMQ, Provider: "emqx", Enabled: enabled, Config: string(raw)}).Error
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
return clean.Model(&row).Updates(map[string]any{"enabled": enabled, "config": string(raw)}).Error
|
||||
}
|
||||
}
|
||||
|
||||
func loadMQIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_MQ, bool, error) {
|
||||
var row integrationConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).Where("kind = ? AND provider = ?", integrationKindMQ, "emqx").First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !json.Valid([]byte(row.Config)) {
|
||||
return nil, false, errors.New("invalid emqx integration configuration")
|
||||
}
|
||||
config := defaultMQIntegrationConfig()
|
||||
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), config); err != nil {
|
||||
return nil, false, fmt.Errorf("decode emqx integration configuration: %w", err)
|
||||
}
|
||||
config.Enabled = row.Enabled
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
func resolveMQIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_MQ) (*conf.AdminBackend_MQ, error) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return defaultMQIntegrationConfig(), nil
|
||||
}
|
||||
return proto.Clone(legacy).(*conf.AdminBackend_MQ), nil
|
||||
}
|
||||
loaded, found, err := loadMQIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return loaded, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = defaultMQIntegrationConfig()
|
||||
}
|
||||
if err = saveMQIntegrationConfig(clean, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded, _, err = loadMQIntegrationConfig(clean)
|
||||
return loaded, err
|
||||
}
|
||||
|
||||
func (d *Data) persistMQIntegrationConfig(ctx context.Context, config *conf.AdminBackend_MQ) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
db := d.gormDB.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
return errors.New("integration configuration table does not exist")
|
||||
}
|
||||
return saveMQIntegrationConfig(db, config)
|
||||
}
|
||||
|
||||
func (integrationConfigPO) TableName() string { return "sys_integration_configs" }
|
||||
|
||||
var storageProviderNames = []string{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"kra/internal/integrationruntime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func readIntegrationRuntime(db *gorm.DB) ([]integrationruntime.Config, error) {
|
||||
if db == nil || !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
return nil, nil
|
||||
}
|
||||
var rows []integrationConfigPO
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).
|
||||
Where("kind IN ?", []string{"mq", "websocket"}).
|
||||
Order("kind ASC, provider ASC").
|
||||
Find(&rows).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
configs := make([]integrationruntime.Config, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
configs = append(configs, integrationruntime.Config{Kind: row.Kind, Provider: row.Provider, Enabled: row.Enabled, Values: []byte(row.Config)})
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func (d *Data) loadIntegrationRuntime(db *gorm.DB) error {
|
||||
configs, err := readIntegrationRuntime(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.integrations != nil {
|
||||
d.integrations.Replace(configs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"time"
|
||||
|
||||
"kra/internal/biz"
|
||||
"kra/internal/integrationruntime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
|
@ -65,7 +66,11 @@ func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, confi
|
|||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(values)
|
||||
return db.Create(&integrationConfigPO{Kind: config.Kind, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error
|
||||
if err := db.Create(&integrationConfigPO{Kind: config.Kind, Provider: config.Provider, Enabled: config.Enabled, Config: string(encoded)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.publish(config.Kind, config.Provider, config.Enabled, encoded)
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -77,11 +82,27 @@ func (r *integrationConfigRepo) SaveIntegrationConfig(ctx context.Context, confi
|
|||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(values)
|
||||
return db.Model(&row).Updates(map[string]any{"enabled": config.Enabled, "config": string(encoded)}).Error
|
||||
if err := db.Model(&row).Updates(map[string]any{"enabled": config.Enabled, "config": string(encoded)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
r.publish(config.Kind, config.Provider, config.Enabled, encoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *integrationConfigRepo) DeleteIntegrationConfig(ctx context.Context, kind, provider string) error {
|
||||
return r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error
|
||||
if err := r.data.DB().WithContext(ctx).Where("kind = ? AND provider = ?", kind, provider).Delete(&integrationConfigPO{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime := r.data.IntegrationRuntime(); runtime != nil {
|
||||
runtime.Delete(kind, provider)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *integrationConfigRepo) publish(kind, provider string, enabled bool, values []byte) {
|
||||
if runtime := r.data.IntegrationRuntime(); runtime != nil {
|
||||
runtime.Set(integrationruntime.Config{Kind: kind, Provider: provider, Enabled: enabled, Values: values})
|
||||
}
|
||||
}
|
||||
|
||||
func integrationConfigFromPO(row integrationConfigPO) *biz.IntegrationConfig {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package system
|
|||
|
||||
import (
|
||||
"kra/internal/conf"
|
||||
"kra/internal/integrationruntime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
|
@ -13,4 +14,5 @@ type Provider interface {
|
|||
Database(name string) (*gorm.DB, error)
|
||||
DatabaseReady() bool
|
||||
Runtime() *conf.Runtime
|
||||
IntegrationRuntime() *integrationruntime.Store
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const integrationKindWebSocket = "websocket"
|
||||
|
||||
// saveWebSocketIntegrationConfig persists the Melody settings in the shared
|
||||
// integration table. It intentionally lives separately from storage/payment
|
||||
// persistence so adding another transport does not expand their API surface.
|
||||
func saveWebSocketIntegrationConfig(db *gorm.DB, config *conf.AdminBackend_WebSocket) error {
|
||||
if config == nil {
|
||||
config = &conf.AdminBackend_WebSocket{}
|
||||
}
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true, EmitDefaultValues: true}.Marshal(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode websocket integration configuration: %w", err)
|
||||
}
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
var current integrationConfigPO
|
||||
err = clean.Where("kind = ? AND provider = ?", integrationKindWebSocket, "melody").First(¤t).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return clean.Create(&integrationConfigPO{Kind: integrationKindWebSocket, Provider: "melody", Enabled: config.Enabled, Config: string(raw)}).Error
|
||||
case err != nil:
|
||||
return err
|
||||
default:
|
||||
return clean.Model(¤t).Updates(map[string]any{"enabled": config.Enabled, "config": string(raw)}).Error
|
||||
}
|
||||
}
|
||||
|
||||
func loadWebSocketIntegrationConfig(db *gorm.DB) (*conf.AdminBackend_WebSocket, bool, error) {
|
||||
var row integrationConfigPO
|
||||
err := db.Session(&gorm.Session{NewDB: true}).Where("kind = ? AND provider = ?", integrationKindWebSocket, "melody").First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !json.Valid([]byte(row.Config)) {
|
||||
return nil, false, errors.New("invalid websocket integration configuration")
|
||||
}
|
||||
config := &conf.AdminBackend_WebSocket{}
|
||||
if err = (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal([]byte(row.Config), config); err != nil {
|
||||
return nil, false, fmt.Errorf("decode websocket integration configuration: %w", err)
|
||||
}
|
||||
config.Enabled = row.Enabled
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
func resolveWebSocketIntegrationConfig(db *gorm.DB, legacy *conf.AdminBackend_WebSocket) (*conf.AdminBackend_WebSocket, error) {
|
||||
clean := db.Session(&gorm.Session{NewDB: true})
|
||||
if !clean.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
if legacy == nil {
|
||||
return &conf.AdminBackend_WebSocket{}, nil
|
||||
}
|
||||
return proto.Clone(legacy).(*conf.AdminBackend_WebSocket), nil
|
||||
}
|
||||
config, found, err := loadWebSocketIntegrationConfig(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found {
|
||||
return config, nil
|
||||
}
|
||||
if legacy == nil {
|
||||
legacy = &conf.AdminBackend_WebSocket{}
|
||||
}
|
||||
if err = saveWebSocketIntegrationConfig(clean, legacy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, _, err = loadWebSocketIntegrationConfig(clean)
|
||||
return config, err
|
||||
}
|
||||
|
||||
func (d *Data) PersistWebSocketConfig(ctx context.Context, config *conf.AdminBackend_WebSocket) error {
|
||||
if !d.databaseReady.Load() {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
db := d.gormDB.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(&integrationConfigPO{}) {
|
||||
return errors.New("integration configuration table does not exist")
|
||||
}
|
||||
return saveWebSocketIntegrationConfig(db, config)
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"kra/internal/conf"
|
||||
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
)
|
||||
|
||||
func TestWebSocketIntegrationConfigRoundTrip(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
want := &conf.AdminBackend_WebSocket{
|
||||
Enabled: true, Path: "/events", AllowOrigins: []string{"https://admin.example.com"},
|
||||
MaxMessageSize: 4096, WriteWait: durationpb.New(3 * time.Second),
|
||||
PongWait: durationpb.New(20 * time.Second), PingPeriod: durationpb.New(15 * time.Second),
|
||||
MessageBufferSize: 32, ConcurrentMessageHandling: true,
|
||||
}
|
||||
if err := saveWebSocketIntegrationConfig(db, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, found, err := loadWebSocketIntegrationConfig(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("websocket integration configuration was not found")
|
||||
}
|
||||
if !got.Enabled || got.Path != want.Path || got.MaxMessageSize != want.MaxMessageSize || got.MessageBufferSize != want.MessageBufferSize {
|
||||
t.Fatalf("loaded websocket config = %#v", got)
|
||||
}
|
||||
if len(got.AllowOrigins) != 1 || got.AllowOrigins[0] != want.AllowOrigins[0] {
|
||||
t.Fatalf("allow origins = %v", got.AllowOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWebSocketIntegrationConfigPrefersDatabase(t *testing.T) {
|
||||
db := openIntegrationConfigTestDB(t)
|
||||
if err := saveWebSocketIntegrationConfig(db, &conf.AdminBackend_WebSocket{Enabled: true, Path: "/database"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := resolveWebSocketIntegrationConfig(db, &conf.AdminBackend_WebSocket{Path: "/legacy"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Path != "/database" || !got.Enabled {
|
||||
t.Fatalf("resolved websocket config = %#v", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -89,16 +89,6 @@ func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
|
|||
if adminConfig.App != nil {
|
||||
admin["app"] = adminConfig.App
|
||||
}
|
||||
if adminConfig.Websocket != nil {
|
||||
admin["websocket"] = adminConfig.Websocket
|
||||
}
|
||||
if adminConfig.Mq != nil {
|
||||
mqConfig := proto.Clone(adminConfig.Mq).(*conf.AdminBackend_MQ)
|
||||
if mqConfig.Password != "" {
|
||||
mqConfig.Password = "******"
|
||||
}
|
||||
admin["mq"] = mqConfig
|
||||
}
|
||||
}
|
||||
// Never mask secrets on the live runtime object. ConfigurationJSON is a
|
||||
// read-only operation; mutating dataConfig here would replace the actual
|
||||
|
|
@ -115,9 +105,6 @@ func (r *Repo) ConfigurationJSON() (json.RawMessage, error) {
|
|||
safeAdmin.Email.Secret = "******"
|
||||
}
|
||||
maskStorageSecrets(safeAdmin.Storage)
|
||||
if safeAdmin.Mq != nil && safeAdmin.Mq.Password != "" {
|
||||
safeAdmin.Mq.Password = "******"
|
||||
}
|
||||
}
|
||||
dataMap := map[string]any{}
|
||||
if safeData != nil {
|
||||
|
|
@ -320,9 +307,6 @@ func preserveAdminSecrets(next, current *conf.AdminBackend) {
|
|||
next.Email.Secret = current.Email.Secret
|
||||
}
|
||||
preserveStorageSecrets(next.Storage, current.Storage)
|
||||
if next.Mq != nil && current.Mq != nil && maskedSecret(next.Mq.Password) {
|
||||
next.Mq.Password = current.Mq.Password
|
||||
}
|
||||
}
|
||||
|
||||
func maskedSecret(value string) bool { return value == "" || value == "******" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
// Package integrationruntime keeps the active database-backed integration
|
||||
// settings and notifies long-lived provider clients when they change.
|
||||
package integrationruntime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Kind string
|
||||
Provider string
|
||||
Enabled bool
|
||||
Values json.RawMessage
|
||||
}
|
||||
|
||||
type listener struct {
|
||||
kind string
|
||||
provider string
|
||||
callback func(Config)
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
values map[string]Config
|
||||
listeners map[uint64]listener
|
||||
nextID uint64
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{values: make(map[string]Config), listeners: make(map[uint64]listener)}
|
||||
}
|
||||
|
||||
func configKey(kind, provider string) string {
|
||||
return strings.ToLower(strings.TrimSpace(kind)) + "/" + strings.ToLower(strings.TrimSpace(provider))
|
||||
}
|
||||
|
||||
func cloneConfig(config Config) Config {
|
||||
config.Values = append(json.RawMessage(nil), config.Values...)
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *Store) Get(kind, provider string) (Config, bool) {
|
||||
if s == nil {
|
||||
return Config{}, false
|
||||
}
|
||||
s.mu.RLock()
|
||||
config, ok := s.values[configKey(kind, provider)]
|
||||
s.mu.RUnlock()
|
||||
return cloneConfig(config), ok
|
||||
}
|
||||
|
||||
func (s *Store) Set(config Config) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
config.Kind = strings.ToLower(strings.TrimSpace(config.Kind))
|
||||
config.Provider = strings.ToLower(strings.TrimSpace(config.Provider))
|
||||
config = cloneConfig(config)
|
||||
key := configKey(config.Kind, config.Provider)
|
||||
s.mu.Lock()
|
||||
s.values[key] = config
|
||||
callbacks := s.matchingListenersLocked(config.Kind, config.Provider)
|
||||
s.mu.Unlock()
|
||||
for _, callback := range callbacks {
|
||||
callback(cloneConfig(config))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Delete(kind, provider string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
s.mu.Lock()
|
||||
delete(s.values, configKey(kind, provider))
|
||||
callbacks := s.matchingListenersLocked(kind, provider)
|
||||
s.mu.Unlock()
|
||||
config := Config{Kind: kind, Provider: provider}
|
||||
for _, callback := range callbacks {
|
||||
callback(config)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Replace(configs []Config) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
next := make(map[string]Config, len(configs))
|
||||
for _, config := range configs {
|
||||
config.Kind = strings.ToLower(strings.TrimSpace(config.Kind))
|
||||
config.Provider = strings.ToLower(strings.TrimSpace(config.Provider))
|
||||
config = cloneConfig(config)
|
||||
next[configKey(config.Kind, config.Provider)] = config
|
||||
}
|
||||
s.mu.Lock()
|
||||
previous := s.values
|
||||
s.values = next
|
||||
listeners := make([]listener, 0, len(s.listeners))
|
||||
for _, item := range s.listeners {
|
||||
listeners = append(listeners, item)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
changed := make(map[string]Config, len(previous)+len(next))
|
||||
for key, config := range previous {
|
||||
changed[key] = Config{Kind: config.Kind, Provider: config.Provider}
|
||||
}
|
||||
for key, config := range next {
|
||||
changed[key] = config
|
||||
}
|
||||
for _, item := range listeners {
|
||||
if config, ok := changed[configKey(item.kind, item.provider)]; ok {
|
||||
item.callback(cloneConfig(config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Subscribe(kind, provider string, callback func(Config)) func() {
|
||||
if s == nil || callback == nil {
|
||||
return func() {}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
s.listeners[id] = listener{kind: strings.ToLower(strings.TrimSpace(kind)), provider: strings.ToLower(strings.TrimSpace(provider)), callback: callback}
|
||||
s.mu.Unlock()
|
||||
return func() {
|
||||
s.mu.Lock()
|
||||
delete(s.listeners, id)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) matchingListenersLocked(kind, provider string) []func(Config) {
|
||||
callbacks := make([]func(Config), 0)
|
||||
for _, item := range s.listeners {
|
||||
if item.kind == kind && item.provider == provider {
|
||||
callbacks = append(callbacks, item.callback)
|
||||
}
|
||||
}
|
||||
return callbacks
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package integrationruntime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStoreSetDeleteAndSubscribe(t *testing.T) {
|
||||
store := NewStore()
|
||||
updates := make(chan Config, 2)
|
||||
stop := store.Subscribe("mq", "rabbitmq", func(config Config) { updates <- config })
|
||||
defer stop()
|
||||
|
||||
store.Set(Config{Kind: "MQ", Provider: "RabbitMQ", Enabled: true, Values: json.RawMessage(`{"host":"localhost"}`)})
|
||||
loaded, ok := store.Get("mq", "rabbitmq")
|
||||
if !ok || !loaded.Enabled || string(loaded.Values) != `{"host":"localhost"}` {
|
||||
t.Fatalf("loaded config = %#v, ok=%v", loaded, ok)
|
||||
}
|
||||
if update := <-updates; !update.Enabled {
|
||||
t.Fatalf("set update = %#v", update)
|
||||
}
|
||||
|
||||
store.Delete("mq", "rabbitmq")
|
||||
if _, ok = store.Get("mq", "rabbitmq"); ok {
|
||||
t.Fatal("deleted config remained in store")
|
||||
}
|
||||
if update := <-updates; update.Enabled {
|
||||
t.Fatalf("delete update = %#v", update)
|
||||
}
|
||||
}
|
||||
10
pkg/mq/mq.go
10
pkg/mq/mq.go
|
|
@ -35,6 +35,16 @@ type Client interface {
|
|||
Close() error
|
||||
}
|
||||
|
||||
// Registry exposes named broker clients while preserving Client as the
|
||||
// default EMQX/MQTT boundary for existing modules.
|
||||
type Registry interface {
|
||||
Client(provider string) Client
|
||||
PublishTo(context.Context, string, string, []byte, byte, bool) error
|
||||
SubscribeTo(context.Context, string, string, byte, Handler) error
|
||||
UnsubscribeFrom(context.Context, string, ...string) error
|
||||
ConnectedTo(provider string) bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Broker string
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
"/src/view/system/security/forceChangePassword.vue": "ForceChangePassword",
|
||||
"/src/view/system/security/index.vue": "SecurityConfig",
|
||||
"/src/view/system/state.vue": "State",
|
||||
"/src/view/systemTools/integration/config.vue": "IntegrationConfig",
|
||||
"/src/view/systemTools/logViewer/index.vue": "LogViewer",
|
||||
"/src/view/systemTools/sysError/sysError.vue": "SysError",
|
||||
"/src/view/systemTools/system/system.vue": "Config",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,762 @@
|
|||
<template>
|
||||
<div class="kra-table-box integration-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h2>通信集成</h2>
|
||||
<p>消息队列与实时连接</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="load">
|
||||
刷新
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<div v-loading="loading" class="integration-layout">
|
||||
<aside class="provider-panel" aria-label="通信集成列表">
|
||||
<div class="panel-heading">
|
||||
<span>服务</span>
|
||||
<span>{{ integrations.length }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="item in integrations"
|
||||
:key="integrationKey(item)"
|
||||
type="button"
|
||||
class="provider-item"
|
||||
:class="{ active: integrationKey(item) === selectedKey }"
|
||||
@click="selectedKey = integrationKey(item)"
|
||||
>
|
||||
<span class="provider-icon" aria-hidden="true">
|
||||
<el-icon><component :is="providerMeta(item).icon" /></el-icon>
|
||||
</span>
|
||||
<span class="provider-copy">
|
||||
<strong>{{ item.name || providerMeta(item).name }}</strong>
|
||||
<small>{{ providerMeta(item).protocol }}</small>
|
||||
</span>
|
||||
<span class="provider-state" :class="{ enabled: item.enabled }">
|
||||
{{ item.enabled ? '运行中' : '已停用' }}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section v-if="selected" class="editor-panel">
|
||||
<header class="editor-heading">
|
||||
<div class="editor-title-group">
|
||||
<div class="editor-title-row">
|
||||
<h3>{{ selected.name || providerMeta(selected).name }}</h3>
|
||||
<el-tag v-if="isDirty(selected)" type="warning" effect="plain">
|
||||
未保存
|
||||
</el-tag>
|
||||
</div>
|
||||
<p>{{ selected.description || providerMeta(selected).description }}</p>
|
||||
</div>
|
||||
<div class="enable-control">
|
||||
<span>{{ selected.enabled ? '已启用' : '已停用' }}</span>
|
||||
<el-switch
|
||||
:model-value="selected.enabled"
|
||||
:loading="isToggling(selected)"
|
||||
:disabled="isBusy(selected)"
|
||||
aria-label="启用服务"
|
||||
@change="(value) => toggleIntegration(selected, value)"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert
|
||||
v-if="hasMaskedSecret(selected)"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="secret-alert"
|
||||
title="密码已脱敏;保留 ****** 将继续使用当前密码。"
|
||||
/>
|
||||
|
||||
<el-form
|
||||
:model="selected.config"
|
||||
label-position="top"
|
||||
class="config-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<div class="field-grid">
|
||||
<el-form-item
|
||||
v-for="field in selected.fields || []"
|
||||
:key="field.key"
|
||||
:required="field.required"
|
||||
:error="fieldError(selected, field.key)"
|
||||
>
|
||||
<template #label>
|
||||
<span class="field-label">
|
||||
<span>{{ field.label }}</span>
|
||||
<small>{{ field.key }}</small>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<el-select
|
||||
v-if="field.type === 'select'"
|
||||
v-model="selected.config[field.key]"
|
||||
class="field-control"
|
||||
:placeholder="field.placeholder || '请选择'"
|
||||
@update:model-value="clearFieldError(selected, field.key)"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in field.options || []"
|
||||
:key="String(option.value)"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-switch
|
||||
v-else-if="field.type === 'switch'"
|
||||
v-model="selected.config[field.key]"
|
||||
@update:model-value="clearFieldError(selected, field.key)"
|
||||
/>
|
||||
|
||||
<el-input-number
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="selected.config[field.key]"
|
||||
class="field-control"
|
||||
:min="numberConstraint(field.key).min"
|
||||
:max="numberConstraint(field.key).max"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
@update:model-value="clearFieldError(selected, field.key)"
|
||||
/>
|
||||
|
||||
<el-input
|
||||
v-else-if="field.type === 'string-list'"
|
||||
:model-value="stringListDraft(selected, field.key)"
|
||||
class="field-control"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="field.placeholder || '每行一个值'"
|
||||
spellcheck="false"
|
||||
@input="(value) => updateStringList(selected, field.key, value)"
|
||||
/>
|
||||
|
||||
<el-input
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="selected.config[field.key]"
|
||||
class="field-control"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="field.placeholder"
|
||||
:show-password="field.secret"
|
||||
spellcheck="false"
|
||||
@update:model-value="clearFieldError(selected, field.key)"
|
||||
/>
|
||||
|
||||
<el-input
|
||||
v-else
|
||||
v-model="selected.config[field.key]"
|
||||
class="field-control"
|
||||
:type="field.secret ? 'password' : 'text'"
|
||||
:show-password="field.secret"
|
||||
:placeholder="field.placeholder"
|
||||
spellcheck="false"
|
||||
@update:model-value="clearFieldError(selected, field.key)"
|
||||
/>
|
||||
|
||||
<p v-if="field.description" class="field-description">
|
||||
{{ field.description }}
|
||||
</p>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<footer class="editor-actions">
|
||||
<span class="save-state">
|
||||
{{ selected.configured ? '配置已创建' : '尚未保存配置' }}
|
||||
</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="Check"
|
||||
:loading="isSaving(selected)"
|
||||
:disabled="isBusy(selected) || !isDirty(selected)"
|
||||
@click="saveSelected"
|
||||
>
|
||||
保存配置
|
||||
</el-button>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<el-empty v-else description="暂无通信集成配置" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ChatLineRound,
|
||||
Check,
|
||||
Connection,
|
||||
Promotion,
|
||||
Refresh
|
||||
} from '@element-plus/icons-vue'
|
||||
import {
|
||||
getIntegrationConfigs,
|
||||
saveIntegrationConfig
|
||||
} from '@/api/integration'
|
||||
|
||||
defineOptions({ name: 'IntegrationConfig' })
|
||||
|
||||
const TARGETS = {
|
||||
'mq/emqx': {
|
||||
name: 'EMQX',
|
||||
protocol: 'MQTT',
|
||||
description: 'EMQX MQTT 消息服务',
|
||||
icon: Connection
|
||||
},
|
||||
'mq/rabbitmq': {
|
||||
name: 'RabbitMQ',
|
||||
protocol: 'AMQP',
|
||||
description: 'RabbitMQ 消息队列',
|
||||
icon: Promotion
|
||||
},
|
||||
'websocket/melody': {
|
||||
name: 'WebSocket',
|
||||
protocol: 'WS',
|
||||
description: 'WebSocket 实时连接服务',
|
||||
icon: ChatLineRound
|
||||
}
|
||||
}
|
||||
|
||||
const TARGET_ORDER = Object.keys(TARGETS)
|
||||
const NUMBER_CONSTRAINTS = {
|
||||
port: { min: 1, max: 65535 },
|
||||
keep_alive: { min: 1 },
|
||||
connect_timeout: { min: 1 },
|
||||
prefetch_count: { min: 0 },
|
||||
heartbeat: { min: 0 },
|
||||
max_message_size: { min: 0 },
|
||||
message_buffer_size: { min: 0 }
|
||||
}
|
||||
const DURATION_FIELDS = new Set(['write_wait', 'pong_wait', 'ping_period'])
|
||||
const DURATION_PATTERN = /^(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+$/i
|
||||
|
||||
const integrations = ref([])
|
||||
const selectedKey = ref(TARGET_ORDER[0])
|
||||
const loading = ref(false)
|
||||
const pending = reactive({})
|
||||
const errors = reactive({})
|
||||
const listDrafts = reactive({})
|
||||
|
||||
const selected = computed(
|
||||
() =>
|
||||
integrations.value.find(
|
||||
(item) => integrationKey(item) === selectedKey.value
|
||||
) || integrations.value[0]
|
||||
)
|
||||
|
||||
const integrationKey = (item) => `${item.kind}/${item.provider}`
|
||||
const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]]
|
||||
const operationKey = (item) => integrationKey(item)
|
||||
const errorKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
||||
const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}`
|
||||
const cloneConfig = (value) => JSON.parse(JSON.stringify(value || {}))
|
||||
|
||||
const normalizeIntegration = (item) => {
|
||||
const normalized = {
|
||||
...item,
|
||||
enabled: Boolean(item.enabled),
|
||||
configured: Boolean(item.configured),
|
||||
config: cloneConfig(item.config),
|
||||
fields: Array.isArray(item.fields) ? item.fields : []
|
||||
}
|
||||
normalized._savedEnabled = normalized.enabled
|
||||
normalized._savedConfig = cloneConfig(normalized.config)
|
||||
for (const field of normalized.fields) {
|
||||
if (field.type === 'string-list') {
|
||||
const value = normalized.config[field.key]
|
||||
listDrafts[listKey(normalized, field.key)] = Array.isArray(value)
|
||||
? value.join('\n')
|
||||
: ''
|
||||
normalized.config[field.key] = Array.isArray(value) ? value : []
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const [mq, websocket] = await Promise.all([
|
||||
getIntegrationConfigs('mq'),
|
||||
getIntegrationConfigs('websocket')
|
||||
])
|
||||
if (mq.code !== 0 || websocket.code !== 0) return
|
||||
|
||||
Object.keys(listDrafts).forEach((key) => delete listDrafts[key])
|
||||
Object.keys(errors).forEach((key) => delete errors[key])
|
||||
const loaded = [...(mq.data || []), ...(websocket.data || [])]
|
||||
.filter((item) => TARGETS[integrationKey(item)])
|
||||
.map(normalizeIntegration)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
TARGET_ORDER.indexOf(integrationKey(left)) -
|
||||
TARGET_ORDER.indexOf(integrationKey(right))
|
||||
)
|
||||
integrations.value = loaded
|
||||
if (!loaded.some((item) => integrationKey(item) === selectedKey.value)) {
|
||||
selectedKey.value = integrationKey(loaded[0] || { kind: '', provider: '' })
|
||||
}
|
||||
} catch {
|
||||
// The request layer already presents transport errors.
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isBusy = (item) => Boolean(pending[operationKey(item)])
|
||||
const isSaving = (item) => pending[operationKey(item)] === 'save'
|
||||
const isToggling = (item) => pending[operationKey(item)] === 'toggle'
|
||||
const numberConstraint = (fieldKey) =>
|
||||
NUMBER_CONSTRAINTS[fieldKey] || { min: undefined, max: undefined }
|
||||
|
||||
const fieldError = (item, fieldKey) => errors[errorKey(item, fieldKey)] || ''
|
||||
const clearFieldError = (item, fieldKey) => {
|
||||
delete errors[errorKey(item, fieldKey)]
|
||||
}
|
||||
|
||||
const stringListDraft = (item, fieldKey) => listDrafts[listKey(item, fieldKey)] || ''
|
||||
const updateStringList = (item, fieldKey, value) => {
|
||||
listDrafts[listKey(item, fieldKey)] = value
|
||||
item.config[fieldKey] = String(value)
|
||||
.split('\n')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
clearFieldError(item, fieldKey)
|
||||
}
|
||||
|
||||
const isMissing = (value) => {
|
||||
if (Array.isArray(value)) return value.length === 0
|
||||
if (typeof value === 'string') return value.trim() === ''
|
||||
return value === null || typeof value === 'undefined'
|
||||
}
|
||||
|
||||
const validate = (item, enabled = item.enabled) => {
|
||||
for (const field of item.fields || []) clearFieldError(item, field.key)
|
||||
let firstInvalid = ''
|
||||
|
||||
for (const field of item.fields || []) {
|
||||
const value = item.config[field.key]
|
||||
let message = ''
|
||||
if (enabled && field.required && isMissing(value)) {
|
||||
message = `请填写${field.label}`
|
||||
} else if (field.type === 'number' && !isMissing(value)) {
|
||||
const number = Number(value)
|
||||
const constraint = numberConstraint(field.key)
|
||||
if (!Number.isFinite(number)) {
|
||||
message = `${field.label}必须是数字`
|
||||
} else if (constraint.min !== undefined && number < constraint.min) {
|
||||
message = `${field.label}不能小于 ${constraint.min}`
|
||||
} else if (constraint.max !== undefined && number > constraint.max) {
|
||||
message = `${field.label}不能大于 ${constraint.max}`
|
||||
}
|
||||
} else if (
|
||||
DURATION_FIELDS.has(field.key) &&
|
||||
!isMissing(value) &&
|
||||
!DURATION_PATTERN.test(String(value).trim())
|
||||
) {
|
||||
message = `${field.label}格式无效,例如 10s 或 1m30s`
|
||||
} else if (field.key === 'path' && value && !String(value).startsWith('/')) {
|
||||
message = '访问路径必须以 / 开头'
|
||||
}
|
||||
|
||||
if (message) {
|
||||
errors[errorKey(item, field.key)] = message
|
||||
firstInvalid ||= field.label
|
||||
}
|
||||
}
|
||||
|
||||
if (firstInvalid) {
|
||||
ElMessage.warning(`请检查${firstInvalid}等配置项`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const savedSnapshot = (item) =>
|
||||
JSON.stringify({ enabled: item._savedEnabled, config: item._savedConfig })
|
||||
const currentSnapshot = (item) =>
|
||||
JSON.stringify({ enabled: item.enabled, config: item.config })
|
||||
const isDirty = (item) => savedSnapshot(item) !== currentSnapshot(item)
|
||||
|
||||
const hasMaskedSecret = (item) =>
|
||||
(item.fields || []).some(
|
||||
(field) => field.secret && item.config[field.key] === '******'
|
||||
)
|
||||
|
||||
const markSaved = (item) => {
|
||||
item.configured = true
|
||||
item._savedEnabled = item.enabled
|
||||
item._savedConfig = cloneConfig(item.config)
|
||||
}
|
||||
|
||||
const persist = async (item, operation) => {
|
||||
const key = operationKey(item)
|
||||
if (pending[key]) return false
|
||||
pending[key] = operation
|
||||
try {
|
||||
const res = await saveIntegrationConfig(item.kind, item.provider, {
|
||||
enabled: item.enabled,
|
||||
config: item.config
|
||||
})
|
||||
if (res.code !== 0) {
|
||||
ElMessage.error(res.msg || '保存失败')
|
||||
return false
|
||||
}
|
||||
markSaved(item)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
delete pending[key]
|
||||
}
|
||||
}
|
||||
|
||||
const saveSelected = async () => {
|
||||
const item = selected.value
|
||||
if (!item || isBusy(item) || !validate(item)) return
|
||||
if (await persist(item, 'save')) {
|
||||
ElMessage.success(`${item.name || providerMeta(item).name} 配置已保存`)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleIntegration = async (item, enabled) => {
|
||||
if (isBusy(item)) return
|
||||
const previous = item.enabled
|
||||
item.enabled = Boolean(enabled)
|
||||
if (item.enabled && !validate(item, true)) {
|
||||
item.enabled = previous
|
||||
return
|
||||
}
|
||||
|
||||
if (await persist(item, 'toggle')) {
|
||||
ElMessage.success(
|
||||
`${item.name || providerMeta(item).name} 已${item.enabled ? '启用' : '停用'}`
|
||||
)
|
||||
return
|
||||
}
|
||||
item.enabled = previous
|
||||
ElMessage.warning('状态未改变')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.integration-page {
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.page-heading p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.integration-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
min-height: 560px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.provider-panel {
|
||||
padding: 12px 9px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px 11px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
min-height: 58px;
|
||||
padding: 8px 9px;
|
||||
border: 0;
|
||||
border-left: 3px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-item:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.provider-item.active {
|
||||
border-left-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.provider-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.provider-item.active .provider-icon {
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.provider-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.provider-copy strong {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-copy small {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.provider-state {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-state.enabled {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
padding: 22px 28px 20px;
|
||||
}
|
||||
|
||||
.editor-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.editor-title-group {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.editor-title-row h3 {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.editor-title-group p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.enable-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 32px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secret-alert {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
flex: 1;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 22px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-label small {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.field-control {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-description {
|
||||
width: 100%;
|
||||
margin: 5px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.save-state {
|
||||
margin-right: auto;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.integration-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
grid-template-columns: 30px minmax(0, 1fr);
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
.provider-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.provider-state {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.integration-page {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.provider-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.provider-state {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.editor-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.enable-control {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.save-state {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue