feat: add OpenClaw resource management and bootstrap flows

This commit is contained in:
Qingshan Chen
2026-04-03 20:11:03 +08:00
parent 89ad4d8a2c
commit ceb14b3b3f
31 changed files with 6270 additions and 182 deletions
+1 -1
View File
@@ -147,7 +147,7 @@ jobs:
)
text = re.sub(
r"\n\s+nodePort:\s+39443",
r"\n\s+nodePort:\s+\d+",
"",
text,
count=1,
+28 -1
View File
@@ -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))
@@ -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
+4
View File
@@ -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")
+229
View File
@@ -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
}
@@ -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
);
@@ -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;
+31
View File
@@ -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)
}
}
+28 -26
View File
@@ -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)
@@ -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)
}
+28 -27
View File
@@ -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
@@ -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"
}
@@ -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
}
+111 -52
View File
@@ -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)
+5
View File
@@ -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))
+24 -12
View File
@@ -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
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -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)
}
}
+7 -3
View File
@@ -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)
+109 -1
View File
@@ -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
@@ -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<OpenClawConfigPlanSectionProps> = ({
mode,
bundleId,
resourceIds,
onModeChange,
onSelectionChange,
onPreviewChange,
}) => {
const { t } = useI18n();
const [resources, setResources] = useState<OpenClawConfigResource[]>([]);
const [bundles, setBundles] = useState<OpenClawConfigBundle[]>([]);
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 (
<div className="app-panel p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium text-gray-900">{t('openClawInjectionSection.title')}</h2>
<p className="mt-1 text-sm text-gray-500">
{t('openClawInjectionSection.subtitle')}
</p>
</div>
<span className="rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-600">{t('openClawInjectionSection.optional')}</span>
</div>
<div className="mt-5 grid grid-cols-1 gap-3 md:grid-cols-3">
{[
{ value: 'manual', label: t('openClawInjectionSection.modes.manual') },
{ value: 'bundle', label: t('openClawInjectionSection.modes.bundle') },
{ value: 'archive', label: t('openClawInjectionSection.modes.archive') },
].map((item) => (
<button
key={item.value}
type="button"
onClick={() => onModeChange(mode === item.value ? 'none' : item.value as OpenClawInjectionMode)}
className={`rounded-2xl border px-4 py-3 text-left transition ${
mode === item.value ? 'border-indigo-300 bg-indigo-50 text-indigo-700' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
}`}
>
<div className="font-medium">{item.label}</div>
</button>
))}
</div>
{loading && <div className="mt-5 text-sm text-gray-500">{t('openClawInjectionSection.loading')}</div>}
{!loading && mode === 'bundle' && (
<div className="mt-5">
<label className="block text-sm font-medium text-gray-700">{t('openClawInjectionSection.chooseBundle')}</label>
<select
value={bundleId || ''}
onChange={(e) => onSelectionChange({ bundleId: e.target.value ? Number(e.target.value) : undefined, resourceIds: [] })}
className="app-input mt-1 w-full"
>
<option value="">{t('openClawInjectionSection.selectBundle')}</option>
{bundles.map((bundle) => (
<option key={bundle.id} value={bundle.id}>
{bundle.name} ({t('openClawInjectionSection.bundleOptionCount', { count: bundle.items.length })})
</option>
))}
</select>
</div>
)}
{!loading && mode === 'manual' && (
<div className="mt-5 space-y-5">
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#b46c50]">{t('openClawInjectionSection.channel')}</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{channelResources.map((item) => {
const checked = resourceIds.includes(item.id);
return (
<label key={item.id} className={`flex cursor-pointer items-start gap-3 rounded-2xl border px-4 py-3 ${checked ? 'border-indigo-300 bg-indigo-50' : 'border-gray-200 bg-white'}`}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onSelectionChange({
bundleId: undefined,
resourceIds: e.target.checked
? [...resourceIds, item.id]
: resourceIds.filter((value) => value !== item.id),
})}
/>
<span>
<span className="block font-medium text-gray-900">{item.name}</span>
<span className="mt-1 block text-xs text-gray-500">{item.resource_key}</span>
</span>
</label>
);
})}
{channelResources.length === 0 && (
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500">
{t('openClawInjectionSection.noChannelResources')}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default OpenClawConfigPlanSection;
+1
View File
@@ -39,6 +39,7 @@ const UserLayout: React.FC<UserLayoutProps> = ({ 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' },
];
File diff suppressed because it is too large Load Diff
@@ -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<string, unknown>;
}
const createChannelTemplate = (
id: string,
label: string,
description: string,
category: OpenClawChannelTemplateCategory,
config: Record<string, unknown>,
): 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<OpenClawChannelTemplateCategory, string> = {
builtin: 'Built-in Channels',
plugin: 'Plugin Channels',
};
export const findOpenClawChannelTemplate = (id: string): OpenClawChannelTemplate | undefined =>
OPENCLAW_CHANNEL_TEMPLATES.find((item) => item.id === id);
@@ -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<UserQuota | null>(null);
const [instances, setInstances] = useState<Instance[]>([]);
const [openClawImportFile, setOpenClawImportFile] = useState<File | null>(null);
const [openClawInjectionMode, setOpenClawInjectionMode] = useState<OpenClawInjectionMode>('none');
const [openClawBundleId, setOpenClawBundleId] = useState<number | undefined>(undefined);
const [openClawResourceIds, setOpenClawResourceIds] = useState<number[]>([]);
const [openClawPreview, setOpenClawPreview] = useState<OpenClawConfigCompilePreview | null>(null);
const [openClawPreviewLoading, setOpenClawPreviewLoading] = useState(false);
const [openClawPreviewError, setOpenClawPreviewError] = useState<string | null>(null);
const openClawImportInputRef = useRef<HTMLInputElement | null>(null);
const [formData, setFormData] = useState<CreateInstanceRequest>({
@@ -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 = () => {
</div>
{formData.type === 'openclaw' && (
<div className="app-panel p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium text-gray-900">{t('instances.openClawImportTitle')}</h2>
<p className="mt-1 text-sm text-gray-500">
{t('instances.openClawImportDesc')}
</p>
</div>
<span className="rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-600">
{t('instances.optional')}
</span>
</div>
<input
ref={openClawImportInputRef}
type="file"
accept=".tar.gz,.tgz,application/gzip,application/x-gzip,application/octet-stream"
className="hidden"
onChange={(e) => setOpenClawImportFile(e.target.files?.[0] || null)}
<div className="space-y-6">
<OpenClawConfigPlanSection
mode={openClawInjectionMode}
bundleId={openClawBundleId}
resourceIds={openClawResourceIds}
onModeChange={(nextMode) => {
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}
/>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => openClawImportInputRef.current?.click()}
className="app-button-secondary"
>
{openClawImportFile ? t('instances.changeOpenClawArchive') : t('instances.chooseOpenClawArchive')}
</button>
{openClawImportFile && (
<button
type="button"
onClick={() => {
setOpenClawImportFile(null);
if (openClawImportInputRef.current) {
openClawImportInputRef.current.value = '';
}
}}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-700 hover:bg-red-100"
>
{t('instances.remove')}
</button>
)}
</div>
{openClawInjectionMode === 'archive' && (
<div className="app-panel p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium text-gray-900">{t('instances.openClawImportTitle')}</h2>
<p className="mt-1 text-sm text-gray-500">
{t('instances.openClawImportDesc')}
</p>
</div>
<span className="rounded-full bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-600">
Required for archive mode
</span>
</div>
<div className="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 text-sm text-gray-600">
{openClawImportFile
? t('instances.selectedArchive', { name: openClawImportFile.name })
: t('instances.noArchiveSelected')}
</div>
<input
ref={openClawImportInputRef}
type="file"
accept=".tar.gz,.tgz,application/gzip,application/x-gzip,application/octet-stream"
className="hidden"
onChange={(e) => setOpenClawImportFile(e.target.files?.[0] || null)}
/>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => openClawImportInputRef.current?.click()}
className="app-button-secondary"
>
{openClawImportFile ? t('instances.changeOpenClawArchive') : t('instances.chooseOpenClawArchive')}
</button>
{openClawImportFile && (
<button
type="button"
onClick={() => {
setOpenClawImportFile(null);
if (openClawImportInputRef.current) {
openClawImportInputRef.current.value = '';
}
}}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-700 hover:bg-red-100"
>
{t('instances.remove')}
</button>
)}
</div>
<div className="mt-4 rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-3 text-sm text-gray-600">
{openClawImportFile
? t('instances.selectedArchive', { name: openClawImportFile.name })
: t('instances.noArchiveSelected')}
</div>
</div>
)}
{(openClawInjectionMode === 'bundle' || openClawInjectionMode === 'manual') && (
<div className="app-panel p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium text-gray-900">Config Preview</h2>
<p className="mt-1 text-sm text-gray-500">
We compile your selected config assets before instance creation so dependency and payload issues show up early.
</p>
</div>
{openClawPreviewLoading && (
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs font-medium text-amber-700">
Compiling...
</span>
)}
</div>
{openClawPreviewError && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{openClawPreviewError}
</div>
)}
{openClawPreview && (
<div className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div className="text-xs uppercase tracking-[0.18em] text-gray-500">Resolved Resources</div>
<div className="mt-2 text-2xl font-semibold text-gray-900">{openClawPreview.resolved_resources.length}</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div className="text-xs uppercase tracking-[0.18em] text-gray-500">Env Variables</div>
<div className="mt-2 text-2xl font-semibold text-gray-900">{openClawPreview.env_names.length}</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
<div className="text-xs uppercase tracking-[0.18em] text-gray-500">Payload Size</div>
<div className="mt-2 text-2xl font-semibold text-gray-900">{openClawPreview.total_payload_bytes} B</div>
</div>
</div>
{openClawPreview.auto_included.length > 0 && (
<div className="rounded-2xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-700">
Auto included dependencies: {openClawPreview.auto_included.map((item) => item.name).join(', ')}
</div>
)}
{openClawPreview.warnings.length > 0 && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{openClawPreview.warnings.join(' ')}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
@@ -642,9 +762,15 @@ const CreateInstancePage: React.FC = () => {
</div>
{formData.type === 'openclaw' && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500">{t('instances.openClawImportTitle')}</dt>
<dd className="mt-1 text-sm text-gray-900">
{openClawImportFile ? openClawImportFile.name : t('instances.noOpenClawArchiveSelected')}
<dt className="text-sm font-medium text-gray-500">OpenClaw Bootstrap</dt>
<dd className="mt-1 space-y-1 text-sm text-gray-900">
<div>Mode: {openClawInjectionMode}</div>
{openClawInjectionMode === 'archive' && (
<div>{openClawImportFile ? openClawImportFile.name : t('instances.noOpenClawArchiveSelected')}</div>
)}
{(openClawInjectionMode === 'bundle' || openClawInjectionMode === 'manual') && openClawPreview && (
<div>{openClawPreview.resolved_resources.length} resource(s), {openClawPreview.env_names.length} env payload(s)</div>
)}
</dd>
</div>
)}
@@ -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')}
</button>
)}
</div>
File diff suppressed because it is too large Load Diff
+9
View File
@@ -21,6 +21,7 @@ import RiskRulesPage from '../pages/admin/RiskRulesPage';
import ModelManagementPage from '../pages/admin/ModelManagementPage';
import SystemSettingsPage from '../pages/admin/SystemSettingsPage';
import UserSettingsPage from '../pages/settings/UserSettingsPage';
import OpenClawConfigCenterPage from '../pages/openclaw/OpenClawConfigCenterPage';
// Instance Pages
import InstanceListPage from '../pages/instances/InstanceListPage';
@@ -179,6 +180,14 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/openclaw-configs"
element={
<ProtectedRoute>
<OpenClawConfigCenterPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
@@ -0,0 +1,92 @@
import api from './api';
import type {
OpenClawConfigBundle,
OpenClawConfigCompilePreview,
OpenClawConfigPlan,
OpenClawConfigResource,
OpenClawInjectionSnapshot,
OpenClawResourceType,
UpsertOpenClawConfigBundleRequest,
UpsertOpenClawConfigResourceRequest,
} from '../types/openclawConfig';
export const openclawConfigService = {
listResources: async (resourceType?: OpenClawResourceType): Promise<OpenClawConfigResource[]> => {
const response = await api.get('/openclaw-configs/resources', {
params: resourceType ? { resource_type: resourceType } : undefined,
});
return response.data.data;
},
getResource: async (id: number): Promise<OpenClawConfigResource> => {
const response = await api.get(`/openclaw-configs/resources/${id}`);
return response.data.data;
},
createResource: async (payload: UpsertOpenClawConfigResourceRequest): Promise<OpenClawConfigResource> => {
const response = await api.post('/openclaw-configs/resources', payload);
return response.data.data;
},
updateResource: async (id: number, payload: UpsertOpenClawConfigResourceRequest): Promise<OpenClawConfigResource> => {
const response = await api.put(`/openclaw-configs/resources/${id}`, payload);
return response.data.data;
},
deleteResource: async (id: number): Promise<void> => {
await api.delete(`/openclaw-configs/resources/${id}`);
},
cloneResource: async (id: number): Promise<OpenClawConfigResource> => {
const response = await api.post(`/openclaw-configs/resources/${id}/clone`);
return response.data.data;
},
validateResource: async (payload: UpsertOpenClawConfigResourceRequest): Promise<void> => {
await api.post('/openclaw-configs/resources/validate', payload);
},
listBundles: async (): Promise<OpenClawConfigBundle[]> => {
const response = await api.get('/openclaw-configs/bundles');
return response.data.data;
},
getBundle: async (id: number): Promise<OpenClawConfigBundle> => {
const response = await api.get(`/openclaw-configs/bundles/${id}`);
return response.data.data;
},
createBundle: async (payload: UpsertOpenClawConfigBundleRequest): Promise<OpenClawConfigBundle> => {
const response = await api.post('/openclaw-configs/bundles', payload);
return response.data.data;
},
updateBundle: async (id: number, payload: UpsertOpenClawConfigBundleRequest): Promise<OpenClawConfigBundle> => {
const response = await api.put(`/openclaw-configs/bundles/${id}`, payload);
return response.data.data;
},
deleteBundle: async (id: number): Promise<void> => {
await api.delete(`/openclaw-configs/bundles/${id}`);
},
cloneBundle: async (id: number): Promise<OpenClawConfigBundle> => {
const response = await api.post(`/openclaw-configs/bundles/${id}/clone`);
return response.data.data;
},
compilePreview: async (payload: OpenClawConfigPlan): Promise<OpenClawConfigCompilePreview> => {
const response = await api.post('/openclaw-configs/compile-preview', payload);
return response.data.data;
},
listInjections: async (limit: number = 50): Promise<OpenClawInjectionSnapshot[]> => {
const response = await api.get('/openclaw-configs/injections', { params: { limit } });
return response.data.data;
},
getInjection: async (id: number): Promise<OpenClawInjectionSnapshot> => {
const response = await api.get(`/openclaw-configs/injections/${id}`);
return response.data.data;
},
};
+4
View File
@@ -1,3 +1,5 @@
import type { OpenClawConfigPlan } from './openclawConfig';
export interface Instance {
id: number;
user_id: number;
@@ -20,6 +22,7 @@ export interface Instance {
pod_namespace?: string;
pod_ip?: string;
access_url?: string;
openclaw_config_snapshot_id?: number;
created_at: string;
updated_at: string;
started_at?: string;
@@ -51,6 +54,7 @@ export interface CreateInstanceRequest {
image_registry?: string;
image_tag?: string;
storage_class?: string;
openclaw_config_plan?: OpenClawConfigPlan;
}
export interface UpdateInstanceRequest {
+110
View File
@@ -0,0 +1,110 @@
export type OpenClawResourceType =
| 'channel'
| 'skill'
| 'session_template'
| 'log_policy'
| 'agent'
| 'scheduled_task';
export type OpenClawConfigMode = 'none' | 'bundle' | 'manual';
export interface OpenClawConfigResourceSummary {
id: number;
resource_type: OpenClawResourceType;
resource_key: string;
name: string;
enabled: boolean;
version: number;
}
export interface OpenClawConfigResource extends OpenClawConfigResourceSummary {
user_id: number;
description?: string;
tags: string[];
content: unknown;
created_at: string;
updated_at: string;
}
export interface UpsertOpenClawConfigResourceRequest {
resource_type: OpenClawResourceType;
resource_key: string;
name: string;
description?: string;
enabled: boolean;
tags: string[];
content: unknown;
}
export interface OpenClawConfigBundleItem {
resource_id: number;
sort_order: number;
required: boolean;
resource?: OpenClawConfigResourceSummary;
}
export interface OpenClawConfigBundle {
id: number;
user_id: number;
name: string;
description?: string;
enabled: boolean;
version: number;
items: OpenClawConfigBundleItem[];
created_at: string;
updated_at: string;
}
export interface UpsertOpenClawConfigBundleRequest {
name: string;
description?: string;
enabled: boolean;
items: OpenClawConfigBundleItem[];
}
export interface OpenClawConfigPlan {
mode: OpenClawConfigMode;
bundle_id?: number;
resource_ids?: number[];
}
export interface OpenClawConfigCompilePreview {
mode: OpenClawConfigMode | 'archive';
bundle?: OpenClawConfigBundle;
selected_resources: OpenClawConfigResourceSummary[];
resolved_resources: OpenClawConfigResourceSummary[];
auto_included: OpenClawConfigResourceSummary[];
warnings: string[];
env_names: string[];
payload_sizes: Record<string, number>;
total_payload_bytes: number;
manifest: unknown;
}
export interface OpenClawInjectionSnapshot {
id: number;
instance_id?: number;
user_id: number;
mode: OpenClawConfigMode;
bundle_id?: number;
selected_resource_ids: number[];
resolved_resources: OpenClawConfigResourceSummary[];
manifest: unknown;
env_names: string[];
payload_sizes: Record<string, number>;
secret_name?: string;
status: string;
error_message?: string;
created_at: string;
updated_at: string;
activated_at?: string;
}
export const OPENCLAW_RESOURCE_TYPES: Array<{ value: OpenClawResourceType; label: string }> = [
{ value: 'channel', label: 'Channel' },
{ value: 'skill', label: 'Skill' },
{ value: 'session_template', label: 'Session Template' },
{ value: 'log_policy', label: 'Log Policy' },
{ value: 'agent', label: 'Agent' },
{ value: 'scheduled_task', label: 'Scheduled Task' },
];