From 366bae194ae1f4e46c970d0f008a71fa76b93944 Mon Sep 17 00:00:00 2001 From: Qingshan Chen <38182824+Iamlovingit@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:20:24 +0800 Subject: [PATCH] feat: improve instance creation environment workflow --- .../db/migrations/001_init_schema.sql | 1 + ...010_add_instance_environment_overrides.sql | 15 + backend/internal/handlers/instance_handler.go | 60 +- backend/internal/models/instance.go | 1 + backend/internal/services/instance_env.go | 84 + .../internal/services/instance_env_test.go | 70 + backend/internal/services/instance_service.go | 89 +- .../components/OpenClawConfigPlanSection.tsx | 180 +- .../pages/instances/CreateInstancePage.tsx | 1941 ++++++++++++----- frontend/src/types/instance.ts | 1 + 10 files changed, 1815 insertions(+), 627 deletions(-) create mode 100644 backend/internal/db/migrations/010_add_instance_environment_overrides.sql create mode 100644 backend/internal/services/instance_env.go create mode 100644 backend/internal/services/instance_env_test.go diff --git a/backend/internal/db/migrations/001_init_schema.sql b/backend/internal/db/migrations/001_init_schema.sql index b039962..aab48bb 100644 --- a/backend/internal/db/migrations/001_init_schema.sql +++ b/backend/internal/db/migrations/001_init_schema.sql @@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS instances ( os_version VARCHAR(50) NOT NULL, image_registry VARCHAR(255), image_tag VARCHAR(100), + environment_overrides_json LONGTEXT, storage_class VARCHAR(50) DEFAULT 'standard', mount_path VARCHAR(255) DEFAULT '/data', pod_name VARCHAR(255), diff --git a/backend/internal/db/migrations/010_add_instance_environment_overrides.sql b/backend/internal/db/migrations/010_add_instance_environment_overrides.sql new file mode 100644 index 0000000..6160f49 --- /dev/null +++ b/backend/internal/db/migrations/010_add_instance_environment_overrides.sql @@ -0,0 +1,15 @@ +SET @instance_environment_overrides_column_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'instances' + AND COLUMN_NAME = 'environment_overrides_json' +); +SET @instance_environment_overrides_column_sql = IF( + @instance_environment_overrides_column_exists = 0, + 'ALTER TABLE instances ADD COLUMN environment_overrides_json LONGTEXT NULL AFTER image_tag', + 'SELECT 1' +); +PREPARE instance_environment_overrides_column_stmt FROM @instance_environment_overrides_column_sql; +EXECUTE instance_environment_overrides_column_stmt; +DEALLOCATE PREPARE instance_environment_overrides_column_stmt; diff --git a/backend/internal/handlers/instance_handler.go b/backend/internal/handlers/instance_handler.go index ec7f272..69c4f0d 100644 --- a/backend/internal/handlers/instance_handler.go +++ b/backend/internal/handlers/instance_handler.go @@ -62,21 +62,22 @@ type PublishConfigRevisionRequest struct { // 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 float64 `json:"cpu_cores" binding:"required,min=0.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"` - SkillIDs []int `json:"skill_ids,omitempty"` + 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 float64 `json:"cpu_cores" binding:"required,min=0.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"` + EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"` + StorageClass string `json:"storage_class"` + OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"` + SkillIDs []int `json:"skill_ids,omitempty"` } // UpdateInstanceRequest represents an update instance request @@ -133,20 +134,21 @@ 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, - OpenClawConfigPlan: req.OpenClawConfigPlan, + 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, + EnvironmentOverrides: req.EnvironmentOverrides, + StorageClass: req.StorageClass, + OpenClawConfigPlan: req.OpenClawConfigPlan, } instance, err := h.instanceService.Create(userID.(int), createReq) diff --git a/backend/internal/models/instance.go b/backend/internal/models/instance.go index 0c233c5..fc8a704 100644 --- a/backend/internal/models/instance.go +++ b/backend/internal/models/instance.go @@ -22,6 +22,7 @@ type Instance struct { 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"` + EnvironmentOverridesJSON *string `db:"environment_overrides_json" json:"-"` 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"` diff --git a/backend/internal/services/instance_env.go b/backend/internal/services/instance_env.go new file mode 100644 index 0000000..9cacbd5 --- /dev/null +++ b/backend/internal/services/instance_env.go @@ -0,0 +1,84 @@ +package services + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "clawreef/internal/models" +) + +var envNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func normalizeEnvironmentOverrides(overrides map[string]string) (map[string]string, error) { + if len(overrides) == 0 { + return nil, nil + } + + normalized := make(map[string]string, len(overrides)) + for rawKey, value := range overrides { + key := strings.TrimSpace(rawKey) + if key == "" { + return nil, fmt.Errorf("environment variable name cannot be empty") + } + if !envNamePattern.MatchString(key) { + return nil, fmt.Errorf("invalid environment variable name: %s", key) + } + if _, exists := normalized[key]; exists { + return nil, fmt.Errorf("duplicate environment variable name: %s", key) + } + normalized[key] = value + } + + return normalized, nil +} + +func marshalEnvironmentOverrides(overrides map[string]string) (*string, error) { + if len(overrides) == 0 { + return nil, nil + } + + raw, err := json.Marshal(overrides) + if err != nil { + return nil, fmt.Errorf("failed to encode environment overrides: %w", err) + } + + encoded := string(raw) + return &encoded, nil +} + +func parseEnvironmentOverridesJSON(raw *string) (map[string]string, error) { + if raw == nil || strings.TrimSpace(*raw) == "" { + return nil, nil + } + + var overrides map[string]string + if err := json.Unmarshal([]byte(strings.TrimSpace(*raw)), &overrides); err != nil { + return nil, fmt.Errorf("failed to decode environment overrides: %w", err) + } + + normalized, err := normalizeEnvironmentOverrides(overrides) + if err != nil { + return nil, err + } + + return normalized, nil +} + +func buildInstancePodEnv(instance *models.Instance, runtimeEnv, gatewayEnv, agentEnv map[string]string) (map[string]string, error) { + if instance == nil { + return nil, fmt.Errorf("instance is required") + } + + overrides, err := parseEnvironmentOverridesJSON(instance.EnvironmentOverridesJSON) + if err != nil { + return nil, err + } + + resolved := mergeEnvMaps(runtimeEnv, mergeEnvMaps(gatewayEnv, agentEnv)) + resolved = withInstanceProxyEnv(instance.Type, instance.ID, resolved) + resolved = mergeEnvMaps(resolved, overrides) + + return resolved, nil +} diff --git a/backend/internal/services/instance_env_test.go b/backend/internal/services/instance_env_test.go new file mode 100644 index 0000000..58a4e49 --- /dev/null +++ b/backend/internal/services/instance_env_test.go @@ -0,0 +1,70 @@ +package services + +import ( + "testing" + + "clawreef/internal/models" +) + +func TestNormalizeEnvironmentOverrides(t *testing.T) { + overrides, err := normalizeEnvironmentOverrides(map[string]string{ + " FOO ": "bar", + "BAR_2": "", + }) + if err != nil { + t.Fatalf("normalizeEnvironmentOverrides returned error: %v", err) + } + + if overrides["FOO"] != "bar" { + t.Fatalf("expected trimmed key FOO to be preserved") + } + if value, ok := overrides["BAR_2"]; !ok || value != "" { + t.Fatalf("expected empty override value to be preserved") + } +} + +func TestNormalizeEnvironmentOverridesRejectsInvalidNames(t *testing.T) { + if _, err := normalizeEnvironmentOverrides(map[string]string{ + "1INVALID": "value", + }); err == nil { + t.Fatalf("expected invalid environment variable name to fail validation") + } +} + +func TestBuildInstancePodEnvAppliesOverridesAfterDefaults(t *testing.T) { + t.Setenv("CLAWMANAGER_EGRESS_PROXY_URL", "") + t.Setenv("CLAWMANAGER_SYSTEM_NAMESPACE", "") + t.Setenv("K8S_NAMESPACE", "") + + raw, err := marshalEnvironmentOverrides(map[string]string{ + "SUBFOLDER": "/custom-proxy", + "CUSTOM": "enabled", + }) + if err != nil { + t.Fatalf("marshalEnvironmentOverrides returned error: %v", err) + } + + instance := &models.Instance{ + ID: 42, + Type: "webtop", + EnvironmentOverridesJSON: raw, + } + + env, err := buildInstancePodEnv(instance, map[string]string{ + "TITLE": "ClawManager Webtop", + "SUBFOLDER": "/", + }, nil, nil) + if err != nil { + t.Fatalf("buildInstancePodEnv returned error: %v", err) + } + + if env["SUBFOLDER"] != "/custom-proxy" { + t.Fatalf("expected SUBFOLDER override to win, got %q", env["SUBFOLDER"]) + } + if env["CUSTOM"] != "enabled" { + t.Fatalf("expected custom environment variable to be merged") + } + if env["TITLE"] != "ClawManager Webtop" { + t.Fatalf("expected default environment variable to remain available") + } +} diff --git a/backend/internal/services/instance_service.go b/backend/internal/services/instance_service.go index ae52425..50d05e2 100644 --- a/backend/internal/services/instance_service.go +++ b/backend/internal/services/instance_service.go @@ -33,20 +33,21 @@ 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 float64 `json:"cpu_cores" validate:"required,min=0.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"` + 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 float64 `json:"cpu_cores" validate:"required,min=0.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"` + EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"` + StorageClass string `json:"storage_class"` + OpenClawConfigPlan *OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"` } // UpdateInstanceRequest holds data for updating an instance @@ -97,6 +98,14 @@ func NewInstanceService(instanceRepo repository.InstanceRepository, quotaRepo re func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models.Instance, error) { ctx := context.Background() req.Name = strings.TrimSpace(req.Name) + environmentOverrides, err := normalizeEnvironmentOverrides(req.EnvironmentOverrides) + if err != nil { + return nil, err + } + environmentOverridesJSON, err := marshalEnvironmentOverrides(environmentOverrides) + if err != nil { + return nil, err + } // Check user quota quota, err := s.quotaRepo.GetByUserID(userID) @@ -184,24 +193,25 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models // Create instance record now := time.Now() instance := &models.Instance{ - UserID: userID, - Name: req.Name, - Description: req.Description, - Type: req.Type, - Status: "creating", - 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, - MountPath: runtimeConfig.MountPath, - CreatedAt: now, - UpdatedAt: now, + UserID: userID, + Name: req.Name, + Description: req.Description, + Type: req.Type, + Status: "creating", + 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, + EnvironmentOverridesJSON: environmentOverridesJSON, + StorageClass: req.StorageClass, + MountPath: runtimeConfig.MountPath, + CreatedAt: now, + UpdatedAt: now, } if err := s.instanceRepo.Create(instance); err != nil { @@ -227,6 +237,11 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models s.instanceRepo.Delete(instance.ID) return nil, fmt.Errorf("failed to build instance agent config: %w", err) } + extraEnv, err := buildInstancePodEnv(instance, runtimeConfig.Env, gatewayEnv, agentEnv) + if err != nil { + s.instanceRepo.Delete(instance.ID) + return nil, fmt.Errorf("failed to resolve instance environment: %w", err) + } var bootstrapSnapshot *models.OpenClawInjectionSnapshot var bootstrapSecretName string @@ -292,7 +307,7 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models Image: runtimeConfig.Image, MountPath: runtimeConfig.MountPath, ContainerPort: runtimeConfig.Port, - ExtraEnv: withInstanceProxyEnv(instance.Type, instance.ID, mergeEnvMaps(runtimeConfig.Env, mergeEnvMaps(gatewayEnv, agentEnv))), + ExtraEnv: extraEnv, EnvFromSecretNames: []string{bootstrapSecretName}, } @@ -438,6 +453,11 @@ func (s *instanceService) Start(instanceID int) error { if err != nil { return fmt.Errorf("failed to build instance agent config: %w", err) } + runtimeConfig := buildRuntimeConfig(instance.Type, instance.OSType, instance.OSVersion, instance.ImageRegistry, instance.ImageTag) + extraEnv, err := buildInstancePodEnv(instance, runtimeConfig.Env, gatewayEnv, agentEnv) + if err != nil { + return fmt.Errorf("failed to resolve instance environment: %w", err) + } bootstrapSecretName := "" if strings.EqualFold(instance.Type, "openclaw") && s.openClawConfigService != nil && instance.OpenClawConfigSnapshotID != nil && *instance.OpenClawConfigSnapshotID > 0 { @@ -448,7 +468,6 @@ func (s *instanceService) Start(instanceID int) error { } // Create new pod - runtimeConfig := buildRuntimeConfig(instance.Type, instance.OSType, instance.OSVersion, instance.ImageRegistry, instance.ImageTag) if requiresRestrictedNetwork(instance.Type) { if err := s.networkPolicyService.EnsureDefaultPolicy(ctx, instance.UserID, instance.ID, instance.Name); err != nil { return fmt.Errorf("failed to create network policy: %w", err) @@ -467,7 +486,7 @@ func (s *instanceService) Start(instanceID int) error { Image: runtimeConfig.Image, MountPath: instance.MountPath, ContainerPort: runtimeConfig.Port, - ExtraEnv: withInstanceProxyEnv(instance.Type, instance.ID, mergeEnvMaps(runtimeConfig.Env, mergeEnvMaps(gatewayEnv, agentEnv))), + ExtraEnv: extraEnv, EnvFromSecretNames: []string{bootstrapSecretName}, } diff --git a/frontend/src/components/OpenClawConfigPlanSection.tsx b/frontend/src/components/OpenClawConfigPlanSection.tsx index 1d23f64..bd32d09 100644 --- a/frontend/src/components/OpenClawConfigPlanSection.tsx +++ b/frontend/src/components/OpenClawConfigPlanSection.tsx @@ -1,22 +1,31 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useI18n } from '../contexts/I18nContext'; -import { openclawConfigService } from '../services/openclawConfigService'; +import React, { useEffect, useMemo, useState } from "react"; +import type { AxiosError } from "axios"; +import { useI18n } from "../contexts/I18nContext"; +import { openclawConfigService } from "../services/openclawConfigService"; import type { OpenClawConfigBundle, OpenClawConfigCompilePreview, OpenClawConfigPlan, OpenClawConfigResource, -} from '../types/openclawConfig'; +} from "../types/openclawConfig"; -export type OpenClawInjectionMode = OpenClawConfigPlan['mode'] | 'archive'; +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; + onSelectionChange: (payload: { + bundleId?: number; + resourceIds: number[]; + }) => void; + onPreviewChange: ( + preview: OpenClawConfigCompilePreview | null, + state: { loading: boolean; error: string | null }, + ) => void; + embedded?: boolean; + hideHeader?: boolean; } const OpenClawConfigPlanSection: React.FC = ({ @@ -26,6 +35,8 @@ const OpenClawConfigPlanSection: React.FC = ({ onModeChange, onSelectionChange, onPreviewChange, + embedded = false, + hideHeader = false, }) => { const { t } = useI18n(); const [resources, setResources] = useState([]); @@ -51,7 +62,7 @@ const OpenClawConfigPlanSection: React.FC = ({ }, []); const channelResources = useMemo( - () => resources.filter((resource) => resource.resource_type === 'channel'), + () => resources.filter((resource) => resource.resource_type === "channel"), [resources], ); @@ -59,31 +70,41 @@ const OpenClawConfigPlanSection: React.FC = ({ let cancelled = false; const compile = async () => { - if (mode === 'none' || mode === 'archive') { + 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') }); + 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') }); + if (mode === "manual" && resourceIds.length === 0) { + onPreviewChange(null, { loading: false, error: null }); return; } try { onPreviewChange(null, { loading: true, error: null }); - const payload: OpenClawConfigPlan = mode === 'bundle' - ? { mode: 'bundle', bundle_id: bundleId } - : { mode: 'manual', resource_ids: resourceIds }; + 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) { + } catch (err: unknown) { + const responseError = err as AxiosError<{ error?: string }>; if (!cancelled) { - onPreviewChange(null, { loading: false, error: err.response?.data?.error || t('openClawInjectionSection.errors.compileFailed') }); + onPreviewChange(null, { + loading: false, + error: + responseError.response?.data?.error || + t("openClawInjectionSection.errors.compileFailed"), + }); } } }; @@ -92,32 +113,57 @@ const OpenClawConfigPlanSection: React.FC = ({ return () => { cancelled = true; }; - }, [bundleId, mode, onPreviewChange, resourceIds]); + }, [bundleId, mode, onPreviewChange, resourceIds, t]); return ( -
-
-
-

{t('openClawInjectionSection.title')}

-

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

+
+ {!hideHeader && ( +
+
+

+ {t("openClawInjectionSection.title")} +

+

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

+
+ + {t("openClawInjectionSection.optional")} +
- {t('openClawInjectionSection.optional')} -
+ )} -
+
{[ - { value: 'manual', label: t('openClawInjectionSection.modes.manual') }, - { value: 'bundle', label: t('openClawInjectionSection.modes.bundle') }, - { value: 'archive', label: t('openClawInjectionSection.modes.archive') }, + { + value: "manual", + label: t("openClawInjectionSection.modes.manual"), + }, + { + value: "bundle", + label: t("openClawInjectionSection.modes.bundle"), + }, + { + value: "archive", + label: t("openClawInjectionSection.modes.archive"), + }, ].map((item) => (
- {loading &&
{t('openClawInjectionSection.loading')}
} + {loading && ( +
+ {t("openClawInjectionSection.loading")} +
+ )} - {!loading && mode === 'bundle' && ( + {!loading && mode === "bundle" && (
- +
)} - {!loading && mode === 'manual' && ( + {!loading && mode === "manual" && (
-
{t('openClawInjectionSection.channel')}
+
+ {t("openClawInjectionSection.channel")} +
{channelResources.map((item) => { const checked = resourceIds.includes(item.id); return ( -
diff --git a/frontend/src/pages/instances/CreateInstancePage.tsx b/frontend/src/pages/instances/CreateInstancePage.tsx index 4cca412..dba44f3 100644 --- a/frontend/src/pages/instances/CreateInstancePage.tsx +++ b/frontend/src/pages/instances/CreateInstancePage.tsx @@ -1,19 +1,190 @@ -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'; -import { skillService } from '../../services/skillService'; -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 { Skill } from '../../types/skill'; -import type { UserQuota } from '../../types/user'; -import { useI18n } from '../../contexts/I18nContext'; -import { systemSettingsService } from '../../services/systemSettingsService'; +import React, { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import type { AxiosError } from "axios"; +import OpenClawConfigPlanSection, { + type OpenClawInjectionMode, +} from "../../components/OpenClawConfigPlanSection"; +import UserLayout from "../../components/UserLayout"; +import { useAuth } from "../../contexts/AuthContext"; +import { instanceService } from "../../services/instanceService"; +import { skillService } from "../../services/skillService"; +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 { Skill } from "../../types/skill"; +import type { UserQuota } from "../../types/user"; +import { useI18n } from "../../contexts/I18nContext"; +import { systemSettingsService } from "../../services/systemSettingsService"; + +type BuiltInEnvTemplate = { + key: string; + description: string; + defaultValue?: string; + defaultLabel?: string; +}; + +type CustomEnvRow = { + id: string; + name: string; + value: string; +}; + +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const BYTES_PER_GIB = 1024 * 1024 * 1024; +const AGENT_PROTOCOL_VERSION = "v1"; +const CUSTOM_RESOURCE_PRESET = "custom"; +const SKILLS_PER_PAGE = 6; + +const getBuiltInEnvTemplates = ( + type: CreateInstanceRequest["type"], + diskGb: number, +): BuiltInEnvTemplate[] => { + const templates: BuiltInEnvTemplate[] = []; + + if (type === "ubuntu") { + templates.push( + { + key: "TITLE", + description: + "Desktop window title injected for the LinuxServer Webtop container.", + defaultValue: "ClawManager Desktop", + }, + { + key: "SUBFOLDER", + description: + "Path prefix used by the ClawManager reverse proxy for browser access.", + defaultLabel: "ClawManager managed proxy path", + }, + ); + } + + if (type === "webtop") { + templates.push( + { + key: "TITLE", + description: + "Desktop window title injected for the LinuxServer Webtop container.", + defaultValue: "ClawManager Webtop", + }, + { + key: "SUBFOLDER", + description: + "Path prefix used by the ClawManager reverse proxy for browser access.", + defaultLabel: "ClawManager managed proxy path", + }, + ); + } + + if (type === "openclaw") { + templates.push( + { + key: "TITLE", + description: + "Desktop title shown by the OpenClaw browser desktop image.", + defaultValue: "ClawManager Desktop", + }, + { + key: "SUBFOLDER", + description: + "Path prefix used by the ClawManager reverse proxy for browser access.", + defaultLabel: "ClawManager managed proxy path", + }, + { + key: "CLAWMANAGER_LLM_BASE_URL", + description: + "OpenClaw gateway base URL generated by ClawManager at runtime.", + defaultLabel: "ClawManager generated at runtime", + }, + { + key: "CLAWMANAGER_LLM_API_KEY", + description: + "Per-instance API key minted by ClawManager for the gateway.", + defaultLabel: "ClawManager generated at runtime", + }, + { + key: "CLAWMANAGER_LLM_MODEL", + description: "Default model name injected into the OpenClaw workspace.", + defaultValue: "auto", + }, + { + key: "CLAWMANAGER_LLM_PROVIDER", + description: "Provider hint for the OpenClaw workspace.", + defaultValue: "openai-compatible", + }, + { + key: "CLAWMANAGER_INSTANCE_TOKEN", + description: + "Per-instance token generated by ClawManager for workspace access.", + defaultLabel: "ClawManager generated at runtime", + }, + { + key: "OPENAI_BASE_URL", + description: + "Compatibility alias for the OpenAI-compatible gateway base URL.", + defaultLabel: "ClawManager generated at runtime", + }, + { + key: "OPENAI_API_BASE", + description: + "Compatibility alias for the OpenAI-compatible gateway base URL.", + defaultLabel: "ClawManager generated at runtime", + }, + { + key: "OPENAI_API_KEY", + description: "Compatibility alias for the gateway API key.", + defaultLabel: "ClawManager generated at runtime", + }, + { + key: "OPENAI_MODEL", + description: "Compatibility alias for the default model name.", + defaultValue: "auto", + }, + { + key: "CLAWMANAGER_AGENT_ENABLED", + description: "Toggles the ClawManager instance agent inside OpenClaw.", + defaultValue: "true", + }, + { + key: "CLAWMANAGER_AGENT_BASE_URL", + description: + "Base URL used by the in-instance agent control plane client.", + defaultLabel: "ClawManager generated at runtime", + }, + { + key: "CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN", + description: + "Bootstrap token generated by ClawManager for the in-instance agent.", + defaultLabel: "ClawManager generated at runtime", + }, + { + key: "CLAWMANAGER_AGENT_DISK_LIMIT_BYTES", + description: "Disk quota injected for the instance agent.", + defaultValue: String(diskGb * BYTES_PER_GIB), + }, + { + key: "CLAWMANAGER_AGENT_INSTANCE_ID", + description: + "Runtime instance identifier provided to the in-instance agent.", + defaultLabel: "Assigned after creation", + }, + { + key: "CLAWMANAGER_AGENT_PERSISTENT_DIR", + description: + "Persistent workspace path mounted into the OpenClaw container.", + defaultValue: "/config", + }, + { + key: "CLAWMANAGER_AGENT_PROTOCOL_VERSION", + description: "Protocol version expected by ClawManager agent control.", + defaultValue: AGENT_PROTOCOL_VERSION, + }, + ); + } + + return templates; +}; const CreateInstancePage: React.FC = () => { const { user } = useAuth(); @@ -27,36 +198,65 @@ const CreateInstancePage: React.FC = () => { const [availableTypes, setAvailableTypes] = useState(INSTANCE_TYPES); const [quota, setQuota] = useState(null); const [instances, setInstances] = useState([]); - const [openClawImportFile, setOpenClawImportFile] = useState(null); - const [openClawInjectionMode, setOpenClawInjectionMode] = useState('none'); - const [openClawBundleId, setOpenClawBundleId] = useState(undefined); + const [openClawImportFile, setOpenClawImportFile] = useState( + null, + ); + const [openClawInjectionMode, setOpenClawInjectionMode] = + useState("none"); + const [openClawBundleId, setOpenClawBundleId] = useState( + undefined, + ); const [openClawResourceIds, setOpenClawResourceIds] = useState([]); - const [openClawPreview, setOpenClawPreview] = useState(null); + const [openClawPreview, setOpenClawPreview] = + useState(null); const [openClawPreviewLoading, setOpenClawPreviewLoading] = useState(false); - const [openClawPreviewError, setOpenClawPreviewError] = useState(null); + const [openClawPreviewError, setOpenClawPreviewError] = useState< + string | null + >(null); const [availableSkills, setAvailableSkills] = useState([]); const [skillLoading, setSkillLoading] = useState(false); const [selectedSkillIds, setSelectedSkillIds] = useState([]); + const [skillPage, setSkillPage] = useState(1); const openClawImportInputRef = useRef(null); + const nextCustomEnvIdRef = useRef(0); + const [showBuiltInEnvEditor, setShowBuiltInEnvEditor] = useState(false); + const [builtinEnvOverrides, setBuiltinEnvOverrides] = useState< + Record + >({}); + const [customEnvRows, setCustomEnvRows] = useState([]); + const [resourcePresetMode, setResourcePresetMode] = useState< + keyof typeof PRESET_CONFIGS | typeof CUSTOM_RESOURCE_PRESET + >("medium"); const [formData, setFormData] = useState({ - name: '', - type: 'ubuntu', + name: "", + type: "ubuntu", cpu_cores: 2, memory_gb: 4, disk_gb: 20, - os_type: 'ubuntu', - os_version: '22.04', + os_type: "ubuntu", + os_version: "22.04", gpu_enabled: false, gpu_count: 0, - storage_class: '' + storage_class: "", }); + const builtInEnvTemplates = getBuiltInEnvTemplates( + formData.type, + formData.disk_gb, + ); + const selectedBuiltInTemplates = builtInEnvTemplates.filter((template) => + Object.prototype.hasOwnProperty.call(builtinEnvOverrides, template.key), + ); + const availableBuiltInTemplates = builtInEnvTemplates.filter( + (template) => + !Object.prototype.hasOwnProperty.call(builtinEnvOverrides, template.key), + ); const getCreateErrorMessage = (rawError?: string) => { - if (rawError === 'instance name already exists') { - return t('instances.nameAlreadyExists'); + if (rawError === "instance name already exists") { + return t("instances.nameAlreadyExists"); } - return rawError || t('instances.createFailed'); + return rawError || t("instances.createFailed"); }; useEffect(() => { @@ -69,7 +269,9 @@ const CreateInstancePage: React.FC = () => { .map((item) => item.instance_type), ); - const filtered = INSTANCE_TYPES.filter((type) => enabledTypes.has(type.id)); + const filtered = INSTANCE_TYPES.filter((type) => + enabledTypes.has(type.id), + ); if (filtered.length > 0) { setAvailableTypes(filtered); setFormData((current) => { @@ -80,7 +282,7 @@ const CreateInstancePage: React.FC = () => { const first = filtered[0]; return { ...current, - type: first.id as CreateInstanceRequest['type'], + type: first.id as CreateInstanceRequest["type"], os_type: first.defaultOs, os_version: first.defaultVersion, }; @@ -99,7 +301,14 @@ const CreateInstancePage: React.FC = () => { try { setSkillLoading(true); const items = await skillService.listSkills(); - setAvailableSkills(items.filter((item) => item.status === 'active' && item.risk_level !== 'medium' && item.risk_level !== 'high')); + setAvailableSkills( + items.filter( + (item) => + item.status === "active" && + item.risk_level !== "medium" && + item.risk_level !== "high", + ), + ); } catch { setAvailableSkills([]); } finally { @@ -110,6 +319,14 @@ const CreateInstancePage: React.FC = () => { void loadSkills(); }, []); + useEffect(() => { + const nextTotalPages = Math.max( + 1, + Math.ceil(availableSkills.length / SKILLS_PER_PAGE), + ); + setSkillPage((current) => Math.min(current, nextTotalPages)); + }, [availableSkills.length]); + useEffect(() => { const loadQuotaAndUsage = async () => { if (!user) { @@ -137,11 +354,11 @@ const CreateInstancePage: React.FC = () => { }, [user]); const handleTypeSelect = (typeId: string) => { - const instanceType = availableTypes.find(t => t.id === typeId); + const instanceType = availableTypes.find((t) => t.id === typeId); if (instanceType) { - if (typeId !== 'openclaw') { + if (typeId !== "openclaw") { setOpenClawImportFile(null); - setOpenClawInjectionMode('none'); + setOpenClawInjectionMode("none"); setOpenClawBundleId(undefined); setOpenClawResourceIds([]); setOpenClawPreview(null); @@ -150,56 +367,181 @@ const CreateInstancePage: React.FC = () => { } setFormData({ ...formData, - type: typeId as any, + type: typeId as CreateInstanceRequest["type"], os_type: instanceType.defaultOs, os_version: instanceType.defaultVersion, - // Auto-set storage class for Ubuntu instances - storage_class: typeId === 'ubuntu' ? 'external-default-sc' : '' + storage_class: "", }); } }; - const handlePresetSelect = (preset: keyof typeof PRESET_CONFIGS) => { + const handlePresetSelect = ( + preset: keyof typeof PRESET_CONFIGS | typeof CUSTOM_RESOURCE_PRESET, + ) => { + if (preset === CUSTOM_RESOURCE_PRESET) { + setResourcePresetMode(CUSTOM_RESOURCE_PRESET); + setFormData((current) => ({ + ...current, + cpu_cores: PRESET_CONFIGS.medium.cpu_cores, + memory_gb: PRESET_CONFIGS.medium.memory_gb, + disk_gb: PRESET_CONFIGS.medium.disk_gb, + })); + return; + } + const config = PRESET_CONFIGS[preset]; - setFormData({ - ...formData, + setResourcePresetMode(preset); + setFormData((current) => ({ + ...current, cpu_cores: config.cpu_cores, memory_gb: config.memory_gb, - disk_gb: config.disk_gb + disk_gb: config.disk_gb, + })); + }; + + const createCustomEnvRow = (): CustomEnvRow => ({ + id: `custom-env-${nextCustomEnvIdRef.current++}`, + name: "", + value: "", + }); + + const addBuiltInOverride = () => { + if (availableBuiltInTemplates.length === 0) { + return; + } + + const nextKey = availableBuiltInTemplates[0].key; + setBuiltinEnvOverrides((current) => ({ + ...current, + [nextKey]: "", + })); + }; + + const removeBuiltInOverride = (keyToRemove: string) => { + setBuiltinEnvOverrides((current) => { + const next = { ...current }; + delete next[keyToRemove]; + return next; }); }; + const updateBuiltInOverrideKey = (previousKey: string, nextKey: string) => { + if (previousKey === nextKey || !nextKey) { + return; + } + + setBuiltinEnvOverrides((current) => { + const next = { ...current }; + const previousValue = next[previousKey] ?? ""; + delete next[previousKey]; + next[nextKey] = previousValue; + return next; + }); + }; + + const buildEnvironmentOverridesPayload = () => { + const overrides: Record = {}; + const builtInKeys = new Set(builtInEnvTemplates.map((item) => item.key)); + + for (const template of builtInEnvTemplates) { + if ( + Object.prototype.hasOwnProperty.call(builtinEnvOverrides, template.key) + ) { + overrides[template.key] = builtinEnvOverrides[template.key]; + } + } + + const seenCustomNames = new Set(); + + for (const row of customEnvRows) { + const name = row.name.trim(); + const hasName = name.length > 0; + const hasValue = row.value.length > 0; + + if (!hasName && !hasValue) { + continue; + } + + if (!hasName) { + return { error: "Custom environment variable name is required." }; + } + + if (!ENV_NAME_PATTERN.test(name)) { + return { error: `Invalid environment variable name: ${name}` }; + } + + if (builtInKeys.has(name)) { + return { + error: `${name} is a ClawManager built-in variable. Edit it in the built-in section instead.`, + }; + } + + if (seenCustomNames.has(name)) { + return { error: `Duplicate environment variable name: ${name}` }; + } + + seenCustomNames.add(name); + overrides[name] = row.value; + } + + return { + overrides: Object.keys(overrides).length > 0 ? overrides : undefined, + }; + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - + // Only submit on step 3 if (step !== 3 || !submitArmed || loading) { return; } - + try { + const { overrides, error: environmentError } = + buildEnvironmentOverridesPayload(); + if (environmentError) { + setError(environmentError); + return; + } + setLoading(true); setError(null); const createPayload: CreateInstanceRequest = { ...formData, - skill_ids: formData.type === 'openclaw' ? selectedSkillIds : undefined, - 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, + environment_overrides: overrides, + skill_ids: formData.type === "openclaw" ? selectedSkillIds : undefined, + 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, }; - const createdInstance = await instanceService.createInstance(createPayload); + const createdInstance = + await instanceService.createInstance(createPayload); - if (formData.type === 'openclaw' && openClawInjectionMode === 'archive' && openClawImportFile) { + if ( + formData.type === "openclaw" && + openClawInjectionMode === "archive" && + openClawImportFile + ) { await waitForInstanceRunning(createdInstance.id); - await instanceService.importOpenClawWorkspace(createdInstance.id, openClawImportFile); + await instanceService.importOpenClawWorkspace( + createdInstance.id, + openClawImportFile, + ); } - navigate('/instances'); - } catch (err: any) { - setError(getCreateErrorMessage(err.response?.data?.error)); + navigate("/instances"); + } catch (err: unknown) { + const responseError = err as AxiosError<{ error?: string }>; + setError(getCreateErrorMessage(responseError.response?.data?.error)); } finally { setLoading(false); } @@ -219,7 +561,7 @@ const CreateInstancePage: React.FC = () => { const timer = window.setTimeout(() => { setSubmitArmed(true); - }, 150) + }, 150); return () => window.clearTimeout(timer); }, [step]); @@ -229,16 +571,16 @@ const CreateInstancePage: React.FC = () => { while (Date.now() < timeoutAt) { const current = await instanceService.getInstance(instanceId); - if (current.status === 'running') { + if (current.status === "running") { return; } - if (current.status === 'error') { - throw new Error(t('instances.waitingForRunningState')); + if (current.status === "error") { + throw new Error(t("instances.waitingForRunningState")); } await new Promise((resolve) => window.setTimeout(resolve, 5000)); } - throw new Error(t('instances.timedOutWaitingForRunning')); + throw new Error(t("instances.timedOutWaitingForRunning")); }; const usedResources = { @@ -246,66 +588,115 @@ const CreateInstancePage: React.FC = () => { cpu: instances.reduce((sum, instance) => sum + instance.cpu_cores, 0), memory: instances.reduce((sum, instance) => sum + instance.memory_gb, 0), storage: instances.reduce((sum, instance) => sum + instance.disk_gb, 0), - gpu: instances.reduce((sum, instance) => sum + (instance.gpu_enabled ? instance.gpu_count : 0), 0), + gpu: instances.reduce( + (sum, instance) => sum + (instance.gpu_enabled ? instance.gpu_count : 0), + 0, + ), }; const quotaChecks = quota ? [ { - key: 'instances', - label: t('instances.quotaInstances'), + key: "instances", + label: t("instances.quotaInstances"), next: usedResources.instances + 1, max: quota.max_instances, exceeded: usedResources.instances + 1 > quota.max_instances, }, { - key: 'cpu', - label: t('common.cpu'), + key: "cpu", + label: t("common.cpu"), next: usedResources.cpu + formData.cpu_cores, max: quota.max_cpu_cores, - exceeded: usedResources.cpu + formData.cpu_cores > quota.max_cpu_cores, + exceeded: + usedResources.cpu + formData.cpu_cores > quota.max_cpu_cores, }, { - key: 'memory', - label: t('instances.memoryLabel'), + key: "memory", + label: t("instances.memoryLabel"), next: usedResources.memory + formData.memory_gb, max: quota.max_memory_gb, - exceeded: usedResources.memory + formData.memory_gb > quota.max_memory_gb, + exceeded: + usedResources.memory + formData.memory_gb > quota.max_memory_gb, }, { - key: 'storage', - label: t('instances.storageLabel'), + key: "storage", + label: t("instances.storageLabel"), next: usedResources.storage + formData.disk_gb, max: quota.max_storage_gb, - exceeded: usedResources.storage + formData.disk_gb > quota.max_storage_gb, + exceeded: + usedResources.storage + formData.disk_gb > quota.max_storage_gb, }, { - key: 'gpu', - label: 'GPU', - next: usedResources.gpu + (formData.gpu_enabled ? formData.gpu_count || 0 : 0), + key: "gpu", + label: "GPU", + next: + usedResources.gpu + + (formData.gpu_enabled ? formData.gpu_count || 0 : 0), max: quota.max_gpu_count, - exceeded: usedResources.gpu + (formData.gpu_enabled ? formData.gpu_count || 0 : 0) > quota.max_gpu_count, + exceeded: + usedResources.gpu + + (formData.gpu_enabled ? formData.gpu_count || 0 : 0) > + quota.max_gpu_count, }, ] : []; const exceededQuotaItems = quotaChecks.filter((item) => item.exceeded); const quotaExceeded = exceededQuotaItems.length > 0; - 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 openClawPlanInvalid = + formData.type === "openclaw" && + ((openClawInjectionMode === "bundle" && + (!openClawBundleId || + !!openClawPreviewError || + openClawPreviewLoading)) || + (openClawInjectionMode === "manual" && + (!!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 handleOpenClawPreviewChange = React.useCallback( + ( + preview: OpenClawConfigCompilePreview | null, + state: { loading: boolean; error: string | null }, + ) => { + setOpenClawPreview(preview); + setOpenClawPreviewLoading(state.loading); + setOpenClawPreviewError(state.error); + }, + [], + ); + + const environmentDraft = buildEnvironmentOverridesPayload(); + const environmentOverrideNames = environmentDraft.overrides + ? Object.keys(environmentDraft.overrides) + : []; + const builtinOverrideCount = selectedBuiltInTemplates.length; + const selectedSkillNames = selectedSkillIds.map( + (skillId) => + availableSkills.find((skill) => skill.id === skillId)?.name || + `Skill #${skillId}`, + ); + const totalSkillPages = Math.max( + 1, + Math.ceil(availableSkills.length / SKILLS_PER_PAGE), + ); + const paginatedSkills = availableSkills.slice( + (skillPage - 1) * SKILLS_PER_PAGE, + skillPage * SKILLS_PER_PAGE, + ); + const resolvedChannelNames = (openClawPreview?.resolved_resources || []) + .filter((resource) => resource.resource_type === "channel") + .map((resource) => resource.name); const renderTypeIcon = (typeId: string) => { - if (typeId === 'openclaw') { + if (typeId === "openclaw") { return ( { } return ( - - + + ); }; + const renderSummaryTagList = ( + items: string[], + emptyText: string, + tone: "neutral" | "indigo" | "emerald" = "neutral", + ) => { + if (items.length === 0) { + return

{emptyText}

; + } + + const toneClassName = + tone === "indigo" + ? "border-indigo-200 bg-indigo-50 text-indigo-700" + : tone === "emerald" + ? "border-emerald-200 bg-emerald-50 text-emerald-700" + : "border-gray-200 bg-gray-50 text-gray-700"; + + return ( +
+ {items.map((item, index) => ( + + {item} + + ))} +
+ ); + }; + return ( {/* Progress Bar */}
-
+
-

{t('instances.createTitle')}

+

+ {t("instances.createTitle")} +

{[1, 2, 3].map((s) => ( -
- {s < step ? '✓' : s} +
+ {s < step ? "✓" : s}
{s < 3 && ( -
+
)} ))}
- {t('instances.stepOf', { step, label: step === 1 ? t('instances.stepBasic') : step === 2 ? t('instances.stepType') : t('instances.stepConfig') })} + {t("instances.stepOf", { + step, + label: + step === 1 + ? t("instances.stepBasic") + : step === 2 + ? t("instances.stepType") + : t("instances.stepConfig"), + })}
{/* Main Content */} -
+
{error && (
{error} -
)} -
{ - // Prevent Enter key from submitting form in step 1 and 2 - if (e.key === 'Enter' && step < 3) { - e.preventDefault(); - if (canProceed()) { - setStep(step + 1); + { + // Prevent Enter key from submitting form in step 1 and 2 + if (e.key === "Enter" && step < 3) { + e.preventDefault(); + if (canProceed()) { + setStep(step + 1); + } } - } - }}> + }} + > {/* Step 1: Basic Information */} {step === 1 && (
-

{t('instances.basicInformation')}

+

+ {t("instances.basicInformation")} +

-
-