feat: improve instance creation environment workflow
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
|
||||
@@ -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<OpenClawConfigPlanSectionProps> = ({
|
||||
@@ -26,6 +35,8 @@ const OpenClawConfigPlanSection: React.FC<OpenClawConfigPlanSectionProps> = ({
|
||||
onModeChange,
|
||||
onSelectionChange,
|
||||
onPreviewChange,
|
||||
embedded = false,
|
||||
hideHeader = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [resources, setResources] = useState<OpenClawConfigResource[]>([]);
|
||||
@@ -51,7 +62,7 @@ const OpenClawConfigPlanSection: React.FC<OpenClawConfigPlanSectionProps> = ({
|
||||
}, []);
|
||||
|
||||
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<OpenClawConfigPlanSectionProps> = ({
|
||||
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<OpenClawConfigPlanSectionProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bundleId, mode, onPreviewChange, resourceIds]);
|
||||
}, [bundleId, mode, onPreviewChange, resourceIds, t]);
|
||||
|
||||
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 className={embedded ? "" : "app-panel p-6"}>
|
||||
{!hideHeader && (
|
||||
<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>
|
||||
<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">
|
||||
<div
|
||||
className={`${hideHeader ? "" : "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') },
|
||||
{
|
||||
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)}
|
||||
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'
|
||||
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>
|
||||
@@ -125,55 +171,83 @@ const OpenClawConfigPlanSection: React.FC<OpenClawConfigPlanSectionProps> = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <div className="mt-5 text-sm text-gray-500">{t('openClawInjectionSection.loading')}</div>}
|
||||
{loading && (
|
||||
<div className="mt-5 text-sm text-gray-500">
|
||||
{t("openClawInjectionSection.loading")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && mode === 'bundle' && (
|
||||
{!loading && mode === "bundle" && (
|
||||
<div className="mt-5">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('openClawInjectionSection.chooseBundle')}</label>
|
||||
<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: [] })}
|
||||
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>
|
||||
<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 })})
|
||||
{bundle.name} (
|
||||
{t("openClawInjectionSection.bundleOptionCount", {
|
||||
count: bundle.items.length,
|
||||
})}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && mode === 'manual' && (
|
||||
{!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="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'}`}>
|
||||
<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),
|
||||
})}
|
||||
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 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')}
|
||||
{t("openClawInjectionSection.noChannelResources")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,7 @@ export interface CreateInstanceRequest {
|
||||
os_version: string;
|
||||
image_registry?: string;
|
||||
image_tag?: string;
|
||||
environment_overrides?: Record<string, string>;
|
||||
storage_class?: string;
|
||||
openclaw_config_plan?: OpenClawConfigPlan;
|
||||
skill_ids?: number[];
|
||||
|
||||
Reference in New Issue
Block a user