Add instance agent control plane and runtime console
This commit is contained in:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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%)",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user