Add instance agent control plane and runtime console

This commit is contained in:
iamlovingit
2026-04-04 22:57:41 +08:00
parent 58936726ed
commit 4ebb5f46d1
33 changed files with 5630 additions and 855 deletions
+27 -2
View File
@@ -54,6 +54,11 @@ func main() {
riskRuleRepo := repository.NewRiskRuleRepository(database)
riskHitRepo := repository.NewRiskHitRepository(database)
openClawConfigRepo := repository.NewOpenClawConfigRepository(database)
instanceAgentRepo := repository.NewInstanceAgentRepository(database)
instanceRuntimeStatusRepo := repository.NewInstanceRuntimeStatusRepository(database)
instanceDesiredStateRepo := repository.NewInstanceDesiredStateRepository(database)
instanceCommandRepo := repository.NewInstanceCommandRepository(database)
instanceConfigRevisionRepo := repository.NewInstanceConfigRevisionRepository(database)
if repaired, repairErr := services.RepairSeededAdminPassword(userRepo); repairErr != nil {
log.Printf("Warning: failed to repair seeded admin password: %v", repairErr)
@@ -80,12 +85,16 @@ func main() {
clusterResourceService := services.NewClusterResourceService(instanceRepo)
services.SetRuntimeImageSettingsProvider(systemImageSettingService)
instanceService := services.NewInstanceService(instanceRepo, quotaRepo, llmModelRepo, openClawConfigService)
instanceAgentService := services.NewInstanceAgentService(instanceRepo, instanceAgentRepo, instanceDesiredStateRepo, instanceRuntimeStatusRepo, instanceCommandRepo)
instanceRuntimeStatusService := services.NewInstanceRuntimeStatusService(instanceRuntimeStatusRepo, instanceAgentRepo, instanceDesiredStateRepo)
instanceCommandService := services.NewInstanceCommandService(instanceCommandRepo, instanceRuntimeStatusRepo, instanceDesiredStateRepo)
instanceConfigRevisionService := services.NewInstanceConfigRevisionService(instanceConfigRevisionRepo)
aiGatewayService := aigateway.NewService(llmModelRepo, modelInvocationService, auditEventService, costRecordService, riskDetectionService, riskHitService, chatSessionService, chatMessageService)
// Initialize handlers
authHandler := handlers.NewAuthHandler(authService)
userHandler := handlers.NewUserHandler(userService, quotaService)
instanceHandler := handlers.NewInstanceHandler(instanceService)
instanceHandler := handlers.NewInstanceHandler(instanceService, instanceAgentService, instanceRuntimeStatusService, instanceCommandService, instanceConfigRevisionService, openClawConfigService)
systemSettingsHandler := handlers.NewSystemSettingsHandler(systemImageSettingService)
llmModelHandler := handlers.NewLLMModelHandler(llmModelService)
aiGatewayHandler := handlers.NewAIGatewayHandler(aiGatewayService)
@@ -94,13 +103,14 @@ func main() {
clusterResourceHandler := handlers.NewClusterResourceHandler(clusterResourceService)
egressProxyHandler := handlers.NewEgressProxyHandler()
openClawConfigHandler := handlers.NewOpenClawConfigHandler(openClawConfigService)
agentHandler := handlers.NewAgentHandler(instanceAgentService, instanceCommandService, instanceRuntimeStatusService, instanceConfigRevisionService)
// Initialize WebSocket hub and handler
wsHub := services.GetHub()
wsHandler := handlers.NewWebSocketHandler(wsHub)
// Start sync service to keep instance status in sync with K8s
syncService := services.NewSyncService(instanceRepo)
syncService := services.NewSyncService(instanceRepo, instanceRuntimeStatusService)
syncService.Start()
defer syncService.Stop()
@@ -164,6 +174,10 @@ func main() {
instances.POST("/:id/stop", instanceHandler.StopInstance)
instances.POST("/:id/restart", instanceHandler.RestartInstance)
instances.GET("/:id/status", instanceHandler.GetInstanceStatus)
instances.GET("/:id/runtime", instanceHandler.GetRuntimeDetails)
instances.POST("/:id/runtime/:command", instanceHandler.CreateRuntimeCommand)
instances.GET("/:id/config/revisions", instanceHandler.ListConfigRevisions)
instances.POST("/:id/config/revisions/publish", instanceHandler.PublishConfigRevision)
instances.POST("/:id/access", instanceHandler.GenerateAccessToken)
instances.GET("/:id/access", instanceHandler.AccessInstance)
instances.POST("/:id/sync", instanceHandler.ForceSync)
@@ -259,6 +273,17 @@ func main() {
gatewayLLM.POST("/chat/completions", aiGatewayHandler.ChatCompletions)
}
agent := api.Group("/agent")
{
agent.POST("/register", agentHandler.Register)
agent.POST("/heartbeat", agentHandler.Heartbeat)
agent.GET("/commands/next", agentHandler.NextCommand)
agent.POST("/commands/:id/start", agentHandler.StartCommand)
agent.POST("/commands/:id/finish", agentHandler.FinishCommand)
agent.POST("/state/report", agentHandler.ReportState)
agent.GET("/config/revisions/:id", agentHandler.GetConfigRevision)
}
// Instance proxy routes (token-based auth, no session required)
// These routes proxy requests to the actual instance pods
api.Any("/instances/:id/proxy", instanceHandler.ProxyInstance)
@@ -0,0 +1,123 @@
SET @instance_agent_bootstrap_token_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instances'
AND COLUMN_NAME = 'agent_bootstrap_token'
);
SET @instance_agent_bootstrap_token_column_sql = IF(
@instance_agent_bootstrap_token_column_exists = 0,
'ALTER TABLE instances ADD COLUMN agent_bootstrap_token VARCHAR(255) NULL AFTER access_token',
'SELECT 1'
);
PREPARE instance_agent_bootstrap_token_column_stmt FROM @instance_agent_bootstrap_token_column_sql;
EXECUTE instance_agent_bootstrap_token_column_stmt;
DEALLOCATE PREPARE instance_agent_bootstrap_token_column_stmt;
CREATE TABLE IF NOT EXISTS instance_agents (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
agent_id VARCHAR(255) NOT NULL,
agent_version VARCHAR(50) NOT NULL,
protocol_version VARCHAR(50) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'online',
capabilities_json LONGTEXT NOT NULL,
host_info_json LONGTEXT NULL,
session_token VARCHAR(255) NULL,
session_expires_at TIMESTAMP NULL,
last_heartbeat_at TIMESTAMP NULL,
last_reported_at TIMESTAMP NULL,
last_seen_ip VARCHAR(45) NULL,
registered_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
UNIQUE KEY uk_instance_agents_instance (instance_id),
UNIQUE KEY uk_instance_agents_session_token (session_token),
INDEX idx_instance_agents_agent_id (agent_id),
INDEX idx_instance_agents_status (status),
INDEX idx_instance_agents_last_heartbeat (last_heartbeat_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_runtime_status (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
infra_status VARCHAR(30) NOT NULL DEFAULT 'creating',
agent_status VARCHAR(30) NOT NULL DEFAULT 'offline',
openclaw_status VARCHAR(30) NOT NULL DEFAULT 'unknown',
openclaw_pid INT NULL,
openclaw_version VARCHAR(100) NULL,
current_config_revision_id INT NULL,
desired_config_revision_id INT NULL,
summary_json LONGTEXT NULL,
system_info_json LONGTEXT NULL,
health_json LONGTEXT NULL,
last_reported_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
UNIQUE KEY uk_instance_runtime_status_instance (instance_id),
INDEX idx_instance_runtime_status_agent_status (agent_status),
INDEX idx_instance_runtime_status_openclaw_status (openclaw_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_desired_state (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
desired_power_state VARCHAR(30) NOT NULL DEFAULT 'running',
desired_config_revision_id INT NULL,
desired_runtime_action VARCHAR(50) NULL,
updated_by INT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uk_instance_desired_state_instance (instance_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_commands (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
agent_id VARCHAR(255) NULL,
command_type VARCHAR(50) NOT NULL,
payload_json LONGTEXT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
idempotency_key VARCHAR(255) NOT NULL,
issued_by INT NULL,
issued_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
dispatched_at TIMESTAMP NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
timeout_seconds INT NOT NULL DEFAULT 300,
result_json LONGTEXT NULL,
error_message TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
FOREIGN KEY (issued_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uk_instance_commands_idempotency (instance_id, idempotency_key),
INDEX idx_instance_commands_instance_status (instance_id, status),
INDEX idx_instance_commands_agent_status (agent_id, status),
INDEX idx_instance_commands_issued_at (issued_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_config_revisions (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
source_snapshot_id INT NULL,
source_bundle_id INT NULL,
revision_no INT NOT NULL,
content_json LONGTEXT NOT NULL,
checksum VARCHAR(255) NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'published',
published_by INT NULL,
published_at TIMESTAMP NULL,
activated_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE,
FOREIGN KEY (published_by) REFERENCES users(id) ON DELETE SET NULL,
UNIQUE KEY uk_instance_config_revision_unique (instance_id, revision_no),
INDEX idx_instance_config_revision_instance (instance_id, revision_no),
INDEX idx_instance_config_revision_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+205
View File
@@ -0,0 +1,205 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"time"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type AgentHandler struct {
agentService services.InstanceAgentService
commandService services.InstanceCommandService
runtimeStatusService services.InstanceRuntimeStatusService
configRevisionService services.InstanceConfigRevisionService
}
func NewAgentHandler(agentService services.InstanceAgentService, commandService services.InstanceCommandService, runtimeStatusService services.InstanceRuntimeStatusService, configRevisionService services.InstanceConfigRevisionService) *AgentHandler {
return &AgentHandler{
agentService: agentService,
commandService: commandService,
runtimeStatusService: runtimeStatusService,
configRevisionService: configRevisionService,
}
}
func (h *AgentHandler) Register(c *gin.Context) {
bootstrapToken := extractBearerToken(c.GetHeader("Authorization"))
if bootstrapToken == "" {
utils.Error(c, http.StatusUnauthorized, "Agent bootstrap token is required")
return
}
var req services.AgentRegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
resp, err := h.agentService.Register(bootstrapToken, req, c.ClientIP())
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent registered successfully", resp)
}
func (h *AgentHandler) Heartbeat(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
var req services.AgentHeartbeatRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if req.Timestamp.IsZero() {
req.Timestamp = time.Now().UTC()
}
resp, err := h.agentService.Heartbeat(session, req, c.ClientIP())
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent heartbeat accepted", resp)
}
func (h *AgentHandler) NextCommand(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
command, err := h.commandService.GetNextForAgent(session)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent next command retrieved successfully", gin.H{"command": command})
}
func (h *AgentHandler) StartCommand(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid command ID")
return
}
var req struct {
AgentID string `json:"agent_id" binding:"required"`
StartedAt *time.Time `json:"started_at,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if req.AgentID != session.Agent.AgentID {
utils.Error(c, http.StatusForbidden, "Agent ID does not match session")
return
}
if err := h.commandService.MarkStarted(session, commandID, req.StartedAt); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent command marked as started", nil)
}
func (h *AgentHandler) FinishCommand(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid command ID")
return
}
var req services.AgentCommandFinishRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if err := h.commandService.MarkFinished(session, commandID, req); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent command result accepted", nil)
}
func (h *AgentHandler) ReportState(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
var req services.AgentStateReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if err := h.runtimeStatusService.Report(session, req, c.ClientIP()); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent state reported successfully", nil)
}
func (h *AgentHandler) GetConfigRevision(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
revisionID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid config revision ID")
return
}
revision, err := h.configRevisionService.GetByID(revisionID)
if err != nil {
utils.HandleError(c, err)
return
}
if revision.InstanceID != session.Instance.ID {
utils.Error(c, http.StatusForbidden, "Access denied")
return
}
utils.Success(c, http.StatusOK, "Config revision retrieved successfully", gin.H{"revision": revision})
}
func (h *AgentHandler) authenticateAgentSession(c *gin.Context) (*services.AgentSession, bool) {
sessionToken := extractBearerToken(c.GetHeader("Authorization"))
if sessionToken == "" {
utils.Error(c, http.StatusUnauthorized, "Agent session token is required")
return nil, false
}
session, err := h.agentService.AuthenticateSession(sessionToken)
if err != nil {
utils.HandleError(c, err)
return nil, false
}
return session, true
}
func extractBearerToken(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parts := strings.SplitN(raw, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return ""
}
return strings.TrimSpace(parts[1])
}
+230 -12
View File
@@ -17,23 +17,47 @@ import (
// InstanceHandler handles instance management requests
type InstanceHandler struct {
instanceService services.InstanceService
accessService *services.InstanceAccessService
proxyService *services.InstanceProxyService
openClawTransferService services.OpenClawTransferService
instanceService services.InstanceService
instanceAgentService services.InstanceAgentService
runtimeStatusService services.InstanceRuntimeStatusService
instanceCommandService services.InstanceCommandService
instanceConfigRevisionService services.InstanceConfigRevisionService
accessService *services.InstanceAccessService
proxyService *services.InstanceProxyService
openClawTransferService services.OpenClawTransferService
openClawConfigService services.OpenClawConfigService
}
// NewInstanceHandler creates a new instance handler
func NewInstanceHandler(instanceService services.InstanceService) *InstanceHandler {
func NewInstanceHandler(instanceService services.InstanceService, instanceAgentService services.InstanceAgentService, runtimeStatusService services.InstanceRuntimeStatusService, instanceCommandService services.InstanceCommandService, instanceConfigRevisionService services.InstanceConfigRevisionService, openClawConfigService services.OpenClawConfigService) *InstanceHandler {
accessService := services.NewInstanceAccessService()
return &InstanceHandler{
instanceService: instanceService,
accessService: accessService,
proxyService: services.NewInstanceProxyService(accessService),
openClawTransferService: services.NewOpenClawTransferService(),
instanceService: instanceService,
instanceAgentService: instanceAgentService,
runtimeStatusService: runtimeStatusService,
instanceCommandService: instanceCommandService,
instanceConfigRevisionService: instanceConfigRevisionService,
accessService: accessService,
proxyService: services.NewInstanceProxyService(accessService),
openClawTransferService: services.NewOpenClawTransferService(),
openClawConfigService: openClawConfigService,
}
}
type InstanceRuntimeDetailsResponse struct {
Runtime *services.InstanceRuntimeStatusPayload `json:"runtime,omitempty"`
Agent *services.InstanceAgentPayload `json:"agent,omitempty"`
Commands []services.InstanceCommandPayload `json:"commands,omitempty"`
}
type CreateRuntimeCommandRequest struct {
IdempotencyKey string `json:"idempotency_key,omitempty"`
}
type PublishConfigRevisionRequest struct {
SnapshotID int `json:"snapshot_id" binding:"required,min=1"`
}
// CreateInstanceRequest represents a create instance request
type CreateInstanceRequest struct {
Name string `json:"name" binding:"required,min=3,max=50"`
@@ -68,6 +92,7 @@ type ListInstancesRequest struct {
// ListInstances lists instances for the current user
func (h *InstanceHandler) ListInstances(c *gin.Context) {
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
var req ListInstancesRequest
if err := c.ShouldBindQuery(&req); err != nil {
@@ -78,7 +103,7 @@ func (h *InstanceHandler) ListInstances(c *gin.Context) {
// Calculate offset
offset := (req.Page - 1) * req.Limit
instances, total, err := h.instanceService.GetByUserID(userID.(int), offset, req.Limit)
instances, total, err := h.instanceService.GetVisibleInstances(userID.(int), fmt.Sprintf("%v", userRole), offset, req.Limit)
if err != nil {
utils.HandleError(c, err)
return
@@ -158,7 +183,14 @@ func (h *InstanceHandler) GetInstance(c *gin.Context) {
return
}
utils.Success(c, http.StatusOK, "Instance retrieved successfully", instance)
runtime, _ := h.runtimeStatusService.GetByInstanceID(instance.ID)
agent, _ := h.instanceAgentService.GetPayloadByInstanceID(instance.ID)
utils.Success(c, http.StatusOK, "Instance retrieved successfully", gin.H{
"instance": instance,
"runtime": runtime,
"agent": agent,
})
}
// UpdateInstance updates an instance
@@ -392,7 +424,193 @@ func (h *InstanceHandler) GetInstanceStatus(c *gin.Context) {
return
}
utils.Success(c, http.StatusOK, "Instance status retrieved successfully", status)
runtime, _ := h.runtimeStatusService.GetByInstanceID(id)
agent, _ := h.instanceAgentService.GetPayloadByInstanceID(id)
utils.Success(c, http.StatusOK, "Instance status retrieved successfully", gin.H{
"instance_status": status,
"runtime": runtime,
"agent": agent,
})
}
func (h *InstanceHandler) GetRuntimeDetails(c *gin.Context) {
id, _, ok := h.resolveOwnedInstance(c)
if !ok {
return
}
runtime, err := h.runtimeStatusService.GetByInstanceID(id)
if err != nil {
utils.HandleError(c, err)
return
}
agent, err := h.instanceAgentService.GetPayloadByInstanceID(id)
if err != nil {
utils.HandleError(c, err)
return
}
commands, err := h.instanceCommandService.ListByInstanceID(id, 20)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance runtime details retrieved successfully", InstanceRuntimeDetailsResponse{
Runtime: runtime,
Agent: agent,
Commands: commands,
})
}
func (h *InstanceHandler) CreateRuntimeCommand(c *gin.Context) {
id, _, ok := h.resolveOwnedInstance(c)
if !ok {
return
}
commandKey := strings.TrimSpace(c.Param("command"))
commandType := ""
switch commandKey {
case "start":
commandType = services.InstanceCommandTypeStartOpenClaw
case "stop":
commandType = services.InstanceCommandTypeStopOpenClaw
case "restart":
commandType = services.InstanceCommandTypeRestartOpenClaw
case "collect-system-info":
commandType = services.InstanceCommandTypeCollectSystemInfo
case "health-check":
commandType = services.InstanceCommandTypeHealthCheck
default:
utils.Error(c, http.StatusBadRequest, "Invalid runtime command")
return
}
var req CreateRuntimeCommandRequest
if err := c.ShouldBindJSON(&req); err != nil && err != io.EOF {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
issuedBy := userID.(int)
command, err := h.instanceCommandService.Create(id, &issuedBy, services.CreateInstanceCommandRequest{
CommandType: commandType,
IdempotencyKey: req.IdempotencyKey,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Instance runtime command created successfully", command)
}
func (h *InstanceHandler) ListConfigRevisions(c *gin.Context) {
id, _, ok := h.resolveOwnedInstance(c)
if !ok {
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
items, err := h.instanceConfigRevisionService.ListByInstanceID(id, limit)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance config revisions retrieved successfully", items)
}
func (h *InstanceHandler) PublishConfigRevision(c *gin.Context) {
id, instance, ok := h.resolveOwnedInstance(c)
if !ok {
return
}
if !strings.EqualFold(instance.Type, "openclaw") {
utils.Error(c, http.StatusBadRequest, "Only openclaw instances support config revisions")
return
}
var req PublishConfigRevisionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
snapshot, err := h.openClawConfigService.GetSnapshot(userID.(int), req.SnapshotID)
if err != nil {
utils.HandleError(c, err)
return
}
if snapshot.InstanceID != nil && *snapshot.InstanceID != id {
utils.Error(c, http.StatusBadRequest, "Snapshot does not belong to this instance")
return
}
modelSnapshot := &models.OpenClawInjectionSnapshot{
ID: snapshot.ID,
InstanceID: snapshot.InstanceID,
UserID: snapshot.UserID,
BundleID: snapshot.BundleID,
Mode: snapshot.Mode,
RenderedManifestJSON: string(snapshot.Manifest),
}
issuedBy := userID.(int)
revision, err := h.instanceConfigRevisionService.CreateFromSnapshot(id, modelSnapshot, &issuedBy)
if err != nil {
utils.HandleError(c, err)
return
}
command, err := h.instanceCommandService.Create(id, &issuedBy, services.CreateInstanceCommandRequest{
CommandType: services.InstanceCommandTypeApplyConfigRevision,
IdempotencyKey: fmt.Sprintf("apply-config-revision-%d", revision.ID),
Payload: map[string]interface{}{
"revision_id": revision.ID,
"snapshot_id": snapshot.ID,
},
TimeoutSeconds: 300,
})
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Instance config revision published successfully", gin.H{
"revision": revision,
"command": command,
})
}
func (h *InstanceHandler) resolveOwnedInstance(c *gin.Context) (int, *models.Instance, bool) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "Invalid instance ID")
return 0, nil, false
}
instance, err := h.instanceService.GetByID(id)
if err != nil {
utils.HandleError(c, err)
return 0, nil, false
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return 0, nil, false
}
userID, _ := c.Get("userID")
userRole, _ := c.Get("userRole")
if userRole != "admin" && instance.UserID != userID.(int) {
utils.Error(c, http.StatusForbidden, "Access denied")
return 0, nil, false
}
return id, instance, true
}
// GenerateAccessToken generates an access token for an instance
+1
View File
@@ -29,6 +29,7 @@ type Instance struct {
PodIP *string `db:"pod_ip" json:"pod_ip,omitempty"`
AccessURL *string `db:"access_url" json:"access_url,omitempty"`
AccessToken *string `db:"access_token" json:"-"`
AgentBootstrapToken *string `db:"agent_bootstrap_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"`
+26
View File
@@ -0,0 +1,26 @@
package models
import "time"
type InstanceAgent struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
InstanceID int `db:"instance_id" json:"instance_id"`
AgentID string `db:"agent_id" json:"agent_id"`
AgentVersion string `db:"agent_version" json:"agent_version"`
ProtocolVersion string `db:"protocol_version" json:"protocol_version"`
Status string `db:"status" json:"status"`
CapabilitiesJSON string `db:"capabilities_json" json:"-"`
HostInfoJSON *string `db:"host_info_json" json:"-"`
SessionToken *string `db:"session_token" json:"-"`
SessionExpiresAt *time.Time `db:"session_expires_at" json:"session_expires_at,omitempty"`
LastHeartbeatAt *time.Time `db:"last_heartbeat_at" json:"last_heartbeat_at,omitempty"`
LastReportedAt *time.Time `db:"last_reported_at" json:"last_reported_at,omitempty"`
LastSeenIP *string `db:"last_seen_ip" json:"last_seen_ip,omitempty"`
RegisteredAt *time.Time `db:"registered_at" json:"registered_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (a InstanceAgent) TableName() string {
return "instance_agents"
}
@@ -0,0 +1,27 @@
package models
import "time"
type InstanceCommand struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
InstanceID int `db:"instance_id" json:"instance_id"`
AgentID *string `db:"agent_id" json:"agent_id,omitempty"`
CommandType string `db:"command_type" json:"command_type"`
PayloadJSON *string `db:"payload_json" json:"-"`
Status string `db:"status" json:"status"`
IdempotencyKey string `db:"idempotency_key" json:"idempotency_key"`
IssuedBy *int `db:"issued_by" json:"issued_by,omitempty"`
IssuedAt time.Time `db:"issued_at" json:"issued_at"`
DispatchedAt *time.Time `db:"dispatched_at" json:"dispatched_at,omitempty"`
StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"`
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
TimeoutSeconds int `db:"timeout_seconds" json:"timeout_seconds"`
ResultJSON *string `db:"result_json" json:"-"`
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"`
}
func (c InstanceCommand) TableName() string {
return "instance_commands"
}
@@ -0,0 +1,23 @@
package models
import "time"
type InstanceConfigRevision struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
InstanceID int `db:"instance_id" json:"instance_id"`
SourceSnapshotID *int `db:"source_snapshot_id" json:"source_snapshot_id,omitempty"`
SourceBundleID *int `db:"source_bundle_id" json:"source_bundle_id,omitempty"`
RevisionNo int `db:"revision_no" json:"revision_no"`
ContentJSON string `db:"content_json" json:"-"`
Checksum string `db:"checksum" json:"checksum"`
Status string `db:"status" json:"status"`
PublishedBy *int `db:"published_by" json:"published_by,omitempty"`
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
ActivatedAt *time.Time `db:"activated_at" json:"activated_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (r InstanceConfigRevision) TableName() string {
return "instance_config_revisions"
}
@@ -0,0 +1,18 @@
package models
import "time"
type InstanceDesiredState struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
InstanceID int `db:"instance_id" json:"instance_id"`
DesiredPowerState string `db:"desired_power_state" json:"desired_power_state"`
DesiredConfigRevisionID *int `db:"desired_config_revision_id" json:"desired_config_revision_id,omitempty"`
DesiredRuntimeAction *string `db:"desired_runtime_action" json:"desired_runtime_action,omitempty"`
UpdatedBy *int `db:"updated_by" json:"updated_by,omitempty"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
func (s InstanceDesiredState) TableName() string {
return "instance_desired_state"
}
@@ -0,0 +1,25 @@
package models
import "time"
type InstanceRuntimeStatus struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
InstanceID int `db:"instance_id" json:"instance_id"`
InfraStatus string `db:"infra_status" json:"infra_status"`
AgentStatus string `db:"agent_status" json:"agent_status"`
OpenClawStatus string `db:"openclaw_status" json:"openclaw_status"`
OpenClawPID *int `db:"openclaw_pid" json:"openclaw_pid,omitempty"`
OpenClawVersion *string `db:"openclaw_version" json:"openclaw_version,omitempty"`
CurrentConfigRevisionID *int `db:"current_config_revision_id" json:"current_config_revision_id,omitempty"`
DesiredConfigRevisionID *int `db:"desired_config_revision_id" json:"desired_config_revision_id,omitempty"`
SummaryJSON *string `db:"summary_json" json:"-"`
SystemInfoJSON *string `db:"system_info_json" json:"-"`
HealthJSON *string `db:"health_json" json:"-"`
LastReportedAt *time.Time `db:"last_reported_at" json:"last_reported_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s InstanceRuntimeStatus) TableName() string {
return "instance_runtime_status"
}
@@ -0,0 +1,69 @@
package repository
import (
"fmt"
"time"
"clawreef/internal/models"
"github.com/upper/db/v4"
)
type InstanceAgentRepository interface {
GetByInstanceID(instanceID int) (*models.InstanceAgent, error)
GetBySessionToken(sessionToken string) (*models.InstanceAgent, error)
Create(agent *models.InstanceAgent) error
Update(agent *models.InstanceAgent) error
}
type instanceAgentRepository struct {
sess db.Session
}
func NewInstanceAgentRepository(sess db.Session) InstanceAgentRepository {
return &instanceAgentRepository{sess: sess}
}
func (r *instanceAgentRepository) GetByInstanceID(instanceID int) (*models.InstanceAgent, error) {
var item models.InstanceAgent
if err := r.sess.Collection("instance_agents").Find(db.Cond{"instance_id": instanceID}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get instance agent: %w", err)
}
return &item, nil
}
func (r *instanceAgentRepository) GetBySessionToken(sessionToken string) (*models.InstanceAgent, error) {
var item models.InstanceAgent
if err := r.sess.Collection("instance_agents").Find(db.Cond{"session_token": sessionToken}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get instance agent by session token: %w", err)
}
return &item, nil
}
func (r *instanceAgentRepository) Create(agent *models.InstanceAgent) error {
ensureTimestamps(&agent.CreatedAt, &agent.UpdatedAt)
res, err := r.sess.Collection("instance_agents").Insert(agent)
if err != nil {
return fmt.Errorf("failed to create instance agent: %w", err)
}
if id, ok := res.ID().(int64); ok {
agent.ID = int(id)
}
return nil
}
func (r *instanceAgentRepository) Update(agent *models.InstanceAgent) error {
if agent.UpdatedAt.IsZero() {
agent.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("instance_agents").Find(db.Cond{"id": agent.ID}).Update(agent); err != nil {
return fmt.Errorf("failed to update instance agent: %w", err)
}
return nil
}
@@ -0,0 +1,97 @@
package repository
import (
"fmt"
"time"
"clawreef/internal/models"
"github.com/upper/db/v4"
)
type InstanceCommandRepository interface {
Create(command *models.InstanceCommand) error
Update(command *models.InstanceCommand) error
GetByID(id int) (*models.InstanceCommand, error)
GetByInstanceIdempotencyKey(instanceID int, idempotencyKey string) (*models.InstanceCommand, error)
GetNextPendingByInstance(instanceID int) (*models.InstanceCommand, error)
ListByInstanceID(instanceID int, limit int) ([]models.InstanceCommand, error)
}
type instanceCommandRepository struct {
sess db.Session
}
func NewInstanceCommandRepository(sess db.Session) InstanceCommandRepository {
return &instanceCommandRepository{sess: sess}
}
func (r *instanceCommandRepository) Create(command *models.InstanceCommand) error {
ensureTimestamps(&command.CreatedAt, &command.UpdatedAt)
if command.IssuedAt.IsZero() {
command.IssuedAt = time.Now().UTC()
}
res, err := r.sess.Collection("instance_commands").Insert(command)
if err != nil {
return fmt.Errorf("failed to create instance command: %w", err)
}
if id, ok := res.ID().(int64); ok {
command.ID = int(id)
}
return nil
}
func (r *instanceCommandRepository) Update(command *models.InstanceCommand) error {
if command.UpdatedAt.IsZero() {
command.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("instance_commands").Find(db.Cond{"id": command.ID}).Update(command); err != nil {
return fmt.Errorf("failed to update instance command: %w", err)
}
return nil
}
func (r *instanceCommandRepository) GetByID(id int) (*models.InstanceCommand, error) {
var item models.InstanceCommand
if err := r.sess.Collection("instance_commands").Find(db.Cond{"id": id}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get instance command: %w", err)
}
return &item, nil
}
func (r *instanceCommandRepository) GetByInstanceIdempotencyKey(instanceID int, idempotencyKey string) (*models.InstanceCommand, error) {
var item models.InstanceCommand
if err := r.sess.Collection("instance_commands").Find(db.Cond{"instance_id": instanceID, "idempotency_key": idempotencyKey}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get instance command by idempotency key: %w", err)
}
return &item, nil
}
func (r *instanceCommandRepository) GetNextPendingByInstance(instanceID int) (*models.InstanceCommand, error) {
var item models.InstanceCommand
if err := r.sess.Collection("instance_commands").Find(db.Cond{"instance_id": instanceID, "status": "pending"}).OrderBy("issued_at", "id").One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get next pending instance command: %w", err)
}
return &item, nil
}
func (r *instanceCommandRepository) ListByInstanceID(instanceID int, limit int) ([]models.InstanceCommand, error) {
if limit <= 0 {
limit = 20
}
var items []models.InstanceCommand
if err := r.sess.Collection("instance_commands").Find(db.Cond{"instance_id": instanceID}).OrderBy("-issued_at", "-id").Limit(limit).All(&items); err != nil {
return nil, fmt.Errorf("failed to list instance commands: %w", err)
}
return items, nil
}
@@ -0,0 +1,82 @@
package repository
import (
"fmt"
"time"
"clawreef/internal/models"
"github.com/upper/db/v4"
)
type InstanceConfigRevisionRepository interface {
Create(revision *models.InstanceConfigRevision) error
Update(revision *models.InstanceConfigRevision) error
GetByID(id int) (*models.InstanceConfigRevision, error)
GetLatestByInstanceID(instanceID int) (*models.InstanceConfigRevision, error)
ListByInstanceID(instanceID int, limit int) ([]models.InstanceConfigRevision, error)
}
type instanceConfigRevisionRepository struct {
sess db.Session
}
func NewInstanceConfigRevisionRepository(sess db.Session) InstanceConfigRevisionRepository {
return &instanceConfigRevisionRepository{sess: sess}
}
func (r *instanceConfigRevisionRepository) Create(revision *models.InstanceConfigRevision) error {
ensureTimestamps(&revision.CreatedAt, &revision.UpdatedAt)
res, err := r.sess.Collection("instance_config_revisions").Insert(revision)
if err != nil {
return fmt.Errorf("failed to create instance config revision: %w", err)
}
if id, ok := res.ID().(int64); ok {
revision.ID = int(id)
}
return nil
}
func (r *instanceConfigRevisionRepository) Update(revision *models.InstanceConfigRevision) error {
if revision.UpdatedAt.IsZero() {
revision.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("instance_config_revisions").Find(db.Cond{"id": revision.ID}).Update(revision); err != nil {
return fmt.Errorf("failed to update instance config revision: %w", err)
}
return nil
}
func (r *instanceConfigRevisionRepository) GetByID(id int) (*models.InstanceConfigRevision, error) {
var item models.InstanceConfigRevision
if err := r.sess.Collection("instance_config_revisions").Find(db.Cond{"id": id}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get instance config revision: %w", err)
}
return &item, nil
}
func (r *instanceConfigRevisionRepository) GetLatestByInstanceID(instanceID int) (*models.InstanceConfigRevision, error) {
var item models.InstanceConfigRevision
if err := r.sess.Collection("instance_config_revisions").Find(db.Cond{"instance_id": instanceID}).OrderBy("-revision_no", "-id").One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get latest instance config revision: %w", err)
}
return &item, nil
}
func (r *instanceConfigRevisionRepository) ListByInstanceID(instanceID int, limit int) ([]models.InstanceConfigRevision, error) {
if limit <= 0 {
limit = 20
}
var items []models.InstanceConfigRevision
if err := r.sess.Collection("instance_config_revisions").Find(db.Cond{"instance_id": instanceID}).OrderBy("-revision_no", "-id").Limit(limit).All(&items); err != nil {
return nil, fmt.Errorf("failed to list instance config revisions: %w", err)
}
return items, nil
}
@@ -0,0 +1,57 @@
package repository
import (
"fmt"
"time"
"clawreef/internal/models"
"github.com/upper/db/v4"
)
type InstanceDesiredStateRepository interface {
GetByInstanceID(instanceID int) (*models.InstanceDesiredState, error)
Create(state *models.InstanceDesiredState) error
Update(state *models.InstanceDesiredState) error
}
type instanceDesiredStateRepository struct {
sess db.Session
}
func NewInstanceDesiredStateRepository(sess db.Session) InstanceDesiredStateRepository {
return &instanceDesiredStateRepository{sess: sess}
}
func (r *instanceDesiredStateRepository) GetByInstanceID(instanceID int) (*models.InstanceDesiredState, error) {
var item models.InstanceDesiredState
if err := r.sess.Collection("instance_desired_state").Find(db.Cond{"instance_id": instanceID}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get instance desired state: %w", err)
}
return &item, nil
}
func (r *instanceDesiredStateRepository) Create(state *models.InstanceDesiredState) error {
ensureTimestamps(&state.CreatedAt, &state.UpdatedAt)
res, err := r.sess.Collection("instance_desired_state").Insert(state)
if err != nil {
return fmt.Errorf("failed to create instance desired state: %w", err)
}
if id, ok := res.ID().(int64); ok {
state.ID = int(id)
}
return nil
}
func (r *instanceDesiredStateRepository) Update(state *models.InstanceDesiredState) error {
if state.UpdatedAt.IsZero() {
state.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("instance_desired_state").Find(db.Cond{"id": state.ID}).Update(state); err != nil {
return fmt.Errorf("failed to update instance desired state: %w", err)
}
return nil
}
@@ -13,6 +13,9 @@ type InstanceRepository interface {
Create(instance *models.Instance) error
GetByID(id int) (*models.Instance, error)
GetByAccessToken(accessToken string) (*models.Instance, error)
GetByAgentBootstrapToken(bootstrapToken string) (*models.Instance, error)
GetAll(offset, limit int) ([]models.Instance, error)
CountAll() (int, error)
GetByUserID(userID int, offset, limit int) ([]models.Instance, error)
CountByUserID(userID int) (int, error)
ExistsByUserIDAndName(userID int, name string) (bool, error)
@@ -70,6 +73,35 @@ func (r *instanceRepository) GetByAccessToken(accessToken string) (*models.Insta
return &instance, nil
}
func (r *instanceRepository) GetByAgentBootstrapToken(bootstrapToken string) (*models.Instance, error) {
var instance models.Instance
err := r.sess.Collection("instances").Find(db.Cond{"agent_bootstrap_token": bootstrapToken}).One(&instance)
if err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get instance by agent bootstrap token: %w", err)
}
return &instance, nil
}
func (r *instanceRepository) GetAll(offset, limit int) ([]models.Instance, error) {
var instances []models.Instance
err := r.sess.Collection("instances").Find().Offset(offset).Limit(limit).All(&instances)
if err != nil {
return nil, fmt.Errorf("failed to get all instances: %w", err)
}
return instances, nil
}
func (r *instanceRepository) CountAll() (int, error) {
count, err := r.sess.Collection("instances").Find().Count()
if err != nil {
return 0, fmt.Errorf("failed to count all instances: %w", err)
}
return int(count), nil
}
// GetByUserID gets instances by user ID with pagination
func (r *instanceRepository) GetByUserID(userID int, offset, limit int) ([]models.Instance, error) {
var instances []models.Instance
@@ -0,0 +1,57 @@
package repository
import (
"fmt"
"time"
"clawreef/internal/models"
"github.com/upper/db/v4"
)
type InstanceRuntimeStatusRepository interface {
GetByInstanceID(instanceID int) (*models.InstanceRuntimeStatus, error)
Create(status *models.InstanceRuntimeStatus) error
Update(status *models.InstanceRuntimeStatus) error
}
type instanceRuntimeStatusRepository struct {
sess db.Session
}
func NewInstanceRuntimeStatusRepository(sess db.Session) InstanceRuntimeStatusRepository {
return &instanceRuntimeStatusRepository{sess: sess}
}
func (r *instanceRuntimeStatusRepository) GetByInstanceID(instanceID int) (*models.InstanceRuntimeStatus, error) {
var item models.InstanceRuntimeStatus
if err := r.sess.Collection("instance_runtime_status").Find(db.Cond{"instance_id": instanceID}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get instance runtime status: %w", err)
}
return &item, nil
}
func (r *instanceRuntimeStatusRepository) Create(status *models.InstanceRuntimeStatus) error {
ensureTimestamps(&status.CreatedAt, &status.UpdatedAt)
res, err := r.sess.Collection("instance_runtime_status").Insert(status)
if err != nil {
return fmt.Errorf("failed to create instance runtime status: %w", err)
}
if id, ok := res.ID().(int64); ok {
status.ID = int(id)
}
return nil
}
func (r *instanceRuntimeStatusRepository) Update(status *models.InstanceRuntimeStatus) error {
if status.UpdatedAt.IsZero() {
status.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("instance_runtime_status").Find(db.Cond{"id": status.ID}).Update(status); err != nil {
return fmt.Errorf("failed to update instance runtime status: %w", err)
}
return nil
}
+13
View File
@@ -0,0 +1,13 @@
package repository
import "time"
func ensureTimestamps(createdAt *time.Time, updatedAt *time.Time) {
now := time.Now().UTC()
if createdAt != nil && createdAt.IsZero() {
*createdAt = now
}
if updatedAt != nil && updatedAt.IsZero() {
*updatedAt = now
}
}
@@ -0,0 +1,414 @@
package services
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"clawreef/internal/models"
"clawreef/internal/repository"
)
const (
AgentProtocolVersionV1 = "v1"
agentStatusOnline = "online"
agentStatusOffline = "offline"
agentStatusStale = "stale"
openClawStatusUnknown = "unknown"
)
type AgentRegisterRequest struct {
InstanceID int `json:"instance_id" binding:"required,min=1"`
AgentID string `json:"agent_id" binding:"required"`
AgentVersion string `json:"agent_version" binding:"required"`
ProtocolVersion string `json:"protocol_version" binding:"required"`
Capabilities []string `json:"capabilities"`
HostInfo map[string]interface{} `json:"host_info"`
}
type AgentRegisterResponse struct {
SessionToken string `json:"session_token"`
SessionExpiresAt time.Time `json:"session_expires_at"`
HeartbeatIntervalSeconds int `json:"heartbeat_interval_seconds"`
CommandPollIntervalSeconds int `json:"command_poll_interval_seconds"`
ServerTime time.Time `json:"server_time"`
}
type AgentHeartbeatRequest struct {
AgentID string `json:"agent_id" binding:"required"`
Timestamp time.Time `json:"timestamp"`
OpenClawStatus string `json:"openclaw_status"`
CurrentConfigRevisionID *int `json:"current_config_revision_id,omitempty"`
Summary map[string]interface{} `json:"summary"`
}
type AgentHeartbeatResponse struct {
ServerTime time.Time `json:"server_time"`
HasPendingCommand bool `json:"has_pending_command"`
DesiredPowerState string `json:"desired_power_state"`
DesiredConfigRevisionID *int `json:"desired_config_revision_id,omitempty"`
}
type InstanceAgentPayload struct {
AgentID string `json:"agent_id"`
AgentVersion string `json:"agent_version"`
ProtocolVersion string `json:"protocol_version"`
Status string `json:"status"`
Capabilities []string `json:"capabilities"`
HostInfo map[string]interface{} `json:"host_info,omitempty"`
LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"`
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
LastSeenIP *string `json:"last_seen_ip,omitempty"`
RegisteredAt *time.Time `json:"registered_at,omitempty"`
}
type AgentSession struct {
Instance *models.Instance
Agent *models.InstanceAgent
}
type InstanceAgentService interface {
Register(bootstrapToken string, req AgentRegisterRequest, clientIP string) (*AgentRegisterResponse, error)
AuthenticateSession(sessionToken string) (*AgentSession, error)
Heartbeat(session *AgentSession, req AgentHeartbeatRequest, clientIP string) (*AgentHeartbeatResponse, error)
GetPayloadByInstanceID(instanceID int) (*InstanceAgentPayload, error)
}
type instanceAgentService struct {
instanceRepo repository.InstanceRepository
agentRepo repository.InstanceAgentRepository
desiredStateRepo repository.InstanceDesiredStateRepository
runtimeRepo repository.InstanceRuntimeStatusRepository
commandRepo repository.InstanceCommandRepository
}
func NewInstanceAgentService(instanceRepo repository.InstanceRepository, agentRepo repository.InstanceAgentRepository, desiredStateRepo repository.InstanceDesiredStateRepository, runtimeRepo repository.InstanceRuntimeStatusRepository, commandRepo repository.InstanceCommandRepository) InstanceAgentService {
return &instanceAgentService{
instanceRepo: instanceRepo,
agentRepo: agentRepo,
desiredStateRepo: desiredStateRepo,
runtimeRepo: runtimeRepo,
commandRepo: commandRepo,
}
}
func (s *instanceAgentService) Register(bootstrapToken string, req AgentRegisterRequest, clientIP string) (*AgentRegisterResponse, error) {
bootstrapToken = strings.TrimSpace(bootstrapToken)
if bootstrapToken == "" {
return nil, fmt.Errorf("agent bootstrap token is required")
}
if strings.TrimSpace(req.AgentID) == "" {
return nil, fmt.Errorf("agent id is required")
}
if strings.TrimSpace(req.ProtocolVersion) != AgentProtocolVersionV1 {
return nil, fmt.Errorf("unsupported agent protocol version")
}
instance, err := s.instanceRepo.GetByAgentBootstrapToken(bootstrapToken)
if err != nil {
return nil, err
}
if instance == nil || instance.ID != req.InstanceID {
return nil, fmt.Errorf("invalid agent bootstrap token")
}
if !strings.EqualFold(instance.Type, "openclaw") {
return nil, fmt.Errorf("agent registration is only supported for openclaw instances")
}
now := time.Now().UTC()
sessionToken, err := generatePrefixedToken("agt_sess")
if err != nil {
return nil, fmt.Errorf("failed to generate agent session token: %w", err)
}
sessionExpiresAt := now.Add(24 * time.Hour)
capabilitiesJSON, err := marshalJSON(req.Capabilities)
if err != nil {
return nil, fmt.Errorf("failed to encode agent capabilities: %w", err)
}
hostInfoJSON, err := marshalOptionalJSON(req.HostInfo)
if err != nil {
return nil, fmt.Errorf("failed to encode agent host info: %w", err)
}
agent, err := s.agentRepo.GetByInstanceID(instance.ID)
if err != nil {
return nil, err
}
if agent == nil {
agent = &models.InstanceAgent{
InstanceID: instance.ID,
AgentID: strings.TrimSpace(req.AgentID),
AgentVersion: strings.TrimSpace(req.AgentVersion),
ProtocolVersion: strings.TrimSpace(req.ProtocolVersion),
Status: agentStatusOnline,
CapabilitiesJSON: capabilitiesJSON,
HostInfoJSON: hostInfoJSON,
SessionToken: &sessionToken,
SessionExpiresAt: &sessionExpiresAt,
LastHeartbeatAt: &now,
LastReportedAt: nil,
LastSeenIP: optionalString(strings.TrimSpace(clientIP)),
RegisteredAt: &now,
}
if err := s.agentRepo.Create(agent); err != nil {
return nil, err
}
} else {
agent.AgentID = strings.TrimSpace(req.AgentID)
agent.AgentVersion = strings.TrimSpace(req.AgentVersion)
agent.ProtocolVersion = strings.TrimSpace(req.ProtocolVersion)
agent.Status = agentStatusOnline
agent.CapabilitiesJSON = capabilitiesJSON
agent.HostInfoJSON = hostInfoJSON
agent.SessionToken = &sessionToken
agent.SessionExpiresAt = &sessionExpiresAt
agent.LastHeartbeatAt = &now
agent.LastSeenIP = optionalString(strings.TrimSpace(clientIP))
agent.RegisteredAt = &now
if err := s.agentRepo.Update(agent); err != nil {
return nil, err
}
}
if _, err := s.ensureDesiredState(instance); err != nil {
return nil, err
}
if _, err := s.ensureRuntimeStatus(instance.ID); err != nil {
return nil, err
}
return &AgentRegisterResponse{
SessionToken: sessionToken,
SessionExpiresAt: sessionExpiresAt,
HeartbeatIntervalSeconds: 15,
CommandPollIntervalSeconds: 5,
ServerTime: now,
}, nil
}
func (s *instanceAgentService) AuthenticateSession(sessionToken string) (*AgentSession, error) {
sessionToken = strings.TrimSpace(sessionToken)
if sessionToken == "" {
return nil, fmt.Errorf("agent session token is required")
}
agent, err := s.agentRepo.GetBySessionToken(sessionToken)
if err != nil {
return nil, err
}
if agent == nil || agent.SessionExpiresAt == nil || agent.SessionExpiresAt.Before(time.Now().UTC()) {
return nil, fmt.Errorf("invalid or expired agent session token")
}
instance, err := s.instanceRepo.GetByID(agent.InstanceID)
if err != nil {
return nil, err
}
if instance == nil {
return nil, fmt.Errorf("instance not found")
}
return &AgentSession{Instance: instance, Agent: agent}, nil
}
func (s *instanceAgentService) Heartbeat(session *AgentSession, req AgentHeartbeatRequest, clientIP string) (*AgentHeartbeatResponse, error) {
if session == nil || session.Agent == nil || session.Instance == nil {
return nil, fmt.Errorf("agent session is required")
}
if strings.TrimSpace(req.AgentID) == "" || strings.TrimSpace(req.AgentID) != session.Agent.AgentID {
return nil, fmt.Errorf("agent id does not match session")
}
now := time.Now().UTC()
session.Agent.Status = agentStatusOnline
session.Agent.LastHeartbeatAt = &now
session.Agent.LastSeenIP = optionalString(strings.TrimSpace(clientIP))
if err := s.agentRepo.Update(session.Agent); err != nil {
return nil, err
}
runtimeStatus, err := s.ensureRuntimeStatus(session.Instance.ID)
if err != nil {
return nil, err
}
runtimeStatus.AgentStatus = agentStatusOnline
if strings.TrimSpace(req.OpenClawStatus) != "" {
runtimeStatus.OpenClawStatus = strings.TrimSpace(req.OpenClawStatus)
}
runtimeStatus.CurrentConfigRevisionID = req.CurrentConfigRevisionID
if req.Summary != nil {
summaryJSON, err := marshalOptionalJSON(req.Summary)
if err != nil {
return nil, fmt.Errorf("failed to encode heartbeat summary: %w", err)
}
runtimeStatus.SummaryJSON = summaryJSON
if openClawPID, ok := req.Summary["openclaw_pid"].(float64); ok {
pid := int(openClawPID)
runtimeStatus.OpenClawPID = &pid
}
}
runtimeStatus.LastReportedAt = &now
if err := s.runtimeRepo.Update(runtimeStatus); err != nil {
return nil, err
}
desiredState, err := s.ensureDesiredState(session.Instance)
if err != nil {
return nil, err
}
nextCommand, err := s.commandRepo.GetNextPendingByInstance(session.Instance.ID)
if err != nil {
return nil, err
}
return &AgentHeartbeatResponse{
ServerTime: now,
HasPendingCommand: nextCommand != nil,
DesiredPowerState: desiredState.DesiredPowerState,
DesiredConfigRevisionID: desiredState.DesiredConfigRevisionID,
}, nil
}
func (s *instanceAgentService) GetPayloadByInstanceID(instanceID int) (*InstanceAgentPayload, error) {
agent, err := s.agentRepo.GetByInstanceID(instanceID)
if err != nil {
return nil, err
}
if agent == nil {
return nil, nil
}
payload := &InstanceAgentPayload{
AgentID: agent.AgentID,
AgentVersion: agent.AgentVersion,
ProtocolVersion: agent.ProtocolVersion,
Status: deriveAgentStatus(agent),
LastHeartbeatAt: agent.LastHeartbeatAt,
LastReportedAt: agent.LastReportedAt,
LastSeenIP: agent.LastSeenIP,
RegisteredAt: agent.RegisteredAt,
}
if err := unmarshalJSON(agent.CapabilitiesJSON, &payload.Capabilities); err != nil {
return nil, fmt.Errorf("failed to decode agent capabilities: %w", err)
}
if agent.HostInfoJSON != nil && strings.TrimSpace(*agent.HostInfoJSON) != "" {
if err := unmarshalJSON(*agent.HostInfoJSON, &payload.HostInfo); err != nil {
return nil, fmt.Errorf("failed to decode agent host info: %w", err)
}
}
return payload, nil
}
func (s *instanceAgentService) ensureDesiredState(instance *models.Instance) (*models.InstanceDesiredState, error) {
state, err := s.desiredStateRepo.GetByInstanceID(instance.ID)
if err != nil {
return nil, err
}
if state != nil {
return state, nil
}
desiredPowerState := "stopped"
if instance.Status == "running" || instance.Status == "creating" {
desiredPowerState = "running"
}
now := time.Now().UTC()
state = &models.InstanceDesiredState{
InstanceID: instance.ID,
DesiredPowerState: desiredPowerState,
UpdatedAt: now,
CreatedAt: now,
}
if err := s.desiredStateRepo.Create(state); err != nil {
return nil, err
}
return state, nil
}
func (s *instanceAgentService) ensureRuntimeStatus(instanceID int) (*models.InstanceRuntimeStatus, error) {
status, err := s.runtimeRepo.GetByInstanceID(instanceID)
if err != nil {
return nil, err
}
if status != nil {
return status, nil
}
now := time.Now().UTC()
status = &models.InstanceRuntimeStatus{
InstanceID: instanceID,
InfraStatus: "creating",
AgentStatus: agentStatusOffline,
OpenClawStatus: openClawStatusUnknown,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.runtimeRepo.Create(status); err != nil {
return nil, err
}
return status, nil
}
func deriveAgentStatus(agent *models.InstanceAgent) string {
if agent == nil || agent.LastHeartbeatAt == nil {
return agentStatusOffline
}
age := time.Since(*agent.LastHeartbeatAt)
switch {
case age <= 45*time.Second:
return agentStatusOnline
case age <= 120*time.Second:
return agentStatusStale
default:
return agentStatusOffline
}
}
func generatePrefixedToken(prefix string) (string, error) {
bytes := make([]byte, 24)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return prefix + "_" + hex.EncodeToString(bytes), nil
}
func marshalJSON(value interface{}) (string, error) {
raw, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(raw), nil
}
func marshalOptionalJSON(value interface{}) (*string, error) {
if value == nil {
return nil, nil
}
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
encoded := string(raw)
return &encoded, nil
}
func unmarshalJSON(raw string, target interface{}) error {
if strings.TrimSpace(raw) == "" {
return nil
}
return json.Unmarshal([]byte(raw), target)
}
func optionalString(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
}
trimmed := strings.TrimSpace(value)
return &trimmed
}
@@ -0,0 +1,337 @@
package services
import (
"encoding/json"
"fmt"
"strings"
"time"
"clawreef/internal/models"
"clawreef/internal/repository"
)
const (
InstanceCommandTypeStartOpenClaw = "start_openclaw"
InstanceCommandTypeStopOpenClaw = "stop_openclaw"
InstanceCommandTypeRestartOpenClaw = "restart_openclaw"
InstanceCommandTypeCollectSystemInfo = "collect_system_info"
InstanceCommandTypeApplyConfigRevision = "apply_config_revision"
InstanceCommandTypeReloadConfig = "reload_config"
InstanceCommandTypeHealthCheck = "health_check"
instanceCommandStatusPending = "pending"
instanceCommandStatusDispatched = "dispatched"
instanceCommandStatusRunning = "running"
instanceCommandStatusSucceeded = "succeeded"
instanceCommandStatusFailed = "failed"
)
type CreateInstanceCommandRequest struct {
CommandType string `json:"command_type"`
Payload map[string]interface{} `json:"payload,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
TimeoutSeconds int `json:"timeout_seconds"`
}
type InstanceCommandPayload struct {
ID int `json:"id"`
CommandType string `json:"command_type"`
Payload map[string]interface{} `json:"payload,omitempty"`
Status string `json:"status"`
IdempotencyKey string `json:"idempotency_key"`
IssuedBy *int `json:"issued_by,omitempty"`
IssuedAt time.Time `json:"issued_at"`
DispatchedAt *time.Time `json:"dispatched_at,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
TimeoutSeconds int `json:"timeout_seconds"`
Result map[string]interface{} `json:"result,omitempty"`
ErrorMessage *string `json:"error_message,omitempty"`
}
type AgentCommandEnvelope struct {
ID int `json:"id"`
CommandType string `json:"command_type"`
Payload map[string]interface{} `json:"payload,omitempty"`
IssuedAt time.Time `json:"issued_at"`
TimeoutSeconds int `json:"timeout_seconds"`
IdempotencyKey string `json:"idempotency_key"`
}
type AgentCommandFinishRequest struct {
AgentID string `json:"agent_id" binding:"required"`
Status string `json:"status" binding:"required"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Result map[string]interface{} `json:"result,omitempty"`
ErrorMessage string `json:"error_message"`
}
type InstanceCommandService interface {
Create(instanceID int, issuedBy *int, req CreateInstanceCommandRequest) (*InstanceCommandPayload, error)
GetNextForAgent(session *AgentSession) (*AgentCommandEnvelope, error)
MarkStarted(session *AgentSession, commandID int, startedAt *time.Time) error
MarkFinished(session *AgentSession, commandID int, req AgentCommandFinishRequest) error
ListByInstanceID(instanceID int, limit int) ([]InstanceCommandPayload, error)
}
type instanceCommandService struct {
commandRepo repository.InstanceCommandRepository
runtimeRepo repository.InstanceRuntimeStatusRepository
desiredStateRepo repository.InstanceDesiredStateRepository
}
func NewInstanceCommandService(commandRepo repository.InstanceCommandRepository, runtimeRepo repository.InstanceRuntimeStatusRepository, desiredStateRepo repository.InstanceDesiredStateRepository) InstanceCommandService {
return &instanceCommandService{
commandRepo: commandRepo,
runtimeRepo: runtimeRepo,
desiredStateRepo: desiredStateRepo,
}
}
func (s *instanceCommandService) Create(instanceID int, issuedBy *int, req CreateInstanceCommandRequest) (*InstanceCommandPayload, error) {
commandType := strings.TrimSpace(req.CommandType)
if !isSupportedCommandType(commandType) {
return nil, fmt.Errorf("invalid instance command type")
}
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
if idempotencyKey == "" {
idempotencyKey = fmt.Sprintf("%s-%d", commandType, time.Now().UTC().UnixNano())
}
existing, err := s.commandRepo.GetByInstanceIdempotencyKey(instanceID, idempotencyKey)
if err != nil {
return nil, err
}
if existing != nil {
return commandPayloadFromModel(*existing)
}
payloadJSON, err := marshalOptionalJSON(req.Payload)
if err != nil {
return nil, fmt.Errorf("failed to encode command payload: %w", err)
}
timeoutSeconds := req.TimeoutSeconds
if timeoutSeconds <= 0 {
timeoutSeconds = 300
}
now := time.Now().UTC()
command := &models.InstanceCommand{
InstanceID: instanceID,
CommandType: commandType,
PayloadJSON: payloadJSON,
Status: instanceCommandStatusPending,
IdempotencyKey: idempotencyKey,
IssuedBy: issuedBy,
IssuedAt: now,
TimeoutSeconds: timeoutSeconds,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.commandRepo.Create(command); err != nil {
return nil, err
}
if err := s.applyDesiredStateSideEffects(instanceID, commandType, req.Payload); err != nil {
return nil, err
}
return commandPayloadFromModel(*command)
}
func (s *instanceCommandService) GetNextForAgent(session *AgentSession) (*AgentCommandEnvelope, error) {
command, err := s.commandRepo.GetNextPendingByInstance(session.Instance.ID)
if err != nil {
return nil, err
}
if command == nil {
return nil, nil
}
now := time.Now().UTC()
command.Status = instanceCommandStatusDispatched
command.AgentID = &session.Agent.AgentID
command.DispatchedAt = &now
if err := s.commandRepo.Update(command); err != nil {
return nil, err
}
payload := map[string]interface{}{}
if command.PayloadJSON != nil && strings.TrimSpace(*command.PayloadJSON) != "" {
if err := json.Unmarshal([]byte(*command.PayloadJSON), &payload); err != nil {
return nil, fmt.Errorf("failed to decode command payload: %w", err)
}
}
return &AgentCommandEnvelope{
ID: command.ID,
CommandType: command.CommandType,
Payload: payload,
IssuedAt: command.IssuedAt,
TimeoutSeconds: command.TimeoutSeconds,
IdempotencyKey: command.IdempotencyKey,
}, nil
}
func (s *instanceCommandService) MarkStarted(session *AgentSession, commandID int, startedAt *time.Time) error {
command, err := s.commandRepo.GetByID(commandID)
if err != nil {
return err
}
if command == nil || command.InstanceID != session.Instance.ID {
return fmt.Errorf("instance command not found")
}
if command.Status == instanceCommandStatusRunning || command.Status == instanceCommandStatusSucceeded || command.Status == instanceCommandStatusFailed {
return nil
}
now := time.Now().UTC()
if startedAt == nil || startedAt.IsZero() {
startedAt = &now
}
command.Status = instanceCommandStatusRunning
command.AgentID = &session.Agent.AgentID
command.StartedAt = startedAt
return s.commandRepo.Update(command)
}
func (s *instanceCommandService) MarkFinished(session *AgentSession, commandID int, req AgentCommandFinishRequest) error {
command, err := s.commandRepo.GetByID(commandID)
if err != nil {
return err
}
if command == nil || command.InstanceID != session.Instance.ID {
return fmt.Errorf("instance command not found")
}
if strings.TrimSpace(req.AgentID) != session.Agent.AgentID {
return fmt.Errorf("agent id does not match session")
}
if req.Status != instanceCommandStatusSucceeded && req.Status != instanceCommandStatusFailed {
return fmt.Errorf("invalid instance command finish status")
}
if command.Status == instanceCommandStatusSucceeded || command.Status == instanceCommandStatusFailed {
return nil
}
finishedAt := req.FinishedAt
if finishedAt == nil || finishedAt.IsZero() {
now := time.Now().UTC()
finishedAt = &now
}
command.Status = req.Status
command.AgentID = &session.Agent.AgentID
command.FinishedAt = finishedAt
if req.Result != nil {
resultJSON, err := marshalOptionalJSON(req.Result)
if err != nil {
return fmt.Errorf("failed to encode command result: %w", err)
}
command.ResultJSON = resultJSON
}
if strings.TrimSpace(req.ErrorMessage) != "" {
command.ErrorMessage = optionalString(strings.TrimSpace(req.ErrorMessage))
}
return s.commandRepo.Update(command)
}
func (s *instanceCommandService) ListByInstanceID(instanceID int, limit int) ([]InstanceCommandPayload, error) {
items, err := s.commandRepo.ListByInstanceID(instanceID, limit)
if err != nil {
return nil, err
}
result := make([]InstanceCommandPayload, 0, len(items))
for _, item := range items {
payload, err := commandPayloadFromModel(item)
if err != nil {
return nil, err
}
result = append(result, *payload)
}
return result, nil
}
func commandPayloadFromModel(item models.InstanceCommand) (*InstanceCommandPayload, error) {
payload := &InstanceCommandPayload{
ID: item.ID,
CommandType: item.CommandType,
Status: item.Status,
IdempotencyKey: item.IdempotencyKey,
IssuedBy: item.IssuedBy,
IssuedAt: item.IssuedAt,
DispatchedAt: item.DispatchedAt,
StartedAt: item.StartedAt,
FinishedAt: item.FinishedAt,
TimeoutSeconds: item.TimeoutSeconds,
ErrorMessage: item.ErrorMessage,
}
if item.PayloadJSON != nil && strings.TrimSpace(*item.PayloadJSON) != "" {
if err := json.Unmarshal([]byte(*item.PayloadJSON), &payload.Payload); err != nil {
return nil, fmt.Errorf("failed to decode command payload: %w", err)
}
}
if item.ResultJSON != nil && strings.TrimSpace(*item.ResultJSON) != "" {
if err := json.Unmarshal([]byte(*item.ResultJSON), &payload.Result); err != nil {
return nil, fmt.Errorf("failed to decode command result: %w", err)
}
}
return payload, nil
}
func isSupportedCommandType(commandType string) bool {
switch strings.TrimSpace(commandType) {
case InstanceCommandTypeStartOpenClaw,
InstanceCommandTypeStopOpenClaw,
InstanceCommandTypeRestartOpenClaw,
InstanceCommandTypeCollectSystemInfo,
InstanceCommandTypeApplyConfigRevision,
InstanceCommandTypeReloadConfig,
InstanceCommandTypeHealthCheck:
return true
default:
return false
}
}
func (s *instanceCommandService) applyDesiredStateSideEffects(instanceID int, commandType string, payload map[string]interface{}) error {
state, err := s.desiredStateRepo.GetByInstanceID(instanceID)
if err != nil {
return err
}
if state == nil {
return nil
}
updated := false
switch commandType {
case InstanceCommandTypeStartOpenClaw, InstanceCommandTypeRestartOpenClaw:
if state.DesiredPowerState != "running" {
state.DesiredPowerState = "running"
updated = true
}
case InstanceCommandTypeStopOpenClaw:
if state.DesiredPowerState != "stopped" {
state.DesiredPowerState = "stopped"
updated = true
}
case InstanceCommandTypeApplyConfigRevision:
if revisionID, ok := payload["revision_id"].(float64); ok {
rev := int(revisionID)
state.DesiredConfigRevisionID = &rev
updated = true
}
}
if !updated {
return nil
}
state.UpdatedAt = time.Now().UTC()
if err := s.desiredStateRepo.Update(state); err != nil {
return err
}
runtime, err := s.runtimeRepo.GetByInstanceID(instanceID)
if err == nil && runtime != nil {
runtime.DesiredConfigRevisionID = state.DesiredConfigRevisionID
_ = s.runtimeRepo.Update(runtime)
}
return nil
}
@@ -0,0 +1,124 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"clawreef/internal/models"
"clawreef/internal/repository"
)
type InstanceConfigRevisionPayload struct {
ID int `json:"id"`
InstanceID int `json:"instance_id"`
SourceSnapshotID *int `json:"source_snapshot_id,omitempty"`
SourceBundleID *int `json:"source_bundle_id,omitempty"`
RevisionNo int `json:"revision_no"`
Content json.RawMessage `json:"content"`
Checksum string `json:"checksum"`
Status string `json:"status"`
PublishedBy *int `json:"published_by,omitempty"`
PublishedAt *time.Time `json:"published_at,omitempty"`
ActivatedAt *time.Time `json:"activated_at,omitempty"`
}
type InstanceConfigRevisionService interface {
GetByID(id int) (*InstanceConfigRevisionPayload, error)
ListByInstanceID(instanceID int, limit int) ([]InstanceConfigRevisionPayload, error)
CreateFromSnapshot(instanceID int, snapshot *models.OpenClawInjectionSnapshot, publishedBy *int) (*InstanceConfigRevisionPayload, error)
}
type instanceConfigRevisionService struct {
repo repository.InstanceConfigRevisionRepository
}
func NewInstanceConfigRevisionService(repo repository.InstanceConfigRevisionRepository) InstanceConfigRevisionService {
return &instanceConfigRevisionService{repo: repo}
}
func (s *instanceConfigRevisionService) GetByID(id int) (*InstanceConfigRevisionPayload, error) {
item, err := s.repo.GetByID(id)
if err != nil {
return nil, err
}
if item == nil {
return nil, fmt.Errorf("instance config revision not found")
}
return configRevisionPayloadFromModel(*item)
}
func (s *instanceConfigRevisionService) ListByInstanceID(instanceID int, limit int) ([]InstanceConfigRevisionPayload, error) {
items, err := s.repo.ListByInstanceID(instanceID, limit)
if err != nil {
return nil, err
}
result := make([]InstanceConfigRevisionPayload, 0, len(items))
for _, item := range items {
payload, err := configRevisionPayloadFromModel(item)
if err != nil {
return nil, err
}
result = append(result, *payload)
}
return result, nil
}
func (s *instanceConfigRevisionService) CreateFromSnapshot(instanceID int, snapshot *models.OpenClawInjectionSnapshot, publishedBy *int) (*InstanceConfigRevisionPayload, error) {
if snapshot == nil {
return nil, fmt.Errorf("openclaw injection snapshot is required")
}
latest, err := s.repo.GetLatestByInstanceID(instanceID)
if err != nil {
return nil, err
}
revisionNo := 1
if latest != nil {
revisionNo = latest.RevisionNo + 1
}
contentJSON := strings.TrimSpace(snapshot.RenderedManifestJSON)
if contentJSON == "" {
contentJSON = "{}"
}
sum := sha256.Sum256([]byte(contentJSON))
checksum := "sha256:" + hex.EncodeToString(sum[:])
now := time.Now().UTC()
revision := &models.InstanceConfigRevision{
InstanceID: instanceID,
SourceSnapshotID: &snapshot.ID,
RevisionNo: revisionNo,
ContentJSON: contentJSON,
Checksum: checksum,
Status: "published",
PublishedBy: publishedBy,
PublishedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.Create(revision); err != nil {
return nil, err
}
return configRevisionPayloadFromModel(*revision)
}
func configRevisionPayloadFromModel(item models.InstanceConfigRevision) (*InstanceConfigRevisionPayload, error) {
payload := &InstanceConfigRevisionPayload{
ID: item.ID,
InstanceID: item.InstanceID,
SourceSnapshotID: item.SourceSnapshotID,
SourceBundleID: item.SourceBundleID,
RevisionNo: item.RevisionNo,
Checksum: item.Checksum,
Status: item.Status,
PublishedBy: item.PublishedBy,
PublishedAt: item.PublishedAt,
ActivatedAt: item.ActivatedAt,
Content: json.RawMessage(item.ContentJSON),
}
return payload, nil
}
+42 -1
View File
@@ -58,6 +58,7 @@ func buildRuntimeConfig(instanceType, osType, osVersion string, registry, tag *s
"SUBFOLDER": "/",
}
case "openclaw":
config.MountPath = "/config"
if (registry == nil || strings.TrimSpace(*registry) == "") && (tag == nil || strings.TrimSpace(*tag) == "") {
config.Image = defaultSystemImageSettings["openclaw"]
} else {
@@ -84,7 +85,7 @@ func defaultPortForInstanceType(instanceType string) int32 {
func defaultMountPathForInstanceType(instanceType string) string {
switch instanceType {
case "ubuntu", "webtop":
case "ubuntu", "webtop", "openclaw":
return "/config"
default:
return "/home/user/data"
@@ -211,6 +212,46 @@ func defaultGatewayBaseURL() (string, bool) {
return fmt.Sprintf("http://%s.%s.svc.cluster.local:%s/api/v1/gateway/llm", serviceName, systemNamespace, port), true
}
func defaultAgentControlBaseURL() (string, bool) {
if override := strings.TrimSpace(os.Getenv("CLAWMANAGER_AGENT_CONTROL_BASE_URL")); override != "" {
return override, true
}
systemNamespace := strings.TrimSpace(os.Getenv("CLAWMANAGER_SYSTEM_NAMESPACE"))
if systemNamespace == "" {
if client := k8s.GetClient(); client != nil {
systemNamespace = client.GetSystemNamespace()
} else if baseNamespace := strings.TrimSpace(os.Getenv("K8S_NAMESPACE")); baseNamespace != "" {
systemNamespace = fmt.Sprintf("%s-system", baseNamespace)
}
}
if systemNamespace == "" {
return "", false
}
serviceName := strings.TrimSpace(os.Getenv("CLAWMANAGER_AGENT_CONTROL_SERVICE_NAME"))
if serviceName == "" {
serviceName = strings.TrimSpace(os.Getenv("CLAWMANAGER_LLM_GATEWAY_SERVICE_NAME"))
}
if serviceName == "" {
serviceName = strings.TrimSpace(os.Getenv("CLAWMANAGER_LLM_GATEWAY_SERVICE"))
}
if serviceName == "" {
serviceName = "clawmanager-gateway"
}
port := normalizePortValue(
strings.TrimSpace(os.Getenv("CLAWMANAGER_AGENT_CONTROL_SERVICE_PORT")),
strings.TrimSpace(os.Getenv("CLAWMANAGER_LLM_GATEWAY_SERVICE_PORT")),
strings.TrimSpace(os.Getenv("CLAWMANAGER_LLM_GATEWAY_PORT")),
)
if port == "" {
port = "9001"
}
return fmt.Sprintf("http://%s.%s.svc.cluster.local:%s", serviceName, systemNamespace, port), true
}
func defaultNoProxyList() string {
if override := strings.TrimSpace(os.Getenv("CLAWMANAGER_NO_PROXY")); override != "" {
return override
@@ -0,0 +1,190 @@
package services
import (
"encoding/json"
"fmt"
"strings"
"time"
"clawreef/internal/models"
"clawreef/internal/repository"
)
type AgentStateReportRequest struct {
AgentID string `json:"agent_id" binding:"required"`
ReportedAt *time.Time `json:"reported_at,omitempty"`
Runtime AgentRuntimePayload `json:"runtime"`
SystemInfo map[string]interface{} `json:"system_info"`
Health map[string]interface{} `json:"health"`
}
type AgentRuntimePayload struct {
OpenClawStatus string `json:"openclaw_status"`
OpenClawPID *int `json:"openclaw_pid,omitempty"`
OpenClawVersion string `json:"openclaw_version"`
CurrentConfigRevisionID *int `json:"current_config_revision_id,omitempty"`
}
type InstanceRuntimeStatusPayload struct {
InstanceID int `json:"instance_id"`
InfraStatus string `json:"infra_status"`
AgentStatus string `json:"agent_status"`
OpenClawStatus string `json:"openclaw_status"`
OpenClawPID *int `json:"openclaw_pid,omitempty"`
OpenClawVersion *string `json:"openclaw_version,omitempty"`
CurrentConfigRevisionID *int `json:"current_config_revision_id,omitempty"`
DesiredConfigRevisionID *int `json:"desired_config_revision_id,omitempty"`
SystemInfo map[string]interface{} `json:"system_info,omitempty"`
Health map[string]interface{} `json:"health,omitempty"`
Summary map[string]interface{} `json:"summary,omitempty"`
LastReportedAt *time.Time `json:"last_reported_at,omitempty"`
}
type InstanceRuntimeStatusService interface {
Report(session *AgentSession, req AgentStateReportRequest, clientIP string) error
GetByInstanceID(instanceID int) (*InstanceRuntimeStatusPayload, error)
UpsertInfraStatus(instanceID int, infraStatus string) error
}
type instanceRuntimeStatusService struct {
runtimeRepo repository.InstanceRuntimeStatusRepository
agentRepo repository.InstanceAgentRepository
desiredStateRepo repository.InstanceDesiredStateRepository
}
func NewInstanceRuntimeStatusService(runtimeRepo repository.InstanceRuntimeStatusRepository, agentRepo repository.InstanceAgentRepository, desiredStateRepo repository.InstanceDesiredStateRepository) InstanceRuntimeStatusService {
return &instanceRuntimeStatusService{
runtimeRepo: runtimeRepo,
agentRepo: agentRepo,
desiredStateRepo: desiredStateRepo,
}
}
func (s *instanceRuntimeStatusService) Report(session *AgentSession, req AgentStateReportRequest, clientIP string) error {
if session == nil || session.Agent == nil || session.Instance == nil {
return fmt.Errorf("agent session is required")
}
if strings.TrimSpace(req.AgentID) != session.Agent.AgentID {
return fmt.Errorf("agent id does not match session")
}
status, err := s.getOrCreate(session.Instance.ID)
if err != nil {
return err
}
now := time.Now().UTC()
reportedAt := req.ReportedAt
if reportedAt == nil || reportedAt.IsZero() {
reportedAt = &now
}
status.AgentStatus = agentStatusOnline
if strings.TrimSpace(req.Runtime.OpenClawStatus) != "" {
status.OpenClawStatus = strings.TrimSpace(req.Runtime.OpenClawStatus)
}
status.OpenClawPID = req.Runtime.OpenClawPID
if strings.TrimSpace(req.Runtime.OpenClawVersion) != "" {
version := strings.TrimSpace(req.Runtime.OpenClawVersion)
status.OpenClawVersion = &version
}
status.CurrentConfigRevisionID = req.Runtime.CurrentConfigRevisionID
status.LastReportedAt = reportedAt
systemInfoJSON, err := marshalOptionalJSON(req.SystemInfo)
if err != nil {
return fmt.Errorf("failed to encode system info: %w", err)
}
healthJSON, err := marshalOptionalJSON(req.Health)
if err != nil {
return fmt.Errorf("failed to encode health info: %w", err)
}
status.SystemInfoJSON = systemInfoJSON
status.HealthJSON = healthJSON
if err := s.runtimeRepo.Update(status); err != nil {
return err
}
session.Agent.Status = agentStatusOnline
session.Agent.LastHeartbeatAt = reportedAt
session.Agent.LastReportedAt = reportedAt
session.Agent.LastSeenIP = optionalString(strings.TrimSpace(clientIP))
if err := s.agentRepo.Update(session.Agent); err != nil {
return err
}
return nil
}
func (s *instanceRuntimeStatusService) GetByInstanceID(instanceID int) (*InstanceRuntimeStatusPayload, error) {
status, err := s.runtimeRepo.GetByInstanceID(instanceID)
if err != nil {
return nil, err
}
if status == nil {
return nil, nil
}
payload := &InstanceRuntimeStatusPayload{
InstanceID: status.InstanceID,
InfraStatus: status.InfraStatus,
AgentStatus: status.AgentStatus,
OpenClawStatus: status.OpenClawStatus,
OpenClawPID: status.OpenClawPID,
OpenClawVersion: status.OpenClawVersion,
CurrentConfigRevisionID: status.CurrentConfigRevisionID,
DesiredConfigRevisionID: status.DesiredConfigRevisionID,
LastReportedAt: status.LastReportedAt,
}
if status.SystemInfoJSON != nil && strings.TrimSpace(*status.SystemInfoJSON) != "" {
if err := json.Unmarshal([]byte(*status.SystemInfoJSON), &payload.SystemInfo); err != nil {
return nil, fmt.Errorf("failed to decode system info: %w", err)
}
}
if status.HealthJSON != nil && strings.TrimSpace(*status.HealthJSON) != "" {
if err := json.Unmarshal([]byte(*status.HealthJSON), &payload.Health); err != nil {
return nil, fmt.Errorf("failed to decode health info: %w", err)
}
}
if status.SummaryJSON != nil && strings.TrimSpace(*status.SummaryJSON) != "" {
if err := json.Unmarshal([]byte(*status.SummaryJSON), &payload.Summary); err != nil {
return nil, fmt.Errorf("failed to decode runtime summary: %w", err)
}
}
return payload, nil
}
func (s *instanceRuntimeStatusService) UpsertInfraStatus(instanceID int, infraStatus string) error {
status, err := s.getOrCreate(instanceID)
if err != nil {
return err
}
status.InfraStatus = infraStatus
desiredState, err := s.desiredStateRepo.GetByInstanceID(instanceID)
if err == nil && desiredState != nil {
status.DesiredConfigRevisionID = desiredState.DesiredConfigRevisionID
}
return s.runtimeRepo.Update(status)
}
func (s *instanceRuntimeStatusService) getOrCreate(instanceID int) (*models.InstanceRuntimeStatus, error) {
status, err := s.runtimeRepo.GetByInstanceID(instanceID)
if err != nil {
return nil, err
}
if status != nil {
return status, nil
}
now := time.Now().UTC()
status = &models.InstanceRuntimeStatus{
InstanceID: instanceID,
InfraStatus: "creating",
AgentStatus: agentStatusOffline,
OpenClawStatus: openClawStatusUnknown,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.runtimeRepo.Create(status); err != nil {
return nil, err
}
return status, nil
}
+81 -2
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
@@ -20,6 +21,7 @@ type InstanceService interface {
Create(userID int, req CreateInstanceRequest) (*models.Instance, error)
GetByID(id int) (*models.Instance, error)
GetByUserID(userID int, offset, limit int) ([]models.Instance, int, error)
GetVisibleInstances(userID int, userRole string, offset, limit int) ([]models.Instance, int, error)
Start(instanceID int) error
Stop(instanceID int) error
Restart(instanceID int) error
@@ -210,12 +212,21 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models
s.instanceRepo.Delete(instance.ID)
return nil, fmt.Errorf("failed to provision instance gateway token: %w", err)
}
if _, err := s.ensureAgentBootstrapToken(instance); err != nil {
s.instanceRepo.Delete(instance.ID)
return nil, fmt.Errorf("failed to provision instance agent bootstrap token: %w", err)
}
gatewayEnv, err := s.buildGatewayEnv(instance)
if err != nil {
s.instanceRepo.Delete(instance.ID)
return nil, fmt.Errorf("failed to build instance gateway config: %w", err)
}
agentEnv, err := s.buildAgentEnv(instance)
if err != nil {
s.instanceRepo.Delete(instance.ID)
return nil, fmt.Errorf("failed to build instance agent config: %w", err)
}
var bootstrapSnapshot *models.OpenClawInjectionSnapshot
var bootstrapSecretName string
@@ -281,7 +292,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, gatewayEnv)),
ExtraEnv: withInstanceProxyEnv(instance.Type, instance.ID, mergeEnvMaps(runtimeConfig.Env, mergeEnvMaps(gatewayEnv, agentEnv))),
EnvFromSecretNames: []string{bootstrapSecretName},
}
@@ -377,6 +388,24 @@ func (s *instanceService) GetByUserID(userID int, offset, limit int) ([]models.I
return instances, total, nil
}
func (s *instanceService) GetVisibleInstances(userID int, userRole string, offset, limit int) ([]models.Instance, int, error) {
if strings.EqualFold(strings.TrimSpace(userRole), "admin") {
instances, err := s.instanceRepo.GetAll(offset, limit)
if err != nil {
return nil, 0, err
}
total, err := s.instanceRepo.CountAll()
if err != nil {
return nil, 0, err
}
return instances, total, nil
}
return s.GetByUserID(userID, offset, limit)
}
// Start starts an instance
func (s *instanceService) Start(instanceID int) error {
ctx := context.Background()
@@ -397,11 +426,18 @@ func (s *instanceService) Start(instanceID int) error {
if _, err := s.ensureGatewayToken(instance); err != nil {
return fmt.Errorf("failed to provision instance gateway token: %w", err)
}
if _, err := s.ensureAgentBootstrapToken(instance); err != nil {
return fmt.Errorf("failed to provision instance agent bootstrap token: %w", err)
}
gatewayEnv, err := s.buildGatewayEnv(instance)
if err != nil {
return fmt.Errorf("failed to build instance gateway config: %w", err)
}
agentEnv, err := s.buildAgentEnv(instance)
if err != nil {
return fmt.Errorf("failed to build instance agent config: %w", err)
}
bootstrapSecretName := ""
if strings.EqualFold(instance.Type, "openclaw") && s.openClawConfigService != nil && instance.OpenClawConfigSnapshotID != nil && *instance.OpenClawConfigSnapshotID > 0 {
@@ -431,7 +467,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, gatewayEnv)),
ExtraEnv: withInstanceProxyEnv(instance.Type, instance.ID, mergeEnvMaps(runtimeConfig.Env, mergeEnvMaps(gatewayEnv, agentEnv))),
EnvFromSecretNames: []string{bootstrapSecretName},
}
@@ -533,6 +569,49 @@ func (s *instanceService) buildGatewayEnv(instance *models.Instance) (map[string
}, nil
}
func (s *instanceService) ensureAgentBootstrapToken(instance *models.Instance) (string, error) {
if instance.AgentBootstrapToken != nil && strings.TrimSpace(*instance.AgentBootstrapToken) != "" {
return strings.TrimSpace(*instance.AgentBootstrapToken), nil
}
token, err := generatePrefixedToken("agt_boot")
if err != nil {
return "", fmt.Errorf("failed to generate instance agent bootstrap token: %w", err)
}
instance.AgentBootstrapToken = &token
instance.UpdatedAt = time.Now()
if err := s.instanceRepo.Update(instance); err != nil {
return "", fmt.Errorf("failed to persist instance agent bootstrap token: %w", err)
}
return token, nil
}
func (s *instanceService) buildAgentEnv(instance *models.Instance) (map[string]string, error) {
if instance == nil || !strings.EqualFold(strings.TrimSpace(instance.Type), "openclaw") {
return map[string]string{}, nil
}
if instance.AgentBootstrapToken == nil || strings.TrimSpace(*instance.AgentBootstrapToken) == "" {
return nil, fmt.Errorf("instance agent bootstrap token is not configured")
}
baseURL, ok := defaultAgentControlBaseURL()
if !ok {
return nil, fmt.Errorf("agent control base URL is not configured")
}
diskLimitBytes := int64(instance.DiskGB) * 1024 * 1024 * 1024
return map[string]string{
"CLAWMANAGER_AGENT_ENABLED": "true",
"CLAWMANAGER_AGENT_BASE_URL": baseURL,
"CLAWMANAGER_AGENT_BOOTSTRAP_TOKEN": strings.TrimSpace(*instance.AgentBootstrapToken),
"CLAWMANAGER_AGENT_DISK_LIMIT_BYTES": strconv.FormatInt(diskLimitBytes, 10),
"CLAWMANAGER_AGENT_INSTANCE_ID": fmt.Sprintf("%d", instance.ID),
"CLAWMANAGER_AGENT_PERSISTENT_DIR": "/config",
"CLAWMANAGER_AGENT_PROTOCOL_VERSION": AgentProtocolVersionV1,
}, nil
}
func (s *instanceService) resolveDefaultGatewayModel() (string, error) {
if s.llmModelRepo == nil {
return "", fmt.Errorf("llm model repository not configured")
+38 -9
View File
@@ -14,19 +14,21 @@ import (
// SyncService handles synchronization between database and K8s state
type SyncService struct {
instanceRepo repository.InstanceRepository
podService *k8s.PodService
interval time.Duration
stopChan chan struct{}
instanceRepo repository.InstanceRepository
runtimeStatusService InstanceRuntimeStatusService
podService *k8s.PodService
interval time.Duration
stopChan chan struct{}
}
// NewSyncService creates a new sync service
func NewSyncService(instanceRepo repository.InstanceRepository) *SyncService {
func NewSyncService(instanceRepo repository.InstanceRepository, runtimeStatusService InstanceRuntimeStatusService) *SyncService {
return &SyncService{
instanceRepo: instanceRepo,
podService: k8s.NewPodService(),
interval: 5 * time.Second, // Sync every 5 seconds for more responsive status updates
stopChan: make(chan struct{}),
instanceRepo: instanceRepo,
runtimeStatusService: runtimeStatusService,
podService: k8s.NewPodService(),
interval: 5 * time.Second, // Sync every 5 seconds for more responsive status updates
stopChan: make(chan struct{}),
}
}
@@ -115,6 +117,7 @@ func (s *SyncService) syncInstance(ctx context.Context, instance *models.Instanc
if err := s.instanceRepo.Update(instance); err != nil {
fmt.Printf("Error updating instance %d status: %v\n", instance.ID, err)
} else {
s.updateInfraStatus(instance.ID, nextStatus)
// Broadcast status update
GetHub().BroadcastInstanceStatus(instance.UserID, instance)
}
@@ -133,6 +136,7 @@ func (s *SyncService) syncInstance(ctx context.Context, instance *models.Instanc
instance.Status = desiredStatus
needsUpdate = true
}
s.updateInfraStatus(instance.ID, desiredStatus)
// Update Pod IP if changed
if pod.Status.PodIP != "" {
@@ -167,6 +171,31 @@ func (s *SyncService) syncInstance(ctx context.Context, instance *models.Instanc
}
}
func (s *SyncService) updateInfraStatus(instanceID int, instanceStatus string) {
if s.runtimeStatusService == nil {
return
}
infraStatus := mapInstanceStatusToInfraStatus(instanceStatus)
if err := s.runtimeStatusService.UpsertInfraStatus(instanceID, infraStatus); err != nil {
fmt.Printf("Error updating runtime infra status for instance %d: %v\n", instanceID, err)
}
}
func mapInstanceStatusToInfraStatus(instanceStatus string) string {
switch instanceStatus {
case "running":
return "ready"
case "stopped":
return "stopped"
case "error":
return "error"
case "creating":
return "creating"
default:
return "creating"
}
}
func mapPodToInstanceStatus(pod *corev1.Pod) string {
if pod == nil {
return "error"
+5 -3
View File
@@ -58,16 +58,18 @@ func HandleError(c *gin.Context, err error) {
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", "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":
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", "agent bootstrap token is required", "agent id is required", "unsupported agent protocol version", "invalid agent bootstrap token", "invalid instance command type", "invalid instance command finish status":
Error(c, http.StatusBadRequest, errStr)
case "model is not active or does not exist", "openclaw config resource not found", "openclaw config bundle not found", "openclaw injection snapshot not found":
case "model is not active or does not exist", "openclaw config resource not found", "openclaw config bundle not found", "openclaw injection snapshot not found", "instance command not found", "instance config revision not found":
Error(c, http.StatusNotFound, errStr)
case "risk rule not found":
Error(c, http.StatusNotFound, errStr)
case "sensitive content requires an active secure model", "request was blocked by risk policy":
Error(c, http.StatusForbidden, errStr)
case "invalid username or password", "account is disabled":
case "invalid username or password", "account is disabled", "invalid or expired agent session token":
Error(c, http.StatusUnauthorized, errStr)
case "agent registration is only supported for openclaw instances", "agent id does not match session", "access denied":
Error(c, http.StatusForbidden, errStr)
case "current password is incorrect":
Error(c, http.StatusBadRequest, errStr)
case "user not found", "model not found":
+119 -49
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useI18n } from '../contexts/I18nContext';
import { useInstanceDesktopAccess } from '../hooks/useInstanceDesktopAccess';
import { memo, useState, useEffect, useCallback, useRef } from "react";
import { useI18n } from "../contexts/I18nContext";
import { useInstanceDesktopAccess } from "../hooks/useInstanceDesktopAccess";
interface InstanceAccessProps {
instanceId: number;
@@ -8,10 +8,48 @@ interface InstanceAccessProps {
isRunning: boolean;
}
export function InstanceAccess({ instanceId, instanceName, isRunning }: InstanceAccessProps) {
const desktopConnectPreferenceStore = new Map<number, boolean>();
const DesktopIframeSurface = memo(function DesktopIframeSurface({
frameHeightClass,
iframeRef,
embedUrl,
instanceName,
handleFrameLoad,
handleFrameError,
}: {
frameHeightClass: string;
iframeRef: React.RefObject<HTMLIFrameElement | null>;
embedUrl: string;
instanceName: string;
handleFrameLoad: (frame: HTMLIFrameElement | null) => void;
handleFrameError: () => void;
}) {
return (
<div className={frameHeightClass}>
<iframe
ref={iframeRef}
src={embedUrl}
title={`${instanceName} Desktop`}
className="w-full h-full border-0"
allow="clipboard-read; clipboard-write; fullscreen; autoplay"
onLoad={() => handleFrameLoad(iframeRef.current)}
onError={handleFrameError}
/>
</div>
);
});
export function InstanceAccess({
instanceId,
instanceName,
isRunning,
}: InstanceAccessProps) {
const { t } = useI18n();
const [isFullscreen, setIsFullscreen] = useState(false);
const [shouldConnect, setShouldConnect] = useState(false);
const [shouldConnect, setShouldConnect] = useState(
() => desktopConnectPreferenceStore.get(instanceId) ?? false,
);
const containerRef = useRef<HTMLDivElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -24,12 +62,14 @@ export function InstanceAccess({ instanceId, instanceName, isRunning }: Instance
return url;
}
const explicitOrigin = import.meta.env.VITE_BACKEND_ORIGIN as string | undefined;
const explicitOrigin = import.meta.env.VITE_BACKEND_ORIGIN as
| string
| undefined;
if (explicitOrigin) {
return new URL(url, explicitOrigin).toString();
}
if (window.location.port === '9002' && url.startsWith('/api/')) {
if (window.location.port === "9002" && url.startsWith("/api/")) {
return `${window.location.protocol}//${window.location.hostname}:9001${url}`;
}
@@ -41,41 +81,40 @@ export function InstanceAccess({ instanceId, instanceName, isRunning }: Instance
expiresAt,
loading,
error,
frameKey,
reconnecting,
refreshAccess,
handleFrameLoad,
handleFrameError,
} = useInstanceDesktopAccess({
instanceId,
isRunning: isRunning && shouldConnect,
retainSessionOnStop: shouldConnect,
resolveEmbedUrl,
failedMessage: t('instances.failedToGenerateAccessToken'),
failedMessage: t("instances.failedToGenerateAccessToken"),
});
useEffect(() => {
setShouldConnect(false);
setShouldConnect(desktopConnectPreferenceStore.get(instanceId) ?? false);
}, [instanceId]);
useEffect(() => {
if (!isRunning) {
setShouldConnect(false);
}
}, [isRunning]);
desktopConnectPreferenceStore.set(instanceId, shouldConnect);
}, [instanceId, shouldConnect]);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(document.fullscreenElement === containerRef.current);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, []);
const toggleFullscreen = async () => {
const fullscreenTarget = iframeRef.current ?? containerRef.current;
const fullscreenTarget = containerRef.current;
if (!fullscreenTarget) {
return;
}
@@ -87,18 +126,22 @@ export function InstanceAccess({ instanceId, instanceName, isRunning }: Instance
await fullscreenTarget.requestFullscreen();
}
} catch (fullscreenError) {
console.error('Failed to toggle fullscreen', fullscreenError);
console.error("Failed to toggle fullscreen", fullscreenError);
}
};
const frameHeightClass = 'h-[calc(100vh-180px)] min-h-[780px] max-h-[1280px] md:h-[calc(100vh-160px)]';
const frameHeightClass = isFullscreen
? "min-h-0 flex-1"
: "h-[54vh] min-h-[420px] max-h-[720px] md:h-[58vh] xl:h-[60vh]";
const showStartScreen = !embedUrl;
const hasDesktopSession =
shouldConnect || Boolean(embedUrl) || loading || reconnecting;
const formatTimeRemaining = () => {
if (!expiresAt) return '';
if (!expiresAt) return "";
const now = new Date();
const diff = expiresAt.getTime() - now.getTime();
if (diff <= 0) return t('instances.expired');
if (diff <= 0) return t("instances.expired");
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
return `${minutes}m ${seconds}s`;
@@ -113,7 +156,7 @@ export function InstanceAccess({ instanceId, instanceName, isRunning }: Instance
setShouldConnect(true);
};
if (!isRunning) {
if (!isRunning && !hasDesktopSession) {
return (
<div className="app-panel border-dashed p-12 text-center">
<svg
@@ -129,9 +172,11 @@ export function InstanceAccess({ instanceId, instanceName, isRunning }: Instance
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('instances.startTheInstance')}</h3>
<h3 className="mt-2 text-sm font-medium text-gray-900">
{t("instances.startTheInstance")}
</h3>
<p className="mt-1 text-sm text-gray-500">
{t('instances.startToAccessDesktop')}
{t("instances.startToAccessDesktop")}
</p>
</div>
);
@@ -140,12 +185,14 @@ export function InstanceAccess({ instanceId, instanceName, isRunning }: Instance
if (showStartScreen) {
return (
<div className="relative overflow-hidden rounded-[28px] border border-[#1f2937] bg-[radial-gradient(circle_at_top,rgba(59,130,246,0.2),transparent_28%),linear-gradient(180deg,#111827_0%,#0f172a_100%)] shadow-[0_30px_90px_-56px_rgba(17,24,39,0.9)]">
<div className={`${frameHeightClass} flex flex-col items-center justify-center px-8 text-center`}>
<div
className={`${frameHeightClass} flex flex-col items-center justify-center px-8 text-center`}
>
<button
type="button"
onClick={handleConnect}
disabled={loading}
aria-label={t('instances.generateAccess')}
aria-label={t("instances.generateAccess")}
className="group flex h-24 w-24 items-center justify-center rounded-full border border-white/20 bg-white/10 text-white backdrop-blur transition hover:scale-[1.03] hover:bg-white/16 disabled:cursor-wait disabled:opacity-70"
>
{loading ? (
@@ -162,15 +209,21 @@ export function InstanceAccess({ instanceId, instanceName, isRunning }: Instance
)}
</button>
<h3 className="mt-6 text-xl font-semibold text-white">{t('instances.readyToAccess')}</h3>
<h3 className="mt-6 text-xl font-semibold text-white">
{t("instances.readyToAccess")}
</h3>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-300">
{loading ? t('instances.generatingToken') : t('instances.generateAccessPrompt', { name: instanceName })}
{loading
? t("instances.generatingToken")
: t("instances.generateAccessPrompt", { name: instanceName })}
</p>
{error && shouldConnect && (
<p className="mt-4 max-w-md text-sm text-red-300">{error}</p>
)}
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
{loading ? t('instances.generatingToken') : t('instances.generateAccess')}
{loading
? t("instances.generatingToken")
: t("instances.generateAccess")}
</p>
</div>
</div>
@@ -180,14 +233,16 @@ export function InstanceAccess({ instanceId, instanceName, isRunning }: Instance
return (
<div
ref={containerRef}
className={`relative overflow-hidden bg-[#111827] ${isFullscreen ? 'rounded-none' : 'rounded-[28px] border border-[#1f2937] shadow-[0_30px_90px_-56px_rgba(17,24,39,0.9)]'}`}
className={`relative overflow-hidden bg-[#111827] ${isFullscreen ? "flex h-screen flex-col rounded-none" : "rounded-[28px] border border-[#1f2937] shadow-[0_30px_90px_-56px_rgba(17,24,39,0.9)]"}`}
>
<div className="flex items-center justify-between px-4 py-3 bg-gray-800 text-white">
<div className="flex items-center space-x-4">
<span className="text-sm font-medium">{instanceName}</span>
{expiresAt && (
<span className="text-xs text-gray-400">
{reconnecting ? t('instances.generatingToken') : `${t('instances.expiresIn')}: ${formatTimeRemaining()}`}
{reconnecting
? t("instances.generatingToken")
: `${t("instances.expiresIn")}: ${formatTimeRemaining()}`}
</span>
)}
</div>
@@ -196,38 +251,53 @@ export function InstanceAccess({ instanceId, instanceName, isRunning }: Instance
onClick={() => refreshAccess({ forceReload: true })}
className="rounded-xl bg-[#243041] px-3 py-1 text-xs font-medium text-gray-300 hover:bg-[#31415a] hover:text-white"
>
{t('instances.refreshToken')}
{t("instances.refreshToken")}
</button>
<button
onClick={toggleFullscreen}
className="rounded-xl bg-[#243041] px-3 py-1 text-xs font-medium text-gray-300 hover:bg-[#31415a] hover:text-white"
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
)}
</button>
</div>
</div>
<div className={frameHeightClass}>
<iframe
key={frameKey}
ref={iframeRef}
src={embedUrl || undefined}
title={`${instanceName} Desktop`}
className="w-full h-full border-0"
allow="clipboard-read; clipboard-write; fullscreen; autoplay"
allowFullScreen
onLoad={() => handleFrameLoad(iframeRef.current)}
onError={() => refreshAccess({ forceReload: true, silent: true })}
/>
</div>
<DesktopIframeSurface
frameHeightClass={frameHeightClass}
iframeRef={iframeRef}
embedUrl={embedUrl}
instanceName={instanceName}
handleFrameLoad={handleFrameLoad}
handleFrameError={handleFrameError}
/>
</div>
);
}
@@ -0,0 +1,386 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
type OverlayCommand =
| "start"
| "stop"
| "restart"
| "collect-system-info"
| "health-check";
export function OpenClawDesktopOverlay({
gatewayStatus,
canControl,
actionLoading,
onCommand,
}: {
gatewayStatus: string;
canControl: boolean;
actionLoading: string | null;
onCommand: (command: OverlayCommand) => void;
}) {
const rootRef = useRef<HTMLDivElement | null>(null);
const orbRef = useRef<HTMLButtonElement | null>(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const pointerStartRef = useRef({ x: 0, y: 0 });
const hideTimerRef = useRef<number | null>(null);
const movedRef = useRef(false);
const [menuOpen, setMenuOpen] = useState(false);
const [position, setPosition] = useState({ x: 18, y: 18 });
const [isDragging, setIsDragging] = useState(false);
const orbSize = 62;
const ballTone = gatewayBallTone(gatewayStatus);
const clampPosition = useCallback((x: number, y: number) => {
const root = rootRef.current;
if (!root) {
return { x, y };
}
const maxX = Math.max(root.clientWidth - orbSize - 12, 12);
const maxY = Math.max(root.clientHeight - orbSize - 12, 12);
return {
x: Math.min(Math.max(12, x), maxX),
y: Math.min(Math.max(12, y), maxY),
};
}, []);
useEffect(() => {
const handleResize = () => {
setPosition((current) => clampPosition(current.x, current.y));
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [clampPosition]);
useEffect(() => {
return () => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
}
};
}, []);
const scheduleHide = () => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
}
hideTimerRef.current = window.setTimeout(() => {
setMenuOpen(false);
}, 360);
};
const cancelHide = () => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
};
const handlePointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
const orb = orbRef.current;
if (!orb) {
return;
}
movedRef.current = false;
pointerStartRef.current = {
x: event.clientX,
y: event.clientY,
};
const rect = orb.getBoundingClientRect();
dragOffsetRef.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
setIsDragging(true);
orb.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
const root = rootRef.current;
const orb = orbRef.current;
if (!root || !orb || !orb.hasPointerCapture(event.pointerId)) {
return;
}
const rootRect = root.getBoundingClientRect();
const nextX = event.clientX - rootRect.left - dragOffsetRef.current.x;
const nextY = event.clientY - rootRect.top - dragOffsetRef.current.y;
const next = clampPosition(nextX, nextY);
const movedDistance = Math.hypot(
event.clientX - pointerStartRef.current.x,
event.clientY - pointerStartRef.current.y,
);
if (movedDistance > 3) {
movedRef.current = true;
}
setPosition(next);
};
const handlePointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
const orb = orbRef.current;
if (orb?.hasPointerCapture(event.pointerId)) {
orb.releasePointerCapture(event.pointerId);
}
setIsDragging(false);
setPosition((current) => {
const root = rootRef.current;
if (!root) {
return current;
}
const maxX = Math.max(root.clientWidth - orbSize - 12, 12);
const snappedX =
current.x + orbSize / 2 < root.clientWidth / 2 ? 12 : maxX;
return { ...current, x: snappedX };
});
if (!movedRef.current) {
setMenuOpen((current) => !current);
}
window.setTimeout(() => {
movedRef.current = false;
}, 0);
};
const actionVectors =
position.x > 260
? [
{ x: -84, y: -12 },
{ x: -56, y: -92 },
{ x: -30, y: 66 },
]
: [
{ x: 84, y: -12 },
{ x: 56, y: -92 },
{ x: 30, y: 66 },
];
const actions = [
{
key: "start",
label: "Start",
icon: <StartIcon />,
vector: actionVectors[0],
disabled:
!canControl ||
gatewayStatus === "running" ||
actionLoading === "runtime-start",
loading: actionLoading === "runtime-start",
onClick: () => {
setMenuOpen(false);
onCommand("start");
},
},
{
key: "stop",
label: "Stop",
icon: <StopIcon />,
vector: actionVectors[1],
disabled:
!canControl ||
gatewayStatus === "stopped" ||
actionLoading === "runtime-stop",
loading: actionLoading === "runtime-stop",
onClick: () => {
setMenuOpen(false);
onCommand("stop");
},
},
{
key: "restart",
label: "Restart",
icon: <RestartIcon />,
vector: actionVectors[2],
disabled: !canControl || actionLoading === "runtime-restart",
loading: actionLoading === "runtime-restart",
onClick: () => {
setMenuOpen(false);
onCommand("restart");
},
},
];
return (
<div ref={rootRef} className="pointer-events-none absolute inset-0 z-20">
<button
ref={orbRef}
type="button"
aria-label="Open OpenClaw gateway controls"
onMouseEnter={cancelHide}
onMouseLeave={scheduleHide}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={() => setIsDragging(false)}
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
className={`pointer-events-auto absolute flex h-[62px] w-[62px] items-center justify-center rounded-full border-4 border-white/90 text-white shadow-[0_20px_40px_-18px_rgba(15,23,42,0.82)] ${
isDragging
? "scale-100 cursor-grabbing"
: "cursor-grab transition-[transform,box-shadow] duration-300 ease-out hover:scale-[1.03] active:scale-100"
}`}
>
<span
className="absolute inset-0 rounded-full"
style={{ background: ballTone.gradient }}
/>
<span className="absolute inset-[7px] rounded-full border border-white/35 bg-[radial-gradient(circle_at_30%_26%,rgba(255,255,255,0.42),rgba(255,255,255,0.08)_42%,rgba(0,0,0,0.08)_100%)] shadow-[inset_0_1px_0_rgba(255,255,255,0.35),inset_0_-8px_14px_rgba(0,0,0,0.12)] backdrop-blur-md" />
<span className="absolute inset-x-[15px] top-[10px] h-[12px] rounded-full bg-white/28 blur-[2px]" />
<span className="relative flex h-10 w-10 items-center justify-center rounded-full border border-white/28 bg-[linear-gradient(180deg,rgba(255,255,255,0.28)_0%,rgba(255,255,255,0.08)_100%)] shadow-[0_8px_18px_-10px_rgba(15,23,42,0.8)] backdrop-blur-md">
<svg
className="h-7 w-7"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden="true"
>
<rect x="6.5" y="7.5" width="11" height="9" rx="3" />
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 4.75v2.75M9 18h6M8.8 11.6h.01M15.2 11.6h.01M10 14.1c.55.4 1.22.6 2 .6s1.45-.2 2-.6"
/>
</svg>
</span>
</button>
{menuOpen ? (
<div
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
className="pointer-events-none absolute"
>
{actions.map((action, index) => (
<OrbActionButton
key={action.key}
label={action.label}
icon={action.icon}
disabled={action.disabled}
loading={action.loading}
onClick={action.onClick}
vector={action.vector}
delayMs={index * 36}
menuOpen={menuOpen}
onMouseEnter={cancelHide}
onMouseLeave={scheduleHide}
/>
))}
</div>
) : null}
</div>
);
}
function OrbActionButton({
icon,
label,
disabled,
loading,
onClick,
vector,
delayMs,
menuOpen,
onMouseEnter,
onMouseLeave,
}: {
icon: React.ReactNode;
label: string;
disabled: boolean;
loading: boolean;
onClick: () => void;
vector: { x: number; y: number };
delayMs: number;
menuOpen: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
disabled={disabled}
style={{
transform: menuOpen
? `translate(${vector.x}px, ${vector.y}px) scale(1)`
: "translate(0px, 0px) scale(0.72)",
transitionDelay: menuOpen ? `${delayMs}ms` : "0ms",
}}
className="pointer-events-auto absolute left-0 top-0 flex h-[54px] w-[54px] items-center justify-center rounded-full border border-white/18 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.42),rgba(255,255,255,0.08)_38%),linear-gradient(180deg,#ffffff_0%,#eef2f7_100%)] text-[#182131] opacity-100 shadow-[0_18px_36px_-18px_rgba(15,23,42,0.9)] transition-[transform,opacity] duration-300 [transition-timing-function:cubic-bezier(0.22,1.22,0.36,1)] hover:scale-105 disabled:cursor-not-allowed disabled:opacity-45"
>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-[#f3f5f8] shadow-inner">
{loading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-[#c6ccd7] border-t-[#182131]" />
) : (
icon
)}
</span>
</button>
);
}
function StartIcon() {
return (
<svg className="h-4.5 w-4.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5.14v13.72L19 12 8 5.14z" />
</svg>
);
}
function StopIcon() {
return (
<svg className="h-4.5 w-4.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 7h10v10H7z" />
</svg>
);
}
function RestartIcon() {
return (
<svg
className="h-4.5 w-4.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20 12a8 8 0 10-2.34 5.66M20 12v-6m0 6h-6"
/>
</svg>
);
}
function gatewayBallTone(status: string) {
const normalized = status.trim().toLowerCase();
switch (normalized) {
case "running":
case "started":
case "online":
return {
gradient:
"radial-gradient(circle at 32% 28%, rgba(255,255,255,0.44), rgba(255,255,255,0.04) 35%), linear-gradient(145deg, #34d399 0%, #16a34a 100%)",
};
case "starting":
case "stopping":
case "configuring":
case "pending":
return {
gradient:
"radial-gradient(circle at 32% 28%, rgba(255,255,255,0.46), rgba(255,255,255,0.05) 35%), linear-gradient(145deg, #fde68a 0%, #eab308 100%)",
};
default:
return {
gradient:
"radial-gradient(circle at 32% 28%, rgba(255,255,255,0.44), rgba(255,255,255,0.04) 35%), linear-gradient(145deg, #fb7185 0%, #dc2626 100%)",
};
}
}
+240 -93
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { instanceService } from '../services/instanceService';
import { useCallback, useEffect, useRef, useState } from "react";
import { instanceService } from "../services/instanceService";
interface RefreshAccessOptions {
forceReload?: boolean;
@@ -9,6 +9,7 @@ interface RefreshAccessOptions {
interface UseInstanceDesktopAccessOptions {
instanceId: number | null;
isRunning: boolean;
retainSessionOnStop?: boolean;
resolveEmbedUrl: (url: string | null) => string | null;
failedMessage: string;
refreshLeadMs?: number;
@@ -17,36 +18,93 @@ interface UseInstanceDesktopAccessOptions {
const DEFAULT_REFRESH_LEAD_MS = 5 * 60 * 1000;
const DEFAULT_RETRY_DELAY_MS = 5000;
const FRAME_ERROR_PATTERN = /(access token expired or invalid|access token required|token does not match instance|failed to proxy request)/i;
const FRAME_ERROR_PATTERN =
/(access token expired or invalid|access token required|token does not match instance|failed to proxy request)/i;
type DesktopSessionSnapshot = {
embedUrl: string | null;
expiresAt: number | null;
hasEstablishedSession: boolean;
};
const desktopSessionStore = new Map<number, DesktopSessionSnapshot>();
export function useInstanceDesktopAccess({
instanceId,
isRunning,
retainSessionOnStop = false,
resolveEmbedUrl,
failedMessage,
refreshLeadMs = DEFAULT_REFRESH_LEAD_MS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
}: UseInstanceDesktopAccessOptions) {
const [embedUrl, setEmbedUrl] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
const initialCachedSession = instanceId
? desktopSessionStore.get(instanceId)
: null;
const [embedUrl, setEmbedUrl] = useState<string | null>(
initialCachedSession?.embedUrl ?? null,
);
const [expiresAt, setExpiresAt] = useState<Date | null>(
initialCachedSession?.expiresAt
? new Date(initialCachedSession.expiresAt)
: null,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [frameKey, setFrameKey] = useState(0);
const [reconnecting, setReconnecting] = useState(false);
const requestIdRef = useRef(0);
const embedUrlRef = useRef<string | null>(null);
const expiresAtRef = useRef<Date | null>(null);
const embedUrlRef = useRef<string | null>(initialCachedSession?.embedUrl ?? null);
const expiresAtRef = useRef<Date | null>(
initialCachedSession?.expiresAt
? new Date(initialCachedSession.expiresAt)
: null,
);
const retryTimeoutRef = useRef<number | null>(null);
const refreshTimeoutRef = useRef<number | null>(null);
const refreshAccessRef = useRef<((options?: RefreshAccessOptions) => Promise<void>) | null>(null);
const refreshAccessRef = useRef<
((options?: RefreshAccessOptions) => Promise<void>) | null
>(null);
const hasEstablishedSessionRef = useRef(
initialCachedSession?.hasEstablishedSession ?? false,
);
const syncSessionStore = useCallback(
(patch: Partial<DesktopSessionSnapshot>) => {
if (!instanceId) {
return;
}
const current = desktopSessionStore.get(instanceId) ?? {
embedUrl: embedUrlRef.current,
expiresAt: expiresAtRef.current?.getTime() ?? null,
hasEstablishedSession: hasEstablishedSessionRef.current,
};
desktopSessionStore.set(instanceId, {
embedUrl:
patch.embedUrl !== undefined ? patch.embedUrl : current.embedUrl,
expiresAt:
patch.expiresAt !== undefined ? patch.expiresAt : current.expiresAt,
hasEstablishedSession:
patch.hasEstablishedSession !== undefined
? patch.hasEstablishedSession
: current.hasEstablishedSession,
});
},
[instanceId],
);
useEffect(() => {
embedUrlRef.current = embedUrl;
}, [embedUrl]);
syncSessionStore({ embedUrl });
}, [embedUrl, syncSessionStore]);
useEffect(() => {
expiresAtRef.current = expiresAt;
}, [expiresAt]);
syncSessionStore({ expiresAt: expiresAt?.getTime() ?? null });
}, [expiresAt, syncSessionStore]);
const clearRetryTimeout = useCallback(() => {
if (retryTimeoutRef.current !== null) {
@@ -68,98 +126,156 @@ export function useInstanceDesktopAccess({
requestIdRef.current += 1;
embedUrlRef.current = null;
expiresAtRef.current = null;
hasEstablishedSessionRef.current = false;
if (instanceId) {
desktopSessionStore.delete(instanceId);
}
setEmbedUrl(null);
setExpiresAt(null);
setError(null);
setLoading(false);
setReconnecting(false);
setFrameKey(0);
}, [clearRefreshTimeout, clearRetryTimeout]);
}, [clearRefreshTimeout, clearRetryTimeout, instanceId]);
const shouldPreserveSession = useCallback(() => {
return (
retainSessionOnStop &&
(hasEstablishedSessionRef.current || Boolean(embedUrlRef.current))
);
}, [retainSessionOnStop]);
const scheduleRetry = useCallback(() => {
clearRetryTimeout();
if (shouldPreserveSession()) {
return;
}
if (!instanceId || !isRunning || document.hidden) {
return;
}
retryTimeoutRef.current = window.setTimeout(() => {
retryTimeoutRef.current = null;
void refreshAccessRef.current?.({ forceReload: !embedUrlRef.current, silent: true });
void refreshAccessRef.current?.({
forceReload: !embedUrlRef.current,
silent: true,
});
}, retryDelayMs);
}, [clearRetryTimeout, instanceId, isRunning, retryDelayMs]);
}, [clearRetryTimeout, instanceId, isRunning, retryDelayMs, shouldPreserveSession]);
const refreshAccess = useCallback(async ({ forceReload = false, silent = false }: RefreshAccessOptions = {}) => {
if (!instanceId || !isRunning) {
clearAccessState();
return;
}
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
clearRetryTimeout();
if (silent) {
setReconnecting(true);
} else {
setLoading(true);
}
try {
const data = await instanceService.generateAccessToken(instanceId);
if (requestId !== requestIdRef.current) {
const refreshAccess = useCallback(
async ({ forceReload = false, silent = false }: RefreshAccessOptions = {}) => {
if (!instanceId || !isRunning) {
if (shouldPreserveSession()) {
return;
}
clearAccessState();
return;
}
const nextEmbedUrl = resolveEmbedUrl(data.proxy_url || data.access_url);
const nextExpiresAt = new Date(data.expires_at);
const previousEmbedUrl = embedUrlRef.current;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
clearRetryTimeout();
expiresAtRef.current = nextExpiresAt;
setExpiresAt(nextExpiresAt);
setError(null);
if (!previousEmbedUrl || forceReload) {
embedUrlRef.current = nextEmbedUrl;
setEmbedUrl(nextEmbedUrl);
setFrameKey((current) => current + 1);
if (silent) {
setReconnecting(true);
} else {
setEmbedUrl(previousEmbedUrl);
}
} catch (err: any) {
if (requestId !== requestIdRef.current) {
return;
setLoading(true);
}
setError(err.response?.data?.error || failedMessage);
if (!embedUrlRef.current) {
setEmbedUrl(null);
setExpiresAt(null);
try {
const data = await instanceService.generateAccessToken(instanceId);
if (requestId !== requestIdRef.current) {
return;
}
const nextEmbedUrl = resolveEmbedUrl(data.proxy_url || data.access_url);
const nextExpiresAt = new Date(data.expires_at);
const previousEmbedUrl = embedUrlRef.current;
expiresAtRef.current = nextExpiresAt;
setExpiresAt(nextExpiresAt);
setError(null);
if (!previousEmbedUrl || forceReload) {
embedUrlRef.current = nextEmbedUrl;
setEmbedUrl(nextEmbedUrl);
} else {
setEmbedUrl(previousEmbedUrl);
}
syncSessionStore({
embedUrl: !previousEmbedUrl || forceReload ? nextEmbedUrl : previousEmbedUrl,
expiresAt: nextExpiresAt.getTime(),
});
} catch (err: any) {
if (requestId !== requestIdRef.current) {
return;
}
setError(err.response?.data?.error || failedMessage);
if (!embedUrlRef.current) {
setEmbedUrl(null);
setExpiresAt(null);
}
scheduleRetry();
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
setReconnecting(false);
}
}
scheduleRetry();
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
setReconnecting(false);
}
}
}, [clearAccessState, clearRetryTimeout, failedMessage, instanceId, isRunning, resolveEmbedUrl, scheduleRetry]);
},
[
clearAccessState,
clearRetryTimeout,
failedMessage,
instanceId,
isRunning,
resolveEmbedUrl,
scheduleRetry,
shouldPreserveSession,
syncSessionStore,
],
);
useEffect(() => {
refreshAccessRef.current = refreshAccess;
}, [refreshAccess]);
useEffect(() => {
if (!instanceId || !isRunning) {
if (!instanceId) {
clearAccessState();
return;
}
void refreshAccess({ forceReload: true });
if (!isRunning) {
if (shouldPreserveSession() || embedUrlRef.current) {
clearRetryTimeout();
clearRefreshTimeout();
return;
}
clearAccessState();
return;
}
if (!embedUrlRef.current) {
void refreshAccess({ forceReload: true });
}
return () => {
clearRetryTimeout();
clearRefreshTimeout();
};
}, [clearAccessState, clearRefreshTimeout, clearRetryTimeout, instanceId, isRunning, refreshAccess]);
}, [
clearAccessState,
clearRefreshTimeout,
clearRetryTimeout,
instanceId,
isRunning,
refreshAccess,
shouldPreserveSession,
]);
useEffect(() => {
if (!instanceId || !isRunning || !expiresAt) {
@@ -167,10 +283,16 @@ export function useInstanceDesktopAccess({
return;
}
if (shouldPreserveSession()) {
clearRefreshTimeout();
return;
}
const remainingMs = expiresAt.getTime() - Date.now();
const delay = remainingMs <= refreshLeadMs
? Math.max(remainingMs - 30 * 1000, 0)
: remainingMs - refreshLeadMs;
const delay =
remainingMs <= refreshLeadMs
? Math.max(remainingMs - 30 * 1000, 0)
: remainingMs - refreshLeadMs;
refreshTimeoutRef.current = window.setTimeout(() => {
refreshTimeoutRef.current = null;
@@ -178,9 +300,20 @@ export function useInstanceDesktopAccess({
}, delay);
return clearRefreshTimeout;
}, [clearRefreshTimeout, expiresAt, instanceId, isRunning, refreshLeadMs]);
}, [
clearRefreshTimeout,
expiresAt,
instanceId,
isRunning,
refreshLeadMs,
shouldPreserveSession,
]);
useEffect(() => {
if (shouldPreserveSession()) {
return;
}
const maybeReconnect = () => {
if (!instanceId || !isRunning) {
return;
@@ -188,7 +321,8 @@ export function useInstanceDesktopAccess({
const currentExpiry = expiresAtRef.current?.getTime() ?? 0;
const hasActiveFrame = Boolean(embedUrlRef.current);
const isNearExpiry = currentExpiry === 0 || currentExpiry - Date.now() <= refreshLeadMs;
const isNearExpiry =
currentExpiry === 0 || currentExpiry - Date.now() <= refreshLeadMs;
if (!hasActiveFrame) {
void refreshAccessRef.current?.({ forceReload: true, silent: true });
@@ -201,41 +335,54 @@ export function useInstanceDesktopAccess({
};
const handleVisibilityChange = () => {
if (document.hidden) {
return;
if (!document.hidden) {
maybeReconnect();
}
maybeReconnect();
};
const handleFocus = () => {
maybeReconnect();
};
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleFocus);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleFocus);
};
}, [instanceId, isRunning, refreshLeadMs]);
}, [instanceId, isRunning, refreshLeadMs, shouldPreserveSession]);
const handleFrameLoad = useCallback((frame: HTMLIFrameElement | null) => {
if (!frame) {
return;
}
window.setTimeout(() => {
try {
const frameText = frame.contentDocument?.body?.textContent?.trim() ?? '';
if (frameText && FRAME_ERROR_PATTERN.test(frameText)) {
void refreshAccessRef.current?.({ forceReload: true, silent: true });
}
} catch (frameError) {
console.error('Failed to inspect desktop frame state', frameError);
const handleFrameLoad = useCallback(
(frame: HTMLIFrameElement | null) => {
if (!frame) {
return;
}
}, 0);
window.setTimeout(() => {
try {
const frameText = frame.contentDocument?.body?.textContent?.trim() ?? "";
if (frameText && FRAME_ERROR_PATTERN.test(frameText)) {
if (!hasEstablishedSessionRef.current) {
void refreshAccessRef.current?.({ forceReload: true, silent: true });
}
return;
}
hasEstablishedSessionRef.current = true;
syncSessionStore({ hasEstablishedSession: true });
} catch (frameError) {
console.error("Failed to inspect desktop frame state", frameError);
}
}, 0);
},
[syncSessionStore],
);
const handleFrameError = useCallback(() => {
if (!hasEstablishedSessionRef.current) {
void refreshAccessRef.current?.({ forceReload: true, silent: true });
}
}, []);
return {
@@ -243,9 +390,9 @@ export function useInstanceDesktopAccess({
expiresAt,
loading,
error,
frameKey,
reconnecting,
refreshAccess,
handleFrameLoad,
handleFrameError,
};
}
File diff suppressed because it is too large Load Diff
+291 -174
View File
@@ -10,6 +10,229 @@ import { useI18n } from '../../contexts/I18nContext';
type ViewMode = 'list' | 'card';
type StatusFilter = 'all' | 'running' | 'stopped' | 'creating' | 'error';
const INSTANCE_FIELDS_TO_COMPARE: Array<keyof Instance> = [
'id',
'user_id',
'name',
'description',
'type',
'status',
'cpu_cores',
'memory_gb',
'disk_gb',
'gpu_enabled',
'gpu_count',
'os_type',
'os_version',
'image_registry',
'image_tag',
'storage_class',
'mount_path',
'pod_name',
'pod_namespace',
'pod_ip',
'access_url',
'openclaw_config_snapshot_id',
'created_at',
'updated_at',
'started_at',
'stopped_at',
];
const instancesEqual = (left: Instance, right: Instance) =>
INSTANCE_FIELDS_TO_COMPARE.every((field) => left[field] === right[field]);
const mergeInstances = (current: Instance[], incoming: Instance[]) => {
const currentById = new Map(current.map((instance) => [instance.id, instance]));
return incoming.map((nextInstance) => {
const existingInstance = currentById.get(nextInstance.id);
return existingInstance && instancesEqual(existingInstance, nextInstance)
? existingInstance
: nextInstance;
});
};
interface InstanceItemProps {
instance: Instance;
actionLoading: number | null;
deletingIds: number[];
onStart: (id: number) => void;
onStop: (id: number) => void;
onRequestDelete: (id: number) => void;
getStatusColor: (status: string) => string;
getStatusIcon: (status: string) => React.ReactNode;
getTypeIcon: (type: string) => string;
t: (key: string, options?: Record<string, string | number>) => string;
}
const InstanceCardItem = React.memo(({
instance,
actionLoading,
deletingIds,
onStart,
onStop,
onRequestDelete,
getStatusColor,
getStatusIcon,
getTypeIcon,
t,
}: InstanceItemProps) => (
<div className="app-panel transition-shadow duration-200 hover:shadow-[0_30px_80px_-52px_rgba(72,44,24,0.62)]">
<div className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-center">
<span className="text-2xl mr-3">{getTypeIcon(instance.type)}</span>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{instance.name}
</h3>
<p className="text-sm text-gray-500">{instance.type}</p>
</div>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] leading-5 font-medium ${getStatusColor(instance.status)}`}>
{getStatusIcon(instance.status)}
{t(`status.${instance.status}`)}
</span>
</div>
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
<div className="bg-gray-50 rounded p-2">
<p className="text-xs text-gray-500">{t('common.cpu')}</p>
<p className="text-sm font-semibold text-gray-900">{instance.cpu_cores}</p>
</div>
<div className="bg-gray-50 rounded p-2">
<p className="text-xs text-gray-500">{t('common.memory')}</p>
<p className="text-sm font-semibold text-gray-900">{instance.memory_gb} GB</p>
</div>
<div className="bg-gray-50 rounded p-2">
<p className="text-xs text-gray-500">{t('common.disk')}</p>
<p className="text-sm font-semibold text-gray-900">{instance.disk_gb} GB</p>
</div>
</div>
{instance.description && (
<p className="mt-4 text-sm text-gray-600 line-clamp-2">{instance.description}</p>
)}
<div className="mt-4 text-xs text-gray-500">
{instance.os_type} {instance.os_version}
</div>
</div>
<div className="flex items-center justify-between border-t border-[#f1e7e1] bg-[rgba(255,248,245,0.82)] px-6 py-4">
<div className="flex space-x-2">
{instance.status === 'running' ? (
<button
onClick={() => onStop(instance.id)}
disabled={actionLoading === instance.id}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:outline-none disabled:opacity-50"
>
{actionLoading === instance.id ? `${t('common.stop')}...` : t('common.stop')}
</button>
) : instance.status === 'stopped' ? (
<button
onClick={() => onStart(instance.id)}
disabled={actionLoading === instance.id}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200 focus:outline-none disabled:opacity-50"
>
{actionLoading === instance.id ? `${t('common.start')}...` : t('common.start')}
</button>
) : null}
</div>
<div className="flex space-x-2">
<Link
to={`/instances/${instance.id}`}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
>
{t('instances.details')}
</Link>
<button
onClick={() => onRequestDelete(instance.id)}
disabled={deletingIds.includes(instance.id)}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none disabled:opacity-50"
>
{deletingIds.includes(instance.id) ? `${t('common.delete')}...` : t('common.delete')}
</button>
</div>
</div>
</div>
));
const InstanceListItem = React.memo(({
instance,
actionLoading,
deletingIds,
onStart,
onStop,
onRequestDelete,
getStatusColor,
getStatusIcon,
getTypeIcon,
t,
}: InstanceItemProps) => (
<li className="px-4 py-4 hover:bg-[#fffaf6] sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center">
<span className="text-xl mr-2">{getTypeIcon(instance.type)}</span>
<h3 className="text-lg font-medium text-[#dc2626] truncate">
{instance.name}
</h3>
<span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(instance.status)}`}>
{getStatusIcon(instance.status)}
{t(`status.${instance.status}`)}
</span>
</div>
<div className="mt-1 flex items-center text-sm text-gray-500">
<span className="mr-4">{instance.type}</span>
<span className="mr-4">{instance.os_type} {instance.os_version}</span>
<span className="mr-4">{instance.cpu_cores} {t('common.cpu')}</span>
<span className="mr-4">{instance.memory_gb} GB {t('common.memory')}</span>
<span>{instance.disk_gb} GB {t('common.disk')}</span>
</div>
{instance.description && (
<p className="mt-1 text-sm text-gray-500">{instance.description}</p>
)}
</div>
<div className="ml-4 flex items-center space-x-2">
{instance.status === 'running' ? (
<button
onClick={() => onStop(instance.id)}
disabled={actionLoading === instance.id}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:outline-none disabled:opacity-50"
>
{actionLoading === instance.id ? `${t('common.stop')}...` : t('common.stop')}
</button>
) : instance.status === 'stopped' ? (
<button
onClick={() => onStart(instance.id)}
disabled={actionLoading === instance.id}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200 focus:outline-none disabled:opacity-50"
>
{actionLoading === instance.id ? `${t('common.start')}...` : t('common.start')}
</button>
) : null}
<Link
to={`/instances/${instance.id}`}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
>
{t('instances.details')}
</Link>
<button
onClick={() => onRequestDelete(instance.id)}
disabled={deletingIds.includes(instance.id)}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none disabled:opacity-50"
>
{deletingIds.includes(instance.id) ? `${t('common.delete')}...` : t('common.delete')}
</button>
</div>
</div>
</li>
));
const InstanceListPage: React.FC = () => {
const { t } = useI18n();
const [instances, setInstances] = useState<Instance[]>([]);
@@ -24,9 +247,26 @@ const InstanceListPage: React.FC = () => {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [searchQuery, setSearchQuery] = useState('');
const loadInstances = useCallback(async (options?: { silent?: boolean }) => {
try {
if (!options?.silent) {
setLoading(true);
}
setError(null);
const data = await instanceService.getInstances();
setInstances((prevInstances) => mergeInstances(prevInstances, data.instances));
} catch (err: any) {
setError(err.response?.data?.error || t('instances.failedToLoad'));
} finally {
if (!options?.silent) {
setLoading(false);
}
}
}, [t]);
useEffect(() => {
loadInstances();
}, []);
void loadInstances();
}, [loadInstances]);
useEffect(() => {
if (!instances.some((instance) => instance.status === 'creating')) {
@@ -34,7 +274,7 @@ const InstanceListPage: React.FC = () => {
}
const intervalId = window.setInterval(() => {
loadInstances();
void loadInstances({ silent: true });
}, 5000);
return () => {
@@ -42,30 +282,21 @@ const InstanceListPage: React.FC = () => {
};
}, [instances]);
const loadInstances = async () => {
try {
setLoading(true);
setError(null);
const data = await instanceService.getInstances();
setInstances(data.instances);
} catch (err: any) {
setError(err.response?.data?.error || t('instances.failedToLoad'));
} finally {
setLoading(false);
}
};
// Handle WebSocket status updates
const handleStatusUpdate = useCallback((update: { instance_id: number; status: string; pod_name?: string; pod_ip?: string }) => {
setInstances(prevInstances =>
prevInstances.map(instance =>
instance.id === update.instance_id
? {
...instance,
status: update.status as Instance['status'],
pod_name: update.pod_name,
pod_ip: update.pod_ip,
}
instance.id === update.instance_id
? (() => {
const nextInstance = {
...instance,
status: update.status as Instance['status'],
pod_name: update.pod_name,
pod_ip: update.pod_ip,
};
return instancesEqual(instance, nextInstance) ? instance : nextInstance;
})()
: instance
)
);
@@ -95,7 +326,7 @@ const InstanceListPage: React.FC = () => {
});
}, [instances, statusFilter, searchQuery]);
const handleDelete = async (id: number) => {
const handleDelete = useCallback(async (id: number) => {
try {
setDeletingIds((prevIds) => [...prevIds, id]);
await instanceService.deleteInstance(id);
@@ -106,31 +337,35 @@ const InstanceListPage: React.FC = () => {
} finally {
setDeletingIds((prevIds) => prevIds.filter((deletingId) => deletingId !== id));
}
};
}, [t]);
const handleStart = async (id: number) => {
const handleStart = useCallback(async (id: number) => {
try {
setActionLoading(id);
await instanceService.startInstance(id);
await loadInstances();
await loadInstances({ silent: true });
} catch (err: any) {
alert(err.response?.data?.error || t('instances.failedToStart'));
} finally {
setActionLoading(null);
}
};
}, [loadInstances, t]);
const handleStop = async (id: number) => {
const handleStop = useCallback(async (id: number) => {
try {
setActionLoading(id);
await instanceService.stopInstance(id);
await loadInstances();
await loadInstances({ silent: true });
} catch (err: any) {
alert(err.response?.data?.error || t('instances.failedToStop'));
} finally {
setActionLoading(null);
}
};
}, [loadInstances, t]);
const handleRequestDelete = useCallback((id: number) => {
setPendingDeleteId(id);
}, []);
const getStatusColor = (status: string) => {
switch (status) {
@@ -192,88 +427,19 @@ const InstanceListPage: React.FC = () => {
const CardView = () => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredInstances.map((instance) => (
<div
<InstanceCardItem
key={instance.id}
className="app-panel transition-shadow duration-200 hover:shadow-[0_30px_80px_-52px_rgba(72,44,24,0.62)]"
>
<div className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-center">
<span className="text-2xl mr-3">{getTypeIcon(instance.type)}</span>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{instance.name}
</h3>
<p className="text-sm text-gray-500">{instance.type}</p>
</div>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[11px] leading-5 font-medium ${getStatusColor(instance.status)}`}>
{getStatusIcon(instance.status)}
{t(`status.${instance.status}`)}
</span>
</div>
<div className="mt-4 grid grid-cols-3 gap-4 text-center">
<div className="bg-gray-50 rounded p-2">
<p className="text-xs text-gray-500">{t('common.cpu')}</p>
<p className="text-sm font-semibold text-gray-900">{instance.cpu_cores}</p>
</div>
<div className="bg-gray-50 rounded p-2">
<p className="text-xs text-gray-500">{t('common.memory')}</p>
<p className="text-sm font-semibold text-gray-900">{instance.memory_gb} GB</p>
</div>
<div className="bg-gray-50 rounded p-2">
<p className="text-xs text-gray-500">{t('common.disk')}</p>
<p className="text-sm font-semibold text-gray-900">{instance.disk_gb} GB</p>
</div>
</div>
{instance.description && (
<p className="mt-4 text-sm text-gray-600 line-clamp-2">{instance.description}</p>
)}
<div className="mt-4 text-xs text-gray-500">
{instance.os_type} {instance.os_version}
</div>
</div>
<div className="flex items-center justify-between border-t border-[#f1e7e1] bg-[rgba(255,248,245,0.82)] px-6 py-4">
<div className="flex space-x-2">
{instance.status === 'running' ? (
<button
onClick={() => handleStop(instance.id)}
disabled={actionLoading === instance.id}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:outline-none disabled:opacity-50"
>
{actionLoading === instance.id ? `${t('common.stop')}...` : t('common.stop')}
</button>
) : instance.status === 'stopped' ? (
<button
onClick={() => handleStart(instance.id)}
disabled={actionLoading === instance.id}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200 focus:outline-none disabled:opacity-50"
>
{actionLoading === instance.id ? `${t('common.start')}...` : t('common.start')}
</button>
) : null}
</div>
<div className="flex space-x-2">
<Link
to={`/instances/${instance.id}`}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
>
{t('instances.details')}
</Link>
<button
onClick={() => setPendingDeleteId(instance.id)}
disabled={deletingIds.includes(instance.id)}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none disabled:opacity-50"
>
{deletingIds.includes(instance.id) ? `${t('common.delete')}...` : t('common.delete')}
</button>
</div>
</div>
</div>
instance={instance}
actionLoading={actionLoading}
deletingIds={deletingIds}
onStart={handleStart}
onStop={handleStop}
onRequestDelete={handleRequestDelete}
getStatusColor={getStatusColor}
getStatusIcon={getStatusIcon}
getTypeIcon={getTypeIcon}
t={t}
/>
))}
</div>
);
@@ -283,66 +449,19 @@ const InstanceListPage: React.FC = () => {
<div className="app-panel overflow-hidden">
<ul className="divide-y divide-[#f1e7e1]">
{filteredInstances.map((instance) => (
<li key={instance.id} className="px-4 py-4 hover:bg-[#fffaf6] sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center">
<span className="text-xl mr-2">{getTypeIcon(instance.type)}</span>
<h3 className="text-lg font-medium text-[#dc2626] truncate">
{instance.name}
</h3>
<span className={`ml-3 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(instance.status)}`}>
{getStatusIcon(instance.status)}
{t(`status.${instance.status}`)}
</span>
</div>
<div className="mt-1 flex items-center text-sm text-gray-500">
<span className="mr-4">{instance.type}</span>
<span className="mr-4">{instance.os_type} {instance.os_version}</span>
<span className="mr-4">{instance.cpu_cores} {t('common.cpu')}</span>
<span className="mr-4">{instance.memory_gb} GB {t('common.memory')}</span>
<span>{instance.disk_gb} GB {t('common.disk')}</span>
</div>
{instance.description && (
<p className="mt-1 text-sm text-gray-500">{instance.description}</p>
)}
</div>
<div className="ml-4 flex items-center space-x-2">
{instance.status === 'running' ? (
<button
onClick={() => handleStop(instance.id)}
disabled={actionLoading === instance.id}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-yellow-700 bg-yellow-100 hover:bg-yellow-200 focus:outline-none disabled:opacity-50"
>
{actionLoading === instance.id ? `${t('common.stop')}...` : t('common.stop')}
</button>
) : instance.status === 'stopped' ? (
<button
onClick={() => handleStart(instance.id)}
disabled={actionLoading === instance.id}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200 focus:outline-none disabled:opacity-50"
>
{actionLoading === instance.id ? `${t('common.start')}...` : t('common.start')}
</button>
) : null}
<Link
to={`/instances/${instance.id}`}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
>
{t('instances.details')}
</Link>
<button
onClick={() => setPendingDeleteId(instance.id)}
disabled={deletingIds.includes(instance.id)}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none disabled:opacity-50"
>
{deletingIds.includes(instance.id) ? `${t('common.delete')}...` : t('common.delete')}
</button>
</div>
</div>
</li>
<InstanceListItem
key={instance.id}
instance={instance}
actionLoading={actionLoading}
deletingIds={deletingIds}
onStart={handleStart}
onStop={handleStop}
onRequestDelete={handleRequestDelete}
getStatusColor={getStatusColor}
getStatusIcon={getStatusIcon}
getTypeIcon={getTypeIcon}
t={t}
/>
))}
</ul>
</div>
@@ -557,5 +676,3 @@ const InstanceListPage: React.FC = () => {
};
export default InstanceListPage;
@@ -1,9 +1,20 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import UserLayout from '../../components/UserLayout';
import { useInstanceDesktopAccess } from '../../hooks/useInstanceDesktopAccess';
import { instanceService } from '../../services/instanceService';
import type { Instance } from '../../types/instance';
import { useI18n } from '../../contexts/I18nContext';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { OpenClawDesktopOverlay } from "../../components/OpenClawDesktopOverlay";
import UserLayout from "../../components/UserLayout";
import { useInstanceDesktopAccess } from "../../hooks/useInstanceDesktopAccess";
import { instanceService } from "../../services/instanceService";
import type { Instance, InstanceRuntimeDetails } from "../../types/instance";
import { useI18n } from "../../contexts/I18nContext";
const PORTAL_RUNTIME_POLL_INTERVAL_MS = 5000;
const PORTAL_RUNTIME_BURST_POLL_INTERVAL_MS = 1000;
const PORTAL_RUNTIME_BURST_WINDOW_MS = 15000;
const InstancePortalPage: React.FC = () => {
const { t } = useI18n();
@@ -13,6 +24,12 @@ const InstancePortalPage: React.FC = () => {
const [selectedId, setSelectedId] = useState<number | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [shouldConnect, setShouldConnect] = useState(false);
const [runtimeDetails, setRuntimeDetails] =
useState<InstanceRuntimeDetails | null>(null);
const [runtimeActionLoading, setRuntimeActionLoading] = useState<
string | null
>(null);
const [runtimeBurstUntil, setRuntimeBurstUntil] = useState<number>(0);
const frameShellRef = useRef<HTMLElement | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -25,12 +42,14 @@ const InstancePortalPage: React.FC = () => {
return url;
}
const explicitOrigin = import.meta.env.VITE_BACKEND_ORIGIN as string | undefined;
const explicitOrigin = import.meta.env.VITE_BACKEND_ORIGIN as
| string
| undefined;
if (explicitOrigin) {
return new URL(url, explicitOrigin).toString();
}
if (window.location.port === '9002' && url.startsWith('/api/')) {
if (window.location.port === "9002" && url.startsWith("/api/")) {
return `${window.location.protocol}//${window.location.hostname}:9001${url}`;
}
@@ -44,15 +63,20 @@ const InstancePortalPage: React.FC = () => {
const data = await instanceService.getInstances(1, 100);
setInstances(data.instances);
setSelectedId((currentSelectedId) => {
if (currentSelectedId && data.instances.some((instance) => instance.id === currentSelectedId)) {
if (
currentSelectedId &&
data.instances.some((instance) => instance.id === currentSelectedId)
) {
return currentSelectedId;
}
const firstRunning = data.instances.find((instance) => instance.status === 'running');
const firstRunning = data.instances.find(
(instance) => instance.status === "running",
);
return firstRunning?.id ?? data.instances[0]?.id ?? null;
});
} catch (err: any) {
setError(err.response?.data?.error || t('instances.failedToLoad'));
setError(err.response?.data?.error || t("instances.failedToLoad"));
} finally {
setLoading(false);
}
@@ -67,9 +91,9 @@ const InstancePortalPage: React.FC = () => {
setIsFullscreen(document.fullscreenElement === frameShellRef.current);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, []);
@@ -83,35 +107,79 @@ const InstancePortalPage: React.FC = () => {
expiresAt,
loading: accessLoading,
error: accessError,
frameKey,
reconnecting,
refreshAccess,
handleFrameLoad,
handleFrameError,
} = useInstanceDesktopAccess({
instanceId: selectedInstance?.id ?? null,
isRunning: selectedInstance?.status === 'running' && shouldConnect,
isRunning: selectedInstance?.status === "running" && shouldConnect,
retainSessionOnStop: shouldConnect,
resolveEmbedUrl,
failedMessage: t('instances.failedToGenerateAccessToken'),
failedMessage: t("instances.failedToGenerateAccessToken"),
});
useEffect(() => {
setShouldConnect(false);
}, [selectedId]);
useEffect(() => {
if (selectedInstance?.status !== 'running') {
setShouldConnect(false);
const loadRuntimeDetails = useCallback(async (instanceId: number) => {
try {
const data = await instanceService.getRuntimeDetails(instanceId);
setRuntimeDetails(data);
} catch (runtimeError) {
console.error("Failed to load portal runtime details", runtimeError);
setRuntimeDetails(null);
}
}, [selectedInstance?.status]);
}, []);
useEffect(() => {
if (!selectedInstance || selectedInstance.type !== "openclaw") {
setRuntimeDetails(null);
return;
}
void loadRuntimeDetails(selectedInstance.id);
const runtimePollInterval =
runtimeBurstUntil > Date.now()
? PORTAL_RUNTIME_BURST_POLL_INTERVAL_MS
: PORTAL_RUNTIME_POLL_INTERVAL_MS;
const timer = window.setInterval(() => {
if (document.hidden) {
return;
}
void loadRuntimeDetails(selectedInstance.id);
}, runtimePollInterval);
return () => {
window.clearInterval(timer);
};
}, [loadRuntimeDetails, runtimeBurstUntil, selectedInstance]);
useEffect(() => {
if (runtimeBurstUntil <= Date.now()) {
return;
}
const timeout = window.setTimeout(() => {
setRuntimeBurstUntil(0);
}, runtimeBurstUntil - Date.now());
return () => {
window.clearTimeout(timeout);
};
}, [runtimeBurstUntil]);
const formatRemaining = () => {
if (!expiresAt) {
return '';
return "";
}
const diff = expiresAt.getTime() - Date.now();
if (diff <= 0) {
return t('instances.expired');
return t("instances.expired");
}
const minutes = Math.floor(diff / 60000);
@@ -119,16 +187,16 @@ const InstancePortalPage: React.FC = () => {
return `${minutes}m ${seconds}s`;
};
const getStatusDot = (status: Instance['status']) => {
const getStatusDot = (status: Instance["status"]) => {
switch (status) {
case 'running':
return 'bg-green-500';
case 'creating':
return 'bg-amber-500';
case 'error':
return 'bg-red-500';
case "running":
return "bg-green-500";
case "creating":
return "bg-amber-500";
case "error":
return "bg-red-500";
default:
return 'bg-gray-400';
return "bg-gray-400";
}
};
@@ -145,18 +213,18 @@ const InstancePortalPage: React.FC = () => {
await target.requestFullscreen();
}
} catch (fullscreenError) {
console.error('Failed to toggle portal fullscreen', fullscreenError);
console.error("Failed to toggle portal fullscreen", fullscreenError);
}
};
const requestAccess = () => {
if (selectedInstance?.status === 'running') {
if (selectedInstance?.status === "running") {
setShouldConnect(true);
}
};
const retryAccess = () => {
if (!selectedInstance || selectedInstance.status !== 'running') {
if (!selectedInstance || selectedInstance.status !== "running") {
return;
}
@@ -168,38 +236,77 @@ const InstancePortalPage: React.FC = () => {
requestAccess();
};
const handleRuntimeCommand = async (
command:
| "start"
| "stop"
| "restart"
| "collect-system-info"
| "health-check",
) => {
if (!selectedInstance) {
return;
}
try {
setRuntimeActionLoading(`runtime-${command}`);
setRuntimeBurstUntil(Date.now() + PORTAL_RUNTIME_BURST_WINDOW_MS);
await instanceService.createRuntimeCommand(selectedInstance.id, command);
await loadRuntimeDetails(selectedInstance.id);
window.setTimeout(() => {
void loadRuntimeDetails(selectedInstance.id);
}, 800);
window.setTimeout(() => {
void loadRuntimeDetails(selectedInstance.id);
}, 2000);
window.setTimeout(() => {
void loadRuntimeDetails(selectedInstance.id);
}, 5000);
} catch (runtimeError) {
console.error("Failed to queue portal runtime command", runtimeError);
} finally {
setRuntimeActionLoading(null);
}
};
const playerStatusText = !selectedInstance
? t('instances.portalSelectInstanceSubtitle')
? t("instances.portalSelectInstanceSubtitle")
: embedUrl
? accessLoading || reconnecting || !expiresAt
? t('instances.generatingToken')
: `${t('instances.expiresIn')}: ${formatRemaining()}`
: selectedInstance.status === 'running'
? t("instances.generatingToken")
: `${t("instances.expiresIn")}: ${formatRemaining()}`
: selectedInstance.status === "running"
? accessLoading && shouldConnect
? t('instances.generatingToken')
: t('instances.readyToAccess')
: t('instances.instanceMustBeRunning');
? t("instances.generatingToken")
: t("instances.readyToAccess")
: t("instances.instanceMustBeRunning");
return (
<UserLayout title={t('instances.portalTitle')}>
<UserLayout title={t("instances.portalTitle")}>
<div className="space-y-6">
<div className="flex h-[calc(100vh-160px)] min-h-0 gap-4">
<aside className="app-panel flex w-full max-w-[320px] flex-col">
<div className="border-b border-[#f1e7e1] px-5 py-4">
<h2 className="text-sm font-semibold uppercase tracking-[0.14em] text-[#8f8681]">{t('instances.portalWorkspace')}</h2>
<h2 className="text-sm font-semibold uppercase tracking-[0.14em] text-[#8f8681]">
{t("instances.portalWorkspace")}
</h2>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="p-6 text-sm text-[#8f8681]">{t('common.loading')}</div>
<div className="p-6 text-sm text-[#8f8681]">
{t("common.loading")}
</div>
) : error ? (
<div className="p-6 text-sm text-red-600">{error}</div>
) : instances.length === 0 ? (
<div className="p-6 text-sm text-[#8f8681]">{t('instances.noInstances')}</div>
<div className="p-6 text-sm text-[#8f8681]">
{t("instances.noInstances")}
</div>
) : (
<ul className="divide-y divide-[#f5ebe5]">
{instances.map((instance) => {
const isSelected = instance.id === selectedId;
const isRunning = instance.status === 'running';
const isRunning = instance.status === "running";
return (
<li key={instance.id}>
@@ -207,18 +314,26 @@ const InstancePortalPage: React.FC = () => {
type="button"
onClick={() => setSelectedId(instance.id)}
className={`flex w-full items-start gap-3 px-5 py-4 text-left transition-colors ${
isSelected ? 'bg-[#fff7f3]' : 'hover:bg-[#fffaf7]'
isSelected ? "bg-[#fff7f3]" : "hover:bg-[#fffaf7]"
}`}
>
<span className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${getStatusDot(instance.status)}`} />
<span
className={`mt-1 h-2.5 w-2.5 flex-shrink-0 rounded-full ${getStatusDot(instance.status)}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className={`truncate text-sm font-semibold ${isSelected ? 'text-[#dc2626]' : 'text-[#171212]'}`}>
<p
className={`truncate text-sm font-semibold ${isSelected ? "text-[#dc2626]" : "text-[#171212]"}`}
>
{instance.name}
</p>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
isRunning ? 'bg-green-100 text-green-800' : 'bg-[#f7ece6] text-[#8f5b4b]'
}`}>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
isRunning
? "bg-green-100 text-green-800"
: "bg-[#f7ece6] text-[#8f5b4b]"
}`}
>
{t(`status.${instance.status}`)}
</span>
</div>
@@ -226,7 +341,8 @@ const InstancePortalPage: React.FC = () => {
{instance.os_type} {instance.os_version}
</p>
<p className="mt-2 text-xs text-[#8f8681]">
{instance.cpu_cores} {t('common.cpu')} / {instance.memory_gb} GB / {instance.disk_gb} GB
{instance.cpu_cores} {t("common.cpu")} /{" "}
{instance.memory_gb} GB / {instance.disk_gb} GB
</p>
</div>
</button>
@@ -240,24 +356,29 @@ const InstancePortalPage: React.FC = () => {
<section
ref={frameShellRef}
className={`flex min-w-0 flex-1 flex-col overflow-hidden border border-[#1f2937] bg-[#111827] shadow-[0_30px_90px_-56px_rgba(17,24,39,0.9)] ${isFullscreen ? 'rounded-none' : 'rounded-[30px]'}`}
className={`flex min-w-0 flex-1 flex-col overflow-hidden border border-[#1f2937] bg-[#111827] shadow-[0_30px_90px_-56px_rgba(17,24,39,0.9)] ${isFullscreen ? "rounded-none" : "rounded-[30px]"}`}
>
<div className="flex items-center justify-between border-b border-[#2b3443] bg-[#182131] px-4 py-3 text-white">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{selectedInstance?.name || t('instances.portalSelectInstance')}
{selectedInstance?.name ||
t("instances.portalSelectInstance")}
</p>
<p className="mt-1 text-xs text-[#aab4c4]">
{playerStatusText}
</p>
<p className="mt-1 text-xs text-[#aab4c4]">{playerStatusText}</p>
</div>
<div className="flex items-center gap-2">
{selectedInstance && selectedInstance.status === 'running' && embedUrl && (
<button
onClick={() => refreshAccess({ forceReload: true })}
className="rounded-lg bg-[#243041] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#31415a]"
>
{t('instances.refreshToken')}
</button>
)}
{selectedInstance &&
selectedInstance.status === "running" &&
embedUrl && (
<button
onClick={() => refreshAccess({ forceReload: true })}
className="rounded-lg bg-[#243041] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#31415a]"
>
{t("instances.refreshToken")}
</button>
)}
{embedUrl && (
<button
type="button"
@@ -265,12 +386,32 @@ const InstancePortalPage: React.FC = () => {
className="rounded-lg bg-[#243041] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#31415a]"
>
{isFullscreen ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
/>
</svg>
)}
</button>
@@ -280,25 +421,50 @@ const InstancePortalPage: React.FC = () => {
<div className="min-h-0 flex-1">
{embedUrl ? (
<iframe
key={frameKey}
ref={iframeRef}
src={embedUrl}
title={selectedInstance ? `${selectedInstance.name} portal` : 'desktop-portal'}
className="h-full w-full border-0"
allow="clipboard-read; clipboard-write; fullscreen; autoplay"
allowFullScreen
onLoad={() => handleFrameLoad(iframeRef.current)}
onError={() => refreshAccess({ forceReload: true, silent: true })}
/>
) : selectedInstance && selectedInstance.status === 'running' ? (
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,rgba(59,130,246,0.18),transparent_26%),linear-gradient(180deg,#111827_0%,#0f172a_100%)] px-8 text-center">
<div className="relative h-full">
{selectedInstance?.type === "openclaw" && (
<OpenClawDesktopOverlay
gatewayStatus={
runtimeDetails?.runtime?.openclaw_status || "unknown"
}
canControl={selectedInstance.status === "running"}
actionLoading={runtimeActionLoading}
onCommand={handleRuntimeCommand}
/>
)}
<iframe
ref={iframeRef}
src={embedUrl}
title={
selectedInstance
? `${selectedInstance.name} portal`
: "desktop-portal"
}
className="h-full w-full border-0"
allow="clipboard-read; clipboard-write; fullscreen; autoplay"
allowFullScreen
onLoad={() => handleFrameLoad(iframeRef.current)}
onError={handleFrameError}
/>
</div>
) : selectedInstance && selectedInstance.status === "running" ? (
<div className="relative flex h-full items-center justify-center bg-[radial-gradient(circle_at_top,rgba(59,130,246,0.18),transparent_26%),linear-gradient(180deg,#111827_0%,#0f172a_100%)] px-8 text-center">
{selectedInstance.type === "openclaw" && (
<OpenClawDesktopOverlay
gatewayStatus={
runtimeDetails?.runtime?.openclaw_status || "unknown"
}
canControl={selectedInstance.status === "running"}
actionLoading={runtimeActionLoading}
onCommand={handleRuntimeCommand}
/>
)}
<div className="flex max-w-md flex-col items-center">
<button
type="button"
onClick={retryAccess}
disabled={accessLoading}
aria-label={t('instances.generateAccess')}
aria-label={t("instances.generateAccess")}
className="group flex h-24 w-24 items-center justify-center rounded-full border border-white/20 bg-white/10 text-white backdrop-blur transition hover:scale-[1.03] hover:bg-white/16 disabled:cursor-wait disabled:opacity-70"
>
{accessLoading ? (
@@ -315,14 +481,21 @@ const InstancePortalPage: React.FC = () => {
)}
</button>
<h3 className="mt-6 text-xl font-semibold text-white">{t('instances.readyToAccess')}</h3>
<h3 className="mt-6 text-xl font-semibold text-white">
{t("instances.readyToAccess")}
</h3>
<p className="mt-2 max-w-md text-sm leading-6 text-[#b7c1cf]">
{accessLoading
? t('instances.generatingToken')
: accessError || t('instances.generateAccessPrompt', { name: selectedInstance.name })}
? t("instances.generatingToken")
: accessError ||
t("instances.generateAccessPrompt", {
name: selectedInstance.name,
})}
</p>
<p className="mt-4 text-xs font-semibold uppercase tracking-[0.24em] text-slate-400">
{accessLoading ? t('instances.generatingToken') : t('instances.generateAccess')}
{accessLoading
? t("instances.generatingToken")
: t("instances.generateAccess")}
</p>
</div>
</div>
@@ -330,12 +503,15 @@ const InstancePortalPage: React.FC = () => {
<div className="flex h-full items-center justify-center px-8 text-center">
<div>
<h3 className="text-lg font-semibold text-white">
{selectedInstance ? t('instances.portalUnavailable') : t('instances.portalSelectInstance')}
{selectedInstance
? t("instances.portalUnavailable")
: t("instances.portalSelectInstance")}
</h3>
<p className="mt-2 text-sm text-[#b7c1cf]">
{selectedInstance
? accessError || t('instances.portalUnavailableSubtitle')
: t('instances.portalSelectInstanceSubtitle')}
? accessError ||
t("instances.portalUnavailableSubtitle")
: t("instances.portalSelectInstanceSubtitle")}
</p>
</div>
</div>
+75 -18
View File
@@ -1,35 +1,43 @@
import api from './api';
import type {
Instance,
InstanceListResponse,
CreateInstanceRequest,
import api from "./api";
import type {
Instance,
InstanceListResponse,
CreateInstanceRequest,
UpdateInstanceRequest,
InstanceStatus
} from '../types/instance';
InstanceStatus,
InstanceRuntimeDetails,
InstanceConfigRevision,
} from "../types/instance";
export const instanceService = {
// Get instance list
getInstances: async (page: number = 1, limit: number = 20): Promise<InstanceListResponse> => {
const response = await api.get('/instances', {
params: { page, limit }
getInstances: async (
page: number = 1,
limit: number = 20,
): Promise<InstanceListResponse> => {
const response = await api.get("/instances", {
params: { page, limit },
});
return response.data.data;
},
// Create instance
createInstance: async (data: CreateInstanceRequest): Promise<Instance> => {
const response = await api.post('/instances', data);
const response = await api.post("/instances", data);
return response.data.data;
},
// Get instance by ID
getInstance: async (id: number): Promise<Instance> => {
const response = await api.get(`/instances/${id}`);
return response.data.data;
return response.data.data.instance;
},
// Update instance
updateInstance: async (id: number, data: UpdateInstanceRequest): Promise<void> => {
updateInstance: async (
id: number,
data: UpdateInstanceRequest,
): Promise<void> => {
await api.put(`/instances/${id}`, data);
},
@@ -61,11 +69,60 @@ export const instanceService = {
// Get instance status
getInstanceStatus: async (id: number): Promise<InstanceStatus> => {
const response = await api.get(`/instances/${id}/status`);
return response.data.data.instance_status;
},
getRuntimeDetails: async (id: number): Promise<InstanceRuntimeDetails> => {
const response = await api.get(`/instances/${id}/runtime`);
return response.data.data;
},
createRuntimeCommand: async (
id: number,
command:
| "start"
| "stop"
| "restart"
| "collect-system-info"
| "health-check",
idempotencyKey?: string,
): Promise<void> => {
await api.post(
`/instances/${id}/runtime/${command}`,
idempotencyKey ? { idempotency_key: idempotencyKey } : {},
);
},
listConfigRevisions: async (
id: number,
limit: number = 20,
): Promise<InstanceConfigRevision[]> => {
const response = await api.get(`/instances/${id}/config/revisions`, {
params: { limit },
});
return response.data.data;
},
publishConfigRevision: async (
id: number,
snapshotId: number,
): Promise<{
revision: InstanceConfigRevision;
command: unknown;
}> => {
const response = await api.post(
`/instances/${id}/config/revisions/publish`,
{
snapshot_id: snapshotId,
},
);
return response.data.data;
},
// Generate access token
generateAccessToken: async (id: number): Promise<{
generateAccessToken: async (
id: number,
): Promise<{
token: string;
access_url: string;
proxy_url: string;
@@ -82,18 +139,18 @@ export const instanceService = {
exportOpenClawWorkspace: async (id: number): Promise<Blob> => {
const response = await api.get(`/instances/${id}/openclaw/export`, {
responseType: 'blob',
responseType: "blob",
});
return response.data;
},
importOpenClawWorkspace: async (id: number, file: File): Promise<void> => {
const formData = new FormData();
formData.append('file', file);
formData.append("file", file);
await api.post(`/instances/${id}/openclaw/import`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
"Content-Type": "multipart/form-data",
},
});
}
},
};
+112 -48
View File
@@ -1,12 +1,12 @@
import type { OpenClawConfigPlan } from './openclawConfig';
import type { OpenClawConfigPlan } from "./openclawConfig";
export interface Instance {
id: number;
user_id: number;
name: string;
description?: string;
type: 'openclaw' | 'ubuntu' | 'debian' | 'centos' | 'custom' | 'webtop';
status: 'creating' | 'running' | 'stopped' | 'error' | 'deleting';
type: "openclaw" | "ubuntu" | "debian" | "centos" | "custom" | "webtop";
status: "creating" | "running" | "stopped" | "error" | "deleting";
cpu_cores: number;
memory_gb: number;
disk_gb: number;
@@ -40,10 +40,74 @@ export interface InstanceStatus {
started_at?: string;
}
export interface AgentInfo {
agent_id: string;
agent_version: string;
protocol_version: string;
status: string;
capabilities: string[];
host_info?: Record<string, unknown>;
last_heartbeat_at?: string;
last_reported_at?: string;
last_seen_ip?: string;
registered_at?: string;
}
export interface RuntimeStatus {
instance_id: number;
infra_status: string;
agent_status: string;
openclaw_status: string;
openclaw_pid?: number;
openclaw_version?: string;
current_config_revision_id?: number;
desired_config_revision_id?: number;
system_info?: Record<string, unknown>;
health?: Record<string, unknown>;
summary?: Record<string, unknown>;
last_reported_at?: string;
}
export interface InstanceRuntimeCommand {
id: number;
command_type: string;
status: string;
idempotency_key: string;
issued_by?: number;
issued_at: string;
dispatched_at?: string;
started_at?: string;
finished_at?: string;
timeout_seconds: number;
payload?: Record<string, unknown>;
result?: Record<string, unknown>;
error_message?: string;
}
export interface InstanceRuntimeDetails {
runtime?: RuntimeStatus;
agent?: AgentInfo;
commands: InstanceRuntimeCommand[];
}
export interface InstanceConfigRevision {
id: number;
instance_id: number;
source_snapshot_id?: number;
source_bundle_id?: number;
revision_no: number;
checksum: string;
status: string;
published_by?: number;
published_at?: string;
activated_at?: string;
content: unknown;
}
export interface CreateInstanceRequest {
name: string;
description?: string;
type: 'openclaw' | 'ubuntu' | 'debian' | 'centos' | 'custom' | 'webtop';
type: "openclaw" | "ubuntu" | "debian" | "centos" | "custom" | "webtop";
cpu_cores: number;
memory_gb: number;
disk_gb: number;
@@ -80,75 +144,75 @@ export interface InstanceType {
export const INSTANCE_TYPES: InstanceType[] = [
{
id: 'ubuntu',
name: 'Ubuntu Desktop',
description: 'Popular Linux distribution with GNOME desktop',
icon: 'ubuntu',
defaultOs: 'ubuntu',
defaultVersion: '22.04'
id: "ubuntu",
name: "Ubuntu Desktop",
description: "Popular Linux distribution with GNOME desktop",
icon: "ubuntu",
defaultOs: "ubuntu",
defaultVersion: "22.04",
},
{
id: 'debian',
name: 'Debian Desktop',
description: 'Stable and secure Linux distribution',
icon: 'debian',
defaultOs: 'debian',
defaultVersion: '12'
id: "debian",
name: "Debian Desktop",
description: "Stable and secure Linux distribution",
icon: "debian",
defaultOs: "debian",
defaultVersion: "12",
},
{
id: 'centos',
name: 'CentOS Desktop',
description: 'Enterprise-class Linux distribution',
icon: 'centos',
defaultOs: 'centos',
defaultVersion: '9'
id: "centos",
name: "CentOS Desktop",
description: "Enterprise-class Linux distribution",
icon: "centos",
defaultOs: "centos",
defaultVersion: "9",
},
{
id: 'openclaw',
name: 'OpenClaw Desktop',
description: 'Optimized desktop environment',
icon: 'openclaw',
defaultOs: 'openclaw',
defaultVersion: 'latest'
id: "openclaw",
name: "OpenClaw Desktop",
description: "Optimized desktop environment",
icon: "openclaw",
defaultOs: "openclaw",
defaultVersion: "latest",
},
{
id: 'webtop',
name: 'Webtop Desktop',
description: 'Browser-based Linux desktop proxied through ClawManager',
icon: 'webtop',
defaultOs: 'ubuntu',
defaultVersion: 'xfce'
id: "webtop",
name: "Webtop Desktop",
description: "Browser-based Linux desktop proxied through ClawManager",
icon: "webtop",
defaultOs: "ubuntu",
defaultVersion: "xfce",
},
{
id: 'custom',
name: 'Custom Image',
description: 'Use your own custom image',
icon: 'custom',
defaultOs: 'custom',
defaultVersion: 'latest'
}
id: "custom",
name: "Custom Image",
description: "Use your own custom image",
icon: "custom",
defaultOs: "custom",
defaultVersion: "latest",
},
];
export const PRESET_CONFIGS = {
small: {
name: 'Small',
name: "Small",
cpu_cores: 2,
memory_gb: 4,
disk_gb: 20,
description: 'Suitable for light tasks'
description: "Suitable for light tasks",
},
medium: {
name: 'Medium',
name: "Medium",
cpu_cores: 4,
memory_gb: 8,
disk_gb: 50,
description: 'Good for development'
description: "Good for development",
},
large: {
name: 'Large',
name: "Large",
cpu_cores: 8,
memory_gb: 16,
disk_gb: 100,
description: 'For heavy workloads'
}
description: "For heavy workloads",
},
};