From ceb14b3b3f75be99a939e4b4e9ca403d151ec6a3 Mon Sep 17 00:00:00 2001 From: Qingshan Chen <38182824+Iamlovingit@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:11:03 +0800 Subject: [PATCH] feat: add OpenClaw resource management and bootstrap flows --- .github/workflows/build.yml | 2 +- backend/cmd/server/main.go | 29 +- .../deployments/k8s/clawreef-incluster.yaml | 108 ++ backend/internal/db/db.go | 4 + backend/internal/db/migrations.go | 229 +++ .../db/migrations/001_init_schema.sql | 19 +- .../006_add_openclaw_config_center.sql | 82 + backend/internal/db/migrations_test.go | 31 + backend/internal/handlers/instance_handler.go | 54 +- .../handlers/openclaw_config_handler.go | 271 +++ backend/internal/models/instance.go | 55 +- backend/internal/models/openclaw_config.go | 75 + .../repository/openclaw_config_repository.go | 213 +++ backend/internal/services/instance_service.go | 163 +- backend/internal/services/k8s/client.go | 5 + backend/internal/services/k8s/pod_service.go | 36 +- .../internal/services/k8s/secret_service.go | 58 +- .../services/openclaw_config_service.go | 1636 +++++++++++++++++ .../services/openclaw_config_service_test.go | 136 ++ backend/internal/utils/response.go | 10 +- deployments/k8s/clawmanager.yaml | 110 +- .../components/OpenClawConfigPlanSection.tsx | 187 ++ frontend/src/components/UserLayout.tsx | 1 + frontend/src/lib/i18n.ts | 955 ++++++++++ frontend/src/lib/openclawChannelTemplates.ts | 71 + .../pages/instances/CreateInstancePage.tsx | 234 ++- .../openclaw/OpenClawConfigCenterPage.tsx | 1463 +++++++++++++++ frontend/src/router/index.tsx | 9 + .../src/services/openclawConfigService.ts | 92 + frontend/src/types/instance.ts | 4 + frontend/src/types/openclawConfig.ts | 110 ++ 31 files changed, 6270 insertions(+), 182 deletions(-) create mode 100644 backend/internal/db/migrations.go create mode 100644 backend/internal/db/migrations/006_add_openclaw_config_center.sql create mode 100644 backend/internal/db/migrations_test.go create mode 100644 backend/internal/handlers/openclaw_config_handler.go create mode 100644 backend/internal/models/openclaw_config.go create mode 100644 backend/internal/repository/openclaw_config_repository.go create mode 100644 backend/internal/services/openclaw_config_service.go create mode 100644 backend/internal/services/openclaw_config_service_test.go create mode 100644 frontend/src/components/OpenClawConfigPlanSection.tsx create mode 100644 frontend/src/lib/openclawChannelTemplates.ts create mode 100644 frontend/src/pages/openclaw/OpenClawConfigCenterPage.tsx create mode 100644 frontend/src/services/openclawConfigService.ts create mode 100644 frontend/src/types/openclawConfig.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a6b9844..fb5656f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,7 +147,7 @@ jobs: ) text = re.sub( - r"\n\s+nodePort:\s+39443", + r"\n\s+nodePort:\s+\d+", "", text, count=1, diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3abca6b..e169049 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -53,6 +53,7 @@ func main() { chatMessageRepo := repository.NewChatMessageRepository(database) riskRuleRepo := repository.NewRiskRuleRepository(database) riskHitRepo := repository.NewRiskHitRepository(database) + openClawConfigRepo := repository.NewOpenClawConfigRepository(database) if repaired, repairErr := services.RepairSeededAdminPassword(userRepo); repairErr != nil { log.Printf("Warning: failed to repair seeded admin password: %v", repairErr) @@ -74,10 +75,11 @@ func main() { riskDetectionService := services.NewRiskDetectionService(riskRuleRepo) riskHitService := services.NewRiskHitService(riskHitRepo) riskRuleService := services.NewRiskRuleService(riskRuleRepo) + openClawConfigService := services.NewOpenClawConfigService(openClawConfigRepo) aiObservabilityService := services.NewAIObservabilityService(modelInvocationRepo, auditEventRepo, costRecordRepo, riskHitRepo, chatMessageRepo, llmModelRepo, instanceRepo, userRepo) clusterResourceService := services.NewClusterResourceService(instanceRepo) services.SetRuntimeImageSettingsProvider(systemImageSettingService) - instanceService := services.NewInstanceService(instanceRepo, quotaRepo, llmModelRepo) + instanceService := services.NewInstanceService(instanceRepo, quotaRepo, llmModelRepo, openClawConfigService) aiGatewayService := aigateway.NewService(llmModelRepo, modelInvocationService, auditEventService, costRecordService, riskDetectionService, riskHitService, chatSessionService, chatMessageService) // Initialize handlers @@ -91,6 +93,7 @@ func main() { riskRuleHandler := handlers.NewRiskRuleHandler(riskRuleService) clusterResourceHandler := handlers.NewClusterResourceHandler(clusterResourceService) egressProxyHandler := handlers.NewEgressProxyHandler() + openClawConfigHandler := handlers.NewOpenClawConfigHandler(openClawConfigService) // Initialize WebSocket hub and handler wsHub := services.GetHub() @@ -168,6 +171,30 @@ func main() { instances.POST("/:id/openclaw/import", instanceHandler.ImportOpenClaw) } + openClawConfigs := api.Group("/openclaw-configs") + openClawConfigs.Use(middleware.Auth()) + openClawConfigs.Use(middleware.SetUserInfo(userRepo)) + { + openClawConfigs.GET("/resources", openClawConfigHandler.ListResources) + openClawConfigs.POST("/resources", openClawConfigHandler.CreateResource) + openClawConfigs.POST("/resources/validate", openClawConfigHandler.ValidateResource) + openClawConfigs.GET("/resources/:id", openClawConfigHandler.GetResource) + openClawConfigs.PUT("/resources/:id", openClawConfigHandler.UpdateResource) + openClawConfigs.DELETE("/resources/:id", openClawConfigHandler.DeleteResource) + openClawConfigs.POST("/resources/:id/clone", openClawConfigHandler.CloneResource) + + openClawConfigs.GET("/bundles", openClawConfigHandler.ListBundles) + openClawConfigs.POST("/bundles", openClawConfigHandler.CreateBundle) + openClawConfigs.GET("/bundles/:id", openClawConfigHandler.GetBundle) + openClawConfigs.PUT("/bundles/:id", openClawConfigHandler.UpdateBundle) + openClawConfigs.DELETE("/bundles/:id", openClawConfigHandler.DeleteBundle) + openClawConfigs.POST("/bundles/:id/clone", openClawConfigHandler.CloneBundle) + + openClawConfigs.POST("/compile-preview", openClawConfigHandler.CompilePreview) + openClawConfigs.GET("/injections", openClawConfigHandler.ListSnapshots) + openClawConfigs.GET("/injections/:id", openClawConfigHandler.GetSnapshot) + } + systemSettings := api.Group("/system-settings") systemSettings.Use(middleware.Auth()) systemSettings.Use(middleware.SetUserInfo(userRepo)) diff --git a/backend/deployments/k8s/clawreef-incluster.yaml b/backend/deployments/k8s/clawreef-incluster.yaml index 9e832b3..45a967d 100644 --- a/backend/deployments/k8s/clawreef-incluster.yaml +++ b/backend/deployments/k8s/clawreef-incluster.yaml @@ -171,6 +171,114 @@ data: USE clawreef; ALTER TABLE instances MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu'; + 003_add_system_image_settings.sql: | + USE clawreef; + CREATE TABLE IF NOT EXISTS system_image_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_type VARCHAR(50) NOT NULL UNIQUE, + display_name VARCHAR(255) NOT NULL, + image VARCHAR(500) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_instance_type (instance_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + 004_fix_seeded_admin_password.sql: | + USE clawreef; + UPDATE users + SET password_hash = '$2a$10$pbenze514mwv3pvQySQBVOsF5J4DBXL2kVo1hLa8JFhQu5x3AKvBi' + WHERE username = 'admin' + AND password_hash = '$2a$10$N9qo8uLOickgx2ZMRZoMy.MqrzL9wGC3qD3Q.ZHqQH6t3q7l1L5uG'; + 005_update_openclaw_default_image.sql: | + USE clawreef; + UPDATE system_image_settings + SET image = 'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest' + WHERE instance_type = 'openclaw' + AND image = 'ericpearlee/openclaw:v2026.3.24'; + 006_add_openclaw_config_center.sql: | + USE clawreef; + SET @openclaw_snapshot_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'instances' + AND COLUMN_NAME = 'openclaw_config_snapshot_id' + ); + SET @openclaw_snapshot_column_sql = IF( + @openclaw_snapshot_column_exists = 0, + 'ALTER TABLE instances ADD COLUMN openclaw_config_snapshot_id INT NULL AFTER access_token', + 'SELECT 1' + ); + PREPARE openclaw_snapshot_column_stmt FROM @openclaw_snapshot_column_sql; + EXECUTE openclaw_snapshot_column_stmt; + DEALLOCATE PREPARE openclaw_snapshot_column_stmt; + + CREATE TABLE IF NOT EXISTS openclaw_config_resources ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + resource_type VARCHAR(50) NOT NULL, + resource_key VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + version INT NOT NULL DEFAULT 1, + tags_json LONGTEXT NOT NULL, + content_json LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uk_openclaw_resource_key (user_id, resource_type, resource_key), + INDEX idx_openclaw_resource_user_type (user_id, resource_type), + INDEX idx_openclaw_resource_user_enabled (user_id, enabled) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS openclaw_config_bundles ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_openclaw_bundle_user (user_id), + INDEX idx_openclaw_bundle_user_enabled (user_id, enabled) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS openclaw_config_bundle_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + bundle_id INT NOT NULL, + resource_id INT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + required BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (bundle_id) REFERENCES openclaw_config_bundles(id) ON DELETE CASCADE, + FOREIGN KEY (resource_id) REFERENCES openclaw_config_resources(id) ON DELETE CASCADE, + UNIQUE KEY uk_openclaw_bundle_resource (bundle_id, resource_id), + INDEX idx_openclaw_bundle_item_bundle (bundle_id, sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS openclaw_injection_snapshots ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NULL, + user_id INT NOT NULL, + mode VARCHAR(20) NOT NULL, + bundle_id INT NULL, + selected_resource_ids_json LONGTEXT NOT NULL, + resolved_resources_json LONGTEXT NOT NULL, + rendered_manifest_json LONGTEXT NOT NULL, + rendered_env_json LONGTEXT NOT NULL, + secret_name VARCHAR(255) NULL, + status VARCHAR(30) NOT NULL DEFAULT 'pending', + error_message TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + activated_at TIMESTAMP NULL, + INDEX idx_openclaw_snapshot_user_created (user_id, created_at), + INDEX idx_openclaw_snapshot_instance (instance_id), + INDEX idx_openclaw_snapshot_bundle (bundle_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; --- apiVersion: v1 kind: PersistentVolumeClaim diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index b74a8e4..48d33c0 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -35,6 +35,10 @@ func Initialize(cfg config.DatabaseConfig) (db.Session, error) { _ = session.Close() return nil, fmt.Errorf("failed to configure database connection charset: %w", err) } + if err := applyEmbeddedMigrations(session); err != nil { + _ = session.Close() + return nil, fmt.Errorf("failed to apply database migrations: %w", err) + } Session = session log.Println("Database connected successfully") diff --git a/backend/internal/db/migrations.go b/backend/internal/db/migrations.go new file mode 100644 index 0000000..7df40da --- /dev/null +++ b/backend/internal/db/migrations.go @@ -0,0 +1,229 @@ +package db + +import ( + "embed" + "fmt" + "log" + "path" + "sort" + "strings" + "time" + + "github.com/upper/db/v4" +) + +//go:embed migrations/*.sql +var embeddedMigrations embed.FS + +const schemaMigrationsTable = "schema_migrations" + +func applyEmbeddedMigrations(session db.Session) error { + if err := ensureSchemaMigrationsTable(session); err != nil { + return err + } + + applied, err := listAppliedMigrations(session) + if err != nil { + return err + } + + entries, err := embeddedMigrations.ReadDir("migrations") + if err != nil { + return fmt.Errorf("failed to list embedded migrations: %w", err) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + if _, ok := applied[entry.Name()]; ok { + continue + } + + rawSQL, err := embeddedMigrations.ReadFile(path.Join("migrations", entry.Name())) + if err != nil { + return fmt.Errorf("failed to read embedded migration %s: %w", entry.Name(), err) + } + + statements := splitSQLStatements(string(rawSQL)) + for idx, statement := range statements { + if _, err := session.SQL().Exec(statement); err != nil { + return fmt.Errorf("failed to execute migration %s statement %d: %w", entry.Name(), idx+1, err) + } + } + + if _, err := session.SQL().Exec( + fmt.Sprintf("INSERT INTO %s (filename, applied_at) VALUES (?, ?)", schemaMigrationsTable), + entry.Name(), + time.Now().UTC(), + ); err != nil { + return fmt.Errorf("failed to record applied migration %s: %w", entry.Name(), err) + } + + log.Printf("Applied database migration %s", entry.Name()) + } + + return nil +} + +func ensureSchemaMigrationsTable(session db.Session) error { + _, err := session.SQL().Exec(fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS %s ( + id INT AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_schema_migrations_filename (filename) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `, schemaMigrationsTable)) + if err != nil { + return fmt.Errorf("failed to ensure schema migrations table: %w", err) + } + return nil +} + +func listAppliedMigrations(session db.Session) (map[string]struct{}, error) { + rows, err := session.SQL().Query(fmt.Sprintf("SELECT filename FROM %s", schemaMigrationsTable)) + if err != nil { + return nil, fmt.Errorf("failed to query applied migrations: %w", err) + } + defer rows.Close() + + applied := make(map[string]struct{}) + for rows.Next() { + var filename string + if err := rows.Scan(&filename); err != nil { + return nil, fmt.Errorf("failed to scan applied migration: %w", err) + } + applied[filename] = struct{}{} + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate applied migrations: %w", err) + } + + return applied, nil +} + +func splitSQLStatements(input string) []string { + statements := make([]string, 0) + var current strings.Builder + + inSingleQuote := false + inDoubleQuote := false + inBacktick := false + inLineComment := false + inBlockComment := false + + for i := 0; i < len(input); i++ { + ch := input[i] + var next byte + if i+1 < len(input) { + next = input[i+1] + } + + if inLineComment { + if ch == '\n' { + inLineComment = false + } + continue + } + + if inBlockComment { + if ch == '*' && next == '/' { + inBlockComment = false + i++ + } + continue + } + + if inSingleQuote { + current.WriteByte(ch) + if ch == '\\' && next != 0 { + i++ + current.WriteByte(next) + continue + } + if ch == '\'' { + if next == '\'' { + i++ + current.WriteByte(next) + continue + } + inSingleQuote = false + } + continue + } + + if inDoubleQuote { + current.WriteByte(ch) + if ch == '\\' && next != 0 { + i++ + current.WriteByte(next) + continue + } + if ch == '"' { + if next == '"' { + i++ + current.WriteByte(next) + continue + } + inDoubleQuote = false + } + continue + } + + if inBacktick { + current.WriteByte(ch) + if ch == '`' { + inBacktick = false + } + continue + } + + if ch == '-' && next == '-' { + var afterNext byte + if i+2 < len(input) { + afterNext = input[i+2] + } + if afterNext == 0 || afterNext == ' ' || afterNext == '\t' || afterNext == '\r' || afterNext == '\n' { + inLineComment = true + i++ + continue + } + } + + if ch == '/' && next == '*' { + inBlockComment = true + i++ + continue + } + + switch ch { + case '\'': + inSingleQuote = true + current.WriteByte(ch) + case '"': + inDoubleQuote = true + current.WriteByte(ch) + case '`': + inBacktick = true + current.WriteByte(ch) + case ';': + statement := strings.TrimSpace(current.String()) + if statement != "" { + statements = append(statements, statement) + } + current.Reset() + default: + current.WriteByte(ch) + } + } + + if statement := strings.TrimSpace(current.String()); statement != "" { + statements = append(statements, statement) + } + + return statements +} diff --git a/backend/internal/db/migrations/001_init_schema.sql b/backend/internal/db/migrations/001_init_schema.sql index f34adb2..b039962 100644 --- a/backend/internal/db/migrations/001_init_schema.sql +++ b/backend/internal/db/migrations/001_init_schema.sql @@ -146,9 +146,22 @@ CREATE TABLE IF NOT EXISTS audit_logs ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- Insert default admin user (password: admin123) -INSERT INTO users (username, email, password_hash, role, is_active) VALUES - ('admin', 'admin@clawreef.local', '$2a$10$pbenze514mwv3pvQySQBVOsF5J4DBXL2kVo1hLa8JFhQu5x3AKvBi', 'admin', TRUE); +INSERT INTO users (username, email, password_hash, role, is_active) +SELECT 'admin', 'admin@clawreef.local', '$2a$10$pbenze514mwv3pvQySQBVOsF5J4DBXL2kVo1hLa8JFhQu5x3AKvBi', 'admin', TRUE +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM users + WHERE username = 'admin' OR email = 'admin@clawreef.local' +); -- Insert default quota for admin INSERT INTO user_quotas (user_id, max_instances, max_cpu_cores, max_memory_gb, max_storage_gb, max_gpu_count) -SELECT id, 100, 200, 1000, 5000, 10 FROM users WHERE username = 'admin'; +SELECT users.id, 100, 200, 1000, 5000, 10 +FROM users +WHERE username = 'admin' + AND NOT EXISTS ( + SELECT 1 + FROM user_quotas + WHERE user_quotas.user_id = users.id + ); diff --git a/backend/internal/db/migrations/006_add_openclaw_config_center.sql b/backend/internal/db/migrations/006_add_openclaw_config_center.sql new file mode 100644 index 0000000..0318f26 --- /dev/null +++ b/backend/internal/db/migrations/006_add_openclaw_config_center.sql @@ -0,0 +1,82 @@ +SET @openclaw_snapshot_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'instances' + AND COLUMN_NAME = 'openclaw_config_snapshot_id' +); +SET @openclaw_snapshot_column_sql = IF( + @openclaw_snapshot_column_exists = 0, + 'ALTER TABLE instances ADD COLUMN openclaw_config_snapshot_id INT NULL AFTER access_token', + 'SELECT 1' +); +PREPARE openclaw_snapshot_column_stmt FROM @openclaw_snapshot_column_sql; +EXECUTE openclaw_snapshot_column_stmt; +DEALLOCATE PREPARE openclaw_snapshot_column_stmt; + +CREATE TABLE IF NOT EXISTS openclaw_config_resources ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + resource_type VARCHAR(50) NOT NULL, + resource_key VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + version INT NOT NULL DEFAULT 1, + tags_json LONGTEXT NOT NULL, + content_json LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uk_openclaw_resource_key (user_id, resource_type, resource_key), + INDEX idx_openclaw_resource_user_type (user_id, resource_type), + INDEX idx_openclaw_resource_user_enabled (user_id, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS openclaw_config_bundles ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_openclaw_bundle_user (user_id), + INDEX idx_openclaw_bundle_user_enabled (user_id, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS openclaw_config_bundle_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + bundle_id INT NOT NULL, + resource_id INT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + required BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (bundle_id) REFERENCES openclaw_config_bundles(id) ON DELETE CASCADE, + FOREIGN KEY (resource_id) REFERENCES openclaw_config_resources(id) ON DELETE CASCADE, + UNIQUE KEY uk_openclaw_bundle_resource (bundle_id, resource_id), + INDEX idx_openclaw_bundle_item_bundle (bundle_id, sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS openclaw_injection_snapshots ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NULL, + user_id INT NOT NULL, + mode VARCHAR(20) NOT NULL, + bundle_id INT NULL, + selected_resource_ids_json LONGTEXT NOT NULL, + resolved_resources_json LONGTEXT NOT NULL, + rendered_manifest_json LONGTEXT NOT NULL, + rendered_env_json LONGTEXT NOT NULL, + secret_name VARCHAR(255) NULL, + status VARCHAR(30) NOT NULL DEFAULT 'pending', + error_message TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + activated_at TIMESTAMP NULL, + INDEX idx_openclaw_snapshot_user_created (user_id, created_at), + INDEX idx_openclaw_snapshot_instance (instance_id), + INDEX idx_openclaw_snapshot_bundle (bundle_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/internal/db/migrations_test.go b/backend/internal/db/migrations_test.go new file mode 100644 index 0000000..9eec03e --- /dev/null +++ b/backend/internal/db/migrations_test.go @@ -0,0 +1,31 @@ +package db + +import ( + "reflect" + "testing" +) + +func TestSplitSQLStatements(t *testing.T) { + input := ` +-- create table comment +CREATE TABLE demo ( + id INT PRIMARY KEY, + note VARCHAR(255) DEFAULT 'hello;world' +); + +/* block comment */ +INSERT INTO demo (id, note) VALUES (1, 'value'); +UPDATE demo SET note = "a;quoted" WHERE id = 1; +` + + got := splitSQLStatements(input) + want := []string{ + "CREATE TABLE demo (\n id INT PRIMARY KEY,\n note VARCHAR(255) DEFAULT 'hello;world'\n)", + "INSERT INTO demo (id, note) VALUES (1, 'value')", + `UPDATE demo SET note = "a;quoted" WHERE id = 1`, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected statements:\nwant: %#v\ngot: %#v", want, got) + } +} diff --git a/backend/internal/handlers/instance_handler.go b/backend/internal/handlers/instance_handler.go index 73dddb3..22b9330 100644 --- a/backend/internal/handlers/instance_handler.go +++ b/backend/internal/handlers/instance_handler.go @@ -36,19 +36,20 @@ func NewInstanceHandler(instanceService services.InstanceService) *InstanceHandl // CreateInstanceRequest represents a create instance request type CreateInstanceRequest struct { - Name string `json:"name" binding:"required,min=3,max=50"` - Description *string `json:"description,omitempty"` - Type string `json:"type" binding:"required,oneof=openclaw ubuntu debian centos custom webtop"` - CPUCores int `json:"cpu_cores" binding:"required,min=1,max=32"` - MemoryGB int `json:"memory_gb" binding:"required,min=1,max=128"` - DiskGB int `json:"disk_gb" binding:"required,min=10,max=1000"` - GPUEnabled bool `json:"gpu_enabled"` - GPUCount int `json:"gpu_count" binding:"min=0,max=4"` - OSType string `json:"os_type" binding:"required"` - OSVersion string `json:"os_version" binding:"required"` - ImageRegistry *string `json:"image_registry,omitempty"` - ImageTag *string `json:"image_tag,omitempty"` - StorageClass string `json:"storage_class"` + Name string `json:"name" binding:"required,min=3,max=50"` + Description *string `json:"description,omitempty"` + Type string `json:"type" binding:"required,oneof=openclaw ubuntu debian centos custom webtop"` + CPUCores int `json:"cpu_cores" binding:"required,min=1,max=32"` + MemoryGB int `json:"memory_gb" binding:"required,min=1,max=128"` + DiskGB int `json:"disk_gb" binding:"required,min=10,max=1000"` + GPUEnabled bool `json:"gpu_enabled"` + GPUCount int `json:"gpu_count" binding:"min=0,max=4"` + OSType string `json:"os_type" binding:"required"` + OSVersion string `json:"os_version" binding:"required"` + ImageRegistry *string `json:"image_registry,omitempty"` + ImageTag *string `json:"image_tag,omitempty"` + StorageClass string `json:"storage_class"` + OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"` } // UpdateInstanceRequest represents an update instance request @@ -104,19 +105,20 @@ func (h *InstanceHandler) CreateInstance(c *gin.Context) { } createReq := services.CreateInstanceRequest{ - Name: req.Name, - Description: req.Description, - Type: req.Type, - CPUCores: req.CPUCores, - MemoryGB: req.MemoryGB, - DiskGB: req.DiskGB, - GPUEnabled: req.GPUEnabled, - GPUCount: req.GPUCount, - OSType: req.OSType, - OSVersion: req.OSVersion, - ImageRegistry: req.ImageRegistry, - ImageTag: req.ImageTag, - StorageClass: req.StorageClass, + Name: req.Name, + Description: req.Description, + Type: req.Type, + CPUCores: req.CPUCores, + MemoryGB: req.MemoryGB, + DiskGB: req.DiskGB, + GPUEnabled: req.GPUEnabled, + GPUCount: req.GPUCount, + OSType: req.OSType, + OSVersion: req.OSVersion, + ImageRegistry: req.ImageRegistry, + ImageTag: req.ImageTag, + StorageClass: req.StorageClass, + OpenClawConfigPlan: req.OpenClawConfigPlan, } instance, err := h.instanceService.Create(userID.(int), createReq) diff --git a/backend/internal/handlers/openclaw_config_handler.go b/backend/internal/handlers/openclaw_config_handler.go new file mode 100644 index 0000000..9bcbf64 --- /dev/null +++ b/backend/internal/handlers/openclaw_config_handler.go @@ -0,0 +1,271 @@ +package handlers + +import ( + "net/http" + "strconv" + + "clawreef/internal/services" + "clawreef/internal/utils" + + "github.com/gin-gonic/gin" +) + +// OpenClawConfigHandler handles OpenClaw config center APIs. +type OpenClawConfigHandler struct { + service services.OpenClawConfigService +} + +// NewOpenClawConfigHandler creates a new OpenClaw config handler. +func NewOpenClawConfigHandler(service services.OpenClawConfigService) *OpenClawConfigHandler { + return &OpenClawConfigHandler{service: service} +} + +func (h *OpenClawConfigHandler) ListResources(c *gin.Context) { + userID, _ := c.Get("userID") + resourceType := c.Query("resource_type") + + items, err := h.service.ListResources(userID.(int), resourceType) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config resources retrieved successfully", items) +} + +func (h *OpenClawConfigHandler) GetResource(c *gin.Context) { + userID, _ := c.Get("userID") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid resource ID") + return + } + + item, err := h.service.GetResource(userID.(int), id) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config resource retrieved successfully", item) +} + +func (h *OpenClawConfigHandler) CreateResource(c *gin.Context) { + userID, _ := c.Get("userID") + var req services.UpsertOpenClawConfigResourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + + item, err := h.service.CreateResource(userID.(int), req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "OpenClaw config resource created successfully", item) +} + +func (h *OpenClawConfigHandler) UpdateResource(c *gin.Context) { + userID, _ := c.Get("userID") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid resource ID") + return + } + + var req services.UpsertOpenClawConfigResourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + + item, err := h.service.UpdateResource(userID.(int), id, req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config resource updated successfully", item) +} + +func (h *OpenClawConfigHandler) DeleteResource(c *gin.Context) { + userID, _ := c.Get("userID") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid resource ID") + return + } + + if err := h.service.DeleteResource(userID.(int), id); err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config resource deleted successfully", nil) +} + +func (h *OpenClawConfigHandler) CloneResource(c *gin.Context) { + userID, _ := c.Get("userID") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid resource ID") + return + } + + item, err := h.service.CloneResource(userID.(int), id) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "OpenClaw config resource cloned successfully", item) +} + +func (h *OpenClawConfigHandler) ValidateResource(c *gin.Context) { + var req services.UpsertOpenClawConfigResourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + + if err := h.service.ValidateResource(req); err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config resource validated successfully", gin.H{"valid": true}) +} + +func (h *OpenClawConfigHandler) ListBundles(c *gin.Context) { + userID, _ := c.Get("userID") + items, err := h.service.ListBundles(userID.(int)) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config bundles retrieved successfully", items) +} + +func (h *OpenClawConfigHandler) GetBundle(c *gin.Context) { + userID, _ := c.Get("userID") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid bundle ID") + return + } + + item, err := h.service.GetBundle(userID.(int), id) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config bundle retrieved successfully", item) +} + +func (h *OpenClawConfigHandler) CreateBundle(c *gin.Context) { + userID, _ := c.Get("userID") + var req services.UpsertOpenClawConfigBundleRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + + item, err := h.service.CreateBundle(userID.(int), req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "OpenClaw config bundle created successfully", item) +} + +func (h *OpenClawConfigHandler) UpdateBundle(c *gin.Context) { + userID, _ := c.Get("userID") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid bundle ID") + return + } + + var req services.UpsertOpenClawConfigBundleRequest + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + + item, err := h.service.UpdateBundle(userID.(int), id, req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config bundle updated successfully", item) +} + +func (h *OpenClawConfigHandler) DeleteBundle(c *gin.Context) { + userID, _ := c.Get("userID") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid bundle ID") + return + } + + if err := h.service.DeleteBundle(userID.(int), id); err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config bundle deleted successfully", nil) +} + +func (h *OpenClawConfigHandler) CloneBundle(c *gin.Context) { + userID, _ := c.Get("userID") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid bundle ID") + return + } + + item, err := h.service.CloneBundle(userID.(int), id) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusCreated, "OpenClaw config bundle cloned successfully", item) +} + +func (h *OpenClawConfigHandler) CompilePreview(c *gin.Context) { + userID, _ := c.Get("userID") + var req services.OpenClawConfigPlan + if err := c.ShouldBindJSON(&req); err != nil { + utils.ValidationError(c, err) + return + } + + result, err := h.service.CompilePreview(userID.(int), req) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw config preview compiled successfully", result) +} + +func (h *OpenClawConfigHandler) ListSnapshots(c *gin.Context) { + userID, _ := c.Get("userID") + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + + items, err := h.service.ListSnapshots(userID.(int), limit) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw injection snapshots retrieved successfully", items) +} + +func (h *OpenClawConfigHandler) GetSnapshot(c *gin.Context) { + userID, _ := c.Get("userID") + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + utils.Error(c, http.StatusBadRequest, "Invalid snapshot ID") + return + } + + item, err := h.service.GetSnapshot(userID.(int), id) + if err != nil { + utils.HandleError(c, err) + return + } + utils.Success(c, http.StatusOK, "OpenClaw injection snapshot retrieved successfully", item) +} diff --git a/backend/internal/models/instance.go b/backend/internal/models/instance.go index 356cf18..5c9a84f 100644 --- a/backend/internal/models/instance.go +++ b/backend/internal/models/instance.go @@ -6,33 +6,34 @@ import ( // Instance represents a virtual desktop instance type Instance struct { - ID int `db:"id,primarykey,autoincrement" json:"id"` - UserID int `db:"user_id" json:"user_id"` - Name string `db:"name" json:"name"` - Description *string `db:"description" json:"description,omitempty"` - Type string `db:"type" json:"type"` - Status string `db:"status" json:"status"` - CPUCores int `db:"cpu_cores" json:"cpu_cores"` - MemoryGB int `db:"memory_gb" json:"memory_gb"` - DiskGB int `db:"disk_gb" json:"disk_gb"` - GPUEnabled bool `db:"gpu_enabled" json:"gpu_enabled"` - GPUType *string `db:"gpu_type" json:"gpu_type,omitempty"` - GPUCount int `db:"gpu_count" json:"gpu_count"` - OSType string `db:"os_type" json:"os_type"` - OSVersion string `db:"os_version" json:"os_version"` - ImageRegistry *string `db:"image_registry" json:"image_registry,omitempty"` - ImageTag *string `db:"image_tag" json:"image_tag,omitempty"` - StorageClass string `db:"storage_class" json:"storage_class"` - MountPath string `db:"mount_path" json:"mount_path"` - PodName *string `db:"pod_name" json:"pod_name,omitempty"` - PodNamespace *string `db:"pod_namespace" json:"pod_namespace,omitempty"` - PodIP *string `db:"pod_ip" json:"pod_ip,omitempty"` - AccessURL *string `db:"access_url" json:"access_url,omitempty"` - AccessToken *string `db:"access_token" json:"-"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"` - StoppedAt *time.Time `db:"stopped_at" json:"stopped_at,omitempty"` + ID int `db:"id,primarykey,autoincrement" json:"id"` + UserID int `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description,omitempty"` + Type string `db:"type" json:"type"` + Status string `db:"status" json:"status"` + CPUCores int `db:"cpu_cores" json:"cpu_cores"` + MemoryGB int `db:"memory_gb" json:"memory_gb"` + DiskGB int `db:"disk_gb" json:"disk_gb"` + GPUEnabled bool `db:"gpu_enabled" json:"gpu_enabled"` + GPUType *string `db:"gpu_type" json:"gpu_type,omitempty"` + GPUCount int `db:"gpu_count" json:"gpu_count"` + OSType string `db:"os_type" json:"os_type"` + OSVersion string `db:"os_version" json:"os_version"` + ImageRegistry *string `db:"image_registry" json:"image_registry,omitempty"` + ImageTag *string `db:"image_tag" json:"image_tag,omitempty"` + StorageClass string `db:"storage_class" json:"storage_class"` + MountPath string `db:"mount_path" json:"mount_path"` + PodName *string `db:"pod_name" json:"pod_name,omitempty"` + PodNamespace *string `db:"pod_namespace" json:"pod_namespace,omitempty"` + PodIP *string `db:"pod_ip" json:"pod_ip,omitempty"` + AccessURL *string `db:"access_url" json:"access_url,omitempty"` + AccessToken *string `db:"access_token" json:"-"` + OpenClawConfigSnapshotID *int `db:"openclaw_config_snapshot_id" json:"openclaw_config_snapshot_id,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"` + StoppedAt *time.Time `db:"stopped_at" json:"stopped_at,omitempty"` } // TableName returns the table name for the Instance model diff --git a/backend/internal/models/openclaw_config.go b/backend/internal/models/openclaw_config.go new file mode 100644 index 0000000..dab66a6 --- /dev/null +++ b/backend/internal/models/openclaw_config.go @@ -0,0 +1,75 @@ +package models + +import "time" + +// OpenClawConfigResource stores a reusable OpenClaw bootstrap resource. +type OpenClawConfigResource struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + UserID int `db:"user_id" json:"user_id"` + ResourceType string `db:"resource_type" json:"resource_type"` + ResourceKey string `db:"resource_key" json:"resource_key"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description,omitempty"` + Enabled bool `db:"enabled" json:"enabled"` + Version int `db:"version" json:"version"` + TagsJSON string `db:"tags_json" json:"-"` + ContentJSON string `db:"content_json" json:"-"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (r OpenClawConfigResource) TableName() string { + return "openclaw_config_resources" +} + +// OpenClawConfigBundle stores a reusable bundle of OpenClaw resources. +type OpenClawConfigBundle struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + UserID int `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description,omitempty"` + Enabled bool `db:"enabled" json:"enabled"` + Version int `db:"version" json:"version"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (b OpenClawConfigBundle) TableName() string { + return "openclaw_config_bundles" +} + +// OpenClawConfigBundleItem stores a resource membership inside a bundle. +type OpenClawConfigBundleItem struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + BundleID int `db:"bundle_id" json:"bundle_id"` + ResourceID int `db:"resource_id" json:"resource_id"` + SortOrder int `db:"sort_order" json:"sort_order"` + Required bool `db:"required" json:"required"` +} + +func (i OpenClawConfigBundleItem) TableName() string { + return "openclaw_config_bundle_items" +} + +// OpenClawInjectionSnapshot stores the rendered bootstrap payload used by an instance. +type OpenClawInjectionSnapshot struct { + ID int `db:"id,primarykey,autoincrement" json:"id"` + InstanceID *int `db:"instance_id" json:"instance_id,omitempty"` + UserID int `db:"user_id" json:"user_id"` + Mode string `db:"mode" json:"mode"` + BundleID *int `db:"bundle_id" json:"bundle_id,omitempty"` + SelectedResourceIDsJSON string `db:"selected_resource_ids_json" json:"-"` + ResolvedResourcesJSON string `db:"resolved_resources_json" json:"-"` + RenderedManifestJSON string `db:"rendered_manifest_json" json:"-"` + RenderedEnvJSON string `db:"rendered_env_json" json:"-"` + SecretName *string `db:"secret_name" json:"secret_name,omitempty"` + Status string `db:"status" json:"status"` + ErrorMessage *string `db:"error_message" json:"error_message,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ActivatedAt *time.Time `db:"activated_at" json:"activated_at,omitempty"` +} + +func (s OpenClawInjectionSnapshot) TableName() string { + return "openclaw_injection_snapshots" +} diff --git a/backend/internal/repository/openclaw_config_repository.go b/backend/internal/repository/openclaw_config_repository.go new file mode 100644 index 0000000..040d5cc --- /dev/null +++ b/backend/internal/repository/openclaw_config_repository.go @@ -0,0 +1,213 @@ +package repository + +import ( + "fmt" + + "clawreef/internal/models" + + "github.com/upper/db/v4" +) + +// OpenClawConfigRepository defines storage operations for the OpenClaw config center. +type OpenClawConfigRepository interface { + ListResources(userID int, resourceType string) ([]models.OpenClawConfigResource, error) + GetResourceByID(id int) (*models.OpenClawConfigResource, error) + GetResourceByUserTypeKey(userID int, resourceType, resourceKey string) (*models.OpenClawConfigResource, error) + CreateResource(resource *models.OpenClawConfigResource) error + UpdateResource(resource *models.OpenClawConfigResource) error + DeleteResource(id int) error + + ListBundles(userID int) ([]models.OpenClawConfigBundle, error) + GetBundleByID(id int) (*models.OpenClawConfigBundle, error) + CreateBundle(bundle *models.OpenClawConfigBundle) error + UpdateBundle(bundle *models.OpenClawConfigBundle) error + DeleteBundle(id int) error + ListBundleItems(bundleID int) ([]models.OpenClawConfigBundleItem, error) + ReplaceBundleItems(bundleID int, items []models.OpenClawConfigBundleItem) error + + CreateSnapshot(snapshot *models.OpenClawInjectionSnapshot) error + UpdateSnapshot(snapshot *models.OpenClawInjectionSnapshot) error + GetSnapshotByID(id int) (*models.OpenClawInjectionSnapshot, error) + ListSnapshotsByUser(userID int, limit int) ([]models.OpenClawInjectionSnapshot, error) +} + +type openClawConfigRepository struct { + sess db.Session +} + +// NewOpenClawConfigRepository creates a new OpenClaw config repository. +func NewOpenClawConfigRepository(sess db.Session) OpenClawConfigRepository { + return &openClawConfigRepository{sess: sess} +} + +func (r *openClawConfigRepository) ListResources(userID int, resourceType string) ([]models.OpenClawConfigResource, error) { + find := r.sess.Collection("openclaw_config_resources").Find(db.Cond{"user_id": userID}) + if resourceType != "" { + find = find.And("resource_type", resourceType) + } + + var items []models.OpenClawConfigResource + if err := find.OrderBy("-updated_at", "-id").All(&items); err != nil { + return nil, fmt.Errorf("failed to list openclaw config resources: %w", err) + } + return items, nil +} + +func (r *openClawConfigRepository) GetResourceByID(id int) (*models.OpenClawConfigResource, error) { + var item models.OpenClawConfigResource + if err := r.sess.Collection("openclaw_config_resources").Find(db.Cond{"id": id}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get openclaw config resource: %w", err) + } + return &item, nil +} + +func (r *openClawConfigRepository) GetResourceByUserTypeKey(userID int, resourceType, resourceKey string) (*models.OpenClawConfigResource, error) { + var item models.OpenClawConfigResource + if err := r.sess.Collection("openclaw_config_resources").Find(db.Cond{ + "user_id": userID, + "resource_type": resourceType, + "resource_key": resourceKey, + }).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get openclaw config resource by key: %w", err) + } + return &item, nil +} + +func (r *openClawConfigRepository) CreateResource(resource *models.OpenClawConfigResource) error { + res, err := r.sess.Collection("openclaw_config_resources").Insert(resource) + if err != nil { + return fmt.Errorf("failed to create openclaw config resource: %w", err) + } + if id, ok := res.ID().(int64); ok { + resource.ID = int(id) + } + return nil +} + +func (r *openClawConfigRepository) UpdateResource(resource *models.OpenClawConfigResource) error { + if err := r.sess.Collection("openclaw_config_resources").Find(db.Cond{"id": resource.ID}).Update(resource); err != nil { + return fmt.Errorf("failed to update openclaw config resource: %w", err) + } + return nil +} + +func (r *openClawConfigRepository) DeleteResource(id int) error { + if err := r.sess.Collection("openclaw_config_resources").Find(db.Cond{"id": id}).Delete(); err != nil { + return fmt.Errorf("failed to delete openclaw config resource: %w", err) + } + return nil +} + +func (r *openClawConfigRepository) ListBundles(userID int) ([]models.OpenClawConfigBundle, error) { + var items []models.OpenClawConfigBundle + if err := r.sess.Collection("openclaw_config_bundles").Find(db.Cond{"user_id": userID}).OrderBy("-updated_at", "-id").All(&items); err != nil { + return nil, fmt.Errorf("failed to list openclaw config bundles: %w", err) + } + return items, nil +} + +func (r *openClawConfigRepository) GetBundleByID(id int) (*models.OpenClawConfigBundle, error) { + var item models.OpenClawConfigBundle + if err := r.sess.Collection("openclaw_config_bundles").Find(db.Cond{"id": id}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get openclaw config bundle: %w", err) + } + return &item, nil +} + +func (r *openClawConfigRepository) CreateBundle(bundle *models.OpenClawConfigBundle) error { + res, err := r.sess.Collection("openclaw_config_bundles").Insert(bundle) + if err != nil { + return fmt.Errorf("failed to create openclaw config bundle: %w", err) + } + if id, ok := res.ID().(int64); ok { + bundle.ID = int(id) + } + return nil +} + +func (r *openClawConfigRepository) UpdateBundle(bundle *models.OpenClawConfigBundle) error { + if err := r.sess.Collection("openclaw_config_bundles").Find(db.Cond{"id": bundle.ID}).Update(bundle); err != nil { + return fmt.Errorf("failed to update openclaw config bundle: %w", err) + } + return nil +} + +func (r *openClawConfigRepository) DeleteBundle(id int) error { + if err := r.sess.Collection("openclaw_config_bundles").Find(db.Cond{"id": id}).Delete(); err != nil { + return fmt.Errorf("failed to delete openclaw config bundle: %w", err) + } + return nil +} + +func (r *openClawConfigRepository) ListBundleItems(bundleID int) ([]models.OpenClawConfigBundleItem, error) { + var items []models.OpenClawConfigBundleItem + if err := r.sess.Collection("openclaw_config_bundle_items").Find(db.Cond{"bundle_id": bundleID}).OrderBy("sort_order", "id").All(&items); err != nil { + return nil, fmt.Errorf("failed to list openclaw config bundle items: %w", err) + } + return items, nil +} + +func (r *openClawConfigRepository) ReplaceBundleItems(bundleID int, items []models.OpenClawConfigBundleItem) error { + if err := r.sess.Collection("openclaw_config_bundle_items").Find(db.Cond{"bundle_id": bundleID}).Delete(); err != nil && err != db.ErrNoMoreRows { + return fmt.Errorf("failed to delete existing openclaw config bundle items: %w", err) + } + + for _, item := range items { + item.BundleID = bundleID + if _, err := r.sess.Collection("openclaw_config_bundle_items").Insert(item); err != nil { + return fmt.Errorf("failed to add openclaw config bundle item: %w", err) + } + } + + return nil +} + +func (r *openClawConfigRepository) CreateSnapshot(snapshot *models.OpenClawInjectionSnapshot) error { + res, err := r.sess.Collection("openclaw_injection_snapshots").Insert(snapshot) + if err != nil { + return fmt.Errorf("failed to create openclaw injection snapshot: %w", err) + } + if id, ok := res.ID().(int64); ok { + snapshot.ID = int(id) + } + return nil +} + +func (r *openClawConfigRepository) UpdateSnapshot(snapshot *models.OpenClawInjectionSnapshot) error { + if err := r.sess.Collection("openclaw_injection_snapshots").Find(db.Cond{"id": snapshot.ID}).Update(snapshot); err != nil { + return fmt.Errorf("failed to update openclaw injection snapshot: %w", err) + } + return nil +} + +func (r *openClawConfigRepository) GetSnapshotByID(id int) (*models.OpenClawInjectionSnapshot, error) { + var item models.OpenClawInjectionSnapshot + if err := r.sess.Collection("openclaw_injection_snapshots").Find(db.Cond{"id": id}).One(&item); err != nil { + if err == db.ErrNoMoreRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get openclaw injection snapshot: %w", err) + } + return &item, nil +} + +func (r *openClawConfigRepository) ListSnapshotsByUser(userID int, limit int) ([]models.OpenClawInjectionSnapshot, error) { + if limit <= 0 { + limit = 50 + } + + var items []models.OpenClawInjectionSnapshot + if err := r.sess.Collection("openclaw_injection_snapshots").Find(db.Cond{"user_id": userID}).OrderBy("-created_at", "-id").Limit(limit).All(&items); err != nil { + return nil, fmt.Errorf("failed to list openclaw injection snapshots: %w", err) + } + return items, nil +} diff --git a/backend/internal/services/instance_service.go b/backend/internal/services/instance_service.go index d29fefc..e47dede 100644 --- a/backend/internal/services/instance_service.go +++ b/backend/internal/services/instance_service.go @@ -31,19 +31,20 @@ type InstanceService interface { // CreateInstanceRequest holds data for creating an instance type CreateInstanceRequest struct { - Name string `json:"name" validate:"required,min=3,max=50"` - Description *string `json:"description,omitempty"` - Type string `json:"type" validate:"required,oneof=openclaw ubuntu debian centos custom webtop"` - CPUCores int `json:"cpu_cores" validate:"required,min=1,max=32"` - MemoryGB int `json:"memory_gb" validate:"required,min=1,max=128"` - DiskGB int `json:"disk_gb" validate:"required,min=10,max=1000"` - GPUEnabled bool `json:"gpu_enabled"` - GPUCount int `json:"gpu_count" validate:"min=0,max=4"` - OSType string `json:"os_type" validate:"required"` - OSVersion string `json:"os_version" validate:"required"` - ImageRegistry *string `json:"image_registry,omitempty"` - ImageTag *string `json:"image_tag,omitempty"` - StorageClass string `json:"storage_class"` + Name string `json:"name" validate:"required,min=3,max=50"` + Description *string `json:"description,omitempty"` + Type string `json:"type" validate:"required,oneof=openclaw ubuntu debian centos custom webtop"` + CPUCores int `json:"cpu_cores" validate:"required,min=1,max=32"` + MemoryGB int `json:"memory_gb" validate:"required,min=1,max=128"` + DiskGB int `json:"disk_gb" validate:"required,min=10,max=1000"` + GPUEnabled bool `json:"gpu_enabled"` + GPUCount int `json:"gpu_count" validate:"min=0,max=4"` + OSType string `json:"os_type" validate:"required"` + OSVersion string `json:"os_version" validate:"required"` + ImageRegistry *string `json:"image_registry,omitempty"` + ImageTag *string `json:"image_tag,omitempty"` + StorageClass string `json:"storage_class"` + OpenClawConfigPlan *OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"` } // UpdateInstanceRequest holds data for updating an instance @@ -66,25 +67,27 @@ type InstanceStatus struct { // instanceService implements InstanceService type instanceService struct { - instanceRepo repository.InstanceRepository - quotaRepo repository.QuotaRepository - llmModelRepo repository.LLMModelRepository - podService *k8s.PodService - pvcService *k8s.PVCService - serviceService *k8s.ServiceService - networkPolicyService *k8s.NetworkPolicyService + instanceRepo repository.InstanceRepository + quotaRepo repository.QuotaRepository + llmModelRepo repository.LLMModelRepository + openClawConfigService OpenClawConfigService + podService *k8s.PodService + pvcService *k8s.PVCService + serviceService *k8s.ServiceService + networkPolicyService *k8s.NetworkPolicyService } // NewInstanceService creates a new instance service -func NewInstanceService(instanceRepo repository.InstanceRepository, quotaRepo repository.QuotaRepository, llmModelRepo repository.LLMModelRepository) InstanceService { +func NewInstanceService(instanceRepo repository.InstanceRepository, quotaRepo repository.QuotaRepository, llmModelRepo repository.LLMModelRepository, openClawConfigService OpenClawConfigService) InstanceService { return &instanceService{ - instanceRepo: instanceRepo, - quotaRepo: quotaRepo, - llmModelRepo: llmModelRepo, - podService: k8s.NewPodService(), - pvcService: k8s.NewPVCService(), - serviceService: k8s.NewServiceService(), - networkPolicyService: k8s.NewNetworkPolicyService(), + instanceRepo: instanceRepo, + quotaRepo: quotaRepo, + llmModelRepo: llmModelRepo, + openClawConfigService: openClawConfigService, + podService: k8s.NewPodService(), + pvcService: k8s.NewPVCService(), + serviceService: k8s.NewServiceService(), + networkPolicyService: k8s.NewNetworkPolicyService(), } } @@ -214,6 +217,31 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models return nil, fmt.Errorf("failed to build instance gateway config: %w", err) } + var bootstrapSnapshot *models.OpenClawInjectionSnapshot + var bootstrapSecretName string + if strings.EqualFold(instance.Type, "openclaw") && s.openClawConfigService != nil && req.OpenClawConfigPlan != nil && hasOpenClawConfigSelections(*req.OpenClawConfigPlan) { + bootstrapSnapshot, err = s.openClawConfigService.CreateSnapshotForInstance(userID, instance, req.OpenClawConfigPlan) + if err != nil { + s.instanceRepo.Delete(instance.ID) + return nil, fmt.Errorf("failed to compile openclaw bootstrap config: %w", err) + } + if bootstrapSnapshot != nil { + instance.OpenClawConfigSnapshotID = &bootstrapSnapshot.ID + instance.UpdatedAt = time.Now() + if err := s.instanceRepo.Update(instance); err != nil { + s.instanceRepo.Delete(instance.ID) + return nil, fmt.Errorf("failed to persist openclaw snapshot reference: %w", err) + } + + bootstrapSecretName, err = s.openClawConfigService.EnsureSnapshotSecret(ctx, userID, instance, bootstrapSnapshot.ID) + if err != nil { + _ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err) + s.instanceRepo.Delete(instance.ID) + return nil, fmt.Errorf("failed to provision openclaw bootstrap secret: %w", err) + } + } + } + // Create PVC // If storage class is not specified in request, use empty string // PVCService will use the default from K8s client config @@ -222,6 +250,9 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models _, err = s.pvcService.CreatePVC(ctx, userID, instance.ID, req.DiskGB, storageClass) if err != nil { // Rollback: delete instance record + if bootstrapSnapshot != nil { + _ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err) + } s.instanceRepo.Delete(instance.ID) return nil, fmt.Errorf("failed to create PVC: %w", err) } @@ -229,6 +260,9 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models if requiresRestrictedNetwork(instance.Type) { if err := s.networkPolicyService.EnsureDefaultPolicy(ctx, userID, instance.ID, instance.Name); err != nil { s.pvcService.DeletePVC(ctx, userID, instance.ID) + if bootstrapSnapshot != nil { + _ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err) + } s.instanceRepo.Delete(instance.ID) return nil, fmt.Errorf("failed to create network policy: %w", err) } @@ -236,18 +270,19 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models // Create Pod podConfig := k8s.PodConfig{ - InstanceID: instance.ID, - InstanceName: instance.Name, - UserID: userID, - Type: instance.Type, - CPUCores: instance.CPUCores, - MemoryGB: instance.MemoryGB, - GPUEnabled: instance.GPUEnabled, - GPUCount: instance.GPUCount, - Image: runtimeConfig.Image, - MountPath: runtimeConfig.MountPath, - ContainerPort: runtimeConfig.Port, - ExtraEnv: withInstanceProxyEnv(instance.Type, instance.ID, mergeEnvMaps(runtimeConfig.Env, gatewayEnv)), + InstanceID: instance.ID, + InstanceName: instance.Name, + UserID: userID, + Type: instance.Type, + CPUCores: instance.CPUCores, + MemoryGB: instance.MemoryGB, + GPUEnabled: instance.GPUEnabled, + GPUCount: instance.GPUCount, + Image: runtimeConfig.Image, + MountPath: runtimeConfig.MountPath, + ContainerPort: runtimeConfig.Port, + ExtraEnv: withInstanceProxyEnv(instance.Type, instance.ID, mergeEnvMaps(runtimeConfig.Env, gatewayEnv)), + EnvFromSecretNames: []string{bootstrapSecretName}, } pod, err := s.podService.CreatePod(ctx, podConfig) @@ -257,6 +292,9 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models s.networkPolicyService.DeletePolicy(ctx, userID, instance.ID, instance.Name) } s.pvcService.DeletePVC(ctx, userID, instance.ID) + if bootstrapSnapshot != nil { + _ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err) + } s.instanceRepo.Delete(instance.ID) return nil, fmt.Errorf("failed to create pod: %w", err) } @@ -278,6 +316,9 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models s.networkPolicyService.DeletePolicy(ctx, userID, instance.ID, instance.Name) } s.pvcService.DeletePVC(ctx, userID, instance.ID) + if bootstrapSnapshot != nil { + _ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err) + } s.instanceRepo.Delete(instance.ID) return nil, fmt.Errorf("failed to create service: %w", err) } @@ -295,10 +336,19 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models fmt.Printf("Instance %d created successfully, updating database with status 'creating'\n", instance.ID) if err := s.instanceRepo.Update(instance); err != nil { + if bootstrapSnapshot != nil { + _ = s.openClawConfigService.MarkSnapshotFailed(bootstrapSnapshot, err) + } return nil, fmt.Errorf("failed to update instance with pod info: %w", err) } fmt.Printf("Instance %d database updated, broadcasting status via WebSocket\n", instance.ID) + if bootstrapSnapshot != nil { + if err := s.openClawConfigService.MarkSnapshotActive(bootstrapSnapshot); err != nil { + return nil, fmt.Errorf("failed to activate openclaw bootstrap snapshot: %w", err) + } + } + // Broadcast initial creating status via WebSocket. Sync service will mark it // running only after the pod becomes Ready. GetHub().BroadcastInstanceStatus(userID, instance) @@ -353,6 +403,14 @@ func (s *instanceService) Start(instanceID int) error { return fmt.Errorf("failed to build instance gateway config: %w", err) } + bootstrapSecretName := "" + if strings.EqualFold(instance.Type, "openclaw") && s.openClawConfigService != nil && instance.OpenClawConfigSnapshotID != nil && *instance.OpenClawConfigSnapshotID > 0 { + bootstrapSecretName, err = s.openClawConfigService.EnsureSnapshotSecret(ctx, instance.UserID, instance, *instance.OpenClawConfigSnapshotID) + if err != nil { + return fmt.Errorf("failed to restore openclaw bootstrap secret: %w", err) + } + } + // Create new pod runtimeConfig := buildRuntimeConfig(instance.Type, instance.OSType, instance.OSVersion, instance.ImageRegistry, instance.ImageTag) if requiresRestrictedNetwork(instance.Type) { @@ -362,18 +420,19 @@ func (s *instanceService) Start(instanceID int) error { } podConfig := k8s.PodConfig{ - InstanceID: instance.ID, - InstanceName: instance.Name, - UserID: instance.UserID, - Type: instance.Type, - CPUCores: instance.CPUCores, - MemoryGB: instance.MemoryGB, - GPUEnabled: instance.GPUEnabled, - GPUCount: instance.GPUCount, - Image: runtimeConfig.Image, - MountPath: instance.MountPath, - ContainerPort: runtimeConfig.Port, - ExtraEnv: withInstanceProxyEnv(instance.Type, instance.ID, mergeEnvMaps(runtimeConfig.Env, gatewayEnv)), + InstanceID: instance.ID, + InstanceName: instance.Name, + UserID: instance.UserID, + Type: instance.Type, + CPUCores: instance.CPUCores, + MemoryGB: instance.MemoryGB, + GPUEnabled: instance.GPUEnabled, + GPUCount: instance.GPUCount, + Image: runtimeConfig.Image, + MountPath: instance.MountPath, + ContainerPort: runtimeConfig.Port, + ExtraEnv: withInstanceProxyEnv(instance.Type, instance.ID, mergeEnvMaps(runtimeConfig.Env, gatewayEnv)), + EnvFromSecretNames: []string{bootstrapSecretName}, } pod, err := s.podService.CreatePod(ctx, podConfig) diff --git a/backend/internal/services/k8s/client.go b/backend/internal/services/k8s/client.go index 17a4a4a..41370fa 100644 --- a/backend/internal/services/k8s/client.go +++ b/backend/internal/services/k8s/client.go @@ -243,6 +243,11 @@ func (c *Client) GetServiceName(instanceID int, instanceName string) string { return sanitizeK8sName(fmt.Sprintf("clawreef-%d-%s-svc", instanceID, instanceName)) } +// GetOpenClawBootstrapSecretName returns the secret name used to store rendered OpenClaw bootstrap payloads. +func (c *Client) GetOpenClawBootstrapSecretName(instanceID int, instanceName string) string { + return sanitizeK8sName(fmt.Sprintf("clawreef-%d-%s-openclaw-bootstrap", instanceID, instanceName)) +} + // GetNetworkPolicyName returns the default network policy name for an instance. func (c *Client) GetNetworkPolicyName(instanceID int, instanceName string) string { return sanitizeK8sName(fmt.Sprintf("clawreef-%d-%s-netpol", instanceID, instanceName)) diff --git a/backend/internal/services/k8s/pod_service.go b/backend/internal/services/k8s/pod_service.go index f29071c..240acb1 100644 --- a/backend/internal/services/k8s/pod_service.go +++ b/backend/internal/services/k8s/pod_service.go @@ -31,18 +31,19 @@ func (s *PodService) GetClient() *Client { // PodConfig holds configuration for creating a pod type PodConfig struct { - InstanceID int - InstanceName string - UserID int - Type string - CPUCores int - MemoryGB int - GPUEnabled bool - GPUCount int - Image string - MountPath string - ContainerPort int32 - ExtraEnv map[string]string + InstanceID int + InstanceName string + UserID int + Type string + CPUCores int + MemoryGB int + GPUEnabled bool + GPUCount int + Image string + MountPath string + ContainerPort int32 + ExtraEnv map[string]string + EnvFromSecretNames []string } // CreatePod creates a new pod for an instance @@ -174,6 +175,17 @@ func (s *PodService) CreatePod(ctx context.Context, config PodConfig) (*corev1.P }) } + for _, secretName := range config.EnvFromSecretNames { + if secretName == "" { + continue + } + pod.Spec.Containers[0].EnvFrom = append(pod.Spec.Containers[0].EnvFrom, corev1.EnvFromSource{ + SecretRef: &corev1.SecretEnvSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: secretName}, + }, + }) + } + createdPod, err := s.client.Clientset.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{}) if err != nil { // Check if pod already exists diff --git a/backend/internal/services/k8s/secret_service.go b/backend/internal/services/k8s/secret_service.go index ea69075..8a943b7 100644 --- a/backend/internal/services/k8s/secret_service.go +++ b/backend/internal/services/k8s/secret_service.go @@ -4,18 +4,22 @@ import ( "context" "fmt" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // SecretService handles Kubernetes Secret reads. type SecretService struct { - client *Client + client *Client + namespaceService *NamespaceService } // NewSecretService creates a new secret service. func NewSecretService() *SecretService { return &SecretService{ - client: globalClient, + client: globalClient, + namespaceService: NewNamespaceService(), } } @@ -37,3 +41,53 @@ func (s *SecretService) GetSecretValue(ctx context.Context, namespace, name, key return string(value), nil } + +// UpsertSecret creates or updates a secret in the user's namespace. +func (s *SecretService) UpsertSecret(ctx context.Context, userID int, name string, data map[string]string, labels map[string]string) error { + if s.client == nil || s.client.Clientset == nil { + return fmt.Errorf("k8s client not initialized") + } + + if _, err := s.namespaceService.EnsureNamespace(ctx, userID); err != nil { + return fmt.Errorf("failed to ensure namespace: %w", err) + } + + namespace := s.client.GetNamespace(userID) + secretData := map[string][]byte{} + for key, value := range data { + secretData[key] = []byte(value) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + Type: corev1.SecretTypeOpaque, + Data: secretData, + } + + existing, err := s.client.Clientset.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err == nil && existing != nil { + existing.Data = secretData + if existing.Labels == nil { + existing.Labels = map[string]string{} + } + for key, value := range labels { + existing.Labels[key] = value + } + if _, err := s.client.Clientset.CoreV1().Secrets(namespace).Update(ctx, existing, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("failed to update secret %s/%s: %w", namespace, name, err) + } + return nil + } + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to inspect secret %s/%s: %w", namespace, name, err) + } + + if _, err := s.client.Clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}); err != nil { + return fmt.Errorf("failed to create secret %s/%s: %w", namespace, name, err) + } + return nil +} diff --git a/backend/internal/services/openclaw_config_service.go b/backend/internal/services/openclaw_config_service.go new file mode 100644 index 0000000..d657c26 --- /dev/null +++ b/backend/internal/services/openclaw_config_service.go @@ -0,0 +1,1636 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "sort" + "strings" + "time" + + "clawreef/internal/models" + "clawreef/internal/repository" + "clawreef/internal/services/k8s" +) + +const ( + OpenClawConfigResourceTypeChannel = "channel" + OpenClawConfigResourceTypeSkill = "skill" + OpenClawConfigResourceTypeSessionTemplate = "session_template" + OpenClawConfigResourceTypeLogPolicy = "log_policy" + OpenClawConfigResourceTypeAgent = "agent" + OpenClawConfigResourceTypeScheduledTask = "scheduled_task" + + OpenClawConfigPlanModeNone = "none" + OpenClawConfigPlanModeBundle = "bundle" + OpenClawConfigPlanModeManual = "manual" + + OpenClawBootstrapManifestEnv = "CLAWMANAGER_OPENCLAW_BOOTSTRAP_MANIFEST_JSON" + OpenClawChannelsEnv = "CLAWMANAGER_OPENCLAW_CHANNELS_JSON" + OpenClawSkillsEnv = "CLAWMANAGER_OPENCLAW_SKILLS_JSON" + OpenClawSessionTemplatesEnv = "CLAWMANAGER_OPENCLAW_SESSION_TEMPLATES_JSON" + OpenClawLogPoliciesEnv = "CLAWMANAGER_OPENCLAW_LOG_POLICIES_JSON" + OpenClawAgentsEnv = "CLAWMANAGER_OPENCLAW_AGENTS_JSON" + OpenClawScheduledTasksEnv = "CLAWMANAGER_OPENCLAW_SCHEDULED_TASKS_JSON" + openClawBootstrapPayloadMaxBytes = 64 * 1024 + + openClawCompiledSnapshotStatus = "compiled" + openClawActiveSnapshotStatus = "active" + openClawFailedSnapshotStatus = "failed" + defaultSnapshotListLimit = 50 +) + +var ( + openClawAllowedResourceTypes = map[string]struct{}{ + OpenClawConfigResourceTypeChannel: {}, + OpenClawConfigResourceTypeSkill: {}, + OpenClawConfigResourceTypeSessionTemplate: {}, + OpenClawConfigResourceTypeLogPolicy: {}, + OpenClawConfigResourceTypeAgent: {}, + OpenClawConfigResourceTypeScheduledTask: {}, + } + openClawPlanModes = map[string]struct{}{ + OpenClawConfigPlanModeNone: {}, + OpenClawConfigPlanModeBundle: {}, + OpenClawConfigPlanModeManual: {}, + } + openClawResourceTypeOrder = []string{ + OpenClawConfigResourceTypeChannel, + OpenClawConfigResourceTypeSkill, + OpenClawConfigResourceTypeSessionTemplate, + OpenClawConfigResourceTypeLogPolicy, + OpenClawConfigResourceTypeAgent, + OpenClawConfigResourceTypeScheduledTask, + } + openClawEnvByResourceType = map[string]string{ + OpenClawConfigResourceTypeChannel: OpenClawChannelsEnv, + OpenClawConfigResourceTypeSkill: OpenClawSkillsEnv, + OpenClawConfigResourceTypeSessionTemplate: OpenClawSessionTemplatesEnv, + OpenClawConfigResourceTypeLogPolicy: OpenClawLogPoliciesEnv, + OpenClawConfigResourceTypeAgent: OpenClawAgentsEnv, + OpenClawConfigResourceTypeScheduledTask: OpenClawScheduledTasksEnv, + } + openClawResourceKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]{1,99}$`) +) + +type OpenClawConfigDependency struct { + Type string `json:"type"` + Key string `json:"key"` + Required bool `json:"required"` +} + +type OpenClawConfigEnvelope struct { + SchemaVersion int `json:"schemaVersion"` + Kind string `json:"kind"` + Format string `json:"format"` + DependsOn []OpenClawConfigDependency `json:"dependsOn"` + Config json.RawMessage `json:"config"` +} + +type OpenClawConfigPlan struct { + Mode string `json:"mode"` + BundleID *int `json:"bundle_id,omitempty"` + ResourceIDs []int `json:"resource_ids,omitempty"` +} + +type OpenClawConfigResourceSummary struct { + ID int `json:"id"` + ResourceType string `json:"resource_type"` + ResourceKey string `json:"resource_key"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Version int `json:"version"` +} + +type OpenClawConfigResourcePayload struct { + ID int `json:"id"` + UserID int `json:"user_id"` + ResourceType string `json:"resource_type"` + ResourceKey string `json:"resource_key"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + Version int `json:"version"` + Tags []string `json:"tags"` + Content json.RawMessage `json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpsertOpenClawConfigResourceRequest struct { + ResourceType string `json:"resource_type"` + ResourceKey string `json:"resource_key"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + Tags []string `json:"tags"` + Content json.RawMessage `json:"content"` +} + +type OpenClawConfigBundleItemPayload struct { + ResourceID int `json:"resource_id"` + SortOrder int `json:"sort_order"` + Required bool `json:"required"` + Resource *OpenClawConfigResourceSummary `json:"resource,omitempty"` +} + +type OpenClawConfigBundlePayload struct { + ID int `json:"id"` + UserID int `json:"user_id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + Version int `json:"version"` + Items []OpenClawConfigBundleItemPayload `json:"items"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpsertOpenClawConfigBundleRequest struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Enabled bool `json:"enabled"` + Items []OpenClawConfigBundleItemPayload `json:"items"` +} + +type OpenClawConfigCompilePreview struct { + Mode string `json:"mode"` + Bundle *OpenClawConfigBundlePayload `json:"bundle,omitempty"` + SelectedResources []OpenClawConfigResourceSummary `json:"selected_resources"` + ResolvedResources []OpenClawConfigResourceSummary `json:"resolved_resources"` + AutoIncluded []OpenClawConfigResourceSummary `json:"auto_included"` + Warnings []string `json:"warnings"` + EnvNames []string `json:"env_names"` + PayloadSizes map[string]int `json:"payload_sizes"` + TotalPayloadBytes int `json:"total_payload_bytes"` + Manifest json.RawMessage `json:"manifest"` +} + +type OpenClawInjectionSnapshotPayload struct { + ID int `json:"id"` + InstanceID *int `json:"instance_id,omitempty"` + UserID int `json:"user_id"` + Mode string `json:"mode"` + BundleID *int `json:"bundle_id,omitempty"` + SelectedResourceIDs []int `json:"selected_resource_ids"` + ResolvedResources []OpenClawConfigResourceSummary `json:"resolved_resources"` + Manifest json.RawMessage `json:"manifest"` + EnvNames []string `json:"env_names"` + PayloadSizes map[string]int `json:"payload_sizes"` + SecretName *string `json:"secret_name,omitempty"` + Status string `json:"status"` + ErrorMessage *string `json:"error_message,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ActivatedAt *time.Time `json:"activated_at,omitempty"` +} + +type OpenClawConfigService interface { + ListResources(userID int, resourceType string) ([]OpenClawConfigResourcePayload, error) + GetResource(userID, id int) (*OpenClawConfigResourcePayload, error) + CreateResource(userID int, req UpsertOpenClawConfigResourceRequest) (*OpenClawConfigResourcePayload, error) + UpdateResource(userID, id int, req UpsertOpenClawConfigResourceRequest) (*OpenClawConfigResourcePayload, error) + DeleteResource(userID, id int) error + CloneResource(userID, id int) (*OpenClawConfigResourcePayload, error) + ValidateResource(req UpsertOpenClawConfigResourceRequest) error + + ListBundles(userID int) ([]OpenClawConfigBundlePayload, error) + GetBundle(userID, id int) (*OpenClawConfigBundlePayload, error) + CreateBundle(userID int, req UpsertOpenClawConfigBundleRequest) (*OpenClawConfigBundlePayload, error) + UpdateBundle(userID, id int, req UpsertOpenClawConfigBundleRequest) (*OpenClawConfigBundlePayload, error) + DeleteBundle(userID, id int) error + CloneBundle(userID, id int) (*OpenClawConfigBundlePayload, error) + + CompilePreview(userID int, plan OpenClawConfigPlan) (*OpenClawConfigCompilePreview, error) + CreateSnapshotForInstance(userID int, instance *models.Instance, plan *OpenClawConfigPlan) (*models.OpenClawInjectionSnapshot, error) + MarkSnapshotActive(snapshot *models.OpenClawInjectionSnapshot) error + MarkSnapshotFailed(snapshot *models.OpenClawInjectionSnapshot, err error) error + EnsureSnapshotSecret(ctx context.Context, userID int, instance *models.Instance, snapshotID int) (string, error) + ListSnapshots(userID int, limit int) ([]OpenClawInjectionSnapshotPayload, error) + GetSnapshot(userID, id int) (*OpenClawInjectionSnapshotPayload, error) +} + +type openClawConfigService struct { + repo repository.OpenClawConfigRepository + secretService *k8s.SecretService +} + +type compiledOpenClawResource struct { + model models.OpenClawConfigResource + tags []string + envelope OpenClawConfigEnvelope +} + +type compiledOpenClawConfig struct { + plan OpenClawConfigPlan + bundle *models.OpenClawConfigBundle + selected []compiledOpenClawResource + resolved []compiledOpenClawResource + autoIncluded []compiledOpenClawResource + warnings []string + renderedEnv map[string]string + manifest string + payloadSizes map[string]int + totalPayloadSize int +} + +func NewOpenClawConfigService(repo repository.OpenClawConfigRepository) OpenClawConfigService { + return &openClawConfigService{ + repo: repo, + secretService: k8s.NewSecretService(), + } +} + +func (s *openClawConfigService) ListResources(userID int, resourceType string) ([]OpenClawConfigResourcePayload, error) { + if resourceType != "" && !isValidOpenClawResourceType(resourceType) { + return nil, fmt.Errorf("invalid openclaw resource type") + } + + items, err := s.repo.ListResources(userID, resourceType) + if err != nil { + return nil, err + } + + result := make([]OpenClawConfigResourcePayload, 0, len(items)) + for _, item := range items { + payload, err := resourcePayloadFromModel(item) + if err != nil { + return nil, err + } + result = append(result, payload) + } + return result, nil +} + +func (s *openClawConfigService) GetResource(userID, id int) (*OpenClawConfigResourcePayload, error) { + item, err := s.repo.GetResourceByID(id) + if err != nil { + return nil, err + } + if item == nil || item.UserID != userID { + return nil, fmt.Errorf("openclaw config resource not found") + } + + payload, err := resourcePayloadFromModel(*item) + if err != nil { + return nil, err + } + return &payload, nil +} + +func (s *openClawConfigService) CreateResource(userID int, req UpsertOpenClawConfigResourceRequest) (*OpenClawConfigResourcePayload, error) { + resourceType := normalizeResourceType(req.ResourceType) + resourceKey := normalizeResourceKey(req.ResourceKey) + normalizedContent, err := normalizeOpenClawResourceContent(resourceType, resourceKey, req.Content) + if err != nil { + return nil, err + } + req.Content = normalizedContent + if err := s.ValidateResource(req); err != nil { + return nil, err + } + existing, err := s.repo.GetResourceByUserTypeKey(userID, resourceType, resourceKey) + if err != nil { + return nil, err + } + if existing != nil { + return nil, fmt.Errorf("openclaw config resource key already exists") + } + + now := time.Now() + item := &models.OpenClawConfigResource{ + UserID: userID, + ResourceType: resourceType, + ResourceKey: resourceKey, + Name: strings.TrimSpace(req.Name), + Description: normalizeOptionalString(req.Description), + Enabled: req.Enabled, + Version: 1, + TagsJSON: encodeStringArray(normalizeTags(req.Tags)), + ContentJSON: string(normalizedContent), + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.CreateResource(item); err != nil { + return nil, err + } + + payload, err := resourcePayloadFromModel(*item) + if err != nil { + return nil, err + } + return &payload, nil +} + +func (s *openClawConfigService) UpdateResource(userID, id int, req UpsertOpenClawConfigResourceRequest) (*OpenClawConfigResourcePayload, error) { + item, err := s.repo.GetResourceByID(id) + if err != nil { + return nil, err + } + if item == nil || item.UserID != userID { + return nil, fmt.Errorf("openclaw config resource not found") + } + + resourceType := normalizeResourceType(req.ResourceType) + resourceKey := normalizeResourceKey(req.ResourceKey) + normalizedContent, err := normalizeOpenClawResourceContent(resourceType, resourceKey, req.Content) + if err != nil { + return nil, err + } + req.Content = normalizedContent + if err := s.ValidateResource(req); err != nil { + return nil, err + } + existing, err := s.repo.GetResourceByUserTypeKey(userID, resourceType, resourceKey) + if err != nil { + return nil, err + } + if existing != nil && existing.ID != id { + return nil, fmt.Errorf("openclaw config resource key already exists") + } + + item.ResourceType = resourceType + item.ResourceKey = resourceKey + item.Name = strings.TrimSpace(req.Name) + item.Description = normalizeOptionalString(req.Description) + item.Enabled = req.Enabled + item.Version++ + item.TagsJSON = encodeStringArray(normalizeTags(req.Tags)) + item.ContentJSON = string(normalizedContent) + item.UpdatedAt = time.Now() + + if err := s.repo.UpdateResource(item); err != nil { + return nil, err + } + + payload, err := resourcePayloadFromModel(*item) + if err != nil { + return nil, err + } + return &payload, nil +} + +func (s *openClawConfigService) DeleteResource(userID, id int) error { + item, err := s.repo.GetResourceByID(id) + if err != nil { + return err + } + if item == nil || item.UserID != userID { + return fmt.Errorf("openclaw config resource not found") + } + return s.repo.DeleteResource(id) +} + +func (s *openClawConfigService) CloneResource(userID, id int) (*OpenClawConfigResourcePayload, error) { + item, err := s.repo.GetResourceByID(id) + if err != nil { + return nil, err + } + if item == nil || item.UserID != userID { + return nil, fmt.Errorf("openclaw config resource not found") + } + + clone := *item + clone.ID = 0 + clone.Name = fmt.Sprintf("%s Copy", item.Name) + clone.ResourceKey = fmt.Sprintf("%s-copy-%d", item.ResourceKey, time.Now().Unix()) + clone.Version = 1 + clone.CreatedAt = time.Now() + clone.UpdatedAt = clone.CreatedAt + if normalizedContent, err := normalizeOpenClawResourceContent(clone.ResourceType, item.ResourceKey, json.RawMessage(clone.ContentJSON)); err == nil { + clone.ContentJSON = string(normalizedContent) + } + + if err := s.repo.CreateResource(&clone); err != nil { + return nil, err + } + + payload, err := resourcePayloadFromModel(clone) + if err != nil { + return nil, err + } + return &payload, nil +} + +func (s *openClawConfigService) ValidateResource(req UpsertOpenClawConfigResourceRequest) error { + resourceType := normalizeResourceType(req.ResourceType) + resourceKey := normalizeResourceKey(req.ResourceKey) + name := strings.TrimSpace(req.Name) + + if !isValidOpenClawResourceType(resourceType) { + return fmt.Errorf("invalid openclaw resource type") + } + if name == "" { + return fmt.Errorf("openclaw config resource name is required") + } + if !openClawResourceKeyPattern.MatchString(resourceKey) { + return fmt.Errorf("openclaw config resource key is invalid") + } + + envelope, err := parseOpenClawEnvelope(resourceType, req.Content) + if err != nil { + return err + } + for _, dep := range envelope.DependsOn { + if !isValidOpenClawResourceType(dep.Type) { + return fmt.Errorf("openclaw config dependency type is invalid") + } + if normalizeResourceKey(dep.Key) == "" { + return fmt.Errorf("openclaw config dependency key is required") + } + } + return nil +} + +func (s *openClawConfigService) ListBundles(userID int) ([]OpenClawConfigBundlePayload, error) { + items, err := s.repo.ListBundles(userID) + if err != nil { + return nil, err + } + + result := make([]OpenClawConfigBundlePayload, 0, len(items)) + for _, item := range items { + payload, err := s.bundlePayloadFromModel(item) + if err != nil { + return nil, err + } + result = append(result, payload) + } + return result, nil +} + +func (s *openClawConfigService) GetBundle(userID, id int) (*OpenClawConfigBundlePayload, error) { + item, err := s.repo.GetBundleByID(id) + if err != nil { + return nil, err + } + if item == nil || item.UserID != userID { + return nil, fmt.Errorf("openclaw config bundle not found") + } + + payload, err := s.bundlePayloadFromModel(*item) + if err != nil { + return nil, err + } + return &payload, nil +} + +func (s *openClawConfigService) CreateBundle(userID int, req UpsertOpenClawConfigBundleRequest) (*OpenClawConfigBundlePayload, error) { + if err := s.validateBundleRequest(userID, req); err != nil { + return nil, err + } + + now := time.Now() + item := &models.OpenClawConfigBundle{ + UserID: userID, + Name: strings.TrimSpace(req.Name), + Description: normalizeOptionalString(req.Description), + Enabled: req.Enabled, + Version: 1, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.repo.CreateBundle(item); err != nil { + return nil, err + } + if err := s.repo.ReplaceBundleItems(item.ID, normalizeBundleItems(req.Items)); err != nil { + return nil, err + } + + payload, err := s.bundlePayloadFromModel(*item) + if err != nil { + return nil, err + } + return &payload, nil +} + +func (s *openClawConfigService) UpdateBundle(userID, id int, req UpsertOpenClawConfigBundleRequest) (*OpenClawConfigBundlePayload, error) { + if err := s.validateBundleRequest(userID, req); err != nil { + return nil, err + } + + item, err := s.repo.GetBundleByID(id) + if err != nil { + return nil, err + } + if item == nil || item.UserID != userID { + return nil, fmt.Errorf("openclaw config bundle not found") + } + + item.Name = strings.TrimSpace(req.Name) + item.Description = normalizeOptionalString(req.Description) + item.Enabled = req.Enabled + item.Version++ + item.UpdatedAt = time.Now() + + if err := s.repo.UpdateBundle(item); err != nil { + return nil, err + } + if err := s.repo.ReplaceBundleItems(item.ID, normalizeBundleItems(req.Items)); err != nil { + return nil, err + } + + payload, err := s.bundlePayloadFromModel(*item) + if err != nil { + return nil, err + } + return &payload, nil +} + +func (s *openClawConfigService) DeleteBundle(userID, id int) error { + item, err := s.repo.GetBundleByID(id) + if err != nil { + return err + } + if item == nil || item.UserID != userID { + return fmt.Errorf("openclaw config bundle not found") + } + return s.repo.DeleteBundle(id) +} + +func (s *openClawConfigService) CloneBundle(userID, id int) (*OpenClawConfigBundlePayload, error) { + item, err := s.repo.GetBundleByID(id) + if err != nil { + return nil, err + } + if item == nil || item.UserID != userID { + return nil, fmt.Errorf("openclaw config bundle not found") + } + + items, err := s.repo.ListBundleItems(id) + if err != nil { + return nil, err + } + + clone := *item + clone.ID = 0 + clone.Name = fmt.Sprintf("%s Copy", item.Name) + clone.Version = 1 + clone.CreatedAt = time.Now() + clone.UpdatedAt = clone.CreatedAt + if err := s.repo.CreateBundle(&clone); err != nil { + return nil, err + } + for idx := range items { + items[idx].ID = 0 + items[idx].BundleID = clone.ID + } + if err := s.repo.ReplaceBundleItems(clone.ID, items); err != nil { + return nil, err + } + + payload, err := s.bundlePayloadFromModel(clone) + if err != nil { + return nil, err + } + return &payload, nil +} + +func (s *openClawConfigService) CompilePreview(userID int, plan OpenClawConfigPlan) (*OpenClawConfigCompilePreview, error) { + compiled, err := s.compilePlan(userID, plan) + if err != nil { + return nil, err + } + return s.previewFromCompiled(compiled) +} + +func (s *openClawConfigService) CreateSnapshotForInstance(userID int, instance *models.Instance, plan *OpenClawConfigPlan) (*models.OpenClawInjectionSnapshot, error) { + if instance == nil || plan == nil || !hasOpenClawConfigSelections(*plan) { + return nil, nil + } + + compiled, err := s.compilePlan(userID, *plan) + if err != nil { + return nil, err + } + + selectedIDs := make([]int, 0, len(compiled.selected)) + for _, resource := range compiled.selected { + selectedIDs = append(selectedIDs, resource.model.ID) + } + + resolvedSummaries := make([]OpenClawConfigResourceSummary, 0, len(compiled.resolved)) + for _, resource := range compiled.resolved { + resolvedSummaries = append(resolvedSummaries, resourceSummaryFromModel(resource.model)) + } + + selectedJSON, err := marshalJSONString(selectedIDs) + if err != nil { + return nil, err + } + resolvedJSON, err := marshalJSONString(resolvedSummaries) + if err != nil { + return nil, err + } + envJSON, err := marshalJSONString(compiled.renderedEnv) + if err != nil { + return nil, err + } + + now := time.Now() + instanceID := instance.ID + snapshot := &models.OpenClawInjectionSnapshot{ + InstanceID: &instanceID, + UserID: userID, + Mode: compiled.plan.Mode, + BundleID: compiled.plan.BundleID, + SelectedResourceIDsJSON: selectedJSON, + ResolvedResourcesJSON: resolvedJSON, + RenderedManifestJSON: compiled.manifest, + RenderedEnvJSON: envJSON, + Status: openClawCompiledSnapshotStatus, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.CreateSnapshot(snapshot); err != nil { + return nil, err + } + return snapshot, nil +} + +func (s *openClawConfigService) MarkSnapshotActive(snapshot *models.OpenClawInjectionSnapshot) error { + if snapshot == nil { + return nil + } + now := time.Now() + snapshot.Status = openClawActiveSnapshotStatus + snapshot.ActivatedAt = &now + snapshot.UpdatedAt = now + return s.repo.UpdateSnapshot(snapshot) +} + +func (s *openClawConfigService) MarkSnapshotFailed(snapshot *models.OpenClawInjectionSnapshot, failedErr error) error { + if snapshot == nil { + return nil + } + message := strings.TrimSpace(errorString(failedErr)) + if message == "" { + message = "unknown bootstrap failure" + } + now := time.Now() + snapshot.Status = openClawFailedSnapshotStatus + snapshot.ErrorMessage = &message + snapshot.UpdatedAt = now + return s.repo.UpdateSnapshot(snapshot) +} + +func (s *openClawConfigService) EnsureSnapshotSecret(ctx context.Context, userID int, instance *models.Instance, snapshotID int) (string, error) { + if instance == nil || snapshotID <= 0 { + return "", nil + } + + snapshot, err := s.repo.GetSnapshotByID(snapshotID) + if err != nil { + return "", err + } + if snapshot == nil || snapshot.UserID != userID { + return "", fmt.Errorf("openclaw injection snapshot not found") + } + + var envValues map[string]string + if err := json.Unmarshal([]byte(snapshot.RenderedEnvJSON), &envValues); err != nil { + return "", fmt.Errorf("openclaw injection snapshot env payload is invalid") + } + + secretName := snapshot.SecretName + if secretName == nil || strings.TrimSpace(*secretName) == "" { + client := k8s.GetClient() + if client == nil { + return "", fmt.Errorf("k8s client not initialized") + } + name := client.GetOpenClawBootstrapSecretName(instance.ID, instance.Name) + secretName = &name + } + + if err := s.secretService.UpsertSecret(ctx, userID, *secretName, envValues, map[string]string{ + "app": "clawreef", + "instance-id": fmt.Sprintf("%d", instance.ID), + "instance-name": instance.Name, + "user-id": fmt.Sprintf("%d", userID), + "managed-by": "clawreef", + "resource-type": "openclaw-bootstrap", + }); err != nil { + return "", err + } + + if snapshot.SecretName == nil || *snapshot.SecretName != *secretName { + snapshot.SecretName = secretName + snapshot.UpdatedAt = time.Now() + if err := s.repo.UpdateSnapshot(snapshot); err != nil { + return "", err + } + } + + return *secretName, nil +} + +func (s *openClawConfigService) ListSnapshots(userID int, limit int) ([]OpenClawInjectionSnapshotPayload, error) { + if limit <= 0 { + limit = defaultSnapshotListLimit + } + + items, err := s.repo.ListSnapshotsByUser(userID, limit) + if err != nil { + return nil, err + } + + result := make([]OpenClawInjectionSnapshotPayload, 0, len(items)) + for _, item := range items { + payload, err := snapshotPayloadFromModel(item) + if err != nil { + return nil, err + } + result = append(result, payload) + } + return result, nil +} + +func (s *openClawConfigService) GetSnapshot(userID, id int) (*OpenClawInjectionSnapshotPayload, error) { + item, err := s.repo.GetSnapshotByID(id) + if err != nil { + return nil, err + } + if item == nil || item.UserID != userID { + return nil, fmt.Errorf("openclaw injection snapshot not found") + } + + payload, err := snapshotPayloadFromModel(*item) + if err != nil { + return nil, err + } + return &payload, nil +} + +func (s *openClawConfigService) validateBundleRequest(userID int, req UpsertOpenClawConfigBundleRequest) error { + if strings.TrimSpace(req.Name) == "" { + return fmt.Errorf("openclaw config bundle name is required") + } + if len(req.Items) == 0 { + return fmt.Errorf("openclaw config bundle must include at least one resource") + } + + seen := map[int]struct{}{} + for _, item := range req.Items { + if item.ResourceID <= 0 { + return fmt.Errorf("openclaw config bundle resource id is required") + } + if _, exists := seen[item.ResourceID]; exists { + return fmt.Errorf("openclaw config bundle contains duplicate resources") + } + seen[item.ResourceID] = struct{}{} + + resource, err := s.repo.GetResourceByID(item.ResourceID) + if err != nil { + return err + } + if resource == nil || resource.UserID != userID { + return fmt.Errorf("openclaw config resource not found") + } + } + return nil +} + +func normalizeBundleItems(items []OpenClawConfigBundleItemPayload) []models.OpenClawConfigBundleItem { + result := make([]models.OpenClawConfigBundleItem, 0, len(items)) + for idx, item := range items { + sortOrder := item.SortOrder + if sortOrder == 0 { + sortOrder = idx + 1 + } + result = append(result, models.OpenClawConfigBundleItem{ + ResourceID: item.ResourceID, + SortOrder: sortOrder, + Required: item.Required, + }) + } + return result +} + +func (s *openClawConfigService) bundlePayloadFromModel(item models.OpenClawConfigBundle) (OpenClawConfigBundlePayload, error) { + bundleItems, err := s.repo.ListBundleItems(item.ID) + if err != nil { + return OpenClawConfigBundlePayload{}, err + } + + payloadItems := make([]OpenClawConfigBundleItemPayload, 0, len(bundleItems)) + for _, bundleItem := range bundleItems { + var summary *OpenClawConfigResourceSummary + resource, err := s.repo.GetResourceByID(bundleItem.ResourceID) + if err != nil { + return OpenClawConfigBundlePayload{}, err + } + if resource != nil { + resourceSummary := resourceSummaryFromModel(*resource) + summary = &resourceSummary + } + + payloadItems = append(payloadItems, OpenClawConfigBundleItemPayload{ + ResourceID: bundleItem.ResourceID, + SortOrder: bundleItem.SortOrder, + Required: bundleItem.Required, + Resource: summary, + }) + } + + return OpenClawConfigBundlePayload{ + ID: item.ID, + UserID: item.UserID, + Name: item.Name, + Description: item.Description, + Enabled: item.Enabled, + Version: item.Version, + Items: payloadItems, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }, nil +} + +func (s *openClawConfigService) compilePlan(userID int, plan OpenClawConfigPlan) (*compiledOpenClawConfig, error) { + plan.Mode = normalizePlanMode(plan.Mode) + if plan.Mode == "" { + plan.Mode = OpenClawConfigPlanModeNone + } + if _, ok := openClawPlanModes[plan.Mode]; !ok { + return nil, fmt.Errorf("invalid openclaw config plan mode") + } + + if plan.Mode == OpenClawConfigPlanModeNone { + manifestJSON, err := marshalJSONString(map[string]interface{}{ + "schemaVersion": 1, + "mode": OpenClawConfigPlanModeNone, + "resources": []interface{}{}, + "payloads": []interface{}{}, + }) + if err != nil { + return nil, err + } + return &compiledOpenClawConfig{ + plan: plan, + renderedEnv: map[string]string{}, + manifest: manifestJSON, + payloadSizes: map[string]int{}, + }, nil + } + + selected, bundle, err := s.loadSelectedResources(userID, plan) + if err != nil { + return nil, err + } + + selectedCompiled := make([]compiledOpenClawResource, 0, len(selected)) + resolvedOrdered := make([]compiledOpenClawResource, 0, len(selected)) + autoIncluded := make([]compiledOpenClawResource, 0) + resourceByRef := map[string]compiledOpenClawResource{} + warnings := []string{} + + for _, item := range selected { + compiled, err := compiledResourceFromModel(item) + if err != nil { + return nil, err + } + ref := openClawResourceRef(compiled.model.ResourceType, compiled.model.ResourceKey) + resourceByRef[ref] = compiled + selectedCompiled = append(selectedCompiled, compiled) + resolvedOrdered = append(resolvedOrdered, compiled) + } + + queue := append([]compiledOpenClawResource{}, selectedCompiled...) + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + + for _, dependency := range current.envelope.DependsOn { + depType := normalizeResourceType(dependency.Type) + depKey := normalizeResourceKey(dependency.Key) + if depType == "" || depKey == "" { + return nil, fmt.Errorf("openclaw config dependency is invalid") + } + + ref := openClawResourceRef(depType, depKey) + if _, exists := resourceByRef[ref]; exists { + continue + } + + resource, err := s.repo.GetResourceByUserTypeKey(userID, depType, depKey) + if err != nil { + return nil, err + } + if resource == nil { + if dependency.Required { + return nil, fmt.Errorf("missing required openclaw config dependency: %s/%s", depType, depKey) + } + warnings = append(warnings, fmt.Sprintf("Optional dependency %s/%s is not configured", depType, depKey)) + continue + } + if !resource.Enabled { + if dependency.Required { + return nil, fmt.Errorf("required openclaw config dependency is disabled: %s/%s", depType, depKey) + } + warnings = append(warnings, fmt.Sprintf("Optional dependency %s/%s is disabled", depType, depKey)) + continue + } + + compiledDep, err := compiledResourceFromModel(*resource) + if err != nil { + return nil, err + } + resourceByRef[ref] = compiledDep + resolvedOrdered = append(resolvedOrdered, compiledDep) + autoIncluded = append(autoIncluded, compiledDep) + queue = append(queue, compiledDep) + } + } + + sortCompiledResources(resolvedOrdered) + sortCompiledResources(autoIncluded) + + renderedEnv, manifest, payloadSizes, totalPayloadSize, err := renderCompiledOpenClawPayload(plan, bundle, resolvedOrdered) + if err != nil { + return nil, err + } + + return &compiledOpenClawConfig{ + plan: plan, + bundle: bundle, + selected: selectedCompiled, + resolved: resolvedOrdered, + autoIncluded: autoIncluded, + warnings: warnings, + renderedEnv: renderedEnv, + manifest: manifest, + payloadSizes: payloadSizes, + totalPayloadSize: totalPayloadSize, + }, nil +} + +func (s *openClawConfigService) loadSelectedResources(userID int, plan OpenClawConfigPlan) ([]models.OpenClawConfigResource, *models.OpenClawConfigBundle, error) { + switch plan.Mode { + case OpenClawConfigPlanModeBundle: + if plan.BundleID == nil || *plan.BundleID <= 0 { + return nil, nil, fmt.Errorf("openclaw config bundle is required") + } + + bundle, err := s.repo.GetBundleByID(*plan.BundleID) + if err != nil { + return nil, nil, err + } + if bundle == nil || bundle.UserID != userID { + return nil, nil, fmt.Errorf("openclaw config bundle not found") + } + if !bundle.Enabled { + return nil, nil, fmt.Errorf("openclaw config bundle is disabled") + } + + items, err := s.repo.ListBundleItems(bundle.ID) + if err != nil { + return nil, nil, err + } + if len(items) == 0 { + return nil, nil, fmt.Errorf("openclaw config bundle is empty") + } + + result := make([]models.OpenClawConfigResource, 0, len(items)) + for _, item := range items { + resource, err := s.repo.GetResourceByID(item.ResourceID) + if err != nil { + return nil, nil, err + } + if resource == nil || resource.UserID != userID { + return nil, nil, fmt.Errorf("openclaw config resource not found") + } + if !resource.Enabled { + return nil, nil, fmt.Errorf("openclaw config bundle contains a disabled resource") + } + result = append(result, *resource) + } + return result, bundle, nil + case OpenClawConfigPlanModeManual: + if len(plan.ResourceIDs) == 0 { + return nil, nil, fmt.Errorf("at least one openclaw config resource must be selected") + } + + result := make([]models.OpenClawConfigResource, 0, len(plan.ResourceIDs)) + seen := map[int]struct{}{} + for _, id := range plan.ResourceIDs { + if id <= 0 { + return nil, nil, fmt.Errorf("openclaw config resource id is invalid") + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + + resource, err := s.repo.GetResourceByID(id) + if err != nil { + return nil, nil, err + } + if resource == nil || resource.UserID != userID { + return nil, nil, fmt.Errorf("openclaw config resource not found") + } + if !resource.Enabled { + return nil, nil, fmt.Errorf("openclaw config resource is disabled") + } + result = append(result, *resource) + } + return result, nil, nil + default: + return nil, nil, fmt.Errorf("invalid openclaw config plan mode") + } +} + +func (s *openClawConfigService) previewFromCompiled(compiled *compiledOpenClawConfig) (*OpenClawConfigCompilePreview, error) { + var bundlePayload *OpenClawConfigBundlePayload + if compiled.bundle != nil { + payload, err := s.bundlePayloadFromModel(*compiled.bundle) + if err != nil { + return nil, err + } + bundlePayload = &payload + } + + return &OpenClawConfigCompilePreview{ + Mode: compiled.plan.Mode, + Bundle: bundlePayload, + SelectedResources: summarizeCompiledResources(compiled.selected), + ResolvedResources: summarizeCompiledResources(compiled.resolved), + AutoIncluded: summarizeCompiledResources(compiled.autoIncluded), + Warnings: compiled.warnings, + EnvNames: sortedEnvNames(compiled.renderedEnv), + PayloadSizes: compiled.payloadSizes, + TotalPayloadBytes: compiled.totalPayloadSize, + Manifest: json.RawMessage(compiled.manifest), + }, nil +} + +func resourcePayloadFromModel(item models.OpenClawConfigResource) (OpenClawConfigResourcePayload, error) { + tags, err := decodeStringArray(item.TagsJSON) + if err != nil { + return OpenClawConfigResourcePayload{}, fmt.Errorf("failed to parse openclaw config resource tags") + } + + content := json.RawMessage(item.ContentJSON) + if len(content) == 0 { + content = json.RawMessage("{}") + } + if normalizedContent, err := normalizeOpenClawResourceContent(item.ResourceType, item.ResourceKey, content); err == nil { + content = normalizedContent + } + + return OpenClawConfigResourcePayload{ + ID: item.ID, + UserID: item.UserID, + ResourceType: item.ResourceType, + ResourceKey: item.ResourceKey, + Name: item.Name, + Description: item.Description, + Enabled: item.Enabled, + Version: item.Version, + Tags: tags, + Content: content, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + }, nil +} + +func resourceSummaryFromModel(item models.OpenClawConfigResource) OpenClawConfigResourceSummary { + return OpenClawConfigResourceSummary{ + ID: item.ID, + ResourceType: item.ResourceType, + ResourceKey: item.ResourceKey, + Name: item.Name, + Enabled: item.Enabled, + Version: item.Version, + } +} + +func snapshotPayloadFromModel(item models.OpenClawInjectionSnapshot) (OpenClawInjectionSnapshotPayload, error) { + selectedIDs := []int{} + if strings.TrimSpace(item.SelectedResourceIDsJSON) != "" { + if err := json.Unmarshal([]byte(item.SelectedResourceIDsJSON), &selectedIDs); err != nil { + return OpenClawInjectionSnapshotPayload{}, fmt.Errorf("failed to parse openclaw snapshot selected ids") + } + } + + resolved := []OpenClawConfigResourceSummary{} + if strings.TrimSpace(item.ResolvedResourcesJSON) != "" { + if err := json.Unmarshal([]byte(item.ResolvedResourcesJSON), &resolved); err != nil { + return OpenClawInjectionSnapshotPayload{}, fmt.Errorf("failed to parse openclaw snapshot resources") + } + } + + envValues := map[string]string{} + if strings.TrimSpace(item.RenderedEnvJSON) != "" { + if err := json.Unmarshal([]byte(item.RenderedEnvJSON), &envValues); err != nil { + return OpenClawInjectionSnapshotPayload{}, fmt.Errorf("failed to parse openclaw snapshot env payloads") + } + } + + payloadSizes := map[string]int{} + for key, value := range envValues { + payloadSizes[key] = len(value) + } + + return OpenClawInjectionSnapshotPayload{ + ID: item.ID, + InstanceID: item.InstanceID, + UserID: item.UserID, + Mode: item.Mode, + BundleID: item.BundleID, + SelectedResourceIDs: selectedIDs, + ResolvedResources: resolved, + Manifest: json.RawMessage(item.RenderedManifestJSON), + EnvNames: sortedEnvNames(envValues), + PayloadSizes: payloadSizes, + SecretName: item.SecretName, + Status: item.Status, + ErrorMessage: item.ErrorMessage, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + ActivatedAt: item.ActivatedAt, + }, nil +} + +func renderCompiledOpenClawPayload(plan OpenClawConfigPlan, bundle *models.OpenClawConfigBundle, resources []compiledOpenClawResource) (map[string]string, string, map[string]int, int, error) { + payloads := map[string]interface{}{} + payloadCounts := map[string]int{} + for _, resourceType := range openClawResourceTypeOrder { + payloads[resourceType] = defaultOpenClawEnvPayload(resourceType) + payloadCounts[resourceType] = 0 + } + + resolvedSummaries := make([]map[string]interface{}, 0, len(resources)) + for _, resource := range resources { + nextPayload, err := appendCompiledOpenClawEnvPayload(payloads[resource.model.ResourceType], resource) + if err != nil { + return nil, "", nil, 0, err + } + payloads[resource.model.ResourceType] = nextPayload + payloadCounts[resource.model.ResourceType]++ + resolvedSummaries = append(resolvedSummaries, map[string]interface{}{ + "id": resource.model.ID, + "type": resource.model.ResourceType, + "key": resource.model.ResourceKey, + "name": resource.model.Name, + "version": resource.model.Version, + }) + } + + renderedEnv := map[string]string{} + payloadSizes := map[string]int{} + totalPayloadBytes := 0 + + for _, resourceType := range openClawResourceTypeOrder { + envName := openClawEnvByResourceType[resourceType] + value, err := marshalJSONString(payloads[resourceType]) + if err != nil { + return nil, "", nil, 0, err + } + renderedEnv[envName] = value + payloadSizes[envName] = len(value) + totalPayloadBytes += len(value) + } + + manifest := map[string]interface{}{ + "schemaVersion": 1, + "mode": plan.Mode, + "resources": resolvedSummaries, + "payloads": []map[string]interface{}{}, + } + if bundle != nil { + manifest["bundle"] = map[string]interface{}{ + "id": bundle.ID, + "name": bundle.Name, + "version": bundle.Version, + } + } + + for _, resourceType := range openClawResourceTypeOrder { + envName := openClawEnvByResourceType[resourceType] + manifest["payloads"] = append(manifest["payloads"].([]map[string]interface{}), map[string]interface{}{ + "env": envName, + "count": payloadCounts[resourceType], + }) + } + + manifestJSON, err := marshalJSONString(manifest) + if err != nil { + return nil, "", nil, 0, err + } + renderedEnv[OpenClawBootstrapManifestEnv] = manifestJSON + payloadSizes[OpenClawBootstrapManifestEnv] = len(manifestJSON) + totalPayloadBytes += len(manifestJSON) + + if totalPayloadBytes > openClawBootstrapPayloadMaxBytes { + return nil, "", nil, 0, fmt.Errorf("openclaw bootstrap payload is too large") + } + + return renderedEnv, manifestJSON, payloadSizes, totalPayloadBytes, nil +} + +func defaultOpenClawEnvPayload(resourceType string) interface{} { + if resourceType == OpenClawConfigResourceTypeChannel { + return map[string]interface{}{} + } + + return map[string]interface{}{ + "schemaVersion": 1, + "items": []map[string]interface{}{}, + } +} + +func appendCompiledOpenClawEnvPayload(payload interface{}, resource compiledOpenClawResource) (interface{}, error) { + if resource.model.ResourceType == OpenClawConfigResourceTypeChannel { + channelPayload, ok := payload.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to render openclaw channels payload") + } + + var configPayload interface{} + if len(resource.envelope.Config) == 0 { + configPayload = map[string]interface{}{} + } else if err := json.Unmarshal(resource.envelope.Config, &configPayload); err != nil { + return nil, fmt.Errorf("failed to parse openclaw channel config") + } + + channelPayload[resource.model.ResourceKey] = normalizeOpenClawChannelConfigForEnv(resource.model.ResourceKey, configPayload) + return channelPayload, nil + } + + group, ok := payload.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to render openclaw payload for resource type %s", resource.model.ResourceType) + } + + payloadItem := map[string]interface{}{ + "id": resource.model.ID, + "type": resource.model.ResourceType, + "key": resource.model.ResourceKey, + "name": resource.model.Name, + "version": resource.model.Version, + "tags": resource.tags, + "content": json.RawMessage(resource.model.ContentJSON), + } + group["items"] = append(group["items"].([]map[string]interface{}), payloadItem) + return group, nil +} + +func normalizeOpenClawChannelConfigForEnv(resourceKey string, configPayload interface{}) interface{} { + switch strings.ToLower(strings.TrimSpace(resourceKey)) { + case "feishu": + return normalizeFeishuChannelConfigForEnv(configPayload) + case "slack": + return normalizeSlackChannelConfigForEnv(configPayload) + case "telegram": + return normalizeTelegramChannelConfigForEnv(configPayload) + } + + return configPayload +} + +func normalizeFeishuChannelConfigForEnv(configPayload interface{}) map[string]interface{} { + config, ok := configPayload.(map[string]interface{}) + if !ok { + return map[string]interface{}{ + "enabled": true, + "accounts": map[string]interface{}{ + "main": map[string]interface{}{ + "appId": "", + "appSecret": "", + }, + }, + } + } + + accounts, _ := config["accounts"].(map[string]interface{}) + + var account map[string]interface{} + if mainAccount, ok := accounts["main"].(map[string]interface{}); ok { + account = mainAccount + } else if defaultAccount, ok := accounts["default"].(map[string]interface{}); ok { + account = defaultAccount + } else { + account = map[string]interface{}{} + } + + appID, _ := account["appId"].(string) + if appID == "" { + appID, _ = config["appId"].(string) + } + + appSecret, _ := account["appSecret"].(string) + if appSecret == "" { + appSecret, _ = config["appSecret"].(string) + } + + return map[string]interface{}{ + "enabled": true, + "accounts": map[string]interface{}{ + "main": map[string]interface{}{ + "appId": appID, + "appSecret": appSecret, + }, + }, + } +} + +func normalizeSlackChannelConfigForEnv(configPayload interface{}) map[string]interface{} { + config, ok := configPayload.(map[string]interface{}) + if !ok { + config = map[string]interface{}{} + } + + botToken, _ := config["botToken"].(string) + appToken, _ := config["appToken"].(string) + groupPolicy, _ := config["groupPolicy"].(string) + if strings.TrimSpace(groupPolicy) == "" { + groupPolicy = "allowlist" + } + + channels, _ := config["channels"].(map[string]interface{}) + if channels == nil { + channels = map[string]interface{}{ + "#general": map[string]interface{}{ + "allow": true, + }, + } + } + + capabilities, _ := config["capabilities"].(map[string]interface{}) + if capabilities == nil { + capabilities = map[string]interface{}{ + "interactiveReplies": true, + } + } + + return map[string]interface{}{ + "enabled": true, + "botToken": botToken, + "appToken": appToken, + "groupPolicy": groupPolicy, + "channels": channels, + "capabilities": capabilities, + } +} + +func normalizeTelegramChannelConfigForEnv(configPayload interface{}) map[string]interface{} { + config, ok := configPayload.(map[string]interface{}) + if !ok { + config = map[string]interface{}{} + } + + botToken, _ := config["botToken"].(string) + dmPolicy, _ := config["dmPolicy"].(string) + if strings.TrimSpace(dmPolicy) == "" { + dmPolicy = "open" + } + + allowFrom := normalizeStringArrayForEnv(config["allowFrom"]) + if len(allowFrom) == 0 { + allowFrom = []string{"*"} + } + + return map[string]interface{}{ + "enabled": true, + "botToken": botToken, + "dmPolicy": dmPolicy, + "allowFrom": allowFrom, + } +} + +func normalizeStringArrayForEnv(value interface{}) []string { + items, ok := value.([]interface{}) + if !ok { + return nil + } + + result := make([]string, 0, len(items)) + for _, item := range items { + text, ok := item.(string) + if !ok { + continue + } + result = append(result, text) + } + return result +} + +func normalizeOpenClawResourceContent(resourceType, resourceKey string, raw json.RawMessage) (json.RawMessage, error) { + if normalizeResourceType(resourceType) != OpenClawConfigResourceTypeChannel { + return raw, nil + } + + envelope, err := parseOpenClawEnvelope(resourceType, raw) + if err != nil { + return nil, err + } + + var configPayload interface{} + if err := json.Unmarshal(envelope.Config, &configPayload); err != nil { + return nil, fmt.Errorf("failed to parse openclaw channel config") + } + + normalizedConfig := normalizeOpenClawChannelConfigForEnv(resourceKey, configPayload) + envelope.Config, err = json.Marshal(normalizedConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal normalized openclaw channel config") + } + + normalizedEnvelope, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("failed to marshal normalized openclaw content") + } + + return normalizedEnvelope, nil +} + +func compiledResourceFromModel(item models.OpenClawConfigResource) (compiledOpenClawResource, error) { + tags, err := decodeStringArray(item.TagsJSON) + if err != nil { + return compiledOpenClawResource{}, fmt.Errorf("failed to parse openclaw config tags") + } + + envelope, err := parseOpenClawEnvelope(item.ResourceType, json.RawMessage(item.ContentJSON)) + if err != nil { + return compiledOpenClawResource{}, err + } + + return compiledOpenClawResource{ + model: item, + tags: tags, + envelope: envelope, + }, nil +} + +func parseOpenClawEnvelope(resourceType string, raw json.RawMessage) (OpenClawConfigEnvelope, error) { + if len(raw) == 0 { + return OpenClawConfigEnvelope{}, fmt.Errorf("openclaw config content is required") + } + + var envelope OpenClawConfigEnvelope + if err := json.Unmarshal(raw, &envelope); err != nil { + return OpenClawConfigEnvelope{}, fmt.Errorf("openclaw config content must be valid JSON") + } + if envelope.SchemaVersion <= 0 { + return OpenClawConfigEnvelope{}, fmt.Errorf("openclaw config schemaVersion is required") + } + if canonicalizeOpenClawKind(envelope.Kind) != normalizeResourceType(resourceType) { + return OpenClawConfigEnvelope{}, fmt.Errorf("openclaw config kind does not match resource type") + } + if strings.TrimSpace(envelope.Format) == "" { + return OpenClawConfigEnvelope{}, fmt.Errorf("openclaw config format is required") + } + if len(envelope.Config) == 0 { + return OpenClawConfigEnvelope{}, fmt.Errorf("openclaw config config payload is required") + } + return envelope, nil +} + +func hasOpenClawConfigSelections(plan OpenClawConfigPlan) bool { + switch normalizePlanMode(plan.Mode) { + case OpenClawConfigPlanModeBundle: + return plan.BundleID != nil && *plan.BundleID > 0 + case OpenClawConfigPlanModeManual: + return len(plan.ResourceIDs) > 0 + default: + return false + } +} + +func normalizePlanMode(mode string) string { + return strings.ToLower(strings.TrimSpace(mode)) +} + +func normalizeResourceType(resourceType string) string { + value := strings.TrimSpace(strings.ToLower(resourceType)) + value = strings.ReplaceAll(value, "-", "_") + value = strings.ReplaceAll(value, " ", "_") + return value +} + +func normalizeResourceKey(resourceKey string) string { + return strings.TrimSpace(resourceKey) +} + +func canonicalizeOpenClawKind(kind string) string { + value := normalizeResourceType(kind) + switch value { + case "sessiontemplate": + return OpenClawConfigResourceTypeSessionTemplate + case "logpolicy": + return OpenClawConfigResourceTypeLogPolicy + case "scheduledtask": + return OpenClawConfigResourceTypeScheduledTask + default: + return value + } +} + +func isValidOpenClawResourceType(resourceType string) bool { + _, ok := openClawAllowedResourceTypes[normalizeResourceType(resourceType)] + return ok +} + +func normalizeTags(tags []string) []string { + result := make([]string, 0, len(tags)) + seen := map[string]struct{}{} + for _, tag := range tags { + trimmed := strings.TrimSpace(tag) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + sort.Strings(result) + return result +} + +func encodeStringArray(values []string) string { + if len(values) == 0 { + return "[]" + } + raw, _ := json.Marshal(values) + return string(raw) +} + +func decodeStringArray(raw string) ([]string, error) { + if strings.TrimSpace(raw) == "" { + return []string{}, nil + } + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return nil, err + } + return values, nil +} + +func marshalJSONString(value interface{}) (string, error) { + raw, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("failed to marshal openclaw payload") + } + return string(raw), nil +} + +func openClawResourceRef(resourceType, resourceKey string) string { + return fmt.Sprintf("%s:%s", normalizeResourceType(resourceType), normalizeResourceKey(resourceKey)) +} + +func summarizeCompiledResources(resources []compiledOpenClawResource) []OpenClawConfigResourceSummary { + result := make([]OpenClawConfigResourceSummary, 0, len(resources)) + for _, resource := range resources { + result = append(result, resourceSummaryFromModel(resource.model)) + } + return result +} + +func sortCompiledResources(items []compiledOpenClawResource) { + priority := map[string]int{} + for idx, resourceType := range openClawResourceTypeOrder { + priority[resourceType] = idx + } + + sort.SliceStable(items, func(i, j int) bool { + leftType := normalizeResourceType(items[i].model.ResourceType) + rightType := normalizeResourceType(items[j].model.ResourceType) + if priority[leftType] != priority[rightType] { + return priority[leftType] < priority[rightType] + } + if items[i].model.ResourceKey != items[j].model.ResourceKey { + return items[i].model.ResourceKey < items[j].model.ResourceKey + } + return items[i].model.ID < items[j].model.ID + }) +} + +func sortedEnvNames(values map[string]string) []string { + names := make([]string, 0, len(values)) + for key := range values { + names = append(names, key) + } + sort.Strings(names) + return names +} + +func normalizeOptionalString(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/backend/internal/services/openclaw_config_service_test.go b/backend/internal/services/openclaw_config_service_test.go new file mode 100644 index 0000000..26dc2d1 --- /dev/null +++ b/backend/internal/services/openclaw_config_service_test.go @@ -0,0 +1,136 @@ +package services + +import ( + "encoding/json" + "reflect" + "testing" + + "clawreef/internal/models" +) + +func TestRenderCompiledOpenClawPayloadRendersChannelsAsKeyedConfigMap(t *testing.T) { + t.Parallel() + + resources := []compiledOpenClawResource{ + { + model: models.OpenClawConfigResource{ + ID: 1, + ResourceType: OpenClawConfigResourceTypeChannel, + ResourceKey: "feishu", + Name: "Feishu", + Version: 1, + ContentJSON: `{"schemaVersion":1,"kind":"channel","format":"channel/feishu@v1","dependsOn":[],"config":{"enabled":false,"domain":"feishu","appId":"cli_top","appSecret":"top_secret","defaultAccount":"default","accounts":{"default":{"appId":"cli_xxx","appSecret":"xxx","botName":"old-bot","enabled":true}},"requireMention":true}}`, + }, + envelope: OpenClawConfigEnvelope{ + SchemaVersion: 1, + Kind: "channel", + Format: "channel/feishu@v1", + Config: json.RawMessage(`{"enabled":false,"domain":"feishu","appId":"cli_top","appSecret":"top_secret","defaultAccount":"default","accounts":{"default":{"appId":"cli_xxx","appSecret":"xxx","botName":"old-bot","enabled":true}},"requireMention":true}`), + }, + }, + { + model: models.OpenClawConfigResource{ + ID: 2, + ResourceType: OpenClawConfigResourceTypeChannel, + ResourceKey: "slack", + Name: "Slack", + Version: 1, + ContentJSON: `{"schemaVersion":1,"kind":"channel","format":"channel/slack@v1","dependsOn":[],"config":{"enabled":false,"botToken":"xoxb-xxx","appToken":"xapp-xxx","groupPolicy":"allowlist","channels":{"#general":{"allow":true}},"capabilities":{"interactiveReplies":true}}}`, + }, + envelope: OpenClawConfigEnvelope{ + SchemaVersion: 1, + Kind: "channel", + Format: "channel/slack@v1", + Config: json.RawMessage(`{"enabled":false,"botToken":"xoxb-xxx","appToken":"xapp-xxx","groupPolicy":"allowlist","channels":{"#general":{"allow":true}},"capabilities":{"interactiveReplies":true}}`), + }, + }, + { + model: models.OpenClawConfigResource{ + ID: 3, + ResourceType: OpenClawConfigResourceTypeChannel, + ResourceKey: "telegram", + Name: "Telegram", + Version: 1, + ContentJSON: `{"schemaVersion":1,"kind":"channel","format":"channel/telegram@v1","dependsOn":[],"config":{"enabled":false,"botToken":"123456:xxx","dmPolicy":"open","allowFrom":["*"],"network":{"autoSelectFamily":false}}}`, + }, + envelope: OpenClawConfigEnvelope{ + SchemaVersion: 1, + Kind: "channel", + Format: "channel/telegram@v1", + Config: json.RawMessage(`{"enabled":false,"botToken":"123456:xxx","dmPolicy":"open","allowFrom":["*"],"network":{"autoSelectFamily":false}}`), + }, + }, + { + model: models.OpenClawConfigResource{ + ID: 4, + ResourceType: OpenClawConfigResourceTypeSkill, + ResourceKey: "support-bot", + Name: "Support Bot", + Version: 1, + ContentJSON: `{"schemaVersion":1,"kind":"skill","format":"skill/custom@v1","dependsOn":[],"config":{"prompt":"help"}}`, + }, + tags: []string{"skill"}, + envelope: OpenClawConfigEnvelope{ + SchemaVersion: 1, + Kind: "skill", + Format: "skill/custom@v1", + Config: json.RawMessage(`{"prompt":"help"}`), + }, + }, + } + + renderedEnv, _, _, _, err := renderCompiledOpenClawPayload(OpenClawConfigPlan{Mode: OpenClawConfigPlanModeManual}, nil, resources) + if err != nil { + t.Fatalf("renderCompiledOpenClawPayload returned error: %v", err) + } + + gotChannels := renderedEnv[OpenClawChannelsEnv] + wantChannels := `{"feishu":{"accounts":{"main":{"appId":"cli_xxx","appSecret":"xxx"}},"enabled":true},"slack":{"appToken":"xapp-xxx","botToken":"xoxb-xxx","capabilities":{"interactiveReplies":true},"channels":{"#general":{"allow":true}},"enabled":true,"groupPolicy":"allowlist"},"telegram":{"allowFrom":["*"],"botToken":"123456:xxx","dmPolicy":"open","enabled":true}}` + if gotChannels != wantChannels { + t.Fatalf("unexpected channel payload:\nwant: %s\ngot: %s", wantChannels, gotChannels) + } + + gotSkills := renderedEnv[OpenClawSkillsEnv] + wantSkills := `{"items":[{"content":{"schemaVersion":1,"kind":"skill","format":"skill/custom@v1","dependsOn":[],"config":{"prompt":"help"}},"id":4,"key":"support-bot","name":"Support Bot","tags":["skill"],"type":"skill","version":1}],"schemaVersion":1}` + if gotSkills != wantSkills { + t.Fatalf("unexpected skill payload:\nwant: %s\ngot: %s", wantSkills, gotSkills) + } +} + +func TestResourcePayloadFromModelNormalizesStoredChannelJSON(t *testing.T) { + t.Parallel() + + item := models.OpenClawConfigResource{ + ID: 10, + UserID: 1, + ResourceType: OpenClawConfigResourceTypeChannel, + ResourceKey: "slack", + Name: "Slack", + Enabled: true, + Version: 1, + TagsJSON: `["channel","builtin","slack"]`, + ContentJSON: `{"schemaVersion":1,"kind":"channel","format":"channel/slack@v1","dependsOn":[],"config":{"enabled":false,"botToken":"xoxb-xxxxxxxxx","appToken":"xapp-xxxxxxxxxxxxxx","groupPolicy":"allowlist","channels":{"#general":{"allow":true}},"capabilities":{"interactiveReplies":true},"legacyField":"drop-me"}}`, + } + + payload, err := resourcePayloadFromModel(item) + if err != nil { + t.Fatalf("resourcePayloadFromModel returned error: %v", err) + } + + got := string(payload.Content) + want := `{"schemaVersion":1,"kind":"channel","format":"channel/slack@v1","dependsOn":[],"config":{"enabled":true,"botToken":"xoxb-xxxxxxxxx","appToken":"xapp-xxxxxxxxxxxxxx","groupPolicy":"allowlist","channels":{"#general":{"allow":true}},"capabilities":{"interactiveReplies":true}}}` + + var gotJSON interface{} + if err := json.Unmarshal([]byte(got), &gotJSON); err != nil { + t.Fatalf("failed to unmarshal normalized resource content: %v", err) + } + + var wantJSON interface{} + if err := json.Unmarshal([]byte(want), &wantJSON); err != nil { + t.Fatalf("failed to unmarshal expected normalized resource content: %v", err) + } + + if !reflect.DeepEqual(gotJSON, wantJSON) { + t.Fatalf("unexpected normalized resource content:\nwant: %s\ngot: %s", want, got) + } +} diff --git a/backend/internal/utils/response.go b/backend/internal/utils/response.go index 628f6f7..ddd6caf 100644 --- a/backend/internal/utils/response.go +++ b/backend/internal/utils/response.go @@ -48,15 +48,19 @@ func HandleError(c *gin.Context, err error) { Error(c, http.StatusBadGateway, errStr) return } + if strings.HasPrefix(errStr, "missing required openclaw config dependency:") || strings.HasPrefix(errStr, "required openclaw config dependency is disabled:") { + Error(c, http.StatusBadRequest, errStr) + return + } switch errStr { - case "username already exists", "email already exists", "instance name already exists": + case "username already exists", "email already exists", "instance name already exists", "openclaw config resource key already exists": Error(c, http.StatusConflict, errStr) case "display name already exists": Error(c, http.StatusConflict, errStr) - case "unsupported instance type", "image is required", "display name is required", "provider type is required", "base URL is required", "provider model name is required", "input price must be non-negative", "output price must be non-negative", "base URL is invalid", "automatic model discovery for azure-openai is not supported yet", "provider discovery is not supported", "model is required", "messages are required", "streaming is not supported yet", "provider type is not supported yet", "trace id is required", "event type is required", "message is required", "risk hit record is incomplete", "rule id is required", "rule display name is required", "rule pattern is required", "rule pattern is invalid", "risk severity is invalid", "risk action is invalid", "sample text is required", "secret ref format is invalid", "secret namespace is required in secret ref": + case "unsupported instance type", "image is required", "display name is required", "provider type is required", "base URL is required", "provider model name is required", "input price must be non-negative", "output price must be non-negative", "base URL is invalid", "automatic model discovery for azure-openai is not supported yet", "provider discovery is not supported", "model is required", "messages are required", "streaming is not supported yet", "provider type is not supported yet", "trace id is required", "event type is required", "message is required", "risk hit record is incomplete", "rule id is required", "rule display name is required", "rule pattern is required", "rule pattern is invalid", "risk severity is invalid", "risk action is invalid", "sample text is required", "secret ref format is invalid", "secret namespace is required in secret ref", "invalid openclaw resource type", "invalid openclaw config plan mode", "openclaw config resource name is required", "openclaw config resource key is invalid", "openclaw config schemaVersion is required", "openclaw config kind does not match resource type", "openclaw config format is required", "openclaw config content is required", "openclaw config content must be valid JSON", "openclaw config config payload is required", "openclaw config dependency type is invalid", "openclaw config dependency key is required", "openclaw config dependency is invalid", "openclaw config bundle name is required", "openclaw config bundle must include at least one resource", "openclaw config bundle resource id is required", "openclaw config bundle contains duplicate resources", "openclaw config bundle is required", "openclaw config bundle is disabled", "openclaw config bundle is empty", "at least one openclaw config resource must be selected", "openclaw config resource id is invalid", "openclaw config resource is disabled", "openclaw config bundle contains a disabled resource", "openclaw bootstrap payload is too large": Error(c, http.StatusBadRequest, errStr) - case "model is not active or does not exist": + case "model is not active or does not exist", "openclaw config resource not found", "openclaw config bundle not found", "openclaw injection snapshot not found": Error(c, http.StatusNotFound, errStr) case "risk rule not found": Error(c, http.StatusNotFound, errStr) diff --git a/deployments/k8s/clawmanager.yaml b/deployments/k8s/clawmanager.yaml index bf5e9d5..9f43d10 100644 --- a/deployments/k8s/clawmanager.yaml +++ b/deployments/k8s/clawmanager.yaml @@ -171,6 +171,114 @@ data: USE clawmanager; ALTER TABLE instances MODIFY COLUMN type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu'; + 003_add_system_image_settings.sql: | + USE clawmanager; + CREATE TABLE IF NOT EXISTS system_image_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_type VARCHAR(50) NOT NULL UNIQUE, + display_name VARCHAR(255) NOT NULL, + image VARCHAR(500) NOT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_instance_type (instance_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + 004_fix_seeded_admin_password.sql: | + USE clawmanager; + UPDATE users + SET password_hash = '$2a$10$pbenze514mwv3pvQySQBVOsF5J4DBXL2kVo1hLa8JFhQu5x3AKvBi' + WHERE username = 'admin' + AND password_hash = '$2a$10$N9qo8uLOickgx2ZMRZoMy.MqrzL9wGC3qD3Q.ZHqQH6t3q7l1L5uG'; + 005_update_openclaw_default_image.sql: | + USE clawmanager; + UPDATE system_image_settings + SET image = 'ghcr.io/yuan-lab-llm/clawmanager-openclaw-image/openclaw:latest' + WHERE instance_type = 'openclaw' + AND image = 'ericpearlee/openclaw:v2026.3.24'; + 006_add_openclaw_config_center.sql: | + USE clawmanager; + SET @openclaw_snapshot_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'instances' + AND COLUMN_NAME = 'openclaw_config_snapshot_id' + ); + SET @openclaw_snapshot_column_sql = IF( + @openclaw_snapshot_column_exists = 0, + 'ALTER TABLE instances ADD COLUMN openclaw_config_snapshot_id INT NULL AFTER access_token', + 'SELECT 1' + ); + PREPARE openclaw_snapshot_column_stmt FROM @openclaw_snapshot_column_sql; + EXECUTE openclaw_snapshot_column_stmt; + DEALLOCATE PREPARE openclaw_snapshot_column_stmt; + + CREATE TABLE IF NOT EXISTS openclaw_config_resources ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + resource_type VARCHAR(50) NOT NULL, + resource_key VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + version INT NOT NULL DEFAULT 1, + tags_json LONGTEXT NOT NULL, + content_json LONGTEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY uk_openclaw_resource_key (user_id, resource_type, resource_key), + INDEX idx_openclaw_resource_user_type (user_id, resource_type), + INDEX idx_openclaw_resource_user_enabled (user_id, enabled) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS openclaw_config_bundles ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_openclaw_bundle_user (user_id), + INDEX idx_openclaw_bundle_user_enabled (user_id, enabled) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS openclaw_config_bundle_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + bundle_id INT NOT NULL, + resource_id INT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + required BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (bundle_id) REFERENCES openclaw_config_bundles(id) ON DELETE CASCADE, + FOREIGN KEY (resource_id) REFERENCES openclaw_config_resources(id) ON DELETE CASCADE, + UNIQUE KEY uk_openclaw_bundle_resource (bundle_id, resource_id), + INDEX idx_openclaw_bundle_item_bundle (bundle_id, sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + + CREATE TABLE IF NOT EXISTS openclaw_injection_snapshots ( + id INT AUTO_INCREMENT PRIMARY KEY, + instance_id INT NULL, + user_id INT NOT NULL, + mode VARCHAR(20) NOT NULL, + bundle_id INT NULL, + selected_resource_ids_json LONGTEXT NOT NULL, + resolved_resources_json LONGTEXT NOT NULL, + rendered_manifest_json LONGTEXT NOT NULL, + rendered_env_json LONGTEXT NOT NULL, + secret_name VARCHAR(255) NULL, + status VARCHAR(30) NOT NULL DEFAULT 'pending', + error_message TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + activated_at TIMESTAMP NULL, + INDEX idx_openclaw_snapshot_user_created (user_id, created_at), + INDEX idx_openclaw_snapshot_instance (instance_id), + INDEX idx_openclaw_snapshot_bundle (bundle_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; --- apiVersion: v1 kind: PersistentVolume @@ -378,7 +486,7 @@ spec: - name: https port: 443 targetPort: https - nodePort: 39443 + nodePort: 30443 --- apiVersion: v1 kind: Service diff --git a/frontend/src/components/OpenClawConfigPlanSection.tsx b/frontend/src/components/OpenClawConfigPlanSection.tsx new file mode 100644 index 0000000..1d23f64 --- /dev/null +++ b/frontend/src/components/OpenClawConfigPlanSection.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useI18n } from '../contexts/I18nContext'; +import { openclawConfigService } from '../services/openclawConfigService'; +import type { + OpenClawConfigBundle, + OpenClawConfigCompilePreview, + OpenClawConfigPlan, + OpenClawConfigResource, +} from '../types/openclawConfig'; + +export type OpenClawInjectionMode = OpenClawConfigPlan['mode'] | 'archive'; + +interface OpenClawConfigPlanSectionProps { + mode: OpenClawInjectionMode; + bundleId?: number; + resourceIds: number[]; + onModeChange: (mode: OpenClawInjectionMode) => void; + onSelectionChange: (payload: { bundleId?: number; resourceIds: number[] }) => void; + onPreviewChange: (preview: OpenClawConfigCompilePreview | null, state: { loading: boolean; error: string | null }) => void; +} + +const OpenClawConfigPlanSection: React.FC = ({ + mode, + bundleId, + resourceIds, + onModeChange, + onSelectionChange, + onPreviewChange, +}) => { + const { t } = useI18n(); + const [resources, setResources] = useState([]); + const [bundles, setBundles] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const load = async () => { + try { + setLoading(true); + const [resourceItems, bundleItems] = await Promise.all([ + openclawConfigService.listResources(), + openclawConfigService.listBundles(), + ]); + setResources(resourceItems.filter((item) => item.enabled)); + setBundles(bundleItems.filter((item) => item.enabled)); + } finally { + setLoading(false); + } + }; + + load(); + }, []); + + const channelResources = useMemo( + () => resources.filter((resource) => resource.resource_type === 'channel'), + [resources], + ); + + useEffect(() => { + let cancelled = false; + + const compile = async () => { + if (mode === 'none' || mode === 'archive') { + onPreviewChange(null, { loading: false, error: null }); + return; + } + if (mode === 'bundle' && !bundleId) { + onPreviewChange(null, { loading: false, error: t('openClawInjectionSection.errors.chooseBundle') }); + return; + } + if (mode === 'manual' && resourceIds.length === 0) { + onPreviewChange(null, { loading: false, error: t('openClawInjectionSection.errors.chooseResource') }); + return; + } + + try { + onPreviewChange(null, { loading: true, error: null }); + const payload: OpenClawConfigPlan = mode === 'bundle' + ? { mode: 'bundle', bundle_id: bundleId } + : { mode: 'manual', resource_ids: resourceIds }; + const result = await openclawConfigService.compilePreview(payload); + if (!cancelled) { + onPreviewChange(result, { loading: false, error: null }); + } + } catch (err: any) { + if (!cancelled) { + onPreviewChange(null, { loading: false, error: err.response?.data?.error || t('openClawInjectionSection.errors.compileFailed') }); + } + } + }; + + compile(); + return () => { + cancelled = true; + }; + }, [bundleId, mode, onPreviewChange, resourceIds]); + + return ( +
+
+
+

{t('openClawInjectionSection.title')}

+

+ {t('openClawInjectionSection.subtitle')} +

+
+ {t('openClawInjectionSection.optional')} +
+ +
+ {[ + { value: 'manual', label: t('openClawInjectionSection.modes.manual') }, + { value: 'bundle', label: t('openClawInjectionSection.modes.bundle') }, + { value: 'archive', label: t('openClawInjectionSection.modes.archive') }, + ].map((item) => ( + + ))} +
+ + {loading &&
{t('openClawInjectionSection.loading')}
} + + {!loading && mode === 'bundle' && ( +
+ + +
+ )} + + {!loading && mode === 'manual' && ( +
+
+
{t('openClawInjectionSection.channel')}
+
+ {channelResources.map((item) => { + const checked = resourceIds.includes(item.id); + return ( + + ); + })} + {channelResources.length === 0 && ( +
+ {t('openClawInjectionSection.noChannelResources')} +
+ )} +
+
+
+ )} +
+ ); +}; + +export default OpenClawConfigPlanSection; diff --git a/frontend/src/components/UserLayout.tsx b/frontend/src/components/UserLayout.tsx index 3a224dc..a1ce061 100644 --- a/frontend/src/components/UserLayout.tsx +++ b/frontend/src/components/UserLayout.tsx @@ -39,6 +39,7 @@ const UserLayout: React.FC = ({ children, title }) => { const navItems: UserNavItem[] = [ { path: '/dashboard', label: t('nav.userDashboard'), icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', exact: true }, { path: '/instances', label: t('nav.myInstances'), icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01' }, + { path: '/openclaw-configs', label: t('nav.openClawConfigs'), icon: 'M4 6h16M4 12h16M4 18h9' }, { path: '/settings', label: t('nav.settings'), icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z' }, ]; diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 6b5785e..03f3641 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -71,6 +71,7 @@ export const translations: Record = { models: 'Models', settings: 'Settings', myInstances: 'My Instances', + openClawConfigs: 'Resources', backToDashboard: 'Back to Dashboard', backToUserDashboard: 'Workspace', adminPanel: 'Admin Panel', @@ -665,6 +666,196 @@ export const translations: Record = { gpuDisabled: 'Disabled', noOpenClawArchiveSelected: 'No .openclaw archive selected', }, + openClawResourcesPage: { + title: 'OpenClaw Resource Management', + tabs: { + resources: 'Resources', + bundles: 'Bundles', + injections: 'Injection Records', + }, + resourceTypesTitle: 'Resource Types', + searchPlaceholder: 'Search resources...', + resourceListHint: 'Click a resource card to edit it in a popup instead of editing inline.', + loadingResources: 'Loading resources...', + noResources: 'No resources in this type yet. Click "New" to create one.', + enabled: 'Enabled', + disabled: 'Disabled', + thisResourceType: 'This resource type', + notConfigurableYet: '{type} is not configurable yet.', + onlyChannelConfigurable: 'Channel resources are supported today. Skill, Agent, and Scheduled Task editors will be added in a later update.', + bundlesSubtitle: 'Compose reusable OpenClaw bootstrap packages in a dedicated popup editor.', + loadingBundles: 'Loading bundles...', + noBundles: 'No bundles yet. Click "New Bundle" to create one.', + bundleResourceCount: '{count} resource(s)', + injectionsSubtitle: 'Snapshots are saved at instance creation time and reused on restart.', + snapshot: 'Snapshot', + mode: 'Mode', + resources: 'Resources', + envCount: 'Env Count', + created: 'Created', + loadingSnapshots: 'Loading snapshots...', + noInjectionRecords: 'No OpenClaw injection records yet.', + recordModes: { + none: 'None', + manual: 'Manual', + bundle: 'Bundle', + archive: 'Archive', + }, + recordStatuses: { + compiled: 'Compiled', + active: 'Active', + failed: 'Failed', + }, + editResourceTitle: 'Edit Resource', + newResourceTitle: 'New Resource', + editResourceSubtitle: 'Adjust metadata and JSON in a focused popup editor.', + newResourceSubtitle: 'Create a new OpenClaw resource without exposing the full editor on the page.', + resourceKey: 'Resource Key', + name: 'Name', + tags: 'Tags', + tagsPlaceholder: 'comma,separated,tags', + channelTemplate: 'Channel Template', + chooseStarterTemplate: 'Choose a starter template', + channelTemplateHint: 'Only Slack, Telegram, and Feishu are supported here. Start in form mode, then switch to JSON when you need the full config surface.', + templateKey: 'key: {key}', + invalidChannelJson: 'This channel JSON is currently invalid. Switch to JSON mode to fix it, then come back to the form.', + contentJson: 'Content JSON', + editBundleTitle: 'Edit Bundle', + newBundleTitle: 'New Bundle', + editBundleSubtitle: 'Refine a reusable bootstrap package in a focused popup editor.', + newBundleSubtitle: 'Compose a new reusable bundle without leaving the resources list behind.', + bundleName: 'Bundle Name', + bundleResources: 'Bundle Resources', + noResourcesForGroup: 'No {type} resources yet.', + actions: { + new: 'New', + newBundle: 'New Bundle', + loadTemplate: 'Load Template', + saveResource: 'Save Resource', + createResource: 'Create Resource', + validateJson: 'Validate JSON', + clone: 'Clone', + saveBundle: 'Save Bundle', + createBundle: 'Create Bundle', + }, + resourceTypes: { + channel: 'Channel', + skill: 'Skill', + session_template: 'Session Template', + log_policy: 'Log Policy', + agent: 'Agent', + scheduled_task: 'Scheduled Task', + }, + templateCategories: { + builtin: 'Built-in', + plugin: 'Plugin', + }, + templates: { + telegram: { + label: 'Telegram', + description: 'Telegram Bot API channel with DM and group policy controls.', + }, + slack: { + label: 'Slack', + description: 'Slack workspace app powered by Bolt.', + }, + feishu: { + label: 'Feishu / Lark', + description: 'Feishu or Lark plugin channel with account-aware defaults.', + }, + }, + editorModes: { + form: 'Form', + json: 'JSON', + }, + channelEditors: { + feishu: { + title: 'Feishu Channel Editor', + description: 'Start with the main account form, or switch to JSON when you need the full config surface.', + fields: { + appId: { + label: 'App ID', + placeholder: 'cli_xxx', + }, + appSecret: { + label: 'App Secret', + placeholder: 'app secret', + }, + }, + }, + slack: { + title: 'Slack Channel Editor', + description: 'Start with the token form, or switch to JSON when you need the full config surface.', + fields: { + botToken: { + label: 'Bot Token', + placeholder: 'xoxb-...', + }, + appToken: { + label: 'App Token', + placeholder: 'xapp-...', + }, + }, + }, + telegram: { + title: 'Telegram Channel Editor', + description: 'Start with the bot token form, or switch to JSON when you need the full config surface.', + fields: { + botToken: { + label: 'Bot Token', + placeholder: '123456:ABCDEF', + }, + }, + }, + }, + notices: { + resourceUpdated: 'Resource updated.', + resourceCreated: 'Resource created.', + resourceDeleted: 'Resource deleted.', + resourceCloned: 'Resource cloned.', + resourceJsonValid: 'Resource JSON is valid.', + templateLoaded: 'Loaded {name} template.', + bundleUpdated: 'Bundle updated.', + bundleCreated: 'Bundle created.', + bundleDeleted: 'Bundle deleted.', + bundleCloned: 'Bundle cloned.', + }, + errors: { + loadFailed: 'Failed to load OpenClaw resources.', + saveResource: 'Failed to save resource.', + validationFailed: 'Failed to validate resource JSON.', + deleteResource: 'Failed to delete resource.', + cloneResource: 'Failed to clone resource.', + saveBundle: 'Failed to save bundle.', + deleteBundle: 'Failed to delete bundle.', + cloneBundle: 'Failed to clone bundle.', + }, + confirms: { + deleteResource: 'Delete resource "{name}"?', + deleteBundle: 'Delete bundle "{name}"?', + }, + }, + openClawInjectionSection: { + title: 'OpenClaw Resource Injection', + subtitle: 'Choose how this instance bootstraps OpenClaw. Click an active option again if you want to cancel the selection.', + optional: 'Optional', + modes: { + manual: 'Manual resources', + bundle: 'Bundle', + archive: 'Archive import', + }, + loading: 'Loading resource management assets...', + chooseBundle: 'Choose Bundle', + selectBundle: 'Select a bundle', + bundleOptionCount: '{count} resources', + channel: 'Channel', + noChannelResources: 'No channel resources available.', + errors: { + chooseBundle: 'Choose a bundle before continuing.', + chooseResource: 'Choose at least one resource before continuing.', + compileFailed: 'Failed to compile OpenClaw bootstrap preview.', + }, + }, }, zh: { app: { name: 'ClawManager' }, @@ -720,6 +911,7 @@ export const translations: Record = { models: '模型', settings: '设置', myInstances: '我的实例', + openClawConfigs: '资源管理', backToDashboard: '返回仪表盘', backToUserDashboard: '工作台', adminPanel: '管理后台', @@ -1314,6 +1506,196 @@ export const translations: Record = { gpuDisabled: '未启用', noOpenClawArchiveSelected: '未选择 .openclaw 归档', }, + openClawResourcesPage: { + title: 'OpenClaw 资源管理', + tabs: { + resources: '资源', + bundles: '资源包', + injections: '注入记录', + }, + resourceTypesTitle: '资源类型', + searchPlaceholder: '搜索资源...', + resourceListHint: '点击资源卡片后会以弹窗方式编辑,不再在页面右侧直接展开。', + loadingResources: '正在加载资源...', + noResources: '当前类型下还没有资源,点击“新建”开始创建。', + enabled: '已启用', + disabled: '已禁用', + thisResourceType: '该资源类型', + notConfigurableYet: '{type} 暂不支持配置。', + onlyChannelConfigurable: '当前仅支持配置 Channel。Skill、Agent 和 Scheduled Task 的编辑器会在后续版本提供。', + bundlesSubtitle: '把可复用的 OpenClaw 启动资源组合成资源包,并在弹窗中集中编辑。', + loadingBundles: '正在加载资源包...', + noBundles: '还没有资源包,点击“新建资源包”开始创建。', + bundleResourceCount: '{count} 个资源', + injectionsSubtitle: '快照会在实例创建时保存,并在重启时复用。', + snapshot: '快照', + mode: '模式', + resources: '资源数', + envCount: '环境变量数', + created: '创建时间', + loadingSnapshots: '正在加载快照...', + noInjectionRecords: '还没有 OpenClaw 注入记录。', + recordModes: { + none: '无', + manual: '手动', + bundle: '资源包', + archive: '归档', + }, + recordStatuses: { + compiled: '已编译', + active: '已生效', + failed: '失败', + }, + editResourceTitle: '编辑资源', + newResourceTitle: '新建资源', + editResourceSubtitle: '在聚焦弹窗中统一调整元数据和 JSON。', + newResourceSubtitle: '无需在页面常驻编辑器中操作,直接创建新的 OpenClaw 资源。', + resourceKey: '资源 Key', + name: '名称', + tags: '标签', + tagsPlaceholder: 'tag1,tag2', + channelTemplate: 'Channel 模板', + chooseStarterTemplate: '选择起始模板', + channelTemplateHint: '这里只支持 Slack、Telegram 和飞书。先用表单模式快速填写,再在需要时切换到 JSON 查看完整配置。', + templateKey: '键:{key}', + invalidChannelJson: '当前 Channel JSON 无法解析。请先切到 JSON 模式修复,再回到表单。', + contentJson: '内容 JSON', + editBundleTitle: '编辑资源包', + newBundleTitle: '新建资源包', + editBundleSubtitle: '在聚焦弹窗中调整可复用的启动资源包。', + newBundleSubtitle: '无需离开资源列表,就能创建新的可复用资源包。', + bundleName: '资源包名称', + bundleResources: '资源包内容', + noResourcesForGroup: '当前还没有 {type} 资源。', + actions: { + new: '新建', + newBundle: '新建资源包', + loadTemplate: '加载模板', + saveResource: '保存资源', + createResource: '创建资源', + validateJson: '校验 JSON', + clone: '克隆', + saveBundle: '保存资源包', + createBundle: '创建资源包', + }, + resourceTypes: { + channel: '通道', + skill: '技能', + session_template: '会话模板', + log_policy: '日志策略', + agent: '智能体', + scheduled_task: '定时任务', + }, + templateCategories: { + builtin: '内置', + plugin: '插件', + }, + templates: { + telegram: { + label: 'Telegram', + description: '支持私聊和群组策略控制的 Telegram Bot API 通道。', + }, + slack: { + label: 'Slack', + description: '基于 Bolt 的 Slack 工作区应用通道。', + }, + feishu: { + label: '飞书 / Lark', + description: '带账号默认值的飞书 / Lark 通道。', + }, + }, + editorModes: { + form: '表单', + json: 'JSON', + }, + channelEditors: { + feishu: { + title: '飞书 Channel 编辑器', + description: '先填写主账号表单,只有在需要完整配置时再切到 JSON。', + fields: { + appId: { + label: 'App ID', + placeholder: 'cli_xxx', + }, + appSecret: { + label: 'App Secret', + placeholder: '应用密钥', + }, + }, + }, + slack: { + title: 'Slack Channel 编辑器', + description: '先填写 Token 表单,只有在需要完整配置时再切到 JSON。', + fields: { + botToken: { + label: 'Bot Token', + placeholder: 'xoxb-...', + }, + appToken: { + label: 'App Token', + placeholder: 'xapp-...', + }, + }, + }, + telegram: { + title: 'Telegram Channel 编辑器', + description: '先填写 Bot Token 表单,只有在需要完整配置时再切到 JSON。', + fields: { + botToken: { + label: 'Bot Token', + placeholder: '123456:ABCDEF', + }, + }, + }, + }, + notices: { + resourceUpdated: '资源已更新。', + resourceCreated: '资源已创建。', + resourceDeleted: '资源已删除。', + resourceCloned: '资源已克隆。', + resourceJsonValid: '资源 JSON 校验通过。', + templateLoaded: '已加载 {name} 模板。', + bundleUpdated: '资源包已更新。', + bundleCreated: '资源包已创建。', + bundleDeleted: '资源包已删除。', + bundleCloned: '资源包已克隆。', + }, + errors: { + loadFailed: '加载 OpenClaw 资源失败。', + saveResource: '保存资源失败。', + validationFailed: '资源 JSON 校验失败。', + deleteResource: '删除资源失败。', + cloneResource: '克隆资源失败。', + saveBundle: '保存资源包失败。', + deleteBundle: '删除资源包失败。', + cloneBundle: '克隆资源包失败。', + }, + confirms: { + deleteResource: '确认删除资源“{name}”吗?', + deleteBundle: '确认删除资源包“{name}”吗?', + }, + }, + openClawInjectionSection: { + title: 'OpenClaw 资源注入', + subtitle: '选择这个实例如何启动 OpenClaw。再次点击当前已选项即可取消选择。', + optional: '可选', + modes: { + manual: '手动资源', + bundle: '资源包', + archive: '归档导入', + }, + loading: '正在加载资源管理内容...', + chooseBundle: '选择资源包', + selectBundle: '请选择资源包', + bundleOptionCount: '{count} 个资源', + channel: '通道', + noChannelResources: '当前没有可用的通道资源。', + errors: { + chooseBundle: '请先选择一个资源包。', + chooseResource: '请至少选择一个资源。', + compileFailed: '生成 OpenClaw 启动预览失败。', + }, + }, }, ja: { app: { name: 'ClawManager' }, @@ -1369,6 +1751,7 @@ export const translations: Record = { models: 'モデル', settings: '設定', myInstances: 'マイインスタンス', + openClawConfigs: 'リソース管理', backToDashboard: 'ダッシュボードに戻る', backToUserDashboard: 'ワークスペース', adminPanel: '管理パネル', @@ -1911,6 +2294,196 @@ export const translations: Record = { gpuDisabled: '無効', noOpenClawArchiveSelected: '.openclaw アーカイブ未選択', }, + openClawResourcesPage: { + title: 'OpenClaw リソース管理', + tabs: { + resources: 'リソース', + bundles: 'バンドル', + injections: '注入記録', + }, + resourceTypesTitle: 'リソースタイプ', + searchPlaceholder: 'リソースを検索...', + resourceListHint: 'リソースカードをクリックすると、右側ではなくポップアップで編集できます。', + loadingResources: 'リソースを読み込み中...', + noResources: 'このタイプのリソースはまだありません。「新規作成」をクリックしてください。', + enabled: '有効', + disabled: '無効', + thisResourceType: 'このリソースタイプ', + notConfigurableYet: '{type} はまだ設定できません。', + onlyChannelConfigurable: '現在設定できるのは Channel のみです。Skill、Agent、Scheduled Task のエディタは後続の更新で追加されます。', + bundlesSubtitle: '再利用可能な OpenClaw 起動パッケージを束ねて、専用ポップアップで編集します。', + loadingBundles: 'バンドルを読み込み中...', + noBundles: 'バンドルはまだありません。「新規バンドル」をクリックしてください。', + bundleResourceCount: '{count} 件のリソース', + injectionsSubtitle: 'スナップショットはインスタンス作成時に保存され、再起動時に再利用されます。', + snapshot: 'スナップショット', + mode: 'モード', + resources: 'リソース数', + envCount: '環境変数数', + created: '作成日時', + loadingSnapshots: 'スナップショットを読み込み中...', + noInjectionRecords: 'OpenClaw の注入記録はまだありません。', + recordModes: { + none: 'なし', + manual: '手動', + bundle: 'バンドル', + archive: 'アーカイブ', + }, + recordStatuses: { + compiled: 'コンパイル済み', + active: '有効', + failed: '失敗', + }, + editResourceTitle: 'リソースを編集', + newResourceTitle: 'リソースを新規作成', + editResourceSubtitle: 'メタデータと JSON をポップアップで集中して編集します。', + newResourceSubtitle: 'ページ全体を広げずに、新しい OpenClaw リソースを作成します。', + resourceKey: 'リソースキー', + name: '名前', + tags: 'タグ', + tagsPlaceholder: 'tag1,tag2', + channelTemplate: 'Channel テンプレート', + chooseStarterTemplate: 'スターターテンプレートを選択', + channelTemplateHint: 'ここでは Slack、Telegram、Feishu のみ対応しています。まずフォームで入力し、必要になったら JSON に切り替えて完全な設定を編集してください。', + templateKey: 'キー: {key}', + invalidChannelJson: 'この Channel JSON は現在無効です。先に JSON モードで修正してからフォームに戻ってください。', + contentJson: '内容 JSON', + editBundleTitle: 'バンドルを編集', + newBundleTitle: '新規バンドル', + editBundleSubtitle: '再利用可能な起動パッケージをポップアップで編集します。', + newBundleSubtitle: 'リソース一覧を離れずに新しい再利用可能バンドルを作成します。', + bundleName: 'バンドル名', + bundleResources: 'バンドルのリソース', + noResourcesForGroup: '{type} のリソースはまだありません。', + actions: { + new: '新規作成', + newBundle: '新規バンドル', + loadTemplate: 'テンプレートを読み込む', + saveResource: 'リソースを保存', + createResource: 'リソースを作成', + validateJson: 'JSON を検証', + clone: '複製', + saveBundle: 'バンドルを保存', + createBundle: 'バンドルを作成', + }, + resourceTypes: { + channel: 'チャネル', + skill: 'スキル', + session_template: 'セッションテンプレート', + log_policy: 'ログポリシー', + agent: 'エージェント', + scheduled_task: '定期タスク', + }, + templateCategories: { + builtin: '内蔵', + plugin: 'プラグイン', + }, + templates: { + telegram: { + label: 'Telegram', + description: 'DM とグループポリシーを制御できる Telegram Bot API チャネル。', + }, + slack: { + label: 'Slack', + description: 'Bolt ベースの Slack ワークスペースアプリ。', + }, + feishu: { + label: 'Feishu / Lark', + description: 'アカウント初期値を備えた Feishu / Lark チャネル。', + }, + }, + editorModes: { + form: 'フォーム', + json: 'JSON', + }, + channelEditors: { + feishu: { + title: 'Feishu Channel エディタ', + description: 'まずメインアカウントのフォームから始め、完全な設定が必要なときだけ JSON に切り替えます。', + fields: { + appId: { + label: 'App ID', + placeholder: 'cli_xxx', + }, + appSecret: { + label: 'App Secret', + placeholder: 'app secret', + }, + }, + }, + slack: { + title: 'Slack Channel エディタ', + description: 'まず Token フォームから始め、完全な設定が必要なときだけ JSON に切り替えます。', + fields: { + botToken: { + label: 'Bot Token', + placeholder: 'xoxb-...', + }, + appToken: { + label: 'App Token', + placeholder: 'xapp-...', + }, + }, + }, + telegram: { + title: 'Telegram Channel エディタ', + description: 'まず Bot Token フォームから始め、完全な設定が必要なときだけ JSON に切り替えます。', + fields: { + botToken: { + label: 'Bot Token', + placeholder: '123456:ABCDEF', + }, + }, + }, + }, + notices: { + resourceUpdated: 'リソースを更新しました。', + resourceCreated: 'リソースを作成しました。', + resourceDeleted: 'リソースを削除しました。', + resourceCloned: 'リソースを複製しました。', + resourceJsonValid: 'リソース JSON は有効です。', + templateLoaded: '{name} テンプレートを読み込みました。', + bundleUpdated: 'バンドルを更新しました。', + bundleCreated: 'バンドルを作成しました。', + bundleDeleted: 'バンドルを削除しました。', + bundleCloned: 'バンドルを複製しました。', + }, + errors: { + loadFailed: 'OpenClaw リソースの読み込みに失敗しました。', + saveResource: 'リソースの保存に失敗しました。', + validationFailed: 'リソース JSON の検証に失敗しました。', + deleteResource: 'リソースの削除に失敗しました。', + cloneResource: 'リソースの複製に失敗しました。', + saveBundle: 'バンドルの保存に失敗しました。', + deleteBundle: 'バンドルの削除に失敗しました。', + cloneBundle: 'バンドルの複製に失敗しました。', + }, + confirms: { + deleteResource: 'リソース「{name}」を削除しますか?', + deleteBundle: 'バンドル「{name}」を削除しますか?', + }, + }, + openClawInjectionSection: { + title: 'OpenClaw リソース注入', + subtitle: 'このインスタンスが OpenClaw をどのように起動するかを選択します。選択中の項目をもう一度クリックすると解除できます。', + optional: '任意', + modes: { + manual: '手動リソース', + bundle: 'バンドル', + archive: 'アーカイブ取り込み', + }, + loading: 'リソース管理データを読み込み中...', + chooseBundle: 'バンドルを選択', + selectBundle: 'バンドルを選んでください', + bundleOptionCount: '{count} 件のリソース', + channel: 'チャネル', + noChannelResources: '利用可能なチャネルリソースがありません。', + errors: { + chooseBundle: '先にバンドルを選択してください。', + chooseResource: '少なくとも 1 つのリソースを選択してください。', + compileFailed: 'OpenClaw 起動プレビューの生成に失敗しました。', + }, + }, }, ko: { app: { name: 'ClawManager' }, @@ -1966,6 +2539,7 @@ export const translations: Record = { models: '모델', settings: '설정', myInstances: '내 인스턴스', + openClawConfigs: '리소스 관리', backToDashboard: '대시보드로 돌아가기', backToUserDashboard: '워크스페이스', adminPanel: '관리 패널', @@ -2508,6 +3082,196 @@ export const translations: Record = { gpuDisabled: '비활성화', noOpenClawArchiveSelected: '.openclaw 아카이브 미선택', }, + openClawResourcesPage: { + title: 'OpenClaw 리소스 관리', + tabs: { + resources: '리소스', + bundles: '번들', + injections: '주입 기록', + }, + resourceTypesTitle: '리소스 유형', + searchPlaceholder: '리소스를 검색하세요...', + resourceListHint: '리소스 카드를 클릭하면 오른쪽 고정 편집기 대신 팝업에서 편집합니다.', + loadingResources: '리소스를 불러오는 중...', + noResources: '이 유형에는 아직 리소스가 없습니다. "새로 만들기"를 클릭하세요.', + enabled: '활성화됨', + disabled: '비활성화됨', + thisResourceType: '이 리소스 유형은', + notConfigurableYet: '{type} 아직 설정할 수 없습니다.', + onlyChannelConfigurable: '현재는 Channel만 설정할 수 있습니다. Skill, Agent, Scheduled Task 편집기는 이후 업데이트에서 추가됩니다.', + bundlesSubtitle: '재사용 가능한 OpenClaw 부트스트랩 패키지를 번들로 구성하고 전용 팝업에서 편집합니다.', + loadingBundles: '번들을 불러오는 중...', + noBundles: '아직 번들이 없습니다. "새 번들"을 클릭해 생성하세요.', + bundleResourceCount: '{count}개 리소스', + injectionsSubtitle: '스냅샷은 인스턴스 생성 시 저장되고 재시작 시 다시 사용됩니다.', + snapshot: '스냅샷', + mode: '모드', + resources: '리소스 수', + envCount: '환경 변수 수', + created: '생성 시각', + loadingSnapshots: '스냅샷을 불러오는 중...', + noInjectionRecords: '아직 OpenClaw 주입 기록이 없습니다.', + recordModes: { + none: '없음', + manual: '수동', + bundle: '번들', + archive: '아카이브', + }, + recordStatuses: { + compiled: '컴파일됨', + active: '활성', + failed: '실패', + }, + editResourceTitle: '리소스 편집', + newResourceTitle: '새 리소스', + editResourceSubtitle: '집중된 팝업 편집기에서 메타데이터와 JSON을 함께 수정합니다.', + newResourceSubtitle: '페이지에 전체 편집기를 노출하지 않고 새 OpenClaw 리소스를 만듭니다.', + resourceKey: '리소스 키', + name: '이름', + tags: '태그', + tagsPlaceholder: 'tag1,tag2', + channelTemplate: 'Channel 템플릿', + chooseStarterTemplate: '시작 템플릿 선택', + channelTemplateHint: '여기서는 Slack, Telegram, Feishu만 지원합니다. 먼저 폼 모드로 입력한 뒤, 전체 설정이 필요할 때 JSON으로 전환하세요.', + templateKey: '키: {key}', + invalidChannelJson: '현재 Channel JSON 이 유효하지 않습니다. 먼저 JSON 모드에서 수정한 뒤 폼으로 돌아오세요.', + contentJson: '내용 JSON', + editBundleTitle: '번들 편집', + newBundleTitle: '새 번들', + editBundleSubtitle: '집중된 팝업 편집기에서 재사용 가능한 부트스트랩 패키지를 다듬습니다.', + newBundleSubtitle: '리소스 목록을 벗어나지 않고 새 재사용 번들을 만듭니다.', + bundleName: '번들 이름', + bundleResources: '번들 리소스', + noResourcesForGroup: '{type} 리소스가 아직 없습니다.', + actions: { + new: '새로 만들기', + newBundle: '새 번들', + loadTemplate: '템플릿 불러오기', + saveResource: '리소스 저장', + createResource: '리소스 생성', + validateJson: 'JSON 검증', + clone: '복제', + saveBundle: '번들 저장', + createBundle: '번들 생성', + }, + resourceTypes: { + channel: '채널', + skill: '스킬', + session_template: '세션 템플릿', + log_policy: '로그 정책', + agent: '에이전트', + scheduled_task: '예약 작업', + }, + templateCategories: { + builtin: '기본 제공', + plugin: '플러그인', + }, + templates: { + telegram: { + label: 'Telegram', + description: 'DM 및 그룹 정책 제어를 지원하는 Telegram Bot API 채널입니다.', + }, + slack: { + label: 'Slack', + description: 'Bolt 기반의 Slack 워크스페이스 앱입니다.', + }, + feishu: { + label: 'Feishu / Lark', + description: '계정 기본값을 포함한 Feishu / Lark 채널입니다.', + }, + }, + editorModes: { + form: '폼', + json: 'JSON', + }, + channelEditors: { + feishu: { + title: 'Feishu Channel 편집기', + description: '먼저 메인 계정 폼으로 시작하고, 전체 설정이 필요할 때만 JSON 으로 전환하세요.', + fields: { + appId: { + label: 'App ID', + placeholder: 'cli_xxx', + }, + appSecret: { + label: 'App Secret', + placeholder: 'app secret', + }, + }, + }, + slack: { + title: 'Slack Channel 편집기', + description: '먼저 토큰 폼으로 시작하고, 전체 설정이 필요할 때만 JSON 으로 전환하세요.', + fields: { + botToken: { + label: 'Bot Token', + placeholder: 'xoxb-...', + }, + appToken: { + label: 'App Token', + placeholder: 'xapp-...', + }, + }, + }, + telegram: { + title: 'Telegram Channel 편집기', + description: '먼저 Bot Token 폼으로 시작하고, 전체 설정이 필요할 때만 JSON 으로 전환하세요.', + fields: { + botToken: { + label: 'Bot Token', + placeholder: '123456:ABCDEF', + }, + }, + }, + }, + notices: { + resourceUpdated: '리소스를 업데이트했습니다.', + resourceCreated: '리소스를 생성했습니다.', + resourceDeleted: '리소스를 삭제했습니다.', + resourceCloned: '리소스를 복제했습니다.', + resourceJsonValid: '리소스 JSON 이 유효합니다.', + templateLoaded: '{name} 템플릿을 불러왔습니다.', + bundleUpdated: '번들을 업데이트했습니다.', + bundleCreated: '번들을 생성했습니다.', + bundleDeleted: '번들을 삭제했습니다.', + bundleCloned: '번들을 복제했습니다.', + }, + errors: { + loadFailed: 'OpenClaw 리소스를 불러오지 못했습니다.', + saveResource: '리소스를 저장하지 못했습니다.', + validationFailed: '리소스 JSON 검증에 실패했습니다.', + deleteResource: '리소스를 삭제하지 못했습니다.', + cloneResource: '리소스를 복제하지 못했습니다.', + saveBundle: '번들을 저장하지 못했습니다.', + deleteBundle: '번들을 삭제하지 못했습니다.', + cloneBundle: '번들을 복제하지 못했습니다.', + }, + confirms: { + deleteResource: '리소스 "{name}"을(를) 삭제할까요?', + deleteBundle: '번들 "{name}"을(를) 삭제할까요?', + }, + }, + openClawInjectionSection: { + title: 'OpenClaw 리소스 주입', + subtitle: '이 인스턴스가 OpenClaw 를 어떻게 부트스트랩할지 선택하세요. 현재 선택된 옵션을 다시 클릭하면 선택이 해제됩니다.', + optional: '선택 사항', + modes: { + manual: '수동 리소스', + bundle: '번들', + archive: '아카이브 가져오기', + }, + loading: '리소스 관리 데이터를 불러오는 중...', + chooseBundle: '번들 선택', + selectBundle: '번들을 선택하세요', + bundleOptionCount: '{count}개 리소스', + channel: '채널', + noChannelResources: '사용 가능한 채널 리소스가 없습니다.', + errors: { + chooseBundle: '계속하려면 먼저 번들을 선택하세요.', + chooseResource: '계속하려면 최소 한 개의 리소스를 선택하세요.', + compileFailed: 'OpenClaw 부트스트랩 미리보기를 생성하지 못했습니다.', + }, + }, }, de: { app: { name: 'ClawManager' }, @@ -2563,6 +3327,7 @@ export const translations: Record = { models: 'Modelle', settings: 'Einstellungen', myInstances: 'Meine Instanzen', + openClawConfigs: 'Ressourcen', backToDashboard: 'Zurück zum Dashboard', backToUserDashboard: 'Arbeitsbereich', adminPanel: 'Admin-Bereich', @@ -3105,6 +3870,196 @@ export const translations: Record = { gpuDisabled: 'Deaktiviert', noOpenClawArchiveSelected: 'Kein .openclaw-Archiv ausgewählt', }, + openClawResourcesPage: { + title: 'OpenClaw Ressourcenverwaltung', + tabs: { + resources: 'Ressourcen', + bundles: 'Bundles', + injections: 'Injektionsprotokolle', + }, + resourceTypesTitle: 'Ressourcentypen', + searchPlaceholder: 'Ressourcen suchen...', + resourceListHint: 'Klicken Sie auf eine Ressourcenkarte, um sie in einem Popup statt direkt auf der Seite zu bearbeiten.', + loadingResources: 'Ressourcen werden geladen...', + noResources: 'Für diesen Typ gibt es noch keine Ressourcen. Klicken Sie auf „Neu“, um eine anzulegen.', + enabled: 'Aktiviert', + disabled: 'Deaktiviert', + thisResourceType: 'Dieser Ressourcentyp', + notConfigurableYet: '{type} ist noch nicht konfigurierbar.', + onlyChannelConfigurable: 'Derzeit werden nur Channel-Ressourcen unterstützt. Editoren für Skill, Agent und Scheduled Task folgen in einem späteren Update.', + bundlesSubtitle: 'Stellen Sie wiederverwendbare OpenClaw-Startpakete in einem separaten Popup-Editor zusammen.', + loadingBundles: 'Bundles werden geladen...', + noBundles: 'Es gibt noch keine Bundles. Klicken Sie auf „Neues Bundle“, um eines anzulegen.', + bundleResourceCount: '{count} Ressource(n)', + injectionsSubtitle: 'Snapshots werden beim Erstellen einer Instanz gespeichert und beim Neustart wiederverwendet.', + snapshot: 'Snapshot', + mode: 'Modus', + resources: 'Ressourcen', + envCount: 'Env-Anzahl', + created: 'Erstellt', + loadingSnapshots: 'Snapshots werden geladen...', + noInjectionRecords: 'Es gibt noch keine OpenClaw-Injektionsprotokolle.', + recordModes: { + none: 'Keine', + manual: 'Manuell', + bundle: 'Bundle', + archive: 'Archiv', + }, + recordStatuses: { + compiled: 'Kompiliert', + active: 'Aktiv', + failed: 'Fehlgeschlagen', + }, + editResourceTitle: 'Ressource bearbeiten', + newResourceTitle: 'Neue Ressource', + editResourceSubtitle: 'Passen Sie Metadaten und JSON in einem fokussierten Popup-Editor an.', + newResourceSubtitle: 'Erstellen Sie eine neue OpenClaw-Ressource, ohne den kompletten Editor auf der Seite anzuzeigen.', + resourceKey: 'Ressourcenschlüssel', + name: 'Name', + tags: 'Tags', + tagsPlaceholder: 'tag1,tag2', + channelTemplate: 'Channel-Template', + chooseStarterTemplate: 'Starter-Template auswählen', + channelTemplateHint: 'Hier werden nur Slack, Telegram und Feishu unterstützt. Starten Sie im Formularmodus und wechseln Sie erst zu JSON, wenn Sie die komplette Konfigurationsoberfläche brauchen.', + templateKey: 'Schlüssel: {key}', + invalidChannelJson: 'Dieses Channel-JSON ist derzeit ungültig. Wechseln Sie zuerst in den JSON-Modus, beheben Sie den Fehler und kehren Sie dann zum Formular zurück.', + contentJson: 'Inhalts-JSON', + editBundleTitle: 'Bundle bearbeiten', + newBundleTitle: 'Neues Bundle', + editBundleSubtitle: 'Überarbeiten Sie ein wiederverwendbares Startpaket in einem fokussierten Popup-Editor.', + newBundleSubtitle: 'Erstellen Sie ein neues wiederverwendbares Bundle, ohne die Ressourcenliste zu verlassen.', + bundleName: 'Bundle-Name', + bundleResources: 'Bundle-Ressourcen', + noResourcesForGroup: 'Es gibt noch keine {type}-Ressourcen.', + actions: { + new: 'Neu', + newBundle: 'Neues Bundle', + loadTemplate: 'Template laden', + saveResource: 'Ressource speichern', + createResource: 'Ressource erstellen', + validateJson: 'JSON prüfen', + clone: 'Klonen', + saveBundle: 'Bundle speichern', + createBundle: 'Bundle erstellen', + }, + resourceTypes: { + channel: 'Kanal', + skill: 'Fähigkeit', + session_template: 'Sitzungsvorlage', + log_policy: 'Log-Richtlinie', + agent: 'Agent', + scheduled_task: 'Geplanter Task', + }, + templateCategories: { + builtin: 'Integriert', + plugin: 'Plugin', + }, + templates: { + telegram: { + label: 'Telegram', + description: 'Telegram-Bot-API-Channel mit Steuerung für Direktnachrichten und Gruppenrichtlinien.', + }, + slack: { + label: 'Slack', + description: 'Slack-Workspace-App auf Basis von Bolt.', + }, + feishu: { + label: 'Feishu / Lark', + description: 'Feishu- oder Lark-Channel mit kontobezogenen Standardwerten.', + }, + }, + editorModes: { + form: 'Formular', + json: 'JSON', + }, + channelEditors: { + feishu: { + title: 'Feishu-Channel-Editor', + description: 'Beginnen Sie mit dem Formular für das Hauptkonto und wechseln Sie nur bei Bedarf zur vollständigen JSON-Konfiguration.', + fields: { + appId: { + label: 'App ID', + placeholder: 'cli_xxx', + }, + appSecret: { + label: 'App Secret', + placeholder: 'app secret', + }, + }, + }, + slack: { + title: 'Slack-Channel-Editor', + description: 'Beginnen Sie mit dem Token-Formular und wechseln Sie nur bei Bedarf zur vollständigen JSON-Konfiguration.', + fields: { + botToken: { + label: 'Bot Token', + placeholder: 'xoxb-...', + }, + appToken: { + label: 'App Token', + placeholder: 'xapp-...', + }, + }, + }, + telegram: { + title: 'Telegram-Channel-Editor', + description: 'Beginnen Sie mit dem Bot-Token-Formular und wechseln Sie nur bei Bedarf zur vollständigen JSON-Konfiguration.', + fields: { + botToken: { + label: 'Bot Token', + placeholder: '123456:ABCDEF', + }, + }, + }, + }, + notices: { + resourceUpdated: 'Ressource wurde aktualisiert.', + resourceCreated: 'Ressource wurde erstellt.', + resourceDeleted: 'Ressource wurde gelöscht.', + resourceCloned: 'Ressource wurde geklont.', + resourceJsonValid: 'Ressourcen-JSON ist gültig.', + templateLoaded: 'Template {name} wurde geladen.', + bundleUpdated: 'Bundle wurde aktualisiert.', + bundleCreated: 'Bundle wurde erstellt.', + bundleDeleted: 'Bundle wurde gelöscht.', + bundleCloned: 'Bundle wurde geklont.', + }, + errors: { + loadFailed: 'OpenClaw-Ressourcen konnten nicht geladen werden.', + saveResource: 'Ressource konnte nicht gespeichert werden.', + validationFailed: 'Ressourcen-JSON konnte nicht geprüft werden.', + deleteResource: 'Ressource konnte nicht gelöscht werden.', + cloneResource: 'Ressource konnte nicht geklont werden.', + saveBundle: 'Bundle konnte nicht gespeichert werden.', + deleteBundle: 'Bundle konnte nicht gelöscht werden.', + cloneBundle: 'Bundle konnte nicht geklont werden.', + }, + confirms: { + deleteResource: 'Ressource „{name}“ löschen?', + deleteBundle: 'Bundle „{name}“ löschen?', + }, + }, + openClawInjectionSection: { + title: 'OpenClaw-Ressourceninjektion', + subtitle: 'Wählen Sie, wie diese Instanz OpenClaw startet. Klicken Sie erneut auf eine aktive Option, um die Auswahl aufzuheben.', + optional: 'Optional', + modes: { + manual: 'Manuelle Ressourcen', + bundle: 'Bundle', + archive: 'Archivimport', + }, + loading: 'Daten der Ressourcenverwaltung werden geladen...', + chooseBundle: 'Bundle auswählen', + selectBundle: 'Bundle auswählen', + bundleOptionCount: '{count} Ressourcen', + channel: 'Kanal', + noChannelResources: 'Keine Kanal-Ressourcen verfügbar.', + errors: { + chooseBundle: 'Wählen Sie zuerst ein Bundle aus.', + chooseResource: 'Wählen Sie mindestens eine Ressource aus.', + compileFailed: 'Die OpenClaw-Bootstrap-Vorschau konnte nicht erstellt werden.', + }, + }, }, }; diff --git a/frontend/src/lib/openclawChannelTemplates.ts b/frontend/src/lib/openclawChannelTemplates.ts new file mode 100644 index 0000000..3b4f29f --- /dev/null +++ b/frontend/src/lib/openclawChannelTemplates.ts @@ -0,0 +1,71 @@ +export type OpenClawChannelTemplateCategory = 'builtin' | 'plugin'; + +export interface OpenClawChannelTemplate { + id: string; + label: string; + description: string; + category: OpenClawChannelTemplateCategory; + resourceKey: string; + resourceName: string; + resourceDescription: string; + tags: string[]; + config: Record; +} + +const createChannelTemplate = ( + id: string, + label: string, + description: string, + category: OpenClawChannelTemplateCategory, + config: Record, +): OpenClawChannelTemplate => ({ + id, + label, + description, + category, + resourceKey: id, + resourceName: label, + resourceDescription: `${label} starter template imported from the ClawPanel channel presets.`, + tags: ['channel', category, id], + config, +}); + +export const OPENCLAW_CHANNEL_TEMPLATES: OpenClawChannelTemplate[] = [ + createChannelTemplate('telegram', 'Telegram', 'Telegram Bot API channel with DM and group policy controls.', 'builtin', { + enabled: true, + botToken: '', + dmPolicy: 'open', + allowFrom: ['*'], + }), + createChannelTemplate('slack', 'Slack', 'Slack workspace app powered by Bolt.', 'builtin', { + enabled: true, + appToken: '', + botToken: '', + groupPolicy: 'allowlist', + channels: { + '#general': { + allow: true, + }, + }, + capabilities: { + interactiveReplies: true, + }, + }), + createChannelTemplate('feishu', 'Feishu / Lark', 'Feishu or Lark plugin channel with account-aware defaults.', 'plugin', { + enabled: true, + accounts: { + main: { + appId: '', + appSecret: '', + }, + }, + }), +]; + +export const OPENCLAW_CHANNEL_TEMPLATE_CATEGORY_LABELS: Record = { + builtin: 'Built-in Channels', + plugin: 'Plugin Channels', +}; + +export const findOpenClawChannelTemplate = (id: string): OpenClawChannelTemplate | undefined => + OPENCLAW_CHANNEL_TEMPLATES.find((item) => item.id === id); diff --git a/frontend/src/pages/instances/CreateInstancePage.tsx b/frontend/src/pages/instances/CreateInstancePage.tsx index f591863..516bafc 100644 --- a/frontend/src/pages/instances/CreateInstancePage.tsx +++ b/frontend/src/pages/instances/CreateInstancePage.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import OpenClawConfigPlanSection, { type OpenClawInjectionMode } from '../../components/OpenClawConfigPlanSection'; import UserLayout from '../../components/UserLayout'; import { useAuth } from '../../contexts/AuthContext'; import { instanceService } from '../../services/instanceService'; @@ -7,6 +8,7 @@ import { userService } from '../../services/userService'; import { INSTANCE_TYPES, PRESET_CONFIGS } from '../../types/instance'; import type { CreateInstanceRequest } from '../../types/instance'; import type { Instance } from '../../types/instance'; +import type { OpenClawConfigCompilePreview } from '../../types/openclawConfig'; import type { UserQuota } from '../../types/user'; import { useI18n } from '../../contexts/I18nContext'; import { systemSettingsService } from '../../services/systemSettingsService'; @@ -24,6 +26,12 @@ const CreateInstancePage: React.FC = () => { const [quota, setQuota] = useState(null); const [instances, setInstances] = useState([]); const [openClawImportFile, setOpenClawImportFile] = useState(null); + const [openClawInjectionMode, setOpenClawInjectionMode] = useState('none'); + const [openClawBundleId, setOpenClawBundleId] = useState(undefined); + const [openClawResourceIds, setOpenClawResourceIds] = useState([]); + const [openClawPreview, setOpenClawPreview] = useState(null); + const [openClawPreviewLoading, setOpenClawPreviewLoading] = useState(false); + const [openClawPreviewError, setOpenClawPreviewError] = useState(null); const openClawImportInputRef = useRef(null); const [formData, setFormData] = useState({ @@ -112,6 +120,11 @@ const CreateInstancePage: React.FC = () => { if (instanceType) { if (typeId !== 'openclaw') { setOpenClawImportFile(null); + setOpenClawInjectionMode('none'); + setOpenClawBundleId(undefined); + setOpenClawResourceIds([]); + setOpenClawPreview(null); + setOpenClawPreviewError(null); } setFormData({ ...formData, @@ -145,9 +158,18 @@ const CreateInstancePage: React.FC = () => { try { setLoading(true); setError(null); - const createdInstance = await instanceService.createInstance(formData); + const createPayload: CreateInstanceRequest = { + ...formData, + openclaw_config_plan: formData.type === 'openclaw' && openClawInjectionMode === 'bundle' && openClawBundleId + ? { mode: 'bundle', bundle_id: openClawBundleId } + : formData.type === 'openclaw' && openClawInjectionMode === 'manual' && openClawResourceIds.length > 0 + ? { mode: 'manual', resource_ids: openClawResourceIds } + : undefined, + }; - if (formData.type === 'openclaw' && openClawImportFile) { + const createdInstance = await instanceService.createInstance(createPayload); + + if (formData.type === 'openclaw' && openClawInjectionMode === 'archive' && openClawImportFile) { await waitForInstanceRunning(createdInstance.id); await instanceService.importOpenClawWorkspace(createdInstance.id, openClawImportFile); } @@ -246,7 +268,18 @@ const CreateInstancePage: React.FC = () => { const exceededQuotaItems = quotaChecks.filter((item) => item.exceeded); const quotaExceeded = exceededQuotaItems.length > 0; - const createDisabled = loading || !submitArmed || quotaLoading || !quota || quotaExceeded; + const openClawPlanInvalid = formData.type === 'openclaw' && ( + (openClawInjectionMode === 'bundle' && (!openClawBundleId || !!openClawPreviewError || openClawPreviewLoading)) || + (openClawInjectionMode === 'manual' && (openClawResourceIds.length === 0 || !!openClawPreviewError || openClawPreviewLoading)) || + (openClawInjectionMode === 'archive' && !openClawImportFile) + ); + const createDisabled = loading || !submitArmed || quotaLoading || !quota || quotaExceeded || openClawPlanInvalid; + + const handleOpenClawPreviewChange = React.useCallback((preview: OpenClawConfigCompilePreview | null, state: { loading: boolean; error: string | null }) => { + setOpenClawPreview(preview); + setOpenClawPreviewLoading(state.loading); + setOpenClawPreviewError(state.error); + }, []); const renderTypeIcon = (typeId: string) => { if (typeId === 'openclaw') { @@ -525,56 +558,143 @@ const CreateInstancePage: React.FC = () => { {formData.type === 'openclaw' && ( -
-
-
-

{t('instances.openClawImportTitle')}

-

- {t('instances.openClawImportDesc')} -

-
- - {t('instances.optional')} - -
- - setOpenClawImportFile(e.target.files?.[0] || null)} +
+ { + setOpenClawInjectionMode(nextMode); + setOpenClawPreview(null); + setOpenClawPreviewError(null); + if (nextMode !== 'bundle') { + setOpenClawBundleId(undefined); + } + if (nextMode !== 'manual') { + setOpenClawResourceIds([]); + } + if (nextMode !== 'archive') { + setOpenClawImportFile(null); + if (openClawImportInputRef.current) { + openClawImportInputRef.current.value = ''; + } + } + }} + onSelectionChange={({ bundleId, resourceIds }) => { + setOpenClawBundleId(bundleId); + setOpenClawResourceIds(resourceIds); + }} + onPreviewChange={handleOpenClawPreviewChange} /> -
- - {openClawImportFile && ( - - )} -
+ {openClawInjectionMode === 'archive' && ( +
+
+
+

{t('instances.openClawImportTitle')}

+

+ {t('instances.openClawImportDesc')} +

+
+ + Required for archive mode + +
-
- {openClawImportFile - ? t('instances.selectedArchive', { name: openClawImportFile.name }) - : t('instances.noArchiveSelected')} -
+ setOpenClawImportFile(e.target.files?.[0] || null)} + /> + +
+ + {openClawImportFile && ( + + )} +
+ +
+ {openClawImportFile + ? t('instances.selectedArchive', { name: openClawImportFile.name }) + : t('instances.noArchiveSelected')} +
+
+ )} + + {(openClawInjectionMode === 'bundle' || openClawInjectionMode === 'manual') && ( +
+
+
+

Config Preview

+

+ We compile your selected config assets before instance creation so dependency and payload issues show up early. +

+
+ {openClawPreviewLoading && ( + + Compiling... + + )} +
+ + {openClawPreviewError && ( +
+ {openClawPreviewError} +
+ )} + + {openClawPreview && ( +
+
+
+
Resolved Resources
+
{openClawPreview.resolved_resources.length}
+
+
+
Env Variables
+
{openClawPreview.env_names.length}
+
+
+
Payload Size
+
{openClawPreview.total_payload_bytes} B
+
+
+ + {openClawPreview.auto_included.length > 0 && ( +
+ Auto included dependencies: {openClawPreview.auto_included.map((item) => item.name).join(', ')} +
+ )} + + {openClawPreview.warnings.length > 0 && ( +
+ {openClawPreview.warnings.join(' ')} +
+ )} +
+ )} +
+ )}
)} @@ -642,9 +762,15 @@ const CreateInstancePage: React.FC = () => {
{formData.type === 'openclaw' && (
-
{t('instances.openClawImportTitle')}
-
- {openClawImportFile ? openClawImportFile.name : t('instances.noOpenClawArchiveSelected')} +
OpenClaw Bootstrap
+
+
Mode: {openClawInjectionMode}
+ {openClawInjectionMode === 'archive' && ( +
{openClawImportFile ? openClawImportFile.name : t('instances.noOpenClawArchiveSelected')}
+ )} + {(openClawInjectionMode === 'bundle' || openClawInjectionMode === 'manual') && openClawPreview && ( +
{openClawPreview.resolved_resources.length} resource(s), {openClawPreview.env_names.length} env payload(s)
+ )}
)} @@ -678,7 +804,7 @@ const CreateInstancePage: React.FC = () => { disabled={createDisabled} className="app-button-primary disabled:cursor-not-allowed disabled:opacity-50" > - {quotaLoading ? t('instances.checkingQuota') : loading ? (formData.type === 'openclaw' && openClawImportFile ? t('instances.creatingAndImporting') : t('instances.creatingNow')) : t('instances.createNow')} + {quotaLoading ? t('instances.checkingQuota') : loading ? (formData.type === 'openclaw' && openClawInjectionMode === 'archive' && openClawImportFile ? t('instances.creatingAndImporting') : t('instances.creatingNow')) : t('instances.createNow')} )} diff --git a/frontend/src/pages/openclaw/OpenClawConfigCenterPage.tsx b/frontend/src/pages/openclaw/OpenClawConfigCenterPage.tsx new file mode 100644 index 0000000..96bce7c --- /dev/null +++ b/frontend/src/pages/openclaw/OpenClawConfigCenterPage.tsx @@ -0,0 +1,1463 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import UserLayout from '../../components/UserLayout'; +import { useI18n } from '../../contexts/I18nContext'; +import { + findOpenClawChannelTemplate, + OPENCLAW_CHANNEL_TEMPLATES, +} from '../../lib/openclawChannelTemplates'; +import { openclawConfigService } from '../../services/openclawConfigService'; +import type { + OpenClawConfigBundle, + OpenClawConfigBundleItem, + OpenClawConfigMode, + OpenClawConfigResource, + OpenClawInjectionSnapshot, + OpenClawResourceType, + UpsertOpenClawConfigBundleRequest, + UpsertOpenClawConfigResourceRequest, +} from '../../types/openclawConfig'; +import { OPENCLAW_RESOURCE_TYPES } from '../../types/openclawConfig'; + +type ConfigCenterTab = 'resources' | 'bundles' | 'injections'; + +const CONFIG_CENTER_HIDDEN_RESOURCE_TYPES: OpenClawResourceType[] = ['session_template', 'log_policy']; +const CONFIG_CENTER_RESOURCE_TYPES = OPENCLAW_RESOURCE_TYPES.filter( + (item) => !CONFIG_CENTER_HIDDEN_RESOURCE_TYPES.includes(item.value), +); +const CONFIG_CENTER_CONFIGURABLE_RESOURCE_TYPES: OpenClawResourceType[] = ['channel']; + +const RESOURCE_TYPE_I18N_KEYS: Record = { + channel: 'openClawResourcesPage.resourceTypes.channel', + skill: 'openClawResourcesPage.resourceTypes.skill', + session_template: 'openClawResourcesPage.resourceTypes.session_template', + log_policy: 'openClawResourcesPage.resourceTypes.log_policy', + agent: 'openClawResourcesPage.resourceTypes.agent', + scheduled_task: 'openClawResourcesPage.resourceTypes.scheduled_task', +}; + +const INJECTION_MODE_I18N_KEYS: Record = { + none: 'openClawResourcesPage.recordModes.none', + manual: 'openClawResourcesPage.recordModes.manual', + bundle: 'openClawResourcesPage.recordModes.bundle', + archive: 'openClawResourcesPage.recordModes.archive', +}; + +const SNAPSHOT_STATUS_I18N_KEYS: Record = { + compiled: 'openClawResourcesPage.recordStatuses.compiled', + active: 'openClawResourcesPage.recordStatuses.active', + failed: 'openClawResourcesPage.recordStatuses.failed', +}; + +const CHANNEL_TEMPLATE_LABEL_I18N_KEYS: Record = { + telegram: 'openClawResourcesPage.templates.telegram.label', + slack: 'openClawResourcesPage.templates.slack.label', + feishu: 'openClawResourcesPage.templates.feishu.label', +}; + +const CHANNEL_TEMPLATE_DESCRIPTION_I18N_KEYS: Record = { + telegram: 'openClawResourcesPage.templates.telegram.description', + slack: 'openClawResourcesPage.templates.slack.description', + feishu: 'openClawResourcesPage.templates.feishu.description', +}; + +const defaultContentByType: Record = { + channel: JSON.stringify({}, null, 2), + skill: JSON.stringify({ + schemaVersion: 1, + kind: 'skill', + format: 'skill/custom@v1', + dependsOn: [], + config: {}, + }, null, 2), + session_template: JSON.stringify({ + schemaVersion: 1, + kind: 'session_template', + format: 'session/default@v1', + dependsOn: [], + config: {}, + }, null, 2), + log_policy: JSON.stringify({ + schemaVersion: 1, + kind: 'log_policy', + format: 'log/policy@v1', + dependsOn: [], + config: {}, + }, null, 2), + agent: JSON.stringify({ + schemaVersion: 1, + kind: 'agent', + format: 'agent/default@v1', + dependsOn: [], + config: {}, + }, null, 2), + scheduled_task: JSON.stringify({ + schemaVersion: 1, + kind: 'scheduled_task', + format: 'task/default@v1', + dependsOn: [], + config: {}, + }, null, 2), +}; + +const newResourceForm = (resourceType: OpenClawResourceType) => ({ + id: undefined as number | undefined, + resource_type: resourceType, + resource_key: '', + name: '', + description: '', + enabled: true, + tagsText: '', + contentText: defaultContentByType[resourceType], +}); + +const newBundleForm = () => ({ + id: undefined as number | undefined, + name: '', + description: '', + enabled: true, + itemIds: [] as number[], +}); + +const splitTagText = (value: string): string[] => + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + +const mergeTagText = (current: string, additions: string[]): string => + Array.from(new Set([...splitTagText(current), ...additions])).join(', '); + +type ChannelEditorMode = 'form' | 'json'; + +type SupportedChannelEditorId = 'feishu' | 'slack' | 'telegram'; + +interface SupportedChannelEditorField { + key: string; + labelKey: string; + placeholderKey: string; +} + +interface SupportedChannelEditorDefinition { + id: SupportedChannelEditorId; + titleKey: string; + descriptionKey: string; + fields: SupportedChannelEditorField[]; + readFormState: (contentText: string) => Record | null; + updateContentText: (contentText: string, patch: Record) => string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const parseResourceContentText = (contentText: string): unknown | null => { + try { + return JSON.parse(contentText); + } catch { + return null; + } +}; + +const parseChannelContentText = (contentText: string): Record | null => { + const parsed = parseResourceContentText(contentText); + if (!isRecord(parsed)) { + return null; + } + + const isEnvelope = ( + ('schemaVersion' in parsed) + || ('kind' in parsed) + || ('format' in parsed) + || ('dependsOn' in parsed) + ); + + if (isEnvelope && isRecord(parsed.config)) { + return parsed.config; + } + + return parsed; +}; + +const readStringArray = (value: unknown): string[] | null => ( + Array.isArray(value) && value.every((item) => typeof item === 'string') + ? [...value] + : null +); + +const stringifyChannelContentText = (config: Record): string => ( + JSON.stringify(config, null, 2) +); + +const channelFormatForResourceKey = (resourceKey: string): string => { + const trimmed = resourceKey.trim(); + return trimmed ? `channel/${trimmed}@v1` : 'channel/custom@v1'; +}; + +const buildChannelEnvelopeForRequest = ( + resourceKey: string, + contentText: string, +): Record => { + const config = parseChannelContentText(contentText); + if (!config) { + throw new Error('Channel JSON must be a valid JSON object.'); + } + + return { + schemaVersion: 1, + kind: 'channel', + format: channelFormatForResourceKey(resourceKey), + dependsOn: [], + config, + }; +}; + +const readTelegramChannelFormState = (contentText: string): Record | null => { + const config = parseChannelContentText(contentText); + if (!config) { + return null; + } + + return { + botToken: typeof config.botToken === 'string' ? config.botToken : '', + }; +}; + +const updateTelegramChannelContentText = ( + contentText: string, + patch: Record, +): string => { + const parsed = parseChannelContentText(contentText); + if (!parsed) { + return contentText; + } + + const currentForm = readTelegramChannelFormState(contentText); + if (!currentForm) { + return contentText; + } + + const nextForm = { + ...currentForm, + ...patch, + }; + + const existingConfig = parsed; + const config = { + enabled: true, + botToken: nextForm.botToken, + dmPolicy: typeof existingConfig.dmPolicy === 'string' && existingConfig.dmPolicy + ? existingConfig.dmPolicy + : 'open', + allowFrom: readStringArray(existingConfig.allowFrom) || ['*'], + }; + + return stringifyChannelContentText(config); +}; + +const readSlackChannelFormState = (contentText: string): Record | null => { + const config = parseChannelContentText(contentText); + if (!config) { + return null; + } + + return { + botToken: typeof config.botToken === 'string' ? config.botToken : '', + appToken: typeof config.appToken === 'string' ? config.appToken : '', + }; +}; + +const updateSlackChannelContentText = ( + contentText: string, + patch: Record, +): string => { + const parsed = parseChannelContentText(contentText); + if (!parsed) { + return contentText; + } + + const currentForm = readSlackChannelFormState(contentText); + if (!currentForm) { + return contentText; + } + + const nextForm = { + ...currentForm, + ...patch, + }; + + const existingConfig = parsed; + const config = { + enabled: true, + botToken: nextForm.botToken, + appToken: nextForm.appToken, + groupPolicy: typeof existingConfig.groupPolicy === 'string' && existingConfig.groupPolicy + ? existingConfig.groupPolicy + : 'allowlist', + channels: isRecord(existingConfig.channels) + ? existingConfig.channels + : { + '#general': { + allow: true, + }, + }, + capabilities: isRecord(existingConfig.capabilities) + ? existingConfig.capabilities + : { + interactiveReplies: true, + }, + }; + + return stringifyChannelContentText(config); +}; + +const readFeishuChannelFormState = (contentText: string): Record | null => { + const config = parseChannelContentText(contentText); + if (!config) { + return null; + } + + const accounts = isRecord(config.accounts) ? config.accounts : null; + const mainAccount = accounts && isRecord(accounts.main) + ? accounts.main + : accounts && isRecord(accounts.default) ? accounts.default : null; + + return { + appId: mainAccount && typeof mainAccount.appId === 'string' + ? mainAccount.appId + : typeof config.appId === 'string' ? config.appId : '', + appSecret: mainAccount && typeof mainAccount.appSecret === 'string' + ? mainAccount.appSecret + : typeof config.appSecret === 'string' ? config.appSecret : '', + }; +}; + +const updateFeishuChannelContentText = ( + contentText: string, + patch: Record, +): string => { + const parsed = parseChannelContentText(contentText); + if (!parsed) { + return contentText; + } + + const currentForm = readFeishuChannelFormState(contentText); + if (!currentForm) { + return contentText; + } + + const nextForm = { + ...currentForm, + ...patch, + }; + + const config = { + enabled: true, + accounts: { + main: { + appId: nextForm.appId, + appSecret: nextForm.appSecret, + }, + }, + }; + + return stringifyChannelContentText(config); +}; + +const detectSupportedChannelEditor = ( + resourceKey: string, + contentText: string, +): SupportedChannelEditorId | null => { + const config = parseChannelContentText(contentText); + const normalizedResourceKey = resourceKey.trim().toLowerCase(); + const accounts = config && isRecord(config.accounts) ? config.accounts : null; + const domain = config && typeof config.domain === 'string' ? config.domain.toLowerCase() : ''; + const hasAppToken = config && typeof config.appToken === 'string'; + const hasBotToken = config && typeof config.botToken === 'string'; + + if (normalizedResourceKey === 'feishu' || domain === 'feishu' || !!accounts) { + return 'feishu'; + } + if (normalizedResourceKey === 'slack' || hasAppToken) { + return 'slack'; + } + if (normalizedResourceKey === 'telegram' || hasBotToken) { + return 'telegram'; + } + + return null; +}; + +const normalizeResourceContentTextForEditor = ( + resourceType: OpenClawResourceType, + resourceKey: string, + contentText: string, +): string => { + if (resourceType !== 'channel') { + return contentText; + } + + const channelConfig = parseChannelContentText(contentText); + if (!channelConfig) { + return contentText; + } + + const normalizedContentText = stringifyChannelContentText(channelConfig); + const editorId = detectSupportedChannelEditor(resourceKey, normalizedContentText); + if (editorId === 'feishu') { + const currentForm = readFeishuChannelFormState(normalizedContentText); + return currentForm ? updateFeishuChannelContentText(normalizedContentText, currentForm) : normalizedContentText; + } + if (editorId === 'slack') { + const currentForm = readSlackChannelFormState(normalizedContentText); + return currentForm ? updateSlackChannelContentText(normalizedContentText, currentForm) : normalizedContentText; + } + if (editorId === 'telegram') { + const currentForm = readTelegramChannelFormState(normalizedContentText); + return currentForm ? updateTelegramChannelContentText(normalizedContentText, currentForm) : normalizedContentText; + } + + return normalizedContentText; +}; + +const SUPPORTED_CHANNEL_EDITORS: Record = { + feishu: { + id: 'feishu', + titleKey: 'openClawResourcesPage.channelEditors.feishu.title', + descriptionKey: 'openClawResourcesPage.channelEditors.feishu.description', + fields: [ + { + key: 'appId', + labelKey: 'openClawResourcesPage.channelEditors.feishu.fields.appId.label', + placeholderKey: 'openClawResourcesPage.channelEditors.feishu.fields.appId.placeholder', + }, + { + key: 'appSecret', + labelKey: 'openClawResourcesPage.channelEditors.feishu.fields.appSecret.label', + placeholderKey: 'openClawResourcesPage.channelEditors.feishu.fields.appSecret.placeholder', + }, + ], + readFormState: readFeishuChannelFormState, + updateContentText: updateFeishuChannelContentText, + }, + slack: { + id: 'slack', + titleKey: 'openClawResourcesPage.channelEditors.slack.title', + descriptionKey: 'openClawResourcesPage.channelEditors.slack.description', + fields: [ + { + key: 'botToken', + labelKey: 'openClawResourcesPage.channelEditors.slack.fields.botToken.label', + placeholderKey: 'openClawResourcesPage.channelEditors.slack.fields.botToken.placeholder', + }, + { + key: 'appToken', + labelKey: 'openClawResourcesPage.channelEditors.slack.fields.appToken.label', + placeholderKey: 'openClawResourcesPage.channelEditors.slack.fields.appToken.placeholder', + }, + ], + readFormState: readSlackChannelFormState, + updateContentText: updateSlackChannelContentText, + }, + telegram: { + id: 'telegram', + titleKey: 'openClawResourcesPage.channelEditors.telegram.title', + descriptionKey: 'openClawResourcesPage.channelEditors.telegram.description', + fields: [ + { + key: 'botToken', + labelKey: 'openClawResourcesPage.channelEditors.telegram.fields.botToken.label', + placeholderKey: 'openClawResourcesPage.channelEditors.telegram.fields.botToken.placeholder', + }, + ], + readFormState: readTelegramChannelFormState, + updateContentText: updateTelegramChannelContentText, + }, +}; + +interface EditorModalProps { + open: boolean; + title: string; + subtitle: string; + onClose: () => void; + busy?: boolean; + panelClassName?: string; + children: React.ReactNode; +} + +const EditorModal: React.FC = ({ + open, + title, + subtitle, + onClose, + busy = false, + panelClassName = 'max-w-5xl', + children, +}) => { + const { t } = useI18n(); + + useEffect(() => { + if (!open) { + return undefined; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !busy) { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [busy, onClose, open]); + + if (!open || typeof document === 'undefined') { + return null; + } + + return createPortal( +
{ + if (event.target === event.currentTarget && !busy) { + onClose(); + } + }} + > +
event.stopPropagation()} + > +
+
+

{title}

+

{subtitle}

+
+ +
+
+ {children} +
+
+
, + document.body, + ); +}; + +const resourceFormFromItem = (item: OpenClawConfigResource) => ({ + id: item.id, + resource_type: item.resource_type, + resource_key: item.resource_key, + name: item.name, + description: item.description || '', + enabled: item.enabled, + tagsText: item.tags.join(', '), + contentText: normalizeResourceContentTextForEditor( + item.resource_type, + item.resource_key, + JSON.stringify(item.content, null, 2), + ), +}); + +const bundleFormFromItem = (item: OpenClawConfigBundle) => ({ + id: item.id, + name: item.name, + description: item.description || '', + enabled: item.enabled, + itemIds: item.items.map((bundleItem) => bundleItem.resource_id), +}); + +const OpenClawConfigCenterPage: React.FC = () => { + const { t } = useI18n(); + const [tab, setTab] = useState('resources'); + const [resourceType, setResourceType] = useState('channel'); + const [resourceSearch, setResourceSearch] = useState(''); + const [resources, setResources] = useState([]); + const [bundles, setBundles] = useState([]); + const [snapshots, setSnapshots] = useState([]); + const [resourceForm, setResourceForm] = useState(() => newResourceForm('channel')); + const [bundleForm, setBundleForm] = useState(() => newBundleForm()); + const [selectedResourceId, setSelectedResourceId] = useState(); + const [selectedBundleId, setSelectedBundleId] = useState(); + const [resourceEditorOpen, setResourceEditorOpen] = useState(false); + const [bundleEditorOpen, setBundleEditorOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [selectedChannelTemplateId, setSelectedChannelTemplateId] = useState(''); + const [channelEditorMode, setChannelEditorMode] = useState('form'); + + const loadAll = async () => { + try { + setLoading(true); + setError(null); + const [resourceItems, bundleItems, injectionItems] = await Promise.all([ + openclawConfigService.listResources(), + openclawConfigService.listBundles(), + openclawConfigService.listInjections(50), + ]); + setResources(resourceItems); + setBundles(bundleItems); + setSnapshots(injectionItems); + } catch (err: any) { + setError(err.response?.data?.error || t('openClawResourcesPage.errors.loadFailed')); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadAll(); + }, []); + + const filteredResources = useMemo(() => { + const keyword = resourceSearch.trim().toLowerCase(); + return resources.filter((item) => { + if (item.resource_type !== resourceType) { + return false; + } + if (!keyword) { + return true; + } + return [item.name, item.resource_key, item.description || '', item.tags.join(' ')].some((value) => + value.toLowerCase().includes(keyword), + ); + }); + }, [resourceSearch, resourceType, resources]); + + const getResourceTypeLabel = (value: OpenClawResourceType) => t(RESOURCE_TYPE_I18N_KEYS[value]); + const getChannelTemplateLabel = (templateId: string) => ( + CHANNEL_TEMPLATE_LABEL_I18N_KEYS[templateId] + ? t(CHANNEL_TEMPLATE_LABEL_I18N_KEYS[templateId]) + : templateId + ); + const getChannelTemplateDescription = (templateId: string, fallback: string) => ( + CHANNEL_TEMPLATE_DESCRIPTION_I18N_KEYS[templateId] + ? t(CHANNEL_TEMPLATE_DESCRIPTION_I18N_KEYS[templateId]) + : fallback + ); + const getInjectionModeLabel = (mode: OpenClawConfigMode | 'archive') => ( + INJECTION_MODE_I18N_KEYS[mode] + ? t(INJECTION_MODE_I18N_KEYS[mode]) + : mode + ); + const getSnapshotStatusLabel = (status: string) => ( + SNAPSHOT_STATUS_I18N_KEYS[status] + ? t(SNAPSHOT_STATUS_I18N_KEYS[status]) + : status + ); + const resourceTypeOptions = useMemo(() => ( + CONFIG_CENTER_RESOURCE_TYPES.map((item) => ({ + ...item, + label: getResourceTypeLabel(item.value), + })) + ), [t]); + const groupedResources = useMemo(() => ( + resourceTypeOptions.map(({ value, label }) => ({ + value, + label, + items: resources.filter((item) => item.resource_type === value), + })) + ), [resourceTypeOptions, resources]); + + const selectedChannelTemplate = useMemo( + () => findOpenClawChannelTemplate(selectedChannelTemplateId), + [selectedChannelTemplateId], + ); + const supportedChannelEditorId = useMemo(() => ( + resourceForm.resource_type === 'channel' + ? detectSupportedChannelEditor(resourceForm.resource_key, resourceForm.contentText) + : null + ), [resourceForm.contentText, resourceForm.resource_key, resourceForm.resource_type]); + const supportedChannelEditor = useMemo( + () => (supportedChannelEditorId ? SUPPORTED_CHANNEL_EDITORS[supportedChannelEditorId] : null), + [supportedChannelEditorId], + ); + const supportedChannelForm = useMemo( + () => (supportedChannelEditor ? supportedChannelEditor.readFormState(resourceForm.contentText) : null), + [resourceForm.contentText, supportedChannelEditor], + ); + const selectedResourceTypeOption = useMemo( + () => resourceTypeOptions.find((item) => item.value === resourceType), + [resourceType, resourceTypeOptions], + ); + const selectedEditorTypeOption = useMemo( + () => resourceTypeOptions.find((item) => item.value === resourceForm.resource_type), + [resourceForm.resource_type, resourceTypeOptions], + ); + const resourceTypeIsConfigurable = CONFIG_CENTER_CONFIGURABLE_RESOURCE_TYPES.includes(resourceType); + const resourceEditorIsConfigurable = CONFIG_CENTER_CONFIGURABLE_RESOURCE_TYPES.includes(resourceForm.resource_type); + + const channelTemplatesByCategory = useMemo(() => ({ + builtin: OPENCLAW_CHANNEL_TEMPLATES.filter((item) => item.category === 'builtin'), + plugin: OPENCLAW_CHANNEL_TEMPLATES.filter((item) => item.category === 'plugin'), + }), []); + + const openNewResourceEditor = () => { + setError(null); + setNotice(null); + setSelectedResourceId(undefined); + setSelectedChannelTemplateId(''); + setChannelEditorMode('form'); + setResourceForm(newResourceForm(resourceType)); + setResourceEditorOpen(true); + }; + + const openResourceEditor = (item: OpenClawConfigResource) => { + setError(null); + setNotice(null); + setSelectedResourceId(item.id); + setSelectedChannelTemplateId(''); + setChannelEditorMode('form'); + setResourceType(item.resource_type); + setResourceForm(resourceFormFromItem(item)); + setResourceEditorOpen(true); + }; + + const closeResourceEditor = () => { + if (saving) { + return; + } + setResourceEditorOpen(false); + if (!selectedResourceId) { + setSelectedChannelTemplateId(''); + setResourceForm(newResourceForm(resourceType)); + } + }; + + const openNewBundleEditor = () => { + setError(null); + setNotice(null); + setSelectedBundleId(undefined); + setBundleForm(newBundleForm()); + setBundleEditorOpen(true); + }; + + const openBundleEditor = (item: OpenClawConfigBundle) => { + setError(null); + setNotice(null); + setSelectedBundleId(item.id); + setBundleForm(bundleFormFromItem(item)); + setBundleEditorOpen(true); + }; + + const closeBundleEditor = () => { + if (saving) { + return; + } + setBundleEditorOpen(false); + if (!selectedBundleId) { + setBundleForm(newBundleForm()); + } + }; + + const persistResource = async () => { + try { + setSaving(true); + setError(null); + setNotice(null); + + const payload: UpsertOpenClawConfigResourceRequest = { + resource_type: resourceForm.resource_type, + resource_key: resourceForm.resource_key.trim(), + name: resourceForm.name.trim(), + description: resourceForm.description.trim() || undefined, + enabled: resourceForm.enabled, + tags: splitTagText(resourceForm.tagsText), + content: resourceForm.resource_type === 'channel' + ? buildChannelEnvelopeForRequest(resourceForm.resource_key, resourceForm.contentText) + : JSON.parse(resourceForm.contentText), + }; + + const saved = resourceForm.id + ? await openclawConfigService.updateResource(resourceForm.id, payload) + : await openclawConfigService.createResource(payload); + + await loadAll(); + setSelectedResourceId(saved.id); + setResourceType(saved.resource_type); + setSelectedChannelTemplateId(''); + setResourceForm(resourceFormFromItem(saved)); + setResourceEditorOpen(false); + setNotice(resourceForm.id ? t('openClawResourcesPage.notices.resourceUpdated') : t('openClawResourcesPage.notices.resourceCreated')); + } catch (err: any) { + setError(err.response?.data?.error || err.message || t('openClawResourcesPage.errors.saveResource')); + } finally { + setSaving(false); + } + }; + + const validateResource = async () => { + try { + setSaving(true); + setError(null); + setNotice(null); + await openclawConfigService.validateResource({ + resource_type: resourceForm.resource_type, + resource_key: resourceForm.resource_key.trim(), + name: resourceForm.name.trim(), + description: resourceForm.description.trim() || undefined, + enabled: resourceForm.enabled, + tags: splitTagText(resourceForm.tagsText), + content: resourceForm.resource_type === 'channel' + ? buildChannelEnvelopeForRequest(resourceForm.resource_key, resourceForm.contentText) + : JSON.parse(resourceForm.contentText), + }); + setNotice(t('openClawResourcesPage.notices.resourceJsonValid')); + } catch (err: any) { + setError(err.response?.data?.error || err.message || t('openClawResourcesPage.errors.validationFailed')); + } finally { + setSaving(false); + } + }; + + const removeResource = async () => { + if (!resourceForm.id || !window.confirm(t('openClawResourcesPage.confirms.deleteResource', { name: resourceForm.name }))) { + return; + } + + try { + setSaving(true); + await openclawConfigService.deleteResource(resourceForm.id); + await loadAll(); + setSelectedResourceId(undefined); + setSelectedChannelTemplateId(''); + setResourceForm(newResourceForm(resourceType)); + setResourceEditorOpen(false); + setNotice(t('openClawResourcesPage.notices.resourceDeleted')); + } catch (err: any) { + setError(err.response?.data?.error || t('openClawResourcesPage.errors.deleteResource')); + } finally { + setSaving(false); + } + }; + + const cloneResource = async () => { + if (!resourceForm.id) { + return; + } + + try { + setSaving(true); + const cloned = await openclawConfigService.cloneResource(resourceForm.id); + await loadAll(); + setSelectedResourceId(cloned.id); + setResourceType(cloned.resource_type); + setSelectedChannelTemplateId(''); + setResourceForm(resourceFormFromItem(cloned)); + setNotice(t('openClawResourcesPage.notices.resourceCloned')); + } catch (err: any) { + setError(err.response?.data?.error || t('openClawResourcesPage.errors.cloneResource')); + } finally { + setSaving(false); + } + }; + + const applyChannelTemplate = (templateId: string) => { + const template = findOpenClawChannelTemplate(templateId); + if (!template) { + return; + } + + setResourceType('channel'); + setSelectedChannelTemplateId(template.id); + setChannelEditorMode('form'); + setError(null); + const templateLabel = getChannelTemplateLabel(template.id); + const templateDescription = getChannelTemplateDescription(template.id, template.description); + setNotice(t('openClawResourcesPage.notices.templateLoaded', { name: templateLabel })); + setResourceForm((current) => ({ + ...current, + resource_type: 'channel', + resource_key: current.resource_key.trim() || template.resourceKey, + name: current.name.trim() || templateLabel, + description: current.description.trim() || templateDescription, + tagsText: mergeTagText(current.tagsText, template.tags), + contentText: JSON.stringify(template.config, null, 2), + })); + }; + + const persistBundle = async () => { + try { + setSaving(true); + setError(null); + setNotice(null); + + const payload: UpsertOpenClawConfigBundleRequest = { + name: bundleForm.name.trim(), + description: bundleForm.description.trim() || undefined, + enabled: bundleForm.enabled, + items: bundleForm.itemIds.map((resourceId, index): OpenClawConfigBundleItem => ({ + resource_id: resourceId, + sort_order: index + 1, + required: true, + })), + }; + + const saved = bundleForm.id + ? await openclawConfigService.updateBundle(bundleForm.id, payload) + : await openclawConfigService.createBundle(payload); + + await loadAll(); + setSelectedBundleId(saved.id); + setBundleForm(bundleFormFromItem(saved)); + setBundleEditorOpen(false); + setNotice(bundleForm.id ? t('openClawResourcesPage.notices.bundleUpdated') : t('openClawResourcesPage.notices.bundleCreated')); + } catch (err: any) { + setError(err.response?.data?.error || t('openClawResourcesPage.errors.saveBundle')); + } finally { + setSaving(false); + } + }; + + const removeBundle = async () => { + if (!bundleForm.id || !window.confirm(t('openClawResourcesPage.confirms.deleteBundle', { name: bundleForm.name }))) { + return; + } + + try { + setSaving(true); + await openclawConfigService.deleteBundle(bundleForm.id); + await loadAll(); + setSelectedBundleId(undefined); + setBundleForm(newBundleForm()); + setBundleEditorOpen(false); + setNotice(t('openClawResourcesPage.notices.bundleDeleted')); + } catch (err: any) { + setError(err.response?.data?.error || t('openClawResourcesPage.errors.deleteBundle')); + } finally { + setSaving(false); + } + }; + + const cloneBundle = async () => { + if (!bundleForm.id) { + return; + } + + try { + setSaving(true); + const cloned = await openclawConfigService.cloneBundle(bundleForm.id); + await loadAll(); + setSelectedBundleId(cloned.id); + setBundleForm(bundleFormFromItem(cloned)); + setNotice(t('openClawResourcesPage.notices.bundleCloned')); + } catch (err: any) { + setError(err.response?.data?.error || t('openClawResourcesPage.errors.cloneBundle')); + } finally { + setSaving(false); + } + }; + + return ( + +
+
+
+ {[ + { key: 'resources', label: t('openClawResourcesPage.tabs.resources') }, + { key: 'bundles', label: t('openClawResourcesPage.tabs.bundles') }, + { key: 'injections', label: t('openClawResourcesPage.tabs.injections') }, + ].map((item) => ( + + ))} + +
+ {error &&
{error}
} + {notice &&
{notice}
} +
+ + {tab === 'resources' && ( +
+
+
{t('openClawResourcesPage.resourceTypesTitle')}
+
+ {resourceTypeOptions.map((item) => ( + + ))} +
+
+ + {resourceTypeIsConfigurable ? ( +
+
+ setResourceSearch(e.target.value)} + placeholder={t('openClawResourcesPage.searchPlaceholder')} + className="app-input w-full" + /> + +
+
+ {t('openClawResourcesPage.resourceListHint')} +
+
+ {loading ? ( +
{t('openClawResourcesPage.loadingResources')}
+ ) : filteredResources.length === 0 ? ( +
+ {t('openClawResourcesPage.noResources')} +
+ ) : ( + filteredResources.map((item) => ( + + )) + )} +
+
+ ) : ( +
+
+
{t('common.comingSoon')}
+

+ {t('openClawResourcesPage.notConfigurableYet', { type: selectedResourceTypeOption?.label || t('openClawResourcesPage.thisResourceType') })} +

+

+ {t('openClawResourcesPage.onlyChannelConfigurable')} +

+
+
+ )} +
+ )} + + {tab === 'bundles' && ( +
+
+
+
{t('openClawResourcesPage.tabs.bundles')}
+
{t('openClawResourcesPage.bundlesSubtitle')}
+
+ +
+ +
+ {loading ? ( +
{t('openClawResourcesPage.loadingBundles')}
+ ) : bundles.length === 0 ? ( +
+ {t('openClawResourcesPage.noBundles')} +
+ ) : ( + bundles.map((item) => ( + + )) + )} +
+
+ )} + + {tab === 'injections' && ( +
+
+
+
{t('openClawResourcesPage.tabs.injections')}
+
{t('openClawResourcesPage.injectionsSubtitle')}
+
+
+ +
+ + + + + + + + + + + + + {loading ? ( + + ) : snapshots.length === 0 ? ( + + ) : ( + snapshots.map((item) => ( + + + + + + + + + )) + )} + +
{t('openClawResourcesPage.snapshot')}{t('openClawResourcesPage.mode')}{t('openClawResourcesPage.resources')}{t('openClawResourcesPage.envCount')}{t('common.status')}{t('openClawResourcesPage.created')}
{t('openClawResourcesPage.loadingSnapshots')}
{t('openClawResourcesPage.noInjectionRecords')}
#{item.id}{getInjectionModeLabel(item.mode)}{item.resolved_resources.length}{item.env_names.length} + + {getSnapshotStatusLabel(item.status)} + + {item.error_message &&
{item.error_message}
} +
{new Date(item.created_at).toLocaleString()}
+
+
+ )} +
+ + +
+ {error &&
{error}
} + {notice &&
{notice}
} + +
+
+ + +
+
+ + setResourceForm((current) => ({ ...current, resource_key: e.target.value }))} className="app-input mt-1 w-full" /> +
+
+ + setResourceForm((current) => ({ ...current, name: e.target.value }))} className="app-input mt-1 w-full" /> +
+
+ + setResourceForm((current) => ({ ...current, tagsText: e.target.value }))} className="app-input mt-1 w-full" placeholder={t('openClawResourcesPage.tagsPlaceholder')} /> +
+
+ +
+ +