feat(skill-management): add skill management and skill-scanner integration

This commit is contained in:
iamlovingit
2026-04-08 16:02:18 +08:00
parent d8292b75e3
commit c847c6c6dc
36 changed files with 8915 additions and 189 deletions
+54 -2
View File
@@ -59,6 +59,8 @@ func main() {
instanceDesiredStateRepo := repository.NewInstanceDesiredStateRepository(database)
instanceCommandRepo := repository.NewInstanceCommandRepository(database)
instanceConfigRevisionRepo := repository.NewInstanceConfigRevisionRepository(database)
skillRepo := repository.NewSkillRepository(database)
securityScanRepo := repository.NewSecurityScanRepository(database)
if repaired, repairErr := services.RepairSeededAdminPassword(userRepo); repairErr != nil {
log.Printf("Warning: failed to repair seeded admin password: %v", repairErr)
@@ -81,6 +83,11 @@ func main() {
riskHitService := services.NewRiskHitService(riskHitRepo)
riskRuleService := services.NewRiskRuleService(riskRuleRepo)
openClawConfigService := services.NewOpenClawConfigService(openClawConfigRepo)
objectStorageService, err := services.NewObjectStorageService(cfg.ObjectStorage)
if err != nil {
log.Fatalf("Failed to initialize object storage: %v", err)
}
skillScannerClient := services.NewSkillScannerClient(cfg.SkillScanner)
aiObservabilityService := services.NewAIObservabilityService(modelInvocationRepo, auditEventRepo, costRecordRepo, riskHitRepo, chatMessageRepo, llmModelRepo, instanceRepo, userRepo)
clusterResourceService := services.NewClusterResourceService(instanceRepo)
services.SetRuntimeImageSettingsProvider(systemImageSettingService)
@@ -89,12 +96,14 @@ func main() {
instanceRuntimeStatusService := services.NewInstanceRuntimeStatusService(instanceRuntimeStatusRepo, instanceAgentRepo, instanceDesiredStateRepo)
instanceCommandService := services.NewInstanceCommandService(instanceCommandRepo, instanceRuntimeStatusRepo, instanceDesiredStateRepo)
instanceConfigRevisionService := services.NewInstanceConfigRevisionService(instanceConfigRevisionRepo)
skillService := services.NewSkillService(skillRepo, instanceRepo, instanceCommandService, objectStorageService, skillScannerClient)
securityScanService := services.NewSecurityScanService(securityScanRepo, skillRepo, objectStorageService, skillScannerClient)
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, instanceAgentService, instanceRuntimeStatusService, instanceCommandService, instanceConfigRevisionService, openClawConfigService)
instanceHandler := handlers.NewInstanceHandler(instanceService, instanceAgentService, instanceRuntimeStatusService, instanceCommandService, instanceConfigRevisionService, openClawConfigService, skillService)
systemSettingsHandler := handlers.NewSystemSettingsHandler(systemImageSettingService)
llmModelHandler := handlers.NewLLMModelHandler(llmModelService)
aiGatewayHandler := handlers.NewAIGatewayHandler(aiGatewayService)
@@ -103,7 +112,9 @@ func main() {
clusterResourceHandler := handlers.NewClusterResourceHandler(clusterResourceService)
egressProxyHandler := handlers.NewEgressProxyHandler()
openClawConfigHandler := handlers.NewOpenClawConfigHandler(openClawConfigService)
agentHandler := handlers.NewAgentHandler(instanceAgentService, instanceCommandService, instanceRuntimeStatusService, instanceConfigRevisionService)
skillHandler := handlers.NewSkillHandler(skillService, instanceService)
securityHandler := handlers.NewSecurityHandler(securityScanService)
agentHandler := handlers.NewAgentHandler(instanceAgentService, instanceCommandService, instanceRuntimeStatusService, instanceConfigRevisionService, skillService)
// Initialize WebSocket hub and handler
wsHub := services.GetHub()
@@ -183,6 +194,9 @@ func main() {
instances.POST("/:id/sync", instanceHandler.ForceSync)
instances.GET("/:id/openclaw/export", instanceHandler.ExportOpenClaw)
instances.POST("/:id/openclaw/import", instanceHandler.ImportOpenClaw)
instances.GET("/:id/skills", skillHandler.ListInstanceSkills)
instances.POST("/:id/skills", skillHandler.AttachSkillToInstance)
instances.DELETE("/:id/skills/:skillId", skillHandler.RemoveSkillFromInstance)
}
openClawConfigs := api.Group("/openclaw-configs")
@@ -209,6 +223,20 @@ func main() {
openClawConfigs.GET("/injections/:id", openClawConfigHandler.GetSnapshot)
}
skills := api.Group("/skills")
skills.Use(middleware.Auth())
skills.Use(middleware.SetUserInfo(userRepo))
{
skills.GET("", skillHandler.ListSkills)
skills.POST("/import", skillHandler.ImportSkills)
skills.GET("/:id", skillHandler.GetSkill)
skills.PUT("/:id", skillHandler.UpdateSkill)
skills.DELETE("/:id", skillHandler.DeleteSkill)
skills.GET("/:id/download", skillHandler.DownloadSkill)
skills.GET("/:id/versions", skillHandler.ListVersions)
skills.GET("/:id/scan-results", skillHandler.ListScanResults)
}
systemSettings := api.Group("/system-settings")
systemSettings.Use(middleware.Auth())
systemSettings.Use(middleware.SetUserInfo(userRepo))
@@ -266,6 +294,27 @@ func main() {
adminRiskRules.DELETE("/:ruleId", riskRuleHandler.DeleteRule)
}
adminSkills := api.Group("/admin/skills")
adminSkills.Use(middleware.Auth())
adminSkills.Use(middleware.SetUserInfo(userRepo))
adminSkills.Use(middleware.NewAdminAuth(userRepo))
{
adminSkills.GET("", skillHandler.ListAllSkills)
}
adminSecurity := api.Group("/admin/security")
adminSecurity.Use(middleware.Auth())
adminSecurity.Use(middleware.SetUserInfo(userRepo))
adminSecurity.Use(middleware.NewAdminAuth(userRepo))
{
adminSecurity.GET("/config", securityHandler.GetConfig)
adminSecurity.PUT("/config", securityHandler.SaveConfig)
adminSecurity.POST("/scan-jobs", securityHandler.StartScan)
adminSecurity.POST("/skills/:id/rescan", securityHandler.RescanSkill)
adminSecurity.GET("/scan-jobs", securityHandler.ListJobs)
adminSecurity.GET("/scan-jobs/:id", securityHandler.GetJob)
}
gatewayLLM := api.Group("/gateway/llm")
gatewayLLM.Use(middleware.GatewayAuth(instanceRepo))
{
@@ -281,6 +330,9 @@ func main() {
agent.POST("/commands/:id/start", agentHandler.StartCommand)
agent.POST("/commands/:id/finish", agentHandler.FinishCommand)
agent.POST("/state/report", agentHandler.ReportState)
agent.POST("/skills/inventory", agentHandler.ReportSkillInventory)
agent.POST("/skills/upload", agentHandler.UploadSkillPackage)
agent.GET("/skills/versions/:skillVersion/download", skillHandler.DownloadSkillVersionForAgent)
agent.GET("/config/revisions/:id", agentHandler.GetConfigRevision)
}
+6
View File
@@ -23,10 +23,12 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
@@ -43,10 +45,13 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.85 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -56,6 +61,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/segmentio/fasthash v1.0.3 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+13
View File
@@ -15,6 +15,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
@@ -25,6 +27,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
@@ -79,6 +83,9 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
@@ -94,6 +101,10 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.85 h1:9psTLS/NTvC3MWoyjhjXpwcKoNbkongaCSF3PNpSuXo=
github.com/minio/minio-go/v7 v7.0.85/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -122,6 +133,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM=
github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+83 -4
View File
@@ -3,16 +3,19 @@ package config
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Config holds all application configuration
type Config struct {
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
JWT JWTConfig `yaml:"jwt"`
Kubernetes KubernetesConfig `yaml:"kubernetes"`
Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"`
JWT JWTConfig `yaml:"jwt"`
Kubernetes KubernetesConfig `yaml:"kubernetes"`
ObjectStorage ObjectStorageConfig `yaml:"objectStorage"`
SkillScanner SkillScannerConfig `yaml:"skillScanner"`
}
// ServerConfig holds server-related configuration
@@ -118,6 +121,25 @@ type LoggingConfig struct {
LogAPICalls bool `yaml:"logApiCalls"`
}
type ObjectStorageConfig struct {
Endpoint string `yaml:"endpoint"`
Region string `yaml:"region"`
AccessKey string `yaml:"accessKey"`
SecretKey string `yaml:"secretKey"`
Bucket string `yaml:"bucket"`
UseSSL bool `yaml:"useSSL"`
BasePath string `yaml:"basePath"`
ForcePathStyle bool `yaml:"forcePathStyle"`
LocalFallback string `yaml:"localFallback"`
}
type SkillScannerConfig struct {
BaseURL string `yaml:"baseUrl"`
APIKey string `yaml:"apiKey"`
TimeoutSeconds int `yaml:"timeoutSeconds"`
Enabled bool `yaml:"enabled"`
}
// Load loads configuration from file and environment variables
func Load() (*Config, error) {
config := &Config{
@@ -175,6 +197,23 @@ func Load() (*Config, error) {
LogAPICalls: false,
},
},
ObjectStorage: ObjectStorageConfig{
Endpoint: getEnv("OBJECT_STORAGE_ENDPOINT", ""),
Region: getEnv("OBJECT_STORAGE_REGION", ""),
AccessKey: getEnv("OBJECT_STORAGE_ACCESS_KEY", ""),
SecretKey: getEnv("OBJECT_STORAGE_SECRET_KEY", ""),
Bucket: getEnv("OBJECT_STORAGE_BUCKET", "clawmanager-skills"),
UseSSL: strings.EqualFold(getEnv("OBJECT_STORAGE_USE_SSL", "false"), "true"),
BasePath: getEnv("OBJECT_STORAGE_BASE_PATH", "skills"),
ForcePathStyle: strings.EqualFold(getEnv("OBJECT_STORAGE_FORCE_PATH_STYLE", "true"), "true"),
LocalFallback: getEnv("OBJECT_STORAGE_LOCAL_FALLBACK", ".data/object-storage"),
},
SkillScanner: SkillScannerConfig{
BaseURL: getEnv("SKILL_SCANNER_BASE_URL", ""),
APIKey: getEnv("SKILL_SCANNER_API_KEY", ""),
TimeoutSeconds: 30,
Enabled: strings.EqualFold(getEnv("SKILL_SCANNER_ENABLED", "false"), "true"),
},
}
// Try to load from k8s config file
@@ -263,6 +302,46 @@ func applyEnvOverrides(config *Config) {
if storageClass := os.Getenv("K8S_STORAGE_CLASS"); storageClass != "" {
config.Kubernetes.Common.StorageClass = storageClass
}
if endpoint := os.Getenv("OBJECT_STORAGE_ENDPOINT"); endpoint != "" {
config.ObjectStorage.Endpoint = endpoint
}
if region := os.Getenv("OBJECT_STORAGE_REGION"); region != "" {
config.ObjectStorage.Region = region
}
if accessKey := os.Getenv("OBJECT_STORAGE_ACCESS_KEY"); accessKey != "" {
config.ObjectStorage.AccessKey = accessKey
}
if secretKey := os.Getenv("OBJECT_STORAGE_SECRET_KEY"); secretKey != "" {
config.ObjectStorage.SecretKey = secretKey
}
if bucket := os.Getenv("OBJECT_STORAGE_BUCKET"); bucket != "" {
config.ObjectStorage.Bucket = bucket
}
if useSSL := os.Getenv("OBJECT_STORAGE_USE_SSL"); useSSL != "" {
config.ObjectStorage.UseSSL = strings.EqualFold(useSSL, "true")
}
if basePath := os.Getenv("OBJECT_STORAGE_BASE_PATH"); basePath != "" {
config.ObjectStorage.BasePath = basePath
}
if forcePathStyle := os.Getenv("OBJECT_STORAGE_FORCE_PATH_STYLE"); forcePathStyle != "" {
config.ObjectStorage.ForcePathStyle = strings.EqualFold(forcePathStyle, "true")
}
if localFallback := os.Getenv("OBJECT_STORAGE_LOCAL_FALLBACK"); localFallback != "" {
config.ObjectStorage.LocalFallback = localFallback
}
if baseURL := os.Getenv("SKILL_SCANNER_BASE_URL"); baseURL != "" {
config.SkillScanner.BaseURL = baseURL
}
if apiKey := os.Getenv("SKILL_SCANNER_API_KEY"); apiKey != "" {
config.SkillScanner.APIKey = apiKey
}
if enabled := os.Getenv("SKILL_SCANNER_ENABLED"); enabled != "" {
config.SkillScanner.Enabled = strings.EqualFold(enabled, "true")
}
if timeoutSeconds := os.Getenv("SKILL_SCANNER_TIMEOUT_SECONDS"); timeoutSeconds != "" {
fmt.Sscanf(timeoutSeconds, "%d", &config.SkillScanner.TimeoutSeconds)
}
}
func getEnv(key, defaultValue string) string {
@@ -0,0 +1,91 @@
CREATE TABLE IF NOT EXISTS skill_blobs (
id INT AUTO_INCREMENT PRIMARY KEY,
content_hash VARCHAR(128) NOT NULL,
archive_hash VARCHAR(128) NOT NULL,
object_key VARCHAR(512) NOT NULL,
file_name VARCHAR(255) NOT NULL,
media_type VARCHAR(100) NOT NULL DEFAULT 'application/gzip',
size_bytes BIGINT NOT NULL DEFAULT 0,
scan_status VARCHAR(30) NOT NULL DEFAULT 'pending',
risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown',
last_scanned_at TIMESTAMP NULL,
last_scan_result_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_skill_blobs_content_hash (content_hash),
INDEX idx_skill_blobs_scan_status (scan_status),
INDEX idx_skill_blobs_risk_level (risk_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS skills (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
skill_key VARCHAR(120) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
current_version_id INT NULL,
source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded',
status VARCHAR(30) NOT NULL DEFAULT 'active',
risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown',
last_scanned_at TIMESTAMP NULL,
last_scan_result_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uk_skills_user_key (user_id, skill_key),
INDEX idx_skills_user_status (user_id, status),
INDEX idx_skills_risk_level (risk_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS skill_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
skill_id INT NOT NULL,
blob_id INT NOT NULL,
version_no INT NOT NULL,
manifest_json LONGTEXT NULL,
source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE RESTRICT,
UNIQUE KEY uk_skill_versions_skill_version (skill_id, version_no),
UNIQUE KEY uk_skill_versions_skill_blob (skill_id, blob_id),
INDEX idx_skill_versions_skill_id (skill_id, version_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_skills (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
skill_id INT NOT NULL,
skill_version_id INT NULL,
source_type VARCHAR(40) NOT NULL DEFAULT 'discovered_in_instance',
install_path VARCHAR(1024) NULL,
observed_hash VARCHAR(128) NULL,
status VARCHAR(30) NOT NULL DEFAULT 'active',
last_seen_at TIMESTAMP NULL,
removed_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 (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
FOREIGN KEY (skill_version_id) REFERENCES skill_versions(id) ON DELETE SET NULL,
UNIQUE KEY uk_instance_skills_instance_skill (instance_id, skill_id),
INDEX idx_instance_skills_instance (instance_id, status),
INDEX idx_instance_skills_skill (skill_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS skill_scan_results (
id INT AUTO_INCREMENT PRIMARY KEY,
blob_id INT NOT NULL,
engine VARCHAR(60) NOT NULL,
risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown',
status VARCHAR(30) NOT NULL DEFAULT 'completed',
summary TEXT NULL,
findings_json LONGTEXT NULL,
scanned_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE CASCADE,
INDEX idx_skill_scan_results_blob (blob_id, scanned_at),
INDEX idx_skill_scan_results_risk (risk_level, scanned_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+54 -1
View File
@@ -17,14 +17,16 @@ type AgentHandler struct {
commandService services.InstanceCommandService
runtimeStatusService services.InstanceRuntimeStatusService
configRevisionService services.InstanceConfigRevisionService
skillService services.SkillService
}
func NewAgentHandler(agentService services.InstanceAgentService, commandService services.InstanceCommandService, runtimeStatusService services.InstanceRuntimeStatusService, configRevisionService services.InstanceConfigRevisionService) *AgentHandler {
func NewAgentHandler(agentService services.InstanceAgentService, commandService services.InstanceCommandService, runtimeStatusService services.InstanceRuntimeStatusService, configRevisionService services.InstanceConfigRevisionService, skillService services.SkillService) *AgentHandler {
return &AgentHandler{
agentService: agentService,
commandService: commandService,
runtimeStatusService: runtimeStatusService,
configRevisionService: configRevisionService,
skillService: skillService,
}
}
@@ -178,6 +180,57 @@ func (h *AgentHandler) GetConfigRevision(c *gin.Context) {
utils.Success(c, http.StatusOK, "Config revision retrieved successfully", gin.H{"revision": revision})
}
func (h *AgentHandler) ReportSkillInventory(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
var req services.AgentSkillInventoryReportRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
if strings.TrimSpace(req.AgentID) != "" && strings.TrimSpace(req.AgentID) != session.Agent.AgentID {
utils.Error(c, http.StatusForbidden, "Agent ID does not match session")
return
}
if err := h.skillService.SyncAgentSkills(session.Instance.ID, req); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Agent skill inventory reported successfully", nil)
}
func (h *AgentHandler) UploadSkillPackage(c *gin.Context) {
session, ok := h.authenticateAgentSession(c)
if !ok {
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
utils.Error(c, http.StatusBadRequest, "file is required")
return
}
req := services.AgentSkillPackageUploadRequest{
AgentID: strings.TrimSpace(c.PostForm("agent_id")),
SkillID: strings.TrimSpace(c.PostForm("skill_id")),
SkillVersion: strings.TrimSpace(c.PostForm("skill_version")),
Identifier: strings.TrimSpace(c.PostForm("identifier")),
ContentMD5: strings.TrimSpace(c.PostForm("content_md5")),
Source: strings.TrimSpace(c.PostForm("source")),
}
if req.AgentID != "" && req.AgentID != session.Agent.AgentID {
utils.Error(c, http.StatusForbidden, "Agent ID does not match session")
return
}
item, err := h.skillService.UploadAgentSkillPackage(c.Request.Context(), session.Instance.ID, req, fileHeader)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Agent skill package uploaded successfully", item)
}
func (h *AgentHandler) authenticateAgentSession(c *gin.Context) (*services.AgentSession, bool) {
sessionToken := extractBearerToken(c.GetHeader("Authorization"))
if sessionToken == "" {
+11 -1
View File
@@ -26,10 +26,11 @@ type InstanceHandler struct {
proxyService *services.InstanceProxyService
openClawTransferService services.OpenClawTransferService
openClawConfigService services.OpenClawConfigService
skillService services.SkillService
}
// NewInstanceHandler creates a new instance handler
func NewInstanceHandler(instanceService services.InstanceService, instanceAgentService services.InstanceAgentService, runtimeStatusService services.InstanceRuntimeStatusService, instanceCommandService services.InstanceCommandService, instanceConfigRevisionService services.InstanceConfigRevisionService, openClawConfigService services.OpenClawConfigService) *InstanceHandler {
func NewInstanceHandler(instanceService services.InstanceService, instanceAgentService services.InstanceAgentService, runtimeStatusService services.InstanceRuntimeStatusService, instanceCommandService services.InstanceCommandService, instanceConfigRevisionService services.InstanceConfigRevisionService, openClawConfigService services.OpenClawConfigService, skillService services.SkillService) *InstanceHandler {
accessService := services.NewInstanceAccessService()
return &InstanceHandler{
instanceService: instanceService,
@@ -41,6 +42,7 @@ func NewInstanceHandler(instanceService services.InstanceService, instanceAgentS
proxyService: services.NewInstanceProxyService(accessService),
openClawTransferService: services.NewOpenClawTransferService(),
openClawConfigService: openClawConfigService,
skillService: skillService,
}
}
@@ -74,6 +76,7 @@ type CreateInstanceRequest struct {
ImageTag *string `json:"image_tag,omitempty"`
StorageClass string `json:"storage_class"`
OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"`
SkillIDs []int `json:"skill_ids,omitempty"`
}
// UpdateInstanceRequest represents an update instance request
@@ -152,6 +155,13 @@ func (h *InstanceHandler) CreateInstance(c *gin.Context) {
return
}
for _, skillID := range req.SkillIDs {
if _, err := h.skillService.AttachSkillToInstance(instance.ID, skillID); err != nil {
utils.HandleError(c, err)
return
}
}
utils.Success(c, http.StatusCreated, "Instance created successfully", instance)
}
@@ -0,0 +1,104 @@
package handlers
import (
"net/http"
"strconv"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type SecurityHandler struct {
service services.SecurityScanService
}
func NewSecurityHandler(service services.SecurityScanService) *SecurityHandler {
return &SecurityHandler{service: service}
}
func (h *SecurityHandler) GetConfig(c *gin.Context) {
item, err := h.service.GetConfig()
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Security scan config retrieved successfully", item)
}
func (h *SecurityHandler) SaveConfig(c *gin.Context) {
var req services.SecurityScanConfigPayload
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
item, err := h.service.SaveConfig(userID.(int), req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Security scan config saved successfully", item)
}
func (h *SecurityHandler) StartScan(c *gin.Context) {
var req services.StartSecurityScanRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
item, err := h.service.StartScan(userID.(int), req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Security scan started successfully", item)
}
func (h *SecurityHandler) ListJobs(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
items, err := h.service.ListJobs(limit)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Security scan jobs retrieved successfully", items)
}
func (h *SecurityHandler) GetJob(c *gin.Context) {
jobID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid job ID")
return
}
item, err := h.service.GetJob(jobID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Security scan job retrieved successfully", item)
}
func (h *SecurityHandler) RescanSkill(c *gin.Context) {
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
var req struct {
ScanMode string `json:"scan_mode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
userID, _ := c.Get("userID")
item, err := h.service.RescanSkill(userID.(int), skillID, req.ScanMode)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Skill rescan started successfully", item)
}
+234
View File
@@ -0,0 +1,234 @@
package handlers
import (
"fmt"
"net/http"
"strconv"
"clawreef/internal/services"
"clawreef/internal/utils"
"github.com/gin-gonic/gin"
)
type SkillHandler struct {
service services.SkillService
instanceService services.InstanceService
}
func NewSkillHandler(service services.SkillService, instanceService services.InstanceService) *SkillHandler {
return &SkillHandler{service: service, instanceService: instanceService}
}
func (h *SkillHandler) ImportSkills(c *gin.Context) {
userID, _ := c.Get("userID")
fileHeader, err := c.FormFile("file")
if err != nil {
utils.Error(c, http.StatusBadRequest, "file is required")
return
}
items, err := h.service.ImportArchive(c.Request.Context(), userID.(int), fileHeader)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Skills imported successfully", items)
}
func (h *SkillHandler) ListSkills(c *gin.Context) {
userID, _ := c.Get("userID")
items, err := h.service.ListSkills(userID.(int))
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skills retrieved successfully", items)
}
func (h *SkillHandler) ListAllSkills(c *gin.Context) {
items, err := h.service.ListAllSkills()
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "All skills retrieved successfully", items)
}
func (h *SkillHandler) GetSkill(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
item, err := h.service.GetSkill(userID.(int), skillID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill retrieved successfully", item)
}
func (h *SkillHandler) UpdateSkill(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
var req services.UpdateSkillRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.UpdateSkill(userID.(int), skillID, req)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill updated successfully", item)
}
func (h *SkillHandler) DeleteSkill(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
if err := h.service.DeleteSkill(userID.(int), skillID); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill deleted successfully", nil)
}
func (h *SkillHandler) DownloadSkill(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
content, fileName, err := h.service.DownloadSkill(userID.(int), skillID)
if err != nil {
utils.HandleError(c, err)
return
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Data(http.StatusOK, "application/zip", content)
}
func (h *SkillHandler) DownloadSkillVersionForAgent(c *gin.Context) {
content, fileName, err := h.service.DownloadSkillVersionByExternalID(c.Param("skillVersion"))
if err != nil {
utils.HandleError(c, err)
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
c.Data(http.StatusOK, "application/octet-stream", content)
}
func (h *SkillHandler) ListVersions(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
items, err := h.service.ListVersions(userID.(int), skillID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill versions retrieved successfully", items)
}
func (h *SkillHandler) ListScanResults(c *gin.Context) {
userID, _ := c.Get("userID")
skillID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
items, err := h.service.ListScanResults(userID.(int), skillID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill scan results retrieved successfully", items)
}
func (h *SkillHandler) ListInstanceSkills(c *gin.Context) {
instanceID, ok := h.authorizeOwnedInstance(c)
if !ok {
return
}
items, err := h.service.ListInstanceSkills(instanceID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Instance skills retrieved successfully", items)
}
func (h *SkillHandler) AttachSkillToInstance(c *gin.Context) {
instanceID, ok := h.authorizeOwnedInstance(c)
if !ok {
return
}
var req services.AttachSkillToInstanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ValidationError(c, err)
return
}
item, err := h.service.AttachSkillToInstance(instanceID, req.SkillID)
if err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusCreated, "Skill attached to instance successfully", item)
}
func (h *SkillHandler) RemoveSkillFromInstance(c *gin.Context) {
instanceID, ok := h.authorizeOwnedInstance(c)
if !ok {
return
}
skillID, err := strconv.Atoi(c.Param("skillId"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid skill ID")
return
}
if err := h.service.RemoveSkillFromInstance(instanceID, skillID); err != nil {
utils.HandleError(c, err)
return
}
utils.Success(c, http.StatusOK, "Skill removed from instance successfully", nil)
}
func (h *SkillHandler) authorizeOwnedInstance(c *gin.Context) (int, bool) {
instanceID, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.Error(c, http.StatusBadRequest, "invalid instance ID")
return 0, false
}
instance, err := h.instanceService.GetByID(instanceID)
if err != nil {
utils.HandleError(c, err)
return 0, false
}
if instance == nil {
utils.Error(c, http.StatusNotFound, "Instance not found")
return 0, 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, false
}
return instanceID, true
}
+68
View File
@@ -0,0 +1,68 @@
package models
import "time"
type SecurityScanConfig struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
DefaultMode string `db:"default_mode" json:"default_mode"`
QuickAnalyzersJSON string `db:"quick_analyzers_json" json:"-"`
DeepAnalyzersJSON string `db:"deep_analyzers_json" json:"-"`
QuickTimeoutSeconds int `db:"quick_timeout_seconds" json:"quick_timeout_seconds"`
DeepTimeoutSeconds int `db:"deep_timeout_seconds" json:"deep_timeout_seconds"`
AllowFallback bool `db:"allow_fallback" json:"allow_fallback"`
UpdatedBy *int `db:"updated_by" json:"updated_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s SecurityScanConfig) TableName() string { return "security_scan_configs" }
type SecurityScanJob struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
AssetType string `db:"asset_type" json:"asset_type"`
ScanMode string `db:"scan_mode" json:"scan_mode"`
Status string `db:"status" json:"status"`
RequestedBy *int `db:"requested_by" json:"requested_by,omitempty"`
ScopeJSON *string `db:"scope_json" json:"-"`
TotalItems int `db:"total_items" json:"total_items"`
CompletedItems int `db:"completed_items" json:"completed_items"`
FailedItems int `db:"failed_items" json:"failed_items"`
CurrentItemName *string `db:"current_item_name" json:"current_item_name,omitempty"`
StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"`
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s SecurityScanJob) TableName() string { return "security_scan_jobs" }
type SecurityScanJobItem struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
JobID int `db:"job_id" json:"job_id"`
AssetType string `db:"asset_type" json:"asset_type"`
AssetID int `db:"asset_id" json:"asset_id"`
AssetName string `db:"asset_name" json:"asset_name"`
Status string `db:"status" json:"status"`
ProgressPct int `db:"progress_pct" json:"progress_pct"`
RiskLevel *string `db:"risk_level" json:"risk_level,omitempty"`
Summary *string `db:"summary" json:"summary,omitempty"`
ScanResultID *int `db:"scan_result_id" json:"scan_result_id,omitempty"`
CachedResult bool `db:"cached_result" json:"cached_result"`
ErrorMessage *string `db:"error_message" json:"error_message,omitempty"`
StartedAt *time.Time `db:"started_at" json:"started_at,omitempty"`
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s SecurityScanJobItem) TableName() string { return "security_scan_job_items" }
type SecurityScanReport struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
JobID int `db:"job_id" json:"job_id"`
SummaryJSON string `db:"summary_json" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s SecurityScanReport) TableName() string { return "security_scan_reports" }
+84
View File
@@ -0,0 +1,84 @@
package models
import "time"
type Skill struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
UserID int `db:"user_id" json:"user_id"`
SkillKey string `db:"skill_key" json:"skill_key"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
CurrentVersionID *int `db:"current_version_id" json:"current_version_id,omitempty"`
SourceType string `db:"source_type" json:"source_type"`
Status string `db:"status" json:"status"`
RiskLevel string `db:"risk_level" json:"risk_level"`
LastScannedAt *time.Time `db:"last_scanned_at" json:"last_scanned_at,omitempty"`
LastScanResultID *int `db:"last_scan_result_id" json:"last_scan_result_id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s Skill) TableName() string { return "skills" }
type SkillBlob struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
ContentHash string `db:"content_hash" json:"content_hash"`
ArchiveHash string `db:"archive_hash" json:"archive_hash"`
ObjectKey string `db:"object_key" json:"object_key"`
FileName string `db:"file_name" json:"file_name"`
MediaType string `db:"media_type" json:"media_type"`
SizeBytes int64 `db:"size_bytes" json:"size_bytes"`
ScanStatus string `db:"scan_status" json:"scan_status"`
RiskLevel string `db:"risk_level" json:"risk_level"`
LastScannedAt *time.Time `db:"last_scanned_at" json:"last_scanned_at,omitempty"`
LastScanResultID *int `db:"last_scan_result_id" json:"last_scan_result_id,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s SkillBlob) TableName() string { return "skill_blobs" }
type SkillVersion struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
SkillID int `db:"skill_id" json:"skill_id"`
BlobID int `db:"blob_id" json:"blob_id"`
VersionNo int `db:"version_no" json:"version_no"`
ManifestJSON *string `db:"manifest_json" json:"-"`
SourceType string `db:"source_type" json:"source_type"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s SkillVersion) TableName() string { return "skill_versions" }
type InstanceSkill struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
InstanceID int `db:"instance_id" json:"instance_id"`
SkillID int `db:"skill_id" json:"skill_id"`
SkillVersionID *int `db:"skill_version_id" json:"skill_version_id,omitempty"`
SourceType string `db:"source_type" json:"source_type"`
InstallPath *string `db:"install_path" json:"install_path,omitempty"`
ObservedHash *string `db:"observed_hash" json:"observed_hash,omitempty"`
Status string `db:"status" json:"status"`
LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at,omitempty"`
RemovedAt *time.Time `db:"removed_at" json:"removed_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s InstanceSkill) TableName() string { return "instance_skills" }
type SkillScanResult struct {
ID int `db:"id,primarykey,autoincrement" json:"id"`
BlobID int `db:"blob_id" json:"blob_id"`
Engine string `db:"engine" json:"engine"`
RiskLevel string `db:"risk_level" json:"risk_level"`
Status string `db:"status" json:"status"`
Summary *string `db:"summary" json:"summary,omitempty"`
FindingsJSON *string `db:"findings_json" json:"-"`
ScannedAt *time.Time `db:"scanned_at" json:"scanned_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (s SkillScanResult) TableName() string { return "skill_scan_results" }
@@ -0,0 +1,254 @@
package repository
import (
"fmt"
"time"
"clawreef/internal/models"
"github.com/upper/db/v4"
)
type SecurityScanRepository interface {
GetConfig() (*models.SecurityScanConfig, error)
UpsertConfig(config *models.SecurityScanConfig) error
CreateJob(job *models.SecurityScanJob) error
UpdateJob(job *models.SecurityScanJob) error
GetJobByID(id int) (*models.SecurityScanJob, error)
ListJobs(limit int) ([]models.SecurityScanJob, error)
CreateJobItem(item *models.SecurityScanJobItem) error
UpdateJobItem(item *models.SecurityScanJobItem) error
ListJobItems(jobID int) ([]models.SecurityScanJobItem, error)
UpsertReport(report *models.SecurityScanReport) error
GetReportByJobID(jobID int) (*models.SecurityScanReport, error)
}
type securityScanRepository struct {
sess db.Session
}
func NewSecurityScanRepository(sess db.Session) SecurityScanRepository {
repo := &securityScanRepository{sess: sess}
repo.ensureTables()
return repo
}
func (r *securityScanRepository) ensureTables() {
statements := []string{
`CREATE TABLE IF NOT EXISTS security_scan_configs (
id INT AUTO_INCREMENT PRIMARY KEY,
default_mode VARCHAR(20) NOT NULL DEFAULT 'quick',
quick_analyzers_json LONGTEXT NOT NULL,
deep_analyzers_json LONGTEXT NOT NULL,
quick_timeout_seconds INT NOT NULL DEFAULT 30,
deep_timeout_seconds INT NOT NULL DEFAULT 120,
allow_fallback BOOLEAN NOT NULL DEFAULT TRUE,
updated_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_security_scan_configs_singleton (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS security_scan_jobs (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_type VARCHAR(30) NOT NULL DEFAULT 'skill',
scan_mode VARCHAR(20) NOT NULL DEFAULT 'quick',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
requested_by INT NULL,
scope_json LONGTEXT NULL,
total_items INT NOT NULL DEFAULT 0,
completed_items INT NOT NULL DEFAULT 0,
failed_items INT NOT NULL DEFAULT 0,
current_item_name VARCHAR(255) NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_security_scan_jobs_status (status, created_at),
INDEX idx_security_scan_jobs_asset_type (asset_type, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS security_scan_job_items (
id INT AUTO_INCREMENT PRIMARY KEY,
job_id INT NOT NULL,
asset_type VARCHAR(30) NOT NULL DEFAULT 'skill',
asset_id INT NOT NULL,
asset_name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
progress_pct INT NOT NULL DEFAULT 0,
risk_level VARCHAR(30) NULL,
summary TEXT NULL,
scan_result_id INT NULL,
cached_result BOOLEAN NOT NULL DEFAULT FALSE,
error_message TEXT NULL,
started_at TIMESTAMP NULL,
finished_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (job_id) REFERENCES security_scan_jobs(id) ON DELETE CASCADE,
UNIQUE KEY uk_security_scan_job_items_job_asset (job_id, asset_type, asset_id),
INDEX idx_security_scan_job_items_job (job_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS security_scan_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
job_id INT NOT NULL,
summary_json LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (job_id) REFERENCES security_scan_jobs(id) ON DELETE CASCADE,
UNIQUE KEY uk_security_scan_reports_job (job_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
}
for _, statement := range statements {
if _, err := r.sess.SQL().Exec(statement); err != nil {
panic(fmt.Errorf("failed to ensure security scan tables: %w", err))
}
}
}
func (r *securityScanRepository) GetConfig() (*models.SecurityScanConfig, error) {
var item models.SecurityScanConfig
err := r.sess.Collection("security_scan_configs").Find(db.Cond{"id": 1}).One(&item)
if err == db.ErrNoMoreRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get security scan config: %w", err)
}
return &item, nil
}
func (r *securityScanRepository) UpsertConfig(config *models.SecurityScanConfig) error {
existing, err := r.GetConfig()
if err != nil {
return err
}
now := time.Now().UTC()
if existing == nil {
config.ID = 1
config.CreatedAt = now
config.UpdatedAt = now
if _, err := r.sess.Collection("security_scan_configs").Insert(config); err != nil {
return fmt.Errorf("failed to create security scan config: %w", err)
}
return nil
}
config.ID = existing.ID
config.CreatedAt = existing.CreatedAt
config.UpdatedAt = now
if err := r.sess.Collection("security_scan_configs").Find(db.Cond{"id": existing.ID}).Update(config); err != nil {
return fmt.Errorf("failed to update security scan config: %w", err)
}
return nil
}
func (r *securityScanRepository) CreateJob(job *models.SecurityScanJob) error {
ensureTimestamps(&job.CreatedAt, &job.UpdatedAt)
res, err := r.sess.Collection("security_scan_jobs").Insert(job)
if err != nil {
return fmt.Errorf("failed to create security scan job: %w", err)
}
if id, ok := res.ID().(int64); ok {
job.ID = int(id)
}
return nil
}
func (r *securityScanRepository) UpdateJob(job *models.SecurityScanJob) error {
if job.UpdatedAt.IsZero() {
job.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("security_scan_jobs").Find(db.Cond{"id": job.ID}).Update(job); err != nil {
return fmt.Errorf("failed to update security scan job: %w", err)
}
return nil
}
func (r *securityScanRepository) GetJobByID(id int) (*models.SecurityScanJob, error) {
var item models.SecurityScanJob
err := r.sess.Collection("security_scan_jobs").Find(db.Cond{"id": id}).One(&item)
if err == db.ErrNoMoreRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get security scan job: %w", err)
}
return &item, nil
}
func (r *securityScanRepository) ListJobs(limit int) ([]models.SecurityScanJob, error) {
if limit <= 0 {
limit = 20
}
var items []models.SecurityScanJob
if err := r.sess.Collection("security_scan_jobs").Find().OrderBy("-created_at", "-id").Limit(limit).All(&items); err != nil {
return nil, fmt.Errorf("failed to list security scan jobs: %w", err)
}
return items, nil
}
func (r *securityScanRepository) CreateJobItem(item *models.SecurityScanJobItem) error {
ensureTimestamps(&item.CreatedAt, &item.UpdatedAt)
res, err := r.sess.Collection("security_scan_job_items").Insert(item)
if err != nil {
return fmt.Errorf("failed to create security scan job item: %w", err)
}
if id, ok := res.ID().(int64); ok {
item.ID = int(id)
}
return nil
}
func (r *securityScanRepository) UpdateJobItem(item *models.SecurityScanJobItem) error {
if item.UpdatedAt.IsZero() {
item.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("security_scan_job_items").Find(db.Cond{"id": item.ID}).Update(item); err != nil {
return fmt.Errorf("failed to update security scan job item: %w", err)
}
return nil
}
func (r *securityScanRepository) ListJobItems(jobID int) ([]models.SecurityScanJobItem, error) {
var items []models.SecurityScanJobItem
if err := r.sess.Collection("security_scan_job_items").Find(db.Cond{"job_id": jobID}).OrderBy("id").All(&items); err != nil {
return nil, fmt.Errorf("failed to list security scan job items: %w", err)
}
return items, nil
}
func (r *securityScanRepository) UpsertReport(report *models.SecurityScanReport) error {
var existing models.SecurityScanReport
err := r.sess.Collection("security_scan_reports").Find(db.Cond{"job_id": report.JobID}).One(&existing)
if err == db.ErrNoMoreRows {
ensureTimestamps(&report.CreatedAt, &report.UpdatedAt)
res, err := r.sess.Collection("security_scan_reports").Insert(report)
if err != nil {
return fmt.Errorf("failed to create security scan report: %w", err)
}
if id, ok := res.ID().(int64); ok {
report.ID = int(id)
}
return nil
}
if err != nil {
return fmt.Errorf("failed to get security scan report: %w", err)
}
report.ID = existing.ID
report.CreatedAt = existing.CreatedAt
report.UpdatedAt = time.Now().UTC()
if err := r.sess.Collection("security_scan_reports").Find(db.Cond{"id": existing.ID}).Update(report); err != nil {
return fmt.Errorf("failed to update security scan report: %w", err)
}
return nil
}
func (r *securityScanRepository) GetReportByJobID(jobID int) (*models.SecurityScanReport, error) {
var item models.SecurityScanReport
err := r.sess.Collection("security_scan_reports").Find(db.Cond{"job_id": jobID}).One(&item)
if err == db.ErrNoMoreRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get security scan report: %w", err)
}
return &item, nil
}
@@ -0,0 +1,348 @@
package repository
import (
"fmt"
"strings"
"time"
"clawreef/internal/models"
"github.com/upper/db/v4"
)
type SkillRepository interface {
ListSkillsByUser(userID int) ([]models.Skill, error)
ListAllSkills() ([]models.Skill, error)
GetSkillByID(id int) (*models.Skill, error)
GetSkillByUserKey(userID int, skillKey string) (*models.Skill, error)
CreateSkill(skill *models.Skill) error
UpdateSkill(skill *models.Skill) error
DeleteSkill(id int) error
GetBlobByContentHash(hash string) (*models.SkillBlob, error)
GetBlobByID(id int) (*models.SkillBlob, error)
CreateBlob(blob *models.SkillBlob) error
UpdateBlob(blob *models.SkillBlob) error
ListVersionsBySkillID(skillID int) ([]models.SkillVersion, error)
GetVersionByID(id int) (*models.SkillVersion, error)
GetVersionBySkillAndBlob(skillID, blobID int) (*models.SkillVersion, error)
GetLatestVersionBySkillID(skillID int) (*models.SkillVersion, error)
CreateVersion(version *models.SkillVersion) error
ListInstanceSkills(instanceID int) ([]models.InstanceSkill, error)
GetInstanceSkill(instanceID, skillID int) (*models.InstanceSkill, error)
UpsertInstanceSkill(item *models.InstanceSkill) error
MarkMissingInstanceSkills(instanceID int, activeSkillIDs []int, observedAt time.Time) error
CreateScanResult(result *models.SkillScanResult) error
GetScanResultByID(id int) (*models.SkillScanResult, error)
ListScanResultsByBlobID(blobID int) ([]models.SkillScanResult, error)
GetLatestScanResultByBlobID(blobID int) (*models.SkillScanResult, error)
GetLatestScanResultBySkillID(skillID int) (*models.SkillScanResult, error)
}
type skillRepository struct{ sess db.Session }
func NewSkillRepository(sess db.Session) SkillRepository { return &skillRepository{sess: sess} }
func (r *skillRepository) ListSkillsByUser(userID int) ([]models.Skill, error) {
var items []models.Skill
if err := r.sess.Collection("skills").Find(db.Cond{"user_id": userID}).OrderBy("-updated_at", "-id").All(&items); err != nil {
return nil, fmt.Errorf("failed to list skills by user: %w", err)
}
return items, nil
}
func (r *skillRepository) ListAllSkills() ([]models.Skill, error) {
var items []models.Skill
if err := r.sess.Collection("skills").Find().OrderBy("-updated_at", "-id").All(&items); err != nil {
return nil, fmt.Errorf("failed to list all skills: %w", err)
}
return items, nil
}
func (r *skillRepository) GetSkillByID(id int) (*models.Skill, error) {
var item models.Skill
if err := r.sess.Collection("skills").Find(db.Cond{"id": id}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get skill: %w", err)
}
return &item, nil
}
func (r *skillRepository) GetSkillByUserKey(userID int, skillKey string) (*models.Skill, error) {
var item models.Skill
if err := r.sess.Collection("skills").Find(db.Cond{"user_id": userID, "skill_key": skillKey}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get skill by key: %w", err)
}
return &item, nil
}
func (r *skillRepository) CreateSkill(skill *models.Skill) error {
ensureTimestamps(&skill.CreatedAt, &skill.UpdatedAt)
res, err := r.sess.Collection("skills").Insert(skill)
if err != nil {
return fmt.Errorf("failed to create skill: %w", err)
}
if id, ok := res.ID().(int64); ok {
skill.ID = int(id)
}
return nil
}
func (r *skillRepository) UpdateSkill(skill *models.Skill) error {
if skill.UpdatedAt.IsZero() {
skill.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("skills").Find(db.Cond{"id": skill.ID}).Update(skill); err != nil {
return fmt.Errorf("failed to update skill: %w", err)
}
return nil
}
func (r *skillRepository) DeleteSkill(id int) error {
if err := r.sess.Collection("skills").Find(db.Cond{"id": id}).Delete(); err != nil {
return fmt.Errorf("failed to delete skill: %w", err)
}
return nil
}
func (r *skillRepository) GetBlobByContentHash(hash string) (*models.SkillBlob, error) {
var item models.SkillBlob
if err := r.sess.Collection("skill_blobs").Find(db.Cond{"content_hash": hash}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get skill blob by content hash: %w", err)
}
return &item, nil
}
func (r *skillRepository) GetBlobByID(id int) (*models.SkillBlob, error) {
var item models.SkillBlob
if err := r.sess.Collection("skill_blobs").Find(db.Cond{"id": id}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get skill blob: %w", err)
}
return &item, nil
}
func (r *skillRepository) CreateBlob(blob *models.SkillBlob) error {
ensureTimestamps(&blob.CreatedAt, &blob.UpdatedAt)
res, err := r.sess.Collection("skill_blobs").Insert(blob)
if err != nil {
return fmt.Errorf("failed to create skill blob: %w", err)
}
if id, ok := res.ID().(int64); ok {
blob.ID = int(id)
}
return nil
}
func (r *skillRepository) UpdateBlob(blob *models.SkillBlob) error {
if blob.UpdatedAt.IsZero() {
blob.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("skill_blobs").Find(db.Cond{"id": blob.ID}).Update(blob); err != nil {
return fmt.Errorf("failed to update skill blob: %w", err)
}
return nil
}
func (r *skillRepository) ListVersionsBySkillID(skillID int) ([]models.SkillVersion, error) {
var items []models.SkillVersion
if err := r.sess.Collection("skill_versions").Find(db.Cond{"skill_id": skillID}).OrderBy("-version_no", "-id").All(&items); err != nil {
return nil, fmt.Errorf("failed to list skill versions: %w", err)
}
return items, nil
}
func (r *skillRepository) GetVersionByID(id int) (*models.SkillVersion, error) {
var item models.SkillVersion
if err := r.sess.Collection("skill_versions").Find(db.Cond{"id": id}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get skill version: %w", err)
}
return &item, nil
}
func (r *skillRepository) GetVersionBySkillAndBlob(skillID, blobID int) (*models.SkillVersion, error) {
var item models.SkillVersion
if err := r.sess.Collection("skill_versions").Find(db.Cond{"skill_id": skillID, "blob_id": blobID}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get skill version by blob: %w", err)
}
return &item, nil
}
func (r *skillRepository) GetLatestVersionBySkillID(skillID int) (*models.SkillVersion, error) {
var item models.SkillVersion
if err := r.sess.Collection("skill_versions").Find(db.Cond{"skill_id": skillID}).OrderBy("-version_no", "-id").One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get latest skill version: %w", err)
}
return &item, nil
}
func (r *skillRepository) CreateVersion(version *models.SkillVersion) error {
ensureTimestamps(&version.CreatedAt, &version.UpdatedAt)
res, err := r.sess.Collection("skill_versions").Insert(version)
if err != nil {
return fmt.Errorf("failed to create skill version: %w", err)
}
if id, ok := res.ID().(int64); ok {
version.ID = int(id)
}
return nil
}
func (r *skillRepository) ListInstanceSkills(instanceID int) ([]models.InstanceSkill, error) {
var items []models.InstanceSkill
if err := r.sess.Collection("instance_skills").Find(db.Cond{"instance_id": instanceID}).OrderBy("-updated_at", "-id").All(&items); err != nil {
return nil, fmt.Errorf("failed to list instance skills: %w", err)
}
return items, nil
}
func (r *skillRepository) GetInstanceSkill(instanceID, skillID int) (*models.InstanceSkill, error) {
var item models.InstanceSkill
if err := r.sess.Collection("instance_skills").Find(db.Cond{"instance_id": instanceID, "skill_id": skillID}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get instance skill: %w", err)
}
return &item, nil
}
func (r *skillRepository) UpsertInstanceSkill(item *models.InstanceSkill) error {
existing, err := r.GetInstanceSkill(item.InstanceID, item.SkillID)
if err != nil {
return err
}
if existing == nil {
ensureTimestamps(&item.CreatedAt, &item.UpdatedAt)
res, err := r.sess.Collection("instance_skills").Insert(item)
if err != nil {
if !isDuplicateEntryError(err) {
return fmt.Errorf("failed to create instance skill: %w", err)
}
existing, err = r.GetInstanceSkill(item.InstanceID, item.SkillID)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("failed to create instance skill: %w", err)
}
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
if item.UpdatedAt.IsZero() {
item.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("instance_skills").Find(db.Cond{"id": existing.ID}).Update(item); err != nil {
return fmt.Errorf("failed to update instance skill after duplicate insert: %w", err)
}
return nil
}
if id, ok := res.ID().(int64); ok {
item.ID = int(id)
}
return nil
}
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
if item.UpdatedAt.IsZero() {
item.UpdatedAt = time.Now().UTC()
}
if err := r.sess.Collection("instance_skills").Find(db.Cond{"id": existing.ID}).Update(item); err != nil {
return fmt.Errorf("failed to update instance skill: %w", err)
}
return nil
}
func isDuplicateEntryError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "duplicate entry")
}
func (r *skillRepository) MarkMissingInstanceSkills(instanceID int, activeSkillIDs []int, observedAt time.Time) error {
find := r.sess.Collection("instance_skills").Find(db.Cond{"instance_id": instanceID})
if len(activeSkillIDs) > 0 {
find = find.And(db.Cond{"skill_id NOT IN": activeSkillIDs})
}
var items []models.InstanceSkill
if err := find.All(&items); err != nil && err != db.ErrNoMoreRows {
return fmt.Errorf("failed to list stale instance skills: %w", err)
}
for _, item := range items {
item.Status = "removed"
item.RemovedAt = &observedAt
item.UpdatedAt = observedAt
if err := r.sess.Collection("instance_skills").Find(db.Cond{"id": item.ID}).Update(item); err != nil {
return fmt.Errorf("failed to mark instance skill removed: %w", err)
}
}
return nil
}
func (r *skillRepository) CreateScanResult(result *models.SkillScanResult) error {
ensureTimestamps(&result.CreatedAt, &result.UpdatedAt)
res, err := r.sess.Collection("skill_scan_results").Insert(result)
if err != nil {
return fmt.Errorf("failed to create skill scan result: %w", err)
}
if id, ok := res.ID().(int64); ok {
result.ID = int(id)
}
return nil
}
func (r *skillRepository) GetScanResultByID(id int) (*models.SkillScanResult, error) {
var item models.SkillScanResult
if err := r.sess.Collection("skill_scan_results").Find(db.Cond{"id": id}).One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get skill scan result: %w", err)
}
return &item, nil
}
func (r *skillRepository) ListScanResultsByBlobID(blobID int) ([]models.SkillScanResult, error) {
var items []models.SkillScanResult
if err := r.sess.Collection("skill_scan_results").Find(db.Cond{"blob_id": blobID}).OrderBy("-scanned_at", "-id").All(&items); err != nil {
return nil, fmt.Errorf("failed to list skill scan results: %w", err)
}
return items, nil
}
func (r *skillRepository) GetLatestScanResultByBlobID(blobID int) (*models.SkillScanResult, error) {
var item models.SkillScanResult
if err := r.sess.Collection("skill_scan_results").Find(db.Cond{"blob_id": blobID}).OrderBy("-scanned_at", "-id").One(&item); err != nil {
if err == db.ErrNoMoreRows {
return nil, nil
}
return nil, fmt.Errorf("failed to get latest skill scan result: %w", err)
}
return &item, nil
}
func (r *skillRepository) GetLatestScanResultBySkillID(skillID int) (*models.SkillScanResult, error) {
skill, err := r.GetSkillByID(skillID)
if err != nil || skill == nil || skill.LastScanResultID == nil {
return nil, err
}
return r.GetScanResultByID(*skill.LastScanResultID)
}
@@ -18,6 +18,16 @@ const (
InstanceCommandTypeApplyConfigRevision = "apply_config_revision"
InstanceCommandTypeReloadConfig = "reload_config"
InstanceCommandTypeHealthCheck = "health_check"
InstanceCommandTypeInstallSkill = "install_skill"
InstanceCommandTypeUpdateSkill = "update_skill"
InstanceCommandTypeUninstallSkill = "uninstall_skill"
InstanceCommandTypeRemoveSkill = "remove_skill"
InstanceCommandTypeDisableSkill = "disable_skill"
InstanceCommandTypeQuarantineSkill = "quarantine_skill"
InstanceCommandTypeHandleSkillRisk = "handle_skill_risk"
InstanceCommandTypeSyncSkillInventory = "sync_skill_inventory"
InstanceCommandTypeRefreshSkillInventory = "refresh_skill_inventory"
InstanceCommandTypeCollectSkillPackage = "collect_skill_package"
instanceCommandStatusPending = "pending"
instanceCommandStatusDispatched = "dispatched"
instanceCommandStatusRunning = "running"
@@ -284,7 +294,17 @@ func isSupportedCommandType(commandType string) bool {
InstanceCommandTypeCollectSystemInfo,
InstanceCommandTypeApplyConfigRevision,
InstanceCommandTypeReloadConfig,
InstanceCommandTypeHealthCheck:
InstanceCommandTypeHealthCheck,
InstanceCommandTypeInstallSkill,
InstanceCommandTypeUpdateSkill,
InstanceCommandTypeUninstallSkill,
InstanceCommandTypeRemoveSkill,
InstanceCommandTypeDisableSkill,
InstanceCommandTypeQuarantineSkill,
InstanceCommandTypeHandleSkillRisk,
InstanceCommandTypeSyncSkillInventory,
InstanceCommandTypeRefreshSkillInventory,
InstanceCommandTypeCollectSkillPackage:
return true
default:
return false
@@ -0,0 +1,114 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"clawreef/internal/config"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type ObjectStorageService interface {
PutObject(ctx context.Context, objectKey string, body []byte, contentType string) error
GetObject(ctx context.Context, objectKey string) ([]byte, error)
}
type objectStorageService struct {
minioClient *minio.Client
bucket string
basePath string
localPath string
}
func NewObjectStorageService(cfg config.ObjectStorageConfig) (ObjectStorageService, error) {
service := &objectStorageService{
bucket: strings.TrimSpace(cfg.Bucket),
basePath: strings.Trim(strings.TrimSpace(cfg.BasePath), "/"),
localPath: strings.TrimSpace(cfg.LocalFallback),
}
if service.localPath == "" {
service.localPath = ".data/object-storage"
}
if strings.TrimSpace(cfg.Endpoint) == "" {
return service, nil
}
client, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
BucketLookup: minio.BucketLookupPath,
})
if err != nil {
return nil, fmt.Errorf("failed to initialize object storage client: %w", err)
}
service.minioClient = client
return service, nil
}
func (s *objectStorageService) PutObject(ctx context.Context, objectKey string, body []byte, contentType string) error {
if s.minioClient == nil {
target := filepath.Join(s.localPath, filepath.FromSlash(s.resolveObjectKey(objectKey)))
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("failed to prepare local object storage directory: %w", err)
}
if err := os.WriteFile(target, body, 0o644); err != nil {
return fmt.Errorf("failed to write local object storage object: %w", err)
}
return nil
}
exists, err := s.minioClient.BucketExists(ctx, s.bucket)
if err != nil {
return fmt.Errorf("failed to check object storage bucket: %w", err)
}
if !exists {
if err := s.minioClient.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil {
return fmt.Errorf("failed to create object storage bucket: %w", err)
}
}
_, err = s.minioClient.PutObject(ctx, s.bucket, s.resolveObjectKey(objectKey), bytes.NewReader(body), int64(len(body)), minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return fmt.Errorf("failed to upload object: %w", err)
}
return nil
}
func (s *objectStorageService) GetObject(ctx context.Context, objectKey string) ([]byte, error) {
if s.minioClient == nil {
target := filepath.Join(s.localPath, filepath.FromSlash(s.resolveObjectKey(objectKey)))
content, err := os.ReadFile(target)
if err != nil {
return nil, fmt.Errorf("failed to read local object storage object: %w", err)
}
return content, nil
}
reader, err := s.minioClient.GetObject(ctx, s.bucket, s.resolveObjectKey(objectKey), minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("failed to open object: %w", err)
}
defer reader.Close()
content, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read object: %w", err)
}
return content, nil
}
func (s *objectStorageService) resolveObjectKey(objectKey string) string {
objectKey = strings.Trim(strings.TrimSpace(objectKey), "/")
if s.basePath == "" {
return objectKey
}
return s.basePath + "/" + objectKey
}
@@ -0,0 +1,995 @@
package services
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"clawreef/internal/models"
"clawreef/internal/repository"
"clawreef/internal/services/k8s"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
type SecurityScanConfigPayload struct {
ActiveMode string `json:"active_mode"`
DefaultMode string `json:"default_mode"`
QuickAnalyzers []string `json:"quick_analyzers"`
DeepAnalyzers []string `json:"deep_analyzers"`
QuickTimeoutSeconds int `json:"quick_timeout_seconds"`
DeepTimeoutSeconds int `json:"deep_timeout_seconds"`
AllowFallback bool `json:"allow_fallback"`
ScannerStatus SecurityScannerStatusPayload `json:"scanner_status"`
SkillScannerConfig SkillScannerRuntimeConfigPayload `json:"skill_scanner_config"`
}
type SecurityScannerStatusPayload struct {
Connected bool `json:"connected"`
LLMEnabled bool `json:"llm_enabled"`
StatusLabel string `json:"status_label"`
AvailableCapabilities []string `json:"available_capabilities"`
}
type SkillScannerRuntimeConfigPayload struct {
Namespace string `json:"namespace"`
DeploymentName string `json:"deployment_name"`
LLMAPIKey string `json:"llm_api_key"`
LLMModel string `json:"llm_model"`
LLMBaseURL string `json:"llm_base_url"`
MetaLLMAPIKey string `json:"meta_llm_api_key"`
MetaLLMModel string `json:"meta_llm_model"`
MetaLLMBaseURL string `json:"meta_llm_base_url"`
}
type StartSecurityScanRequest struct {
AssetType string `json:"asset_type"`
ScanMode string `json:"scan_mode"`
ScanScope string `json:"scan_scope"`
AssetID *int `json:"asset_id,omitempty"`
}
type securityScanScope struct {
ScanScope string `json:"scan_scope"`
AssetID *int `json:"asset_id,omitempty"`
}
type SecurityScanJobItemPayload struct {
ID int `json:"id"`
AssetType string `json:"asset_type"`
AssetID int `json:"asset_id"`
AssetName string `json:"asset_name"`
Status string `json:"status"`
ProgressPct int `json:"progress_pct"`
RiskLevel *string `json:"risk_level,omitempty"`
Summary *string `json:"summary,omitempty"`
ScanResultID *int `json:"scan_result_id,omitempty"`
CachedResult bool `json:"cached_result"`
TriggeredAnalyzers []string `json:"triggered_analyzers,omitempty"`
Findings []SkillFindingPayload `json:"findings,omitempty"`
ErrorMessage *string `json:"error_message,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
type SecurityScanReportPayload struct {
JobID int `json:"job_id"`
AssetType string `json:"asset_type"`
ScanMode string `json:"scan_mode"`
ScanScope string `json:"scan_scope"`
Status string `json:"status"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
TotalItems int `json:"total_items"`
CompletedItems int `json:"completed_items"`
FailedItems int `json:"failed_items"`
RiskCounts map[string]int `json:"risk_counts"`
FindingsSummary []map[string]string `json:"findings_summary"`
ConfiguredAnalyzers []string `json:"configured_analyzers"`
AvailableAnalyzers []string `json:"available_analyzers"`
TriggeredAnalyzers []string `json:"triggered_analyzers"`
Items []SecurityScanJobItemPayload `json:"items"`
Config SecurityScanConfigPayload `json:"config"`
}
type SecurityScanJobPayload struct {
ID int `json:"id"`
AssetType string `json:"asset_type"`
ScanMode string `json:"scan_mode"`
ScanScope string `json:"scan_scope"`
Status string `json:"status"`
RequestedBy *int `json:"requested_by,omitempty"`
TotalItems int `json:"total_items"`
CompletedItems int `json:"completed_items"`
FailedItems int `json:"failed_items"`
CurrentItemName *string `json:"current_item_name,omitempty"`
ProgressPct int `json:"progress_pct"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Items []SecurityScanJobItemPayload `json:"items,omitempty"`
Report *SecurityScanReportPayload `json:"report,omitempty"`
}
type SecurityScanService interface {
GetConfig() (*SecurityScanConfigPayload, error)
SaveConfig(updatedBy int, req SecurityScanConfigPayload) (*SecurityScanConfigPayload, error)
StartScan(requestedBy int, req StartSecurityScanRequest) (*SecurityScanJobPayload, error)
RescanSkill(requestedBy, skillID int, scanMode string) (*SecurityScanJobPayload, error)
ListJobs(limit int) ([]SecurityScanJobPayload, error)
GetJob(jobID int) (*SecurityScanJobPayload, error)
}
type securityScanService struct {
repo repository.SecurityScanRepository
skillRepo repository.SkillRepository
storage ObjectStorageService
scanner SkillScannerClient
running sync.Map
}
func NewSecurityScanService(repo repository.SecurityScanRepository, skillRepo repository.SkillRepository, storage ObjectStorageService, scanner SkillScannerClient) SecurityScanService {
return &securityScanService{repo: repo, skillRepo: skillRepo, storage: storage, scanner: scanner}
}
func (s *securityScanService) GetConfig() (*SecurityScanConfigPayload, error) {
item, err := s.ensureConfig()
if err != nil {
return nil, err
}
payload, err := s.toConfigPayload(*item)
if err != nil {
return nil, err
}
return payload, nil
}
func (s *securityScanService) SaveConfig(updatedBy int, req SecurityScanConfigPayload) (*SecurityScanConfigPayload, error) {
item, err := s.ensureConfig()
if err != nil {
return nil, err
}
activeMode := strings.TrimSpace(req.ActiveMode)
if activeMode == "" {
activeMode = strings.TrimSpace(req.DefaultMode)
}
if activeMode == "" {
activeMode = "quick"
}
quickJSON, _ := json.Marshal(normalizeAnalyzerList(req.QuickAnalyzers, defaultQuickAnalyzers()))
deepJSON, _ := json.Marshal(normalizeAnalyzerList(req.DeepAnalyzers, defaultDeepAnalyzers()))
item.DefaultMode = normalizeScanMode(activeMode)
item.QuickAnalyzersJSON = string(quickJSON)
item.DeepAnalyzersJSON = string(deepJSON)
item.QuickTimeoutSeconds = normalizeTimeout(req.QuickTimeoutSeconds, 30)
item.DeepTimeoutSeconds = normalizeTimeout(req.DeepTimeoutSeconds, 120)
item.AllowFallback = false
item.UpdatedBy = &updatedBy
if err := s.repo.UpsertConfig(item); err != nil {
return nil, err
}
if err := s.applySkillScannerConfig(req.SkillScannerConfig); err != nil {
return nil, err
}
return s.toConfigPayload(*item)
}
func (s *securityScanService) StartScan(requestedBy int, req StartSecurityScanRequest) (*SecurityScanJobPayload, error) {
assetType := strings.TrimSpace(req.AssetType)
if assetType == "" {
assetType = "skill"
}
if assetType != "skill" {
return nil, fmt.Errorf("unsupported asset type")
}
config, err := s.ensureConfig()
if err != nil {
return nil, err
}
mode := normalizeScanMode(config.DefaultMode)
scope := normalizeScanScope(req.ScanScope)
skills, err := s.resolveScanSkills(req.AssetID)
if err != nil {
return nil, err
}
scopePayload := securityScanScope{
ScanScope: scope,
AssetID: req.AssetID,
}
scopeJSONBytes, _ := json.Marshal(scopePayload)
scopeJSON := string(scopeJSONBytes)
job := &models.SecurityScanJob{
AssetType: assetType,
ScanMode: mode,
ScopeJSON: &scopeJSON,
Status: "queued",
RequestedBy: &requestedBy,
TotalItems: len(skills),
CompletedItems: 0,
FailedItems: 0,
}
if err := s.repo.CreateJob(job); err != nil {
return nil, err
}
for _, skill := range skills {
item := &models.SecurityScanJobItem{
JobID: job.ID,
AssetType: assetType,
AssetID: skill.ID,
AssetName: skill.Name,
Status: "pending",
ProgressPct: 0,
}
if err := s.repo.CreateJobItem(item); err != nil {
return nil, err
}
}
go s.runJob(job.ID)
return s.GetJob(job.ID)
}
func (s *securityScanService) RescanSkill(requestedBy, skillID int, scanMode string) (*SecurityScanJobPayload, error) {
return s.StartScan(requestedBy, StartSecurityScanRequest{
AssetType: "skill",
ScanMode: scanMode,
ScanScope: "full",
AssetID: &skillID,
})
}
func (s *securityScanService) resolveScanSkills(assetID *int) ([]models.Skill, error) {
if assetID != nil && *assetID > 0 {
skill, err := s.skillRepo.GetSkillByID(*assetID)
if err != nil {
return nil, err
}
if skill == nil {
return nil, fmt.Errorf("skill not found")
}
return []models.Skill{*skill}, nil
}
return s.skillRepo.ListAllSkills()
}
func (s *securityScanService) ListJobs(limit int) ([]SecurityScanJobPayload, error) {
items, err := s.repo.ListJobs(limit)
if err != nil {
return nil, err
}
result := make([]SecurityScanJobPayload, 0, len(items))
for _, item := range items {
payload, err := s.toJobPayload(item, false)
if err != nil {
return nil, err
}
result = append(result, *payload)
}
return result, nil
}
func (s *securityScanService) GetJob(jobID int) (*SecurityScanJobPayload, error) {
item, err := s.repo.GetJobByID(jobID)
if err != nil {
return nil, err
}
if item == nil {
return nil, fmt.Errorf("security scan job not found")
}
return s.toJobPayload(*item, true)
}
func (s *securityScanService) runJob(jobID int) {
if _, loaded := s.running.LoadOrStore(jobID, struct{}{}); loaded {
return
}
defer s.running.Delete(jobID)
job, err := s.repo.GetJobByID(jobID)
if err != nil || job == nil {
return
}
config, err := s.ensureConfig()
if err != nil {
return
}
configPayload, err := s.toConfigPayload(*config)
if err != nil {
return
}
scope := parseSecurityScanScope(job.ScopeJSON)
now := time.Now().UTC()
job.Status = "running"
job.StartedAt = &now
job.UpdatedAt = now
_ = s.repo.UpdateJob(job)
items, err := s.repo.ListJobItems(jobID)
if err != nil {
return
}
for _, item := range items {
currentName := item.AssetName
startedAt := time.Now().UTC()
item.Status = "running"
item.ProgressPct = 10
item.StartedAt = &startedAt
item.UpdatedAt = startedAt
job.CurrentItemName = &currentName
job.UpdatedAt = startedAt
_ = s.repo.UpdateJobItem(&item)
_ = s.repo.UpdateJob(job)
skill, err := s.skillRepo.GetSkillByID(item.AssetID)
if err != nil || skill == nil {
s.markJobItemFailed(job, &item, "skill not found")
continue
}
if skill.CurrentVersionID == nil {
s.markJobItemFailed(job, &item, "skill has no active version")
continue
}
version, err := s.skillRepo.GetVersionByID(*skill.CurrentVersionID)
if err != nil || version == nil {
s.markJobItemFailed(job, &item, "skill version not found")
continue
}
blob, err := s.skillRepo.GetBlobByID(version.BlobID)
if err != nil || blob == nil {
s.markJobItemFailed(job, &item, "skill blob not found")
continue
}
result, cached, err := s.scanBlob(skill, blob, job.ScanMode, scope.ScanScope, *configPayload)
if err != nil {
s.markJobItemFailed(job, &item, err.Error())
continue
}
finishedAt := time.Now().UTC()
item.Status = "completed"
item.ProgressPct = 100
item.CachedResult = cached
item.RiskLevel = &result.RiskLevel
item.Summary = result.Summary
item.ScanResultID = &result.ID
item.FinishedAt = &finishedAt
item.UpdatedAt = finishedAt
job.CompletedItems++
job.CurrentItemName = &currentName
job.UpdatedAt = finishedAt
_ = s.repo.UpdateJobItem(&item)
_ = s.repo.UpdateJob(job)
}
doneAt := time.Now().UTC()
job.Status = "completed"
job.CurrentItemName = nil
job.FinishedAt = &doneAt
job.UpdatedAt = doneAt
_ = s.repo.UpdateJob(job)
_ = s.generateReport(job.ID, *config)
}
func (s *securityScanService) markJobItemFailed(job *models.SecurityScanJob, item *models.SecurityScanJobItem, message string) {
finishedAt := time.Now().UTC()
item.Status = "failed"
item.ProgressPct = 100
item.ErrorMessage = optionalString(message)
item.FinishedAt = &finishedAt
item.UpdatedAt = finishedAt
job.CompletedItems++
job.FailedItems++
job.UpdatedAt = finishedAt
_ = s.repo.UpdateJobItem(item)
_ = s.repo.UpdateJob(job)
}
func (s *securityScanService) scanBlob(skill *models.Skill, blob *models.SkillBlob, mode, scope string, config SecurityScanConfigPayload) (*models.SkillScanResult, bool, error) {
latest, err := s.skillRepo.GetLatestScanResultByBlobID(blob.ID)
if err != nil {
return nil, false, err
}
if scope == "incremental" && latest != nil && latest.Status == "completed" && strings.EqualFold(strings.TrimSpace(latest.Engine), "skill-scanner") {
return latest, true, nil
}
if strings.TrimSpace(blob.ObjectKey) == "" || blob.SizeBytes <= 0 {
now := time.Now().UTC()
blob.ScanStatus = "pending"
blob.UpdatedAt = now
_ = s.skillRepo.UpdateBlob(blob)
return nil, false, fmt.Errorf("skill package has not been collected from agent yet")
}
content, err := s.storage.GetObject(context.Background(), blob.ObjectKey)
if err != nil {
now := time.Now().UTC()
blob.ScanStatus = "pending"
blob.UpdatedAt = now
_ = s.skillRepo.UpdateBlob(blob)
if strings.Contains(strings.ToLower(err.Error()), "specified key does not exist") {
return nil, false, fmt.Errorf("skill package has not been collected from agent yet")
}
return nil, false, err
}
timeout := config.QuickTimeoutSeconds
if mode == "deep" {
timeout = config.DeepTimeoutSeconds
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
options := map[string]string{
"scan_mode": mode,
}
analyzers := config.QuickAnalyzers
if mode == "deep" {
analyzers = config.DeepAnalyzers
}
if len(analyzers) > 0 {
options["analyzers"] = strings.Join(analyzers, ",")
}
if s.scanner == nil {
return nil, false, fmt.Errorf("skill scanner is not configured")
}
riskLevel, findings, summary, err := s.scanner.ScanArchive(ctx, blob.FileName, content, options)
if err != nil {
now := time.Now().UTC()
blob.ScanStatus = "failed"
blob.UpdatedAt = now
_ = s.skillRepo.UpdateBlob(blob)
return nil, false, fmt.Errorf("skill scanner failed: %w", err)
}
if strings.TrimSpace(summary) == "" {
summary = "Skill scanned by external skill-scanner service"
}
scannedAt := time.Now().UTC()
findingsJSON, _ := json.Marshal(findings)
result := &models.SkillScanResult{
BlobID: blob.ID, Engine: "skill-scanner", RiskLevel: riskLevel, Status: "completed",
Summary: &summary, FindingsJSON: optionalString(string(findingsJSON)), ScannedAt: &scannedAt,
}
if err := s.skillRepo.CreateScanResult(result); err != nil {
return nil, false, err
}
blob.ScanStatus = "completed"
blob.RiskLevel = riskLevel
blob.LastScannedAt = &scannedAt
blob.LastScanResultID = &result.ID
if err := s.skillRepo.UpdateBlob(blob); err != nil {
return nil, false, err
}
if skill != nil {
skill.RiskLevel = riskLevel
skill.LastScannedAt = &scannedAt
skill.LastScanResultID = &result.ID
skill.UpdatedAt = scannedAt
_ = s.skillRepo.UpdateSkill(skill)
}
return result, false, nil
}
func (s *securityScanService) generateReport(jobID int, config models.SecurityScanConfig) error {
job, err := s.repo.GetJobByID(jobID)
if err != nil || job == nil {
return err
}
jobItems, err := s.repo.ListJobItems(jobID)
if err != nil {
return err
}
cfgPayload, err := s.toConfigPayload(config)
if err != nil {
return err
}
riskCounts := map[string]int{}
findingsSummary := make([]map[string]string, 0, len(jobItems))
itemPayloads := make([]SecurityScanJobItemPayload, 0, len(jobItems))
triggeredAnalyzers := map[string]struct{}{}
for _, item := range jobItems {
if item.RiskLevel != nil {
riskCounts[*item.RiskLevel]++
}
itemPayload := toSecurityScanJobItemPayload(item)
if item.ScanResultID != nil {
result, err := s.skillRepo.GetScanResultByID(*item.ScanResultID)
if err != nil {
return err
}
findings := parseSkillFindings(result)
itemPayload.TriggeredAnalyzers = extractTriggeredAnalyzers(result)
itemPayload.Findings = topRiskFindings(findings, 5)
if summary := summarizeScanJobItem(item, findings); summary != nil {
itemPayload.Summary = summary
findingsSummary = append(findingsSummary, map[string]string{
"asset_name": item.AssetName,
"summary": *summary,
})
}
for _, analyzer := range itemPayload.TriggeredAnalyzers {
triggeredAnalyzers[analyzer] = struct{}{}
}
} else if summary := summarizeScanJobItem(item, nil); summary != nil {
itemPayload.Summary = summary
findingsSummary = append(findingsSummary, map[string]string{
"asset_name": item.AssetName,
"summary": *summary,
})
}
itemPayloads = append(itemPayloads, itemPayload)
}
configuredAnalyzers := cfgPayload.QuickAnalyzers
if job.ScanMode == "deep" {
configuredAnalyzers = cfgPayload.DeepAnalyzers
}
availableAnalyzers := []string{}
if s.scanner != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if analyzers, err := s.scanner.AvailableAnalyzers(ctx); err == nil {
availableAnalyzers = normalizeAnalyzerList(analyzers, []string{})
}
}
reportPayload := SecurityScanReportPayload{
JobID: job.ID,
AssetType: job.AssetType,
ScanMode: job.ScanMode,
ScanScope: parseSecurityScanScope(job.ScopeJSON).ScanScope,
Status: job.Status,
StartedAt: job.StartedAt,
FinishedAt: job.FinishedAt,
TotalItems: job.TotalItems,
CompletedItems: job.CompletedItems,
FailedItems: job.FailedItems,
RiskCounts: riskCounts,
FindingsSummary: findingsSummary,
ConfiguredAnalyzers: append([]string{}, configuredAnalyzers...),
AvailableAnalyzers: availableAnalyzers,
TriggeredAnalyzers: sortedAnalyzerKeys(triggeredAnalyzers),
Items: itemPayloads,
Config: *cfgPayload,
}
summaryJSON, _ := json.Marshal(reportPayload)
return s.repo.UpsertReport(&models.SecurityScanReport{
JobID: job.ID,
SummaryJSON: string(summaryJSON),
})
}
func (s *securityScanService) ensureConfig() (*models.SecurityScanConfig, error) {
item, err := s.repo.GetConfig()
if err != nil {
return nil, err
}
if item != nil {
return item, nil
}
quickJSON, _ := json.Marshal(defaultQuickAnalyzers())
deepJSON, _ := json.Marshal(defaultDeepAnalyzers())
item = &models.SecurityScanConfig{
ID: 1,
DefaultMode: "quick",
QuickAnalyzersJSON: string(quickJSON),
DeepAnalyzersJSON: string(deepJSON),
QuickTimeoutSeconds: 30,
DeepTimeoutSeconds: 120,
AllowFallback: false,
}
if err := s.repo.UpsertConfig(item); err != nil {
return nil, err
}
return item, nil
}
func summarizeScanJobItem(item models.SecurityScanJobItem, findings []SkillFindingPayload) *string {
if summary := summarizeRiskReason(topRiskFindings(findings, 2)); summary != nil {
return summary
}
if item.ErrorMessage != nil && strings.TrimSpace(*item.ErrorMessage) != "" {
message := strings.TrimSpace(*item.ErrorMessage)
return &message
}
if item.RiskLevel != nil {
switch strings.ToLower(strings.TrimSpace(*item.RiskLevel)) {
case "none":
text := "未发现风险项"
return &text
case "low":
text := "发现低风险提示,建议查看逐项结果"
return &text
case "medium":
text := "发现中风险问题,建议尽快整改"
return &text
case "high":
text := "发现高风险问题,建议立即处置"
return &text
}
}
if item.Status == "completed" {
text := "扫描完成,未返回详细发现"
return &text
}
if item.Summary != nil && strings.TrimSpace(*item.Summary) != "" {
text := strings.TrimSpace(*item.Summary)
return &text
}
return nil
}
func (s *securityScanService) toConfigPayload(item models.SecurityScanConfig) (*SecurityScanConfigPayload, error) {
var quickAnalyzers []string
var deepAnalyzers []string
if strings.TrimSpace(item.QuickAnalyzersJSON) != "" {
if err := json.Unmarshal([]byte(item.QuickAnalyzersJSON), &quickAnalyzers); err != nil {
return nil, fmt.Errorf("failed to decode quick analyzers: %w", err)
}
}
if strings.TrimSpace(item.DeepAnalyzersJSON) != "" {
if err := json.Unmarshal([]byte(item.DeepAnalyzersJSON), &deepAnalyzers); err != nil {
return nil, fmt.Errorf("failed to decode deep analyzers: %w", err)
}
}
return &SecurityScanConfigPayload{
ActiveMode: item.DefaultMode,
DefaultMode: item.DefaultMode,
QuickAnalyzers: normalizeAnalyzerList(quickAnalyzers, defaultQuickAnalyzers()),
DeepAnalyzers: normalizeAnalyzerList(deepAnalyzers, defaultDeepAnalyzers()),
QuickTimeoutSeconds: item.QuickTimeoutSeconds,
DeepTimeoutSeconds: item.DeepTimeoutSeconds,
AllowFallback: item.AllowFallback,
ScannerStatus: resolveSecurityScannerStatus(),
SkillScannerConfig: resolveSkillScannerRuntimeConfig(),
}, nil
}
func resolveSecurityScannerStatus() SecurityScannerStatusPayload {
runtime := resolveSkillScannerRuntimeConfig()
enabled := strings.EqualFold(strings.TrimSpace(os.Getenv("SKILL_SCANNER_ENABLED")), "true") &&
strings.TrimSpace(os.Getenv("SKILL_SCANNER_BASE_URL")) != ""
llmConfigured := strings.TrimSpace(runtime.LLMModel) != "" && strings.TrimSpace(runtime.LLMBaseURL) != "" && strings.TrimSpace(runtime.LLMAPIKey) != "" ||
(strings.TrimSpace(runtime.MetaLLMModel) != "" && strings.TrimSpace(runtime.MetaLLMBaseURL) != "" && strings.TrimSpace(runtime.MetaLLMAPIKey) != "")
status := SecurityScannerStatusPayload{
Connected: enabled,
LLMEnabled: llmConfigured,
StatusLabel: "未启用",
AvailableCapabilities: []string{},
}
if !enabled {
return status
}
status.StatusLabel = "静态扫描可用"
status.AvailableCapabilities = []string{"静态扫描"}
if llmConfigured {
status.StatusLabel = "静态 + LLM 扫描可用"
status.AvailableCapabilities = []string{"静态扫描", "LLM 扫描"}
}
return status
}
func resolveSkillScannerRuntimeConfig() SkillScannerRuntimeConfigPayload {
cfg := SkillScannerRuntimeConfigPayload{
Namespace: skillScannerNamespace(),
DeploymentName: skillScannerDeploymentName(),
}
if envs, err := loadSkillScannerDeploymentEnv(); err == nil {
cfg.LLMAPIKey = envs["SKILL_SCANNER_LLM_API_KEY"]
cfg.LLMModel = envs["SKILL_SCANNER_LLM_MODEL"]
cfg.LLMBaseURL = envs["SKILL_SCANNER_LLM_BASE_URL"]
cfg.MetaLLMAPIKey = envs["SKILL_SCANNER_META_LLM_API_KEY"]
cfg.MetaLLMModel = envs["SKILL_SCANNER_META_LLM_MODEL"]
cfg.MetaLLMBaseURL = envs["SKILL_SCANNER_META_LLM_BASE_URL"]
return cfg
}
cfg.LLMAPIKey = os.Getenv("SKILL_SCANNER_LLM_API_KEY")
cfg.LLMModel = os.Getenv("SKILL_SCANNER_LLM_MODEL")
cfg.LLMBaseURL = os.Getenv("SKILL_SCANNER_LLM_BASE_URL")
cfg.MetaLLMAPIKey = os.Getenv("SKILL_SCANNER_META_LLM_API_KEY")
cfg.MetaLLMModel = os.Getenv("SKILL_SCANNER_META_LLM_MODEL")
cfg.MetaLLMBaseURL = os.Getenv("SKILL_SCANNER_META_LLM_BASE_URL")
return cfg
}
func (s *securityScanService) applySkillScannerConfig(req SkillScannerRuntimeConfigPayload) error {
client := k8s.GetClient()
if client == nil || client.Clientset == nil {
return fmt.Errorf("kubernetes client is unavailable, cannot update skill-scanner config")
}
namespace := skillScannerNamespace()
deploymentName := skillScannerDeploymentName()
deployment, err := client.Clientset.AppsV1().Deployments(namespace).Get(context.Background(), deploymentName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("failed to load skill-scanner deployment: %w", err)
}
if len(deployment.Spec.Template.Spec.Containers) == 0 {
return fmt.Errorf("skill-scanner deployment has no containers")
}
container := &deployment.Spec.Template.Spec.Containers[0]
upsertEnvVar(container, "SKILL_SCANNER_LLM_API_KEY", strings.TrimSpace(req.LLMAPIKey))
upsertEnvVar(container, "SKILL_SCANNER_LLM_MODEL", strings.TrimSpace(req.LLMModel))
upsertEnvVar(container, "SKILL_SCANNER_LLM_BASE_URL", strings.TrimSpace(req.LLMBaseURL))
upsertEnvVar(container, "SKILL_SCANNER_META_LLM_API_KEY", strings.TrimSpace(req.MetaLLMAPIKey))
upsertEnvVar(container, "SKILL_SCANNER_META_LLM_MODEL", strings.TrimSpace(req.MetaLLMModel))
upsertEnvVar(container, "SKILL_SCANNER_META_LLM_BASE_URL", strings.TrimSpace(req.MetaLLMBaseURL))
updated, err := client.Clientset.AppsV1().Deployments(namespace).Update(context.Background(), deployment, metav1.UpdateOptions{})
if err != nil {
return fmt.Errorf("failed to update skill-scanner deployment: %w", err)
}
return waitForSkillScannerRollout(context.Background(), client.Clientset, namespace, deploymentName, updated.Generation, 90*time.Second)
}
func loadSkillScannerDeploymentEnv() (map[string]string, error) {
client := k8s.GetClient()
if client == nil || client.Clientset == nil {
return nil, fmt.Errorf("kubernetes client is unavailable")
}
deployment, err := client.Clientset.AppsV1().Deployments(skillScannerNamespace()).Get(context.Background(), skillScannerDeploymentName(), metav1.GetOptions{})
if err != nil {
return nil, err
}
if len(deployment.Spec.Template.Spec.Containers) == 0 {
return nil, fmt.Errorf("skill-scanner deployment has no containers")
}
envs := map[string]string{}
for _, env := range deployment.Spec.Template.Spec.Containers[0].Env {
envs[env.Name] = env.Value
}
return envs, nil
}
func upsertEnvVar(container *corev1.Container, name, value string) {
for i := range container.Env {
if container.Env[i].Name == name {
container.Env[i].Value = value
container.Env[i].ValueFrom = nil
return
}
}
container.Env = append(container.Env, corev1.EnvVar{Name: name, Value: value})
}
func waitForSkillScannerRollout(ctx context.Context, clientset *kubernetes.Clientset, namespace, deploymentName string, generation int64, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
deployment, err := clientset.AppsV1().Deployments(namespace).Get(ctx, deploymentName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("failed to observe skill-scanner rollout: %w", err)
}
replicas := int32(1)
if deployment.Spec.Replicas != nil {
replicas = *deployment.Spec.Replicas
}
if deployment.Status.ObservedGeneration >= generation &&
deployment.Status.UpdatedReplicas == replicas &&
deployment.Status.AvailableReplicas == replicas &&
deployment.Status.UnavailableReplicas == 0 {
return nil
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("skill-scanner rollout timed out")
}
func skillScannerNamespace() string {
if value := strings.TrimSpace(os.Getenv("SKILL_SCANNER_NAMESPACE")); value != "" {
return value
}
if value := strings.TrimSpace(readInClusterNamespace()); value != "" {
return value
}
return "clawmanager-system"
}
func skillScannerDeploymentName() string {
if value := strings.TrimSpace(os.Getenv("SKILL_SCANNER_DEPLOYMENT")); value != "" {
return value
}
return "skill-scanner"
}
func readInClusterNamespace() string {
data, err := os.ReadFile(filepath.Clean("/var/run/secrets/kubernetes.io/serviceaccount/namespace"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func (s *securityScanService) toJobPayload(item models.SecurityScanJob, includeDetails bool) (*SecurityScanJobPayload, error) {
payload := &SecurityScanJobPayload{
ID: item.ID,
AssetType: item.AssetType,
ScanMode: item.ScanMode,
ScanScope: parseSecurityScanScope(item.ScopeJSON).ScanScope,
Status: item.Status,
RequestedBy: item.RequestedBy,
TotalItems: item.TotalItems,
CompletedItems: item.CompletedItems,
FailedItems: item.FailedItems,
CurrentItemName: item.CurrentItemName,
ProgressPct: computeProgress(item.TotalItems, item.CompletedItems),
StartedAt: item.StartedAt,
FinishedAt: item.FinishedAt,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
if !includeDetails {
return payload, nil
}
items, err := s.repo.ListJobItems(item.ID)
if err != nil {
return nil, err
}
payload.Items = make([]SecurityScanJobItemPayload, 0, len(items))
for _, entry := range items {
payload.Items = append(payload.Items, toSecurityScanJobItemPayload(entry))
}
report, err := s.repo.GetReportByJobID(item.ID)
if err != nil {
return nil, err
}
if report != nil && strings.TrimSpace(report.SummaryJSON) != "" {
var reportPayload SecurityScanReportPayload
if err := json.Unmarshal([]byte(report.SummaryJSON), &reportPayload); err == nil {
payload.Report = &reportPayload
}
}
return payload, nil
}
func toSecurityScanJobItemPayload(item models.SecurityScanJobItem) SecurityScanJobItemPayload {
return SecurityScanJobItemPayload{
ID: item.ID,
AssetType: item.AssetType,
AssetID: item.AssetID,
AssetName: item.AssetName,
Status: item.Status,
ProgressPct: item.ProgressPct,
RiskLevel: item.RiskLevel,
Summary: item.Summary,
ScanResultID: item.ScanResultID,
CachedResult: item.CachedResult,
TriggeredAnalyzers: []string{},
Findings: []SkillFindingPayload{},
ErrorMessage: item.ErrorMessage,
StartedAt: item.StartedAt,
FinishedAt: item.FinishedAt,
}
}
func extractTriggeredAnalyzers(result *models.SkillScanResult) []string {
if result == nil || result.FindingsJSON == nil || strings.TrimSpace(*result.FindingsJSON) == "" {
return []string{}
}
var raw struct {
Findings []struct {
Analyzer string `json:"analyzer"`
} `json:"findings"`
}
if err := json.Unmarshal([]byte(*result.FindingsJSON), &raw); err != nil {
return []string{}
}
seen := map[string]struct{}{}
resultList := make([]string, 0, len(raw.Findings))
for _, item := range raw.Findings {
analyzer := strings.TrimSpace(strings.ToLower(item.Analyzer))
if analyzer == "" {
continue
}
if _, ok := seen[analyzer]; ok {
continue
}
seen[analyzer] = struct{}{}
resultList = append(resultList, analyzer)
}
return resultList
}
func sortedAnalyzerKeys(values map[string]struct{}) []string {
if len(values) == 0 {
return []string{}
}
result := make([]string, 0, len(values))
for value := range values {
result = append(result, value)
}
slices.Sort(result)
return result
}
func defaultQuickAnalyzers() []string {
return []string{"static", "behavioral", "trigger"}
}
func defaultDeepAnalyzers() []string {
return []string{"static", "bytecode", "pipeline", "behavioral", "trigger", "llm", "meta"}
}
func normalizeAnalyzerList(values []string, fallback []string) []string {
if len(values) == 0 {
return append([]string{}, fallback...)
}
allowed := map[string]struct{}{
"static": {},
"bytecode": {},
"pipeline": {},
"behavioral": {},
"trigger": {},
"llm": {},
"meta": {},
}
seen := map[string]struct{}{}
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
continue
}
if _, ok := allowed[value]; !ok {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
if len(result) == 0 {
return append([]string{}, fallback...)
}
return result
}
func normalizeScanMode(value string) string {
if strings.EqualFold(strings.TrimSpace(value), "deep") {
return "deep"
}
return "quick"
}
func normalizeScanScope(value string) string {
if strings.EqualFold(strings.TrimSpace(value), "full") {
return "full"
}
return "incremental"
}
func parseSecurityScanScope(raw *string) securityScanScope {
scope := securityScanScope{ScanScope: "incremental"}
if raw == nil || strings.TrimSpace(*raw) == "" {
return scope
}
if err := json.Unmarshal([]byte(*raw), &scope); err != nil {
return securityScanScope{ScanScope: "incremental"}
}
scope.ScanScope = normalizeScanScope(scope.ScanScope)
return scope
}
func normalizeTimeout(value, fallback int) int {
if value <= 0 {
return fallback
}
return value
}
func computeProgress(total, completed int) int {
if total <= 0 {
return 0
}
if completed >= total {
return 100
}
return int(float64(completed) / float64(total) * 100)
}
@@ -0,0 +1,240 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
"clawreef/internal/config"
)
type SkillScannerClient interface {
ScanArchive(ctx context.Context, fileName string, content []byte, options map[string]string) (string, map[string]interface{}, string, error)
AvailableAnalyzers(ctx context.Context) ([]string, error)
}
type noopSkillScannerClient struct{}
func (n *noopSkillScannerClient) ScanArchive(ctx context.Context, fileName string, content []byte, options map[string]string) (string, map[string]interface{}, string, error) {
return "", nil, "", fmt.Errorf("skill scanner is disabled")
}
func (n *noopSkillScannerClient) AvailableAnalyzers(ctx context.Context) ([]string, error) {
return nil, fmt.Errorf("skill scanner is disabled")
}
type httpSkillScannerClient struct {
baseURL string
apiKey string
client *http.Client
}
func NewSkillScannerClient(cfg config.SkillScannerConfig) SkillScannerClient {
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" {
return &noopSkillScannerClient{}
}
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
return &httpSkillScannerClient{
baseURL: strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/"),
apiKey: strings.TrimSpace(cfg.APIKey),
client: &http.Client{Timeout: timeout},
}
}
func (c *httpSkillScannerClient) ScanArchive(ctx context.Context, fileName string, content []byte, options map[string]string) (string, map[string]interface{}, string, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
return "", nil, "", fmt.Errorf("failed to create skill scanner upload: %w", err)
}
if _, err := part.Write(content); err != nil {
return "", nil, "", fmt.Errorf("failed to write skill scanner upload: %w", err)
}
_ = writer.WriteField("format", "json")
if err := writer.Close(); err != nil {
return "", nil, "", fmt.Errorf("failed to finalize skill scanner upload: %w", err)
}
endpoint, err := url.Parse(c.baseURL + "/scan-upload")
if err != nil {
return "", nil, "", fmt.Errorf("failed to build skill scanner url: %w", err)
}
query := endpoint.Query()
applyScanUploadOptions(query, options)
endpoint.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), &body)
if err != nil {
return "", nil, "", fmt.Errorf("failed to create skill scanner request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Accept", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.client.Do(req)
if err != nil {
return "", nil, "", fmt.Errorf("skill scanner request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", nil, "", fmt.Errorf("skill scanner returned status %d", resp.StatusCode)
}
var raw map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return "", nil, "", fmt.Errorf("failed to decode skill scanner response: %w", err)
}
riskLevel := normalizeScannerRiskLevel(raw)
summary := extractScannerSummary(raw)
return riskLevel, raw, summary, nil
}
func (c *httpSkillScannerClient) AvailableAnalyzers(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
if err != nil {
return nil, fmt.Errorf("failed to create skill scanner health request: %w", err)
}
req.Header.Set("Accept", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("skill scanner health request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("skill scanner health returned status %d", resp.StatusCode)
}
var raw struct {
Analyzers []string `json:"analyzers_available"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("failed to decode skill scanner health response: %w", err)
}
result := make([]string, 0, len(raw.Analyzers))
for _, item := range raw.Analyzers {
item = strings.TrimSpace(item)
if item == "" {
continue
}
item = strings.TrimSuffix(item, "_analyzer")
result = append(result, item)
}
return result, nil
}
func applyScanUploadOptions(query url.Values, options map[string]string) {
analyzers := splitAnalyzerOption(options["analyzers"])
if hasAnalyzer(analyzers, "behavioral") {
query.Set("use_behavioral", "true")
}
if hasAnalyzer(analyzers, "llm") || hasAnalyzer(analyzers, "meta") {
query.Set("use_llm", "true")
query.Set("llm_provider", "openai")
}
}
func splitAnalyzerOption(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
items := strings.Split(value, ",")
result := make([]string, 0, len(items))
for _, item := range items {
item = strings.ToLower(strings.TrimSpace(item))
if item == "" {
continue
}
result = append(result, item)
}
return result
}
func hasAnalyzer(items []string, target string) bool {
target = strings.ToLower(strings.TrimSpace(target))
for _, item := range items {
if item == target {
return true
}
}
return false
}
func normalizeScannerRiskLevel(raw map[string]interface{}) string {
if safe, ok := readBool(raw["is_safe"]); ok && safe {
return skillRiskNone
}
candidates := []string{
readString(raw["risk_level"]),
readString(raw["severity"]),
readString(raw["verdict"]),
readString(raw["max_severity"]),
}
if result, ok := raw["result"].(map[string]interface{}); ok {
if safe, ok := readBool(result["is_safe"]); ok && safe {
return skillRiskNone
}
candidates = append(candidates,
readString(result["risk_level"]),
readString(result["severity"]),
readString(result["verdict"]),
readString(result["max_severity"]),
)
}
for _, value := range candidates {
switch strings.ToLower(strings.TrimSpace(value)) {
case "critical", "high":
return skillRiskHigh
case "medium", "moderate":
return skillRiskMedium
case "low", "warning":
return skillRiskLow
case "none", "clean", "safe", "pass", "info", "informational":
return skillRiskNone
}
}
return skillRiskUnknown
}
func extractScannerSummary(raw map[string]interface{}) string {
candidates := []string{
readString(raw["summary"]),
readString(raw["message"]),
}
if result, ok := raw["result"].(map[string]interface{}); ok {
candidates = append(candidates, readString(result["summary"]), readString(result["message"]))
}
for _, candidate := range candidates {
if strings.TrimSpace(candidate) != "" {
return candidate
}
}
return "Skill scanned by external skill-scanner service"
}
func readString(value interface{}) string {
if text, ok := value.(string); ok {
return text
}
return ""
}
func readBool(value interface{}) (bool, bool) {
boolean, ok := value.(bool)
return boolean, ok
}
File diff suppressed because it is too large Load Diff
+408
View File
@@ -13,6 +13,10 @@ stringData:
mysql-root-password: root123
mysql-password: clawreef123
jwt-secret: change-me-in-production
minio-root-user: minioadmin
minio-root-password: minioadmin123
minio-access-key: minioadmin
minio-secret-key: minioadmin123
---
apiVersion: v1
kind: ConfigMap
@@ -279,6 +283,224 @@ data:
INDEX idx_openclaw_snapshot_instance (instance_id),
INDEX idx_openclaw_snapshot_bundle (bundle_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
007_add_instance_agent_control_plane.sql: |
USE clawmanager;
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;
008_add_skill_management.sql: |
USE clawmanager;
CREATE TABLE IF NOT EXISTS skill_blobs (
id INT AUTO_INCREMENT PRIMARY KEY,
content_hash VARCHAR(128) NOT NULL,
archive_hash VARCHAR(128) NOT NULL,
object_key VARCHAR(512) NOT NULL,
file_name VARCHAR(255) NOT NULL,
media_type VARCHAR(100) NOT NULL DEFAULT 'application/gzip',
size_bytes BIGINT NOT NULL DEFAULT 0,
scan_status VARCHAR(30) NOT NULL DEFAULT 'pending',
risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown',
last_scanned_at TIMESTAMP NULL,
last_scan_result_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_skill_blobs_content_hash (content_hash),
INDEX idx_skill_blobs_scan_status (scan_status),
INDEX idx_skill_blobs_risk_level (risk_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS skills (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
skill_key VARCHAR(120) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
current_version_id INT NULL,
source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded',
status VARCHAR(30) NOT NULL DEFAULT 'active',
risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown',
last_scanned_at TIMESTAMP NULL,
last_scan_result_id INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY uk_skills_user_key (user_id, skill_key),
INDEX idx_skills_user_status (user_id, status),
INDEX idx_skills_risk_level (risk_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS skill_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
skill_id INT NOT NULL,
blob_id INT NOT NULL,
version_no INT NOT NULL,
manifest_json LONGTEXT NULL,
source_type VARCHAR(30) NOT NULL DEFAULT 'uploaded',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE RESTRICT,
UNIQUE KEY uk_skill_versions_skill_version (skill_id, version_no),
UNIQUE KEY uk_skill_versions_skill_blob (skill_id, blob_id),
INDEX idx_skill_versions_skill_id (skill_id, version_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS instance_skills (
id INT AUTO_INCREMENT PRIMARY KEY,
instance_id INT NOT NULL,
skill_id INT NOT NULL,
skill_version_id INT NULL,
source_type VARCHAR(40) NOT NULL DEFAULT 'discovered_in_instance',
install_path VARCHAR(1024) NULL,
observed_hash VARCHAR(128) NULL,
status VARCHAR(30) NOT NULL DEFAULT 'active',
last_seen_at TIMESTAMP NULL,
removed_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 (skill_id) REFERENCES skills(id) ON DELETE CASCADE,
FOREIGN KEY (skill_version_id) REFERENCES skill_versions(id) ON DELETE SET NULL,
UNIQUE KEY uk_instance_skills_instance_skill (instance_id, skill_id),
INDEX idx_instance_skills_instance (instance_id, status),
INDEX idx_instance_skills_skill (skill_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS skill_scan_results (
id INT AUTO_INCREMENT PRIMARY KEY,
blob_id INT NOT NULL,
engine VARCHAR(60) NOT NULL,
risk_level VARCHAR(30) NOT NULL DEFAULT 'unknown',
status VARCHAR(30) NOT NULL DEFAULT 'completed',
summary TEXT NULL,
findings_json LONGTEXT NULL,
scanned_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (blob_id) REFERENCES skill_blobs(id) ON DELETE CASCADE,
INDEX idx_skill_scan_results_blob (blob_id, scanned_at),
INDEX idx_skill_scan_results_risk (risk_level, scanned_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
---
apiVersion: v1
kind: PersistentVolume
@@ -310,6 +532,36 @@ spec:
storageClassName: manual
volumeName: clawmanager-mysql-pv
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: clawmanager-minio-pv
spec:
capacity:
storage: 10Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
hostPath:
path: /tmp/clawmanager/system/minio
type: DirectoryOrCreate
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: minio-data
namespace: clawmanager-system
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: manual
volumeName: clawmanager-minio-pv
---
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -376,6 +628,75 @@ spec:
port: 3306
targetPort: 3306
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: minio
namespace: clawmanager-system
spec:
replicas: 1
selector:
matchLabels:
app: minio
template:
metadata:
labels:
app: minio
spec:
containers:
- name: minio
image: minio/minio:RELEASE.2026-01-24T22-31-39Z
imagePullPolicy: IfNotPresent
args:
- server
- /data
- --console-address
- ":9001"
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: clawmanager-secrets
key: minio-root-user
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: clawmanager-secrets
key: minio-root-password
ports:
- name: api
containerPort: 9000
- name: console
containerPort: 9001
volumeMounts:
- name: minio-data
mountPath: /data
readinessProbe:
tcpSocket:
port: api
initialDelaySeconds: 10
periodSeconds: 10
volumes:
- name: minio-data
persistentVolumeClaim:
claimName: minio-data
---
apiVersion: v1
kind: Service
metadata:
name: minio
namespace: clawmanager-system
spec:
selector:
app: minio
ports:
- name: api
port: 9000
targetPort: api
- name: console
port: 9001
targetPort: console
---
apiVersion: v1
kind: ServiceAccount
metadata:
@@ -397,6 +718,61 @@ subjects:
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: skill-scanner
namespace: clawmanager-system
spec:
replicas: 1
selector:
matchLabels:
app: skill-scanner
template:
metadata:
labels:
app: skill-scanner
spec:
containers:
- name: skill-scanner
image: ghcr.io/yuan-lab-llm/skill-scanner:latest
imagePullPolicy: IfNotPresent
command:
- /opt/skill-scanner-venv/bin/skill-scanner-api
- --host
- 0.0.0.0
- --port
- "8000"
env:
- name: SKILL_SCANNER_LLM_API_KEY
value: ""
- name: SKILL_SCANNER_LLM_MODEL
value: ""
- name: SKILL_SCANNER_LLM_BASE_URL
value: ""
- name: SKILL_SCANNER_META_LLM_API_KEY
value: ""
- name: SKILL_SCANNER_META_LLM_MODEL
value: ""
- name: SKILL_SCANNER_META_LLM_BASE_URL
value: ""
ports:
- name: http
containerPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: skill-scanner
namespace: clawmanager-system
spec:
selector:
app: skill-scanner
ports:
- name: http
port: 8000
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: clawmanager-app
namespace: clawmanager-system
@@ -443,12 +819,44 @@ spec:
secretKeyRef:
name: clawmanager-secrets
key: jwt-secret
- name: OBJECT_STORAGE_ENDPOINT
value: "minio.clawmanager-system.svc.cluster.local:9000"
- name: OBJECT_STORAGE_REGION
value: ""
- name: OBJECT_STORAGE_ACCESS_KEY
valueFrom:
secretKeyRef:
name: clawmanager-secrets
key: minio-access-key
- name: OBJECT_STORAGE_SECRET_KEY
valueFrom:
secretKeyRef:
name: clawmanager-secrets
key: minio-secret-key
- name: OBJECT_STORAGE_BUCKET
value: "clawmanager-skills"
- name: OBJECT_STORAGE_USE_SSL
value: "false"
- name: OBJECT_STORAGE_BASE_PATH
value: "skills"
- name: OBJECT_STORAGE_FORCE_PATH_STYLE
value: "true"
- name: K8S_MODE
value: "incluster"
- name: K8S_NAMESPACE
value: "clawmanager"
- name: K8S_STORAGE_CLASS
value: "manual"
- name: SKILL_SCANNER_ENABLED
value: "true"
- name: SKILL_SCANNER_BASE_URL
value: "http://skill-scanner.clawmanager-system.svc.cluster.local:8000"
- name: SKILL_SCANNER_TIMEOUT_SECONDS
value: "120"
- name: SKILL_SCANNER_NAMESPACE
value: "clawmanager-system"
- name: SKILL_SCANNER_DEPLOYMENT
value: "skill-scanner"
volumeMounts:
- name: tls-cert
mountPath: /var/run/clawreef-tls
+3 -2
View File
@@ -41,6 +41,7 @@ const AdminLayout: React.FC<AdminLayoutProps> = ({ children, title }) => {
{ path: '/admin', label: t('nav.adminDashboard'), icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', exact: true },
{ path: '/admin/users', label: t('nav.users'), icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
{ path: '/admin/instances', label: t('nav.instances'), icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01' },
{ path: '/admin/security', label: t('nav.securityCenter'), icon: 'M12 2l8 4.5v5c0 5.8-3.6 10.8-8 12.5-4.4-1.7-8-6.7-8-12.5v-5L12 2z', matchPaths: ['/admin/assets', '/admin/skills'] },
{
path: '/admin/ai-gateway',
label: t('nav.aiGateway'),
@@ -153,7 +154,7 @@ const AdminLayout: React.FC<AdminLayoutProps> = ({ children, title }) => {
/>
<div>
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-[#b46c50]">
Admin
{t('adminLayout.admin')}
</div>
<div className="mt-0.5 text-[1.45rem] font-bold leading-none">{t('app.name')}</div>
</div>
@@ -162,7 +163,7 @@ const AdminLayout: React.FC<AdminLayoutProps> = ({ children, title }) => {
<nav className="flex-1 overflow-y-auto px-3 pb-6">
<div className="px-3 pb-3 text-xs font-semibold uppercase tracking-[0.18em] text-[#b46c50]">
Navigation
{t('adminLayout.navigation')}
</div>
<div className="space-y-1.5">
{navItems.map((item) => (
+28 -1
View File
@@ -1,4 +1,5 @@
import { memo, useState, useEffect, useCallback, useRef } from "react";
import { OpenClawDesktopOverlay } from "./OpenClawDesktopOverlay";
import { useI18n } from "../contexts/I18nContext";
import { useInstanceDesktopAccess } from "../hooks/useInstanceDesktopAccess";
@@ -6,6 +7,12 @@ interface InstanceAccessProps {
instanceId: number;
instanceName: string;
isRunning: boolean;
overlay?: {
gatewayStatus: string;
canControl: boolean;
actionLoading: string | null;
onCommand: (command: "start" | "stop" | "restart" | "collect-system-info" | "health-check") => void;
};
}
const desktopConnectPreferenceStore = new Map<number, boolean>();
@@ -44,6 +51,7 @@ export function InstanceAccess({
instanceId,
instanceName,
isRunning,
overlay,
}: InstanceAccessProps) {
const { t } = useI18n();
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -184,7 +192,18 @@ export function InstanceAccess({
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
ref={containerRef}
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)]"
>
{overlay ? (
<OpenClawDesktopOverlay
gatewayStatus={overlay.gatewayStatus}
canControl={overlay.canControl}
actionLoading={overlay.actionLoading}
onCommand={overlay.onCommand}
/>
) : null}
<div
className={`${frameHeightClass} flex flex-col items-center justify-center px-8 text-center`}
>
@@ -235,6 +254,14 @@ export function InstanceAccess({
ref={containerRef}
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)]"}`}
>
{overlay ? (
<OpenClawDesktopOverlay
gatewayStatus={overlay.gatewayStatus}
canControl={overlay.canControl}
actionLoading={overlay.actionLoading}
onCommand={overlay.onCommand}
/>
) : null}
<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>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,879 @@
import React, { useEffect, useMemo, useState } from 'react';
import AdminLayout from '../../components/AdminLayout';
import { useI18n } from '../../contexts/I18nContext';
import {
adminService,
type AdminSkillRecord,
type SecurityScanConfig,
type SecurityScanJob,
} from '../../services/adminService';
const AdminSkillsPage: React.FC = () => {
const { t } = useI18n();
const [skills, setSkills] = useState<AdminSkillRecord[]>([]);
const [jobs, setJobs] = useState<SecurityScanJob[]>([]);
const [selectedJobId, setSelectedJobId] = useState<number | null>(null);
const [selectedJob, setSelectedJob] = useState<SecurityScanJob | null>(null);
const [config, setConfig] = useState<SecurityScanConfig | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const [startingScan, setStartingScan] = useState<'' | 'quick' | 'deep'>('');
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [assetPage, setAssetPage] = useState(1);
const [selectedAssetId, setSelectedAssetId] = useState<number | null>(null);
const pageSize = 10;
const loadAll = async (mode: 'initial' | 'refresh' = 'initial') => {
try {
if (mode === 'initial') {
setLoading(true);
} else {
setRefreshing(true);
}
setError(null);
const [skillItems, jobItems, configItem] = await Promise.all([
adminService.listSkills(),
adminService.listSecurityScanJobs(20),
adminService.getSecurityConfig(),
]);
setSkills(skillItems);
setAssetPage(1);
setSelectedAssetId((current) => {
if (current && skillItems.some((item) => item.id === current)) {
return current;
}
return skillItems.find((item) => item.risk_level === 'high')?.id ?? skillItems[0]?.id ?? null;
});
setJobs(jobItems);
setConfig(configItem);
const activeJobId = selectedJobId ?? jobItems[0]?.id ?? null;
setSelectedJobId(activeJobId);
if (activeJobId) {
const detail = await adminService.getSecurityScanJob(activeJobId);
setSelectedJob(detail);
} else {
setSelectedJob(null);
}
} catch (err: any) {
setError(err.response?.data?.error || '加载安全中心失败。');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void loadAll();
}, []);
useEffect(() => {
setAssetPage(1);
}, [search]);
useEffect(() => {
const hasRunningJob = jobs.some((job) => job.status === 'queued' || job.status === 'running');
if (!hasRunningJob) {
return;
}
const timer = window.setInterval(() => {
void loadAll('refresh');
}, 3000);
return () => window.clearInterval(timer);
}, [jobs, selectedJobId]);
const filteredSkills = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return skills;
}
return skills.filter((item) =>
[item.name, item.skill_key, item.source_type, item.risk_level, item.scan_status, String(item.user_id)].some((value) =>
value.toLowerCase().includes(keyword),
),
);
}, [search, skills]);
const summary = useMemo(() => ({
total: skills.length,
uploaded: skills.filter((item) => item.source_type === 'uploaded').length,
discovered: skills.filter((item) => item.source_type !== 'uploaded').length,
highRisk: skills.filter((item) => item.risk_level === 'high').length,
}), [skills]);
const totalAssetPages = Math.max(1, Math.ceil(filteredSkills.length / pageSize));
const currentAssetPage = Math.min(assetPage, totalAssetPages);
const paginatedSkills = useMemo(() => {
const start = (currentAssetPage - 1) * pageSize;
return filteredSkills.slice(start, start + pageSize);
}, [currentAssetPage, filteredSkills]);
const selectedAsset = useMemo(
() => filteredSkills.find((item) => item.id === selectedAssetId) ?? skills.find((item) => item.id === selectedAssetId) ?? null,
[filteredSkills, selectedAssetId, skills],
);
const handleStartScan = async (scanMode: 'quick' | 'deep') => {
try {
setStartingScan(scanMode);
setError(null);
const job = await adminService.startSecurityScan({ asset_type: 'skill', scan_mode: scanMode });
setSelectedJobId(job.id);
await loadAll('refresh');
} catch (err: any) {
setError(err.response?.data?.error || `启动${scanMode === 'deep' ? '深度' : '快速'}扫描失败。`);
} finally {
setStartingScan('');
}
};
const handleSaveConfig = async () => {
if (!config) {
return;
}
try {
setSavingConfig(true);
setError(null);
const saved = await adminService.saveSecurityConfig(config);
setConfig(saved);
} catch (err: any) {
setError(err.response?.data?.error || '保存扫描配置失败。');
} finally {
setSavingConfig(false);
}
};
const handleSelectJob = async (jobId: number) => {
try {
setSelectedJobId(jobId);
const detail = await adminService.getSecurityScanJob(jobId);
setSelectedJob(detail);
} catch (err: any) {
setError(err.response?.data?.error || '加载扫描报告失败。');
}
};
return (
<AdminLayout title="安全中心">
<div className="space-y-6">
<section className="rounded-[28px] border border-[#eadfd8] bg-[rgba(255,250,247,0.96)] p-6 shadow-[0_20px_50px_-36px_rgba(72,44,24,0.42)]">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[#b46c50]">Security Center</p>
<h1 className="mt-2 text-3xl font-semibold text-[#171212]"></h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#6f6661]">
skills
</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="资产总数" value={String(summary.total)} />
<StatCard label="上传资产" value={String(summary.uploaded)} />
<StatCard label="运行时发现" value={String(summary.discovered)} />
<StatCard label="高风险" value={String(summary.highRisk)} />
</div>
</div>
</section>
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
<section className="grid grid-cols-1 gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-lg font-semibold text-[#171212]"></h2>
<p className="mt-1 text-sm text-[#8f8681]"> Quick Scan Deep Scan</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => void loadAll('refresh')}
disabled={loading || refreshing}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{refreshing ? '刷新中...' : '刷新'}
</button>
<button
type="button"
onClick={() => void handleStartScan('quick')}
disabled={loading || startingScan !== ''}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{startingScan === 'quick' ? '启动中...' : '快速扫描'}
</button>
<button
type="button"
onClick={() => void handleStartScan('deep')}
disabled={loading || startingScan !== ''}
className="app-button-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{startingScan === 'deep' ? '启动中...' : '深度扫描'}
</button>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]"></div>
<div className="mt-2 text-lg font-semibold text-[#171212]">{config?.default_mode === 'deep' ? '深度扫描' : config?.default_mode === 'quick' ? '快速扫描' : '--'}</div>
</div>
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]"></div>
<div className="mt-2 text-lg font-semibold text-[#171212]">{config?.quick_timeout_seconds ?? '--'}s</div>
</div>
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]"></div>
<div className="mt-2 text-lg font-semibold text-[#171212]">{config?.deep_timeout_seconds ?? '--'}s</div>
</div>
</div>
<div className="mt-6 rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-5">
<div className="text-sm font-semibold text-[#171212]"></div>
<div className="mt-4 space-y-3">
{loading ? (
<div className="text-sm text-[#8f8681]">{t('common.loading')}</div>
) : jobs.length === 0 ? (
<div className="text-sm text-[#8f8681]"></div>
) : (
jobs.map((job) => (
<button
key={job.id}
type="button"
onClick={() => void handleSelectJob(job.id)}
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${
selectedJobId === job.id ? 'border-[#f2b7a0] bg-[#fff1eb]' : 'border-[#eadfd8] bg-white hover:border-[#dcc6b8]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-[#171212]">
{job.scan_mode === 'deep' ? '深度扫描' : '快速扫描'} #{job.id}
</div>
<div className="mt-1 text-xs text-[#8f8681]">
{assetTypeLabel(job.asset_type)} · {job.completed_items}/{job.total_items}
{job.current_item_name ? ` · 当前 ${job.current_item_name}` : ''}
</div>
</div>
<Badge tone={job.status === 'completed' ? 'green' : job.status === 'failed' ? 'red' : 'orange'}>
{jobStatusLabel(job.status)}
</Badge>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#f3e7df]">
<div className="h-full rounded-full bg-[#dc2626]" style={{ width: `${job.progress_pct}%` }} />
</div>
</button>
))
)}
</div>
</div>
</div>
<div className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-[#171212]"></h2>
<p className="mt-1 text-sm text-[#8f8681]"> analyzer skill-scanner</p>
</div>
<button
type="button"
onClick={handleSaveConfig}
disabled={!config || savingConfig}
className="app-button-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{savingConfig ? '保存中...' : '保存配置'}
</button>
</div>
{config ? (
<div className="mt-6 space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700"></label>
<select
value={config.default_mode}
onChange={(event) => setConfig((current) => current ? { ...current, default_mode: event.target.value } : current)}
className="app-input mt-1 w-full"
>
<option value="quick"></option>
<option value="deep"></option>
</select>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700"></label>
<input
type="number"
min={5}
value={config.quick_timeout_seconds}
onChange={(event) => setConfig((current) => current ? { ...current, quick_timeout_seconds: Number(event.target.value) || 30 } : current)}
className="app-input mt-1 w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"></label>
<input
type="number"
min={10}
value={config.deep_timeout_seconds}
onChange={(event) => setConfig((current) => current ? { ...current, deep_timeout_seconds: Number(event.target.value) || 120 } : current)}
className="app-input mt-1 w-full"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> Analyzers</label>
<input
value={(config.quick_analyzers ?? []).join(', ')}
onChange={(event) => setConfig((current) => current ? { ...current, quick_analyzers: splitAnalyzers(event.target.value) } : current)}
className="app-input mt-1 w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"> Analyzers</label>
<input
value={(config.deep_analyzers ?? []).join(', ')}
onChange={(event) => setConfig((current) => current ? { ...current, deep_analyzers: splitAnalyzers(event.target.value) } : current)}
className="app-input mt-1 w-full"
/>
</div>
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] px-4 py-3 text-sm text-[#6f6661]">
使 <span className="font-semibold text-[#171212]">skill-scanner</span>
退
</div>
</div>
) : (
<div className="mt-6 text-sm text-[#8f8681]">{t('common.loading')}</div>
)}
</div>
</section>
<section className="grid grid-cols-1 gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-lg font-semibold text-[#171212]"></h2>
<p className="mt-1 text-sm text-[#8f8681]">
{loading ? '正在加载资产...' : `当前显示第 ${currentAssetPage} / ${totalAssetPages} 页,共 ${filteredSkills.length} / ${skills.length} 个资产`}
</p>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="按名称、key、用户、来源、风险、扫描状态搜索"
className="app-input w-full max-w-sm"
/>
</div>
<div className="mt-4 overflow-x-auto">
{loading ? (
<div className="py-12 text-center text-[#8f8681]">{t('common.loading')}</div>
) : filteredSkills.length === 0 ? (
<div className="py-12 text-center text-[#8f8681]"></div>
) : (
<>
<table className="min-w-full divide-y divide-[#f3e7df] text-sm">
<thead className="bg-[#fff8f5] text-left text-xs font-semibold uppercase tracking-[0.14em] text-[#8f8681]">
<tr>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-[#f5ebe5]">
{paginatedSkills.map((item) => (
<tr
key={item.id}
onClick={() => setSelectedAssetId(item.id)}
className={`cursor-pointer transition hover:bg-[#fffaf7] ${selectedAssetId === item.id ? 'bg-[#fff6f2]' : ''}`}
>
<td className="px-4 py-3">
<div className="font-medium text-[#171212]">{item.name}</div>
<div className="mt-1 text-xs text-[#8f8681]">{item.skill_key}</div>
{item.risk_level === 'high' && item.risk_reason ? (
<div className="mt-2 text-xs leading-5 text-[#b42318]">
{item.risk_reason}
</div>
) : null}
</td>
<td className="px-4 py-3 text-[#5f5957]">#{item.user_id}</td>
<td className="px-4 py-3"><Badge tone={item.source_type === 'uploaded' ? 'amber' : 'slate'}>{sourceLabel(item.source_type)}</Badge></td>
<td className="px-4 py-3"><Badge tone={scanStatusTone(item.scan_status)}>{scanStatusLabel(item.scan_status)}</Badge></td>
<td className="px-4 py-3"><Badge tone={riskTone(item.risk_level)}>{riskLabel(item.risk_level)}</Badge></td>
<td className="px-4 py-3 text-[#5f5957]">{item.last_scanned_at ? formatDateTime(item.last_scanned_at) : '未扫描'}</td>
<td className="px-4 py-3 text-[#5f5957]">{item.instance_count}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 flex flex-col gap-3 border-t border-[#f3e7df] pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-[#8f8681]">
{pageSize} {(currentAssetPage - 1) * pageSize + 1}-{Math.min(currentAssetPage * pageSize, filteredSkills.length)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setAssetPage((current) => Math.max(1, current - 1))}
disabled={currentAssetPage <= 1}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
<div className="min-w-[88px] text-center text-sm font-medium text-[#5f5957]">
{currentAssetPage} / {totalAssetPages}
</div>
<button
type="button"
onClick={() => setAssetPage((current) => Math.min(totalAssetPages, current + 1))}
disabled={currentAssetPage >= totalAssetPages}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
</div>
{selectedAsset ? (
<div className="mt-5 rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-[#171212]"></div>
<div className="mt-2 text-lg font-semibold text-[#171212]">{selectedAsset.name}</div>
<div className="mt-1 text-xs text-[#8f8681]">{selectedAsset.skill_key}</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge tone={riskTone(selectedAsset.risk_level)}>{riskLabel(selectedAsset.risk_level)}</Badge>
<Badge tone={scanStatusTone(selectedAsset.scan_status)}>{scanStatusLabel(selectedAsset.scan_status)}</Badge>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-[#f1e2d9] bg-white p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]"></div>
<div className="mt-3 text-sm leading-6 text-[#6f6661]">
{selectedAsset.risk_reason || '当前没有摘要原因,可查看下方具体依据。'}
</div>
</div>
<div className="rounded-2xl border border-[#f1e2d9] bg-white p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]"></div>
<div className="mt-3 text-sm leading-6 text-[#6f6661]">
{selectedAsset.risk_level === 'high'
? '立即禁用该资产在实例中的继续使用,并根据命中项移除高风险文件或功能后重新扫描。'
: selectedAsset.risk_level === 'medium'
? '限制继续注入到新实例,并按规则建议整改后复扫。'
: selectedAsset.risk_level === 'low'
? '保留告警,建议按下方规则逐项修复。'
: '当前未发现需要立即处置的风险。'}
</div>
</div>
</div>
<div className="mt-4">
<div className="text-sm font-medium text-[#171212]"></div>
<div className="mt-3 space-y-3">
{(selectedAsset.top_findings ?? []).length === 0 ? (
<div className="rounded-2xl border border-dashed border-[#eadfd8] bg-white px-4 py-5 text-sm text-[#8f8681]">
findings
</div>
) : (
(selectedAsset.top_findings ?? []).map((finding, index) => (
<div key={`${selectedAsset.id}-${finding.rule_id}-${index}`} className="rounded-2xl border border-[#f1d9d9] bg-white px-4 py-4">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={severityTone(finding.severity)}>{severityLabel(finding.severity)}</Badge>
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#8f8681]">{finding.analyzer}</span>
<span className="text-[11px] text-[#8f8681]">{finding.rule_id}</span>
</div>
<div className="mt-2 text-sm font-medium text-[#171212]">{finding.title}</div>
<div className="mt-1 text-sm leading-6 text-[#6f6661]">{finding.description}</div>
{finding.file_path ? (
<div className="mt-2 text-xs text-[#8f8681]">
{finding.file_path}{finding.line_number ? `:${finding.line_number}` : ''}
</div>
) : null}
{finding.snippet ? (
<pre className="mt-2 overflow-x-auto rounded-lg bg-[#fff8f5] px-3 py-2 text-xs text-[#7a4b34]">{finding.snippet}</pre>
) : null}
{finding.remediation ? (
<div className="mt-2 text-xs leading-5 text-[#8f8681]">{finding.remediation}</div>
) : null}
</div>
))
)}
</div>
</div>
</div>
) : null}
</>
)}
</div>
</div>
<div className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div>
<h2 className="text-lg font-semibold text-[#171212]"></h2>
<p className="mt-1 text-sm text-[#8f8681]"></p>
</div>
{!selectedJob ? (
<div className="mt-6 rounded-2xl border border-dashed border-[#eadfd8] px-4 py-10 text-center text-sm text-[#8f8681]">
</div>
) : (
<div className="mt-6 space-y-5">
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-[#171212]">
{selectedJob.scan_mode === 'deep' ? '深度扫描' : '快速扫描'} #{selectedJob.id}
</div>
<Badge tone={selectedJob.status === 'completed' ? 'green' : selectedJob.status === 'failed' ? 'red' : 'orange'}>
{jobStatusLabel(selectedJob.status)}
</Badge>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#f3e7df]">
<div className="h-full rounded-full bg-[#dc2626]" style={{ width: `${selectedJob.progress_pct}%` }} />
</div>
<div className="mt-3 text-xs text-[#8f8681]">
{selectedJob.completed_items}/{selectedJob.total_items}
{selectedJob.current_item_name ? ` · 当前 ${selectedJob.current_item_name}` : ''}
</div>
<div className="mt-2 text-xs text-[#8f8681]">
{selectedJob.started_at ? `开始于 ${formatDateTime(selectedJob.started_at)}` : '等待开始'}
{selectedJob.finished_at ? ` · 完成于 ${formatDateTime(selectedJob.finished_at)}` : ''}
</div>
</div>
{selectedJob.report ? (
<>
<div className="grid grid-cols-2 gap-3">
{Object.entries(selectedJob.report.risk_counts ?? {}).map(([level, count]) => (
<StatCard key={level} label={riskLabel(level)} value={String(count)} />
))}
</div>
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="text-sm font-medium text-[#171212]">Analyzer </div>
<p className="mt-1 text-xs leading-5 text-[#8f8681]">
skill-scanner analyzer scanner /health findings
analyzer findings skill-scanner
</p>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<AnalyzerGroup title="本次配置" items={selectedJob.report.configured_analyzers ?? []} />
<AnalyzerGroup title="当前可用" items={selectedJob.report.available_analyzers ?? []} />
<AnalyzerGroup title="实际命中" items={selectedJob.report.triggered_analyzers ?? []} emptyLabel="本次没有 analyzer 产出 findings" />
</div>
</div>
<div>
<div className="text-sm font-medium text-[#171212]"></div>
<div className="mt-3 space-y-3">
{(selectedJob.report.findings_summary ?? []).length === 0 ? (
<div className="text-sm text-[#8f8681]"></div>
) : (
(selectedJob.report.findings_summary ?? []).slice(0, 8).map((entry, index) => (
<div key={`${entry.asset_name}-${index}`} className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] px-4 py-3">
<div className="font-medium text-[#171212]">{entry.asset_name}</div>
<div className="mt-1 text-sm text-[#6f6661]">{entry.summary}</div>
</div>
))
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-[#171212]"></div>
<div className="mt-3 max-h-[420px] space-y-3 overflow-y-auto pr-1">
{(selectedJob.report.items ?? []).map((item) => (
<div key={item.id} className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-[#171212]">{item.asset_name}</div>
<div className="mt-1 text-xs text-[#8f8681]">
{item.cached_result ? '复用缓存结果' : '实时扫描'} · {jobStatusLabel(item.status)}
</div>
{item.triggered_analyzers && item.triggered_analyzers.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{item.triggered_analyzers.map((analyzer) => (
<span
key={`${item.id}-${analyzer}`}
className="inline-flex rounded-full border border-[#d9e0e7] bg-[#f6f8fb] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#556070]"
>
{analyzer}
</span>
))}
</div>
) : null}
{item.summary ? <div className="mt-2 text-sm text-[#6f6661]">{item.summary}</div> : null}
{item.findings && item.findings.length > 0 ? (
<div className="mt-3 space-y-2">
{item.findings.map((finding, findingIndex) => (
<div key={`${item.id}-${finding.rule_id}-${findingIndex}`} className="rounded-xl border border-[#f1d9d9] bg-white px-3 py-3">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={severityTone(finding.severity)}>{severityLabel(finding.severity)}</Badge>
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#8f8681]">{finding.analyzer}</span>
<span className="text-[11px] text-[#8f8681]">{finding.rule_id}</span>
</div>
<div className="mt-2 text-sm font-medium text-[#171212]">{finding.title}</div>
<div className="mt-1 text-sm leading-6 text-[#6f6661]">{finding.description}</div>
{finding.file_path ? (
<div className="mt-2 text-xs text-[#8f8681]">
{finding.file_path}{finding.line_number ? `:${finding.line_number}` : ''}
</div>
) : null}
{finding.snippet ? (
<pre className="mt-2 overflow-x-auto rounded-lg bg-[#fff8f5] px-3 py-2 text-xs text-[#7a4b34]">{finding.snippet}</pre>
) : null}
{finding.remediation ? (
<div className="mt-2 text-xs leading-5 text-[#8f8681]">{finding.remediation}</div>
) : null}
</div>
))}
</div>
) : null}
{item.error_message ? <div className="mt-2 text-sm text-red-700">{item.error_message}</div> : null}
</div>
<Badge tone={riskTone(item.risk_level || 'unknown')}>{riskLabel(item.risk_level || item.status)}</Badge>
</div>
</div>
))}
</div>
</div>
</>
) : (
<div className="text-sm text-[#8f8681]"></div>
)}
</div>
)}
</div>
</section>
</div>
</AdminLayout>
);
};
function AnalyzerGroup({ title, items, emptyLabel }: { title: string; items: string[]; emptyLabel?: string }) {
const safeItems = Array.isArray(items) ? items : [];
return (
<div className="rounded-2xl border border-[#f1e2d9] bg-white p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{title}</div>
{safeItems.length === 0 ? (
<div className="mt-3 text-sm text-[#8f8681]">{emptyLabel || '暂无'}</div>
) : (
<div className="mt-3 flex flex-wrap gap-2">
{safeItems.map((item) => (
<span
key={`${title}-${item}`}
className="inline-flex rounded-full border border-[#d9e0e7] bg-[#f6f8fb] px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-[#556070]"
>
{item}
</span>
))}
</div>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-2xl border border-[#f1e2d9] bg-white px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{label}</div>
<div className="mt-2 text-2xl font-semibold text-[#171212]">{value}</div>
</div>
);
}
function Badge({
children,
tone,
}: {
children: React.ReactNode;
tone: 'green' | 'yellow' | 'orange' | 'red' | 'amber' | 'slate';
}) {
const toneClass =
tone === 'green'
? 'border-[#bde8ca] bg-[#edfdf2] text-[#177245]'
: tone === 'yellow'
? 'border-[#f5df9f] bg-[#fff8dd] text-[#9a6a00]'
: tone === 'orange'
? 'border-[#f7c8a4] bg-[#fff1e6] text-[#b45309]'
: tone === 'red'
? 'border-[#f2c2c2] bg-[#fff0f0] text-[#b42318]'
: tone === 'amber'
? 'border-[#f1d9c7] bg-[#fff6f0] text-[#b46c50]'
: 'border-[#d9e0e7] bg-[#f6f8fb] text-[#556070]';
return (
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${toneClass}`}>
{children}
</span>
);
}
function splitAnalyzers(value: string): string[] {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function assetTypeLabel(value: string): string {
if (value === 'skill') {
return '技能';
}
return value;
}
function sourceLabel(value: string): string {
if (value === 'uploaded') {
return '用户上传';
}
if (value === 'discovered') {
return '实例发现';
}
return value;
}
function riskLabel(value: string): string {
switch (value) {
case 'high':
return '高风险';
case 'medium':
return '中风险';
case 'low':
return '低风险';
case 'none':
return 'SAFE';
case 'completed':
return '已完成';
case 'running':
return '扫描中';
case 'failed':
return '失败';
case 'pending':
return '等待中';
case 'queued':
return '排队中';
default:
return value;
}
}
function jobStatusLabel(value: string): string {
switch (value) {
case 'queued':
return '排队中';
case 'pending':
return '等待中';
case 'running':
return '扫描中';
case 'completed':
return '已完成';
case 'failed':
return '失败';
default:
return value;
}
}
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function riskTone(value: string): 'green' | 'yellow' | 'orange' | 'red' | 'amber' | 'slate' {
switch (value) {
case 'high':
return 'red';
case 'medium':
return 'orange';
case 'low':
return 'yellow';
case 'none':
return 'green';
case 'uploaded':
return 'amber';
default:
return 'slate';
}
}
function severityTone(value: string): 'green' | 'yellow' | 'orange' | 'red' | 'amber' | 'slate' {
switch (value.toUpperCase()) {
case 'CRITICAL':
case 'HIGH':
return 'red';
case 'MEDIUM':
case 'MODERATE':
return 'orange';
case 'LOW':
case 'WARNING':
return 'yellow';
case 'INFO':
case 'SAFE':
return 'green';
default:
return 'slate';
}
}
function severityLabel(value: string): string {
switch (value.toUpperCase()) {
case 'CRITICAL':
return 'CRITICAL';
case 'HIGH':
return 'HIGH';
case 'MEDIUM':
case 'MODERATE':
return 'MEDIUM';
case 'LOW':
case 'WARNING':
return 'LOW';
case 'INFO':
return 'INFO';
default:
return value;
}
}
function scanStatusLabel(value: string): string {
switch (value) {
case 'completed':
return '已完成';
case 'pending':
return '待扫描';
case 'failed':
return '扫描失败';
case 'running':
return '扫描中';
default:
return value || '未知';
}
}
function scanStatusTone(value: string): 'green' | 'yellow' | 'orange' | 'red' | 'amber' | 'slate' {
switch (value) {
case 'completed':
return 'green';
case 'pending':
return 'amber';
case 'running':
return 'orange';
case 'failed':
return 'red';
default:
return 'slate';
}
}
export default AdminSkillsPage;
@@ -0,0 +1,456 @@
import React, { useMemo, useState } from 'react';
import { useI18n } from '../../../contexts/I18nContext';
import { adminService } from '../../../services/adminService';
import {
Badge,
formatDateTime,
riskLabel,
riskTone,
scanModeLabel,
scanScopeLabel,
scanStatusLabel,
scanStatusTone,
SecurityCenterShell,
sourceLabel,
useSecurityCenterData,
} from './securityCenterShared';
const pageSize = 8;
const AdminSecurityDashboardPage: React.FC = () => {
const { locale, t } = useI18n();
const { skills, jobs, config, loading, error, summary, setError, loadAll } = useSecurityCenterData();
const [search, setSearch] = useState('');
const [assetPage, setAssetPage] = useState(1);
const [startingScan, setStartingScan] = useState<'' | 'incremental' | 'full'>('');
const filteredSkills = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return skills;
}
return skills.filter((item) =>
[item.name, item.skill_key, item.source_type, item.risk_level, item.scan_status, String(item.user_id)].some((value) =>
value.toLowerCase().includes(keyword),
),
);
}, [search, skills]);
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / pageSize));
const currentPage = Math.min(assetPage, totalPages);
const paginatedSkills = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return filteredSkills.slice(start, start + pageSize);
}, [currentPage, filteredSkills]);
const scannedRatio = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0;
const scannedProgress = summary.total > 0 ? summary.completed / summary.total : 0;
const safeCount = skills.filter((item) => item.risk_level === 'none').length;
const lowCount = skills.filter((item) => item.risk_level === 'low').length;
const pendingCount = skills.filter((item) => item.scan_status === 'pending').length;
const failedCount = skills.filter((item) => item.scan_status === 'failed').length;
const riskBands = [
{ label: t('securityCenter.risks.safe'), count: safeCount, color: '#139a74', text: 'text-[#177245]' },
{ label: t('securityCenter.risks.low'), count: lowCount, color: '#d4a11f', text: 'text-[#986200]' },
{ label: t('securityCenter.risks.medium'), count: summary.mediumRisk, color: '#df7a1c', text: 'text-[#b45309]' },
{ label: t('securityCenter.risks.high'), count: summary.highRisk, color: '#c33b31', text: 'text-[#b42318]' },
{ label: t('securityCenter.status.pending'), count: pendingCount, color: '#4d6b89', text: 'text-[#556070]' },
];
const maxRiskCount = Math.max(...riskBands.map((item) => item.count), 1);
const warningAssets = skills.filter((item) => item.risk_level === 'high' || item.risk_level === 'medium').slice(0, 4);
const hotAssets = [...skills]
.sort((left, right) => right.instance_count - left.instance_count || left.name.localeCompare(right.name))
.slice(0, 5);
const recentJobs = jobs.slice(0, 4);
const handleStartScan = async (scanScope: 'incremental' | 'full') => {
try {
setStartingScan(scanScope);
setError(null);
await adminService.startSecurityScan({ asset_type: 'skill', scan_scope: scanScope });
await loadAll('refresh');
} catch (err: any) {
setError(err.response?.data?.error || t(scanScope === 'full' ? 'securityCenter.errors.startFullScan' : 'securityCenter.errors.startIncrementalScan'));
} finally {
setStartingScan('');
}
};
return (
<SecurityCenterShell summary={summary}>
<div className="space-y-6">
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
) : null}
<section className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-[#b46c50]">{t('securityCenter.nav.dashboard')}</p>
<h1 className="mt-2 text-3xl font-semibold text-[#171212]">{t('securityCenter.dashboard.title')}</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[#6f6661]">
{t('securityCenter.dashboard.subtitle')}
</p>
<p className="mt-2 text-sm font-medium text-[#7d5744]">
{t('securityCenter.dashboard.activeMode', {
mode: t(`securityCenter.scanModes.${scanModeLabel(config?.active_mode || config?.default_mode || 'quick')}`),
})}
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => void handleStartScan('incremental')}
disabled={startingScan !== ''}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{startingScan === 'incremental' ? t('securityCenter.dashboard.starting') : t('securityCenter.scanScopes.incremental')}
</button>
<button
type="button"
onClick={() => void handleStartScan('full')}
disabled={startingScan !== ''}
className="app-button-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{startingScan === 'full' ? t('securityCenter.dashboard.starting') : t('securityCenter.scanScopes.full')}
</button>
</div>
</div>
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label={t('securityCenter.dashboard.totalAssets')} value={String(summary.total)} />
<StatCard label={t('securityCenter.dashboard.completedScans')} value={String(summary.completed)} />
<StatCard label={t('securityCenter.risks.high')} value={String(summary.highRisk)} />
<StatCard label={t('securityCenter.risks.medium')} value={String(summary.mediumRisk)} />
</div>
</section>
<section className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="space-y-6">
<div className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[220px_repeat(4,minmax(0,1fr))]">
<div className="rounded-[24px] border border-[#d9edf3] bg-[radial-gradient(circle_at_top,#e8f8ff_0%,#f6fcff_62%,#ffffff_100%)] p-5">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#1980a1]">{t('securityCenter.dashboard.coverage')}</div>
<div className="mt-4 flex items-center gap-4">
<CoverageDonut ratio={scannedRatio} progress={scannedProgress} />
<div className="min-w-0">
<div className="text-sm font-semibold text-[#171212]">{summary.completed}/{summary.total}</div>
<div className="mt-1 text-xs leading-5 text-[#6f6661]">{t('securityCenter.dashboard.coverageDesc')}</div>
</div>
</div>
</div>
<MiniMetric title={t('securityCenter.risks.safe')} value={safeCount} subtitle={t('securityCenter.dashboard.safeSubtitle')} tone="green" />
<MiniMetric title={t('securityCenter.risks.high')} value={summary.highRisk} subtitle={t('securityCenter.dashboard.highRiskSubtitle')} tone="red" />
<MiniMetric title={t('securityCenter.status.pending')} value={pendingCount} subtitle={t('securityCenter.dashboard.pendingSubtitle')} tone="slate" />
<MiniMetric title={t('securityCenter.status.failed')} value={failedCount} subtitle={t('securityCenter.dashboard.failedSubtitle')} tone="orange" />
</div>
<div className="mt-5 grid grid-cols-1 items-start gap-4 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<div className="rounded-[24px] border border-[#efe1d8] bg-[#fffaf7] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{t('securityCenter.dashboard.riskDistribution')}</div>
<div className="mt-2 text-base font-semibold text-[#171212]">{t('securityCenter.dashboard.riskDistributionTitle')}</div>
</div>
<div className="text-xs text-[#8f8681]">{t('securityCenter.dashboard.groupedByRisk')}</div>
</div>
<div className="mt-5 space-y-4">
{riskBands.map((item) => {
const width = item.count <= 0
? '0%'
: `${Math.max(2, Math.round((item.count / maxRiskCount) * 100))}%`;
return (
<div key={`compact-${item.label}`}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-[#6f6661]">{item.label}</div>
<div className={`text-sm font-semibold ${item.text}`}>{item.count}</div>
</div>
<div className="h-3 overflow-hidden rounded-full bg-[#eef1f3]">
<div className="h-full rounded-full" style={{ width, backgroundColor: item.color }} />
</div>
</div>
);
})}
</div>
</div>
<div className="rounded-[24px] border border-[#efe1d8] bg-[#fffaf7] p-5">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{t('securityCenter.dashboard.hotAssets')}</div>
<div className="mt-2 text-base font-semibold text-[#171212]">{t('securityCenter.dashboard.hotAssetsTitle')}</div>
</div>
<div className="text-xs text-[#8f8681]">Top 5</div>
</div>
<div className="mt-5 space-y-3">
{hotAssets.length === 0 ? (
<div className="text-sm text-[#8f8681]">{t('securityCenter.dashboard.noAssetUsage')}</div>
) : (
hotAssets.map((item, index) => (
<div key={`compact-hot-${item.id}`} className="flex items-center gap-4 rounded-2xl border border-[#eadfd8] bg-white px-4 py-3">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-[#f3e7df] text-sm font-semibold text-[#7d5744]">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-[#171212]">{item.name}</div>
<div className="mt-1 truncate text-xs text-[#8f8681]">{item.skill_key}</div>
</div>
<div className="text-right">
<div className="text-base font-semibold text-[#171212]">{item.instance_count}</div>
<div className="text-[11px] uppercase tracking-[0.14em] text-[#8f8681]">{t('securityCenter.dashboard.instances')}</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
<section className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-lg font-semibold text-[#171212]">{t('securityCenter.dashboard.assetListTitle')}</h2>
<p className="mt-1 text-sm text-[#8f8681]">{t('securityCenter.dashboard.assetListDesc')}</p>
</div>
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setAssetPage(1);
}}
placeholder={t('securityCenter.dashboard.searchPlaceholder')}
className="app-input w-full max-w-sm"
/>
</div>
<div className="mt-5 overflow-x-auto">
{loading ? (
<div className="py-12 text-center text-[#8f8681]">{t('securityCenter.dashboard.loadingAssets')}</div>
) : filteredSkills.length === 0 ? (
<div className="py-12 text-center text-[#8f8681]">{t('securityCenter.dashboard.noMatchingAssets')}</div>
) : (
<>
<table className="min-w-full divide-y divide-[#f3e7df] text-sm">
<thead className="bg-[#fff8f5] text-left text-xs font-semibold uppercase tracking-[0.14em] text-[#8f8681]">
<tr>
<th className="px-4 py-3">{t('securityCenter.dashboard.columns.asset')}</th>
<th className="px-4 py-3">{t('securityCenter.dashboard.columns.user')}</th>
<th className="px-4 py-3">{t('securityCenter.dashboard.columns.source')}</th>
<th className="px-4 py-3">{t('securityCenter.dashboard.columns.scanStatus')}</th>
<th className="px-4 py-3">{t('securityCenter.dashboard.columns.risk')}</th>
<th className="px-4 py-3">{t('securityCenter.dashboard.columns.lastScan')}</th>
<th className="px-4 py-3">{t('securityCenter.dashboard.columns.instances')}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#f5ebe5]">
{paginatedSkills.map((item) => (
<tr key={item.id} className="transition hover:bg-[#fffaf7]">
<td className="px-4 py-3">
<div className="font-medium text-[#171212]">{item.name}</div>
<div className="mt-1 text-xs text-[#8f8681]">{item.skill_key}</div>
</td>
<td className="px-4 py-3 text-[#5f5957]">#{item.user_id}</td>
<td className="px-4 py-3"><Badge tone={item.source_type === 'uploaded' ? 'amber' : 'slate'}>{t(`securityCenter.sources.${sourceLabel(item.source_type)}`)}</Badge></td>
<td className="px-4 py-3"><Badge tone={scanStatusTone(item.scan_status)}>{t(`securityCenter.scanStatus.${scanStatusLabel(item.scan_status)}`)}</Badge></td>
<td className="px-4 py-3"><Badge tone={riskTone(item.risk_level)}>{t(`securityCenter.risks.${riskLabel(item.risk_level)}`)}</Badge></td>
<td className="px-4 py-3 text-[#5f5957]">{item.last_scanned_at ? formatDateTime(item.last_scanned_at, locale) : t('securityCenter.dashboard.notScanned')}</td>
<td className="px-4 py-3 text-[#5f5957]">{item.instance_count}</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 flex flex-col gap-3 border-t border-[#f3e7df] pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-[#8f8681]">
{t('securityCenter.pagination.summary', {
pageSize,
from: (currentPage - 1) * pageSize + 1,
to: Math.min(currentPage * pageSize, filteredSkills.length),
})}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setAssetPage((current) => Math.max(1, current - 1))}
disabled={currentPage <= 1}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t('securityCenter.pagination.prev')}
</button>
<div className="min-w-[88px] text-center text-sm font-medium text-[#5f5957]">
{currentPage} / {totalPages}
</div>
<button
type="button"
onClick={() => setAssetPage((current) => Math.min(totalPages, current + 1))}
disabled={currentPage >= totalPages}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t('securityCenter.pagination.next')}
</button>
</div>
</div>
</>
)}
</div>
</section>
</div>
<div className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{t('securityCenter.dashboard.scannerStatus')}</div>
<div className="mt-2 text-lg font-semibold text-[#171212]">{t('securityCenter.dashboard.scannerAvailability')}</div>
<div className="mt-4 rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-[#171212]">{config?.scanner_status.status_label || t('securityCenter.dashboard.notEnabled')}</div>
<Badge tone={config?.scanner_status.connected ? 'green' : 'slate'}>
{config?.scanner_status.connected ? t('securityCenter.dashboard.connected') : t('securityCenter.dashboard.disconnected')}
</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{(config?.scanner_status.available_capabilities ?? []).map((item) => (
<span
key={item}
className="inline-flex rounded-full border border-[#d9e0e7] bg-[#f6f8fb] px-3 py-1 text-xs font-semibold text-[#556070]"
>
{item}
</span>
))}
</div>
<div className="mt-3 text-sm leading-6 text-[#6f6661]">
{config?.scanner_status.llm_enabled
? t('securityCenter.dashboard.scannerLlmEnabled')
: t('securityCenter.dashboard.scannerLlmDisabled')}
</div>
</div>
<div className="mt-6 text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{t('securityCenter.dashboard.alertZone')}</div>
<div className="mt-2 text-lg font-semibold text-[#171212]">{t('securityCenter.dashboard.alertTitle')}</div>
<div className="mt-4 space-y-3">
{warningAssets.length === 0 ? (
<div className="rounded-2xl border border-[#d7ecd9] bg-[#f4fcf5] px-4 py-4 text-sm leading-6 text-[#177245]">
{t('securityCenter.dashboard.noWarnings')}
</div>
) : (
warningAssets.map((item) => (
<div key={item.id} className="rounded-2xl border border-[#f1d9d9] bg-[#fff7f5] px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-[#171212]">{item.name}</div>
<Badge tone={riskTone(item.risk_level)}>{t(`securityCenter.risks.${riskLabel(item.risk_level)}`)}</Badge>
</div>
<div className="mt-2 text-sm leading-6 text-[#6f6661]">
{item.risk_reason || t('securityCenter.dashboard.warningFallback')}
</div>
</div>
))
)}
</div>
<div className="mt-6 text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{t('securityCenter.dashboard.recentScans')}</div>
<div className="mt-2 text-lg font-semibold text-[#171212]">{t('securityCenter.dashboard.recentScansTitle')}</div>
<div className="mt-4 space-y-3">
{recentJobs.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[#eadfd8] px-4 py-5 text-sm text-[#8f8681]">
{t('securityCenter.dashboard.noScanJobs')}
</div>
) : (
recentJobs.map((job) => (
<div key={job.id} className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium text-[#171212]">
{t('securityCenter.dashboard.jobTitle', {
scope: t(`securityCenter.scanScopes.${scanScopeLabel(job.scan_scope)}`),
mode: t(`securityCenter.scanModes.${scanModeLabel(job.scan_mode)}`),
id: job.id,
})}
</div>
<div className="mt-1 text-xs text-[#8f8681]">
{job.completed_items}/{job.total_items} · {formatDateTime(job.created_at, locale)}
</div>
</div>
<Badge tone={job.status === 'completed' ? 'green' : job.status === 'failed' ? 'red' : 'orange'}>
{t(`securityCenter.scanStatus.${scanStatusLabel(job.status)}`)}
</Badge>
</div>
</div>
))
)}
</div>
</div>
</section>
</div>
</SecurityCenterShell>
);
};
function MiniMetric({
title,
value,
subtitle,
tone,
}: {
title: string;
value: number;
subtitle: string;
tone: 'green' | 'red' | 'orange' | 'slate';
}) {
const colorClass =
tone === 'green'
? 'border-[#cfe8d7] bg-[#f4fcf7] text-[#177245]'
: tone === 'red'
? 'border-[#f0d1d1] bg-[#fff6f5] text-[#b42318]'
: tone === 'orange'
? 'border-[#f1ddbf] bg-[#fff8ed] text-[#b45309]'
: 'border-[#dde5ec] bg-[#f7fafc] text-[#556070]';
return (
<div className={`rounded-[24px] border p-5 ${colorClass}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em]">{title}</div>
<div className="mt-3 text-3xl font-semibold">{value}</div>
<div className="mt-2 text-xs leading-5 opacity-80">{subtitle}</div>
</div>
);
}
function CoverageDonut({ ratio, progress }: { ratio: number; progress: number }) {
const normalized = Math.min(Math.max(progress, 0), 1);
const radius = 34;
const circumference = 2 * Math.PI * radius;
const dashOffset = circumference * (1 - normalized);
return (
<div className="relative flex h-24 w-24 items-center justify-center">
<svg viewBox="0 0 88 88" className="h-24 w-24 -rotate-90">
<circle cx="44" cy="44" r={radius} fill="none" stroke="#dbeaf1" strokeWidth="12" />
<circle
cx="44"
cy="44"
r={radius}
fill="none"
stroke="#1786b1"
strokeWidth="12"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex h-16 w-16 flex-col items-center justify-center rounded-full bg-white shadow-[0_8px_18px_-14px_rgba(23,134,177,0.45)]">
<div className="text-xl font-semibold text-[#156b8b]">{ratio}%</div>
</div>
</div>
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-2xl border border-[#f1e2d9] bg-white px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{label}</div>
<div className="mt-2 text-2xl font-semibold text-[#171212]">{value}</div>
</div>
);
}
export default AdminSecurityDashboardPage;
@@ -0,0 +1,249 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../../contexts/I18nContext';
import { adminService } from '../../../services/adminService';
import {
AnalyzerGroup,
assetTypeLabel,
Badge,
formatDateTime,
jobStatusLabel,
riskLabel,
riskTone,
scanModeLabel,
scanScopeLabel,
SecurityCenterShell,
severityLabel,
severityTone,
StatCard,
useSecurityCenterData,
} from './securityCenterShared';
const AdminSecurityReportsPage: React.FC = () => {
const { locale, t } = useI18n();
const { jobs, summary, error, setError } = useSecurityCenterData();
const [selectedJobId, setSelectedJobId] = useState<number | null>(null);
const [selectedJob, setSelectedJob] = useState<any | null>(null);
useEffect(() => {
setSelectedJobId((current) => current ?? jobs[0]?.id ?? null);
}, [jobs]);
useEffect(() => {
if (!selectedJobId) {
setSelectedJob(null);
return;
}
let cancelled = false;
const loadDetail = async () => {
try {
const detail = await adminService.getSecurityScanJob(selectedJobId);
if (!cancelled) {
setSelectedJob(detail);
}
} catch (err: any) {
if (!cancelled) {
setError(err.response?.data?.error || t('securityCenter.errors.loadReports'));
}
}
};
void loadDetail();
return () => {
cancelled = true;
};
}, [selectedJobId, setError, t]);
const riskCards = useMemo(() => Object.entries(selectedJob?.report?.risk_counts ?? {}), [selectedJob]);
return (
<SecurityCenterShell summary={summary}>
<div className="space-y-6">
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
) : null}
<section className="grid grid-cols-1 gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
<div className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div>
<h2 className="text-lg font-semibold text-[#171212]">{t('securityCenter.reports.title')}</h2>
<p className="mt-1 text-sm text-[#8f8681]">{t('securityCenter.reports.subtitle')}</p>
</div>
<div className="mt-5 space-y-3">
{jobs.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[#eadfd8] px-4 py-8 text-sm text-[#8f8681]">{t('securityCenter.reports.noJobs')}</div>
) : (
jobs.map((job) => (
<button
key={job.id}
type="button"
onClick={() => setSelectedJobId(job.id)}
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${
selectedJobId === job.id ? 'border-[#f2b7a0] bg-[#fff1eb]' : 'border-[#eadfd8] bg-[#fffaf7] hover:border-[#dcc6b8]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-[#171212]">
{t('securityCenter.reports.jobTitle', {
scope: t(`securityCenter.scanScopes.${scanScopeLabel(job.scan_scope)}`),
mode: t(`securityCenter.scanModes.${scanModeLabel(job.scan_mode)}`),
id: job.id,
})}
</div>
<div className="mt-1 text-xs text-[#8f8681]">
{t('securityCenter.reports.jobMeta', {
assetType: t(`securityCenter.assetTypes.${assetTypeLabel(job.asset_type)}`),
scope: t(`securityCenter.scanScopes.${scanScopeLabel(job.scan_scope)}`),
mode: t(`securityCenter.scanModes.${scanModeLabel(job.scan_mode)}`),
completed: job.completed_items,
total: job.total_items,
})}
</div>
<div className="mt-1 text-xs text-[#8f8681]">{formatDateTime(job.created_at, locale)}</div>
</div>
<Badge tone={job.status === 'completed' ? 'green' : job.status === 'failed' ? 'red' : 'orange'}>
{t(`securityCenter.status.${jobStatusLabel(job.status)}`)}
</Badge>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#f3e7df]">
<div className="h-full rounded-full bg-[#dc2626]" style={{ width: `${job.progress_pct}%` }} />
</div>
</button>
))
)}
</div>
</div>
<div className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
{!selectedJob ? (
<div className="rounded-2xl border border-dashed border-[#eadfd8] px-4 py-10 text-center text-sm text-[#8f8681]">
{t('securityCenter.reports.selectJob')}
</div>
) : (
<div className="space-y-5">
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-[#171212]">
{t('securityCenter.reports.jobTitle', {
scope: t(`securityCenter.scanScopes.${scanScopeLabel(selectedJob.scan_scope)}`),
mode: t(`securityCenter.scanModes.${scanModeLabel(selectedJob.scan_mode)}`),
id: selectedJob.id,
})}
</div>
<Badge tone={selectedJob.status === 'completed' ? 'green' : selectedJob.status === 'failed' ? 'red' : 'orange'}>
{t(`securityCenter.status.${jobStatusLabel(selectedJob.status)}`)}
</Badge>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-[#f3e7df]">
<div className="h-full rounded-full bg-[#dc2626]" style={{ width: `${selectedJob.progress_pct}%` }} />
</div>
<div className="mt-3 text-xs text-[#8f8681]">
{t('securityCenter.reports.completedProgress', {
completed: selectedJob.completed_items,
total: selectedJob.total_items,
})}
{selectedJob.current_item_name ? ` · ${t('securityCenter.reports.currentItem', { name: selectedJob.current_item_name })}` : ''}
</div>
<div className="mt-2 text-xs text-[#8f8681]">
{t('securityCenter.reports.scopeMode', {
scope: t(`securityCenter.scanScopes.${scanScopeLabel(selectedJob.scan_scope)}`),
mode: t(`securityCenter.scanModes.${scanModeLabel(selectedJob.scan_mode)}`),
})}
</div>
<div className="mt-2 text-xs text-[#8f8681]">
{selectedJob.started_at ? t('securityCenter.reports.startedAt', { value: formatDateTime(selectedJob.started_at, locale) }) : t('securityCenter.reports.waitingToStart')}
{selectedJob.finished_at ? ` · ${t('securityCenter.reports.finishedAt', { value: formatDateTime(selectedJob.finished_at, locale) })}` : ''}
</div>
</div>
{selectedJob.report ? (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{riskCards.map(([level, count]) => (
<StatCard key={level} label={t(`securityCenter.risks.${riskLabel(level)}`)} value={String(count)} />
))}
</div>
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="text-sm font-medium text-[#171212]">{t('securityCenter.reports.analyzerView')}</div>
<p className="mt-1 text-xs leading-5 text-[#8f8681]">
{t('securityCenter.reports.analyzerViewDesc')}
</p>
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-3">
<AnalyzerGroup title={t('securityCenter.reports.configuredAnalyzers')} items={selectedJob.report.configured_analyzers ?? []} />
<AnalyzerGroup title={t('securityCenter.reports.availableAnalyzers')} items={selectedJob.report.available_analyzers ?? []} />
<AnalyzerGroup title={t('securityCenter.reports.triggeredAnalyzers')} items={selectedJob.report.triggered_analyzers ?? []} emptyLabel={t('securityCenter.reports.noAnalyzerFindings')} />
</div>
</div>
<div>
<div className="text-sm font-medium text-[#171212]">{t('securityCenter.reports.itemResults')}</div>
<div className="mt-3 max-h-[720px] space-y-3 overflow-y-auto pr-1">
{(selectedJob.report.items ?? []).map((item: any) => (
<div key={item.id} className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium text-[#171212]">{item.asset_name}</div>
<div className="mt-1 text-xs text-[#8f8681]">
{item.cached_result ? t('securityCenter.reports.cachedResult') : t('securityCenter.reports.liveScan')} · {t(`securityCenter.status.${jobStatusLabel(item.status)}`)}
</div>
{item.triggered_analyzers && item.triggered_analyzers.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{item.triggered_analyzers.map((analyzer: string) => (
<span
key={`${item.id}-${analyzer}`}
className="inline-flex rounded-full border border-[#d9e0e7] bg-[#f6f8fb] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#556070]"
>
{analyzer}
</span>
))}
</div>
) : null}
{item.summary ? <div className="mt-2 text-sm text-[#6f6661]">{item.summary}</div> : null}
{item.findings && item.findings.length > 0 ? (
<div className="mt-3 space-y-2">
{item.findings.map((finding: any, findingIndex: number) => (
<div key={`${item.id}-${finding.rule_id}-${findingIndex}`} className="rounded-xl border border-[#f1d9d9] bg-white px-3 py-3">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={severityTone(finding.severity)}>{severityLabel(finding.severity)}</Badge>
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[#8f8681]">{finding.analyzer}</span>
<span className="text-[11px] text-[#8f8681]">{finding.rule_id}</span>
</div>
<div className="mt-2 text-sm font-medium text-[#171212]">{finding.title}</div>
<div className="mt-1 text-sm leading-6 text-[#6f6661]">{finding.description}</div>
{finding.file_path ? (
<div className="mt-2 text-xs text-[#8f8681]">
{t('securityCenter.reports.evidence', { value: `${finding.file_path}${finding.line_number ? `:${finding.line_number}` : ''}` })}
</div>
) : null}
{finding.snippet ? (
<pre className="mt-2 overflow-x-auto rounded-lg bg-[#fff8f5] px-3 py-2 text-xs text-[#7a4b34]">{finding.snippet}</pre>
) : null}
{finding.remediation ? (
<div className="mt-2 text-xs leading-5 text-[#8f8681]">{t('securityCenter.reports.remediation', { value: finding.remediation })}</div>
) : null}
</div>
))}
</div>
) : null}
{item.error_message ? <div className="mt-2 text-sm text-red-700">{item.error_message}</div> : null}
</div>
<Badge tone={riskTone(item.risk_level || 'unknown')}>{t(`securityCenter.risks.${riskLabel(item.risk_level || item.status)}`)}</Badge>
</div>
</div>
))}
</div>
</div>
</>
) : (
<div className="text-sm text-[#8f8681]">{t('securityCenter.reports.reportGenerating')}</div>
)}
</div>
)}
</div>
</section>
</div>
</SecurityCenterShell>
);
};
export default AdminSecurityReportsPage;
@@ -0,0 +1,515 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../../../contexts/I18nContext';
import { adminService, type SecurityScanConfig } from '../../../services/adminService';
import { modelService, type LLMModel } from '../../../services/modelService';
import { Badge, SecurityCenterShell, useSecurityCenterData } from './securityCenterShared';
const AdminSecurityScannerConfigPage: React.FC = () => {
const { t } = useI18n();
const { config, setConfig, error, setError, summary } = useSecurityCenterData();
const [draftConfig, setDraftConfig] = useState<SecurityScanConfig | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const [models, setModels] = useState<LLMModel[]>([]);
const [showLLMAPIKey, setShowLLMAPIKey] = useState(false);
const [showMetaLLMAPIKey, setShowMetaLLMAPIKey] = useState(false);
useEffect(() => {
if (!config) {
setDraftConfig(null);
setIsDirty(false);
return;
}
if (!isDirty) {
setDraftConfig(config);
}
}, [config, isDirty]);
useEffect(() => {
let cancelled = false;
const loadModels = async () => {
try {
const items = await modelService.getModels();
if (!cancelled) {
setModels(items.filter((item) => item.is_active));
}
} catch {
if (!cancelled) {
setModels([]);
}
}
};
void loadModels();
return () => {
cancelled = true;
};
}, []);
const llmModelOptions = useMemo(() => models.map((item) => ({
id: String(item.id ?? `${item.provider_type}:${item.provider_model_name}`),
label: `${item.display_name} · ${item.provider_model_name}`,
model: item,
})), [models]);
const analyzerOptions = useMemo(() => ([
{ value: 'static', label: 'Static', description: t('securityCenter.scanner.analyzers.static') },
{ value: 'bytecode', label: 'Bytecode', description: t('securityCenter.scanner.analyzers.bytecode') },
{ value: 'pipeline', label: 'Pipeline', description: t('securityCenter.scanner.analyzers.pipeline') },
{ value: 'behavioral', label: 'Behavioral', description: t('securityCenter.scanner.analyzers.behavioral') },
{ value: 'llm', label: 'LLM', description: t('securityCenter.scanner.analyzers.llm') },
{ value: 'meta', label: 'Meta', description: t('securityCenter.scanner.analyzers.meta') },
]), [t]);
const handleSaveConfig = async () => {
if (!draftConfig) {
return;
}
try {
setSavingConfig(true);
setError(null);
const saved = await adminService.saveSecurityConfig(draftConfig);
setDraftConfig(saved);
setIsDirty(false);
setConfig(saved);
} catch (err: any) {
setError(err.response?.data?.error || t('securityCenter.errors.saveConfig'));
} finally {
setSavingConfig(false);
}
};
return (
<SecurityCenterShell summary={summary}>
<div className="space-y-6">
{error ? (
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
) : null}
<section className="rounded-[28px] border border-[#eadfd8] bg-white p-6 shadow-[0_24px_60px_-42px_rgba(72,44,24,0.35)]">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-[#171212]">{t('securityCenter.scanner.title')}</h2>
<p className="mt-1 text-sm text-[#8f8681]">{t('securityCenter.scanner.subtitle')}</p>
</div>
<button
type="button"
onClick={handleSaveConfig}
disabled={!config || savingConfig}
className="app-button-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{savingConfig ? t('securityCenter.scanner.saving') : t('securityCenter.scanner.saveAndApply')}
</button>
</div>
{draftConfig ? (
<div className="mt-6 space-y-5">
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-[#171212]">{t('securityCenter.scanner.serviceStatus')}</div>
<div className="mt-1 text-xs text-[#8f8681]">
{t('securityCenter.scanner.namespaceDeployment', {
namespace: draftConfig.skill_scanner_config.namespace,
deployment: draftConfig.skill_scanner_config.deployment_name,
})}
</div>
</div>
<Badge tone={draftConfig.scanner_status.connected ? 'green' : 'slate'}>
{draftConfig.scanner_status.connected ? t('securityCenter.dashboard.connected') : t('securityCenter.dashboard.disconnected')}
</Badge>
</div>
<div className="mt-3 text-sm text-[#6f6661]">{draftConfig.scanner_status.status_label}</div>
<div className="mt-3 flex flex-wrap gap-2">
{draftConfig.scanner_status.available_capabilities.map((item) => (
<span
key={item}
className="inline-flex rounded-full border border-[#d9e0e7] bg-[#f6f8fb] px-3 py-1 text-xs font-semibold text-[#556070]"
>
{item}
</span>
))}
</div>
</div>
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="text-sm font-semibold text-[#171212]">{t('securityCenter.scanner.llmConfig')}</div>
<p className="mt-1 text-xs leading-5 text-[#8f8681]">
{t('securityCenter.scanner.llmConfigDesc')}
</p>
<div className="mt-4 grid grid-cols-1 gap-4">
<ModelImportField
label={t('securityCenter.scanner.primaryLlmIntegration')}
helper={t('securityCenter.scanner.primaryLlmIntegrationHelp')}
options={llmModelOptions}
onSelect={(model) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? {
...current,
skill_scanner_config: {
...current.skill_scanner_config,
llm_api_key: model.api_key ?? '',
llm_model: model.provider_model_name,
llm_base_url: model.base_url,
},
}
: current,
);
}}
/>
<ConfigField
label="LLM API Key"
helper={t('securityCenter.scanner.llmApiKeyHelp')}
value={draftConfig.skill_scanner_config.llm_api_key}
type={showLLMAPIKey ? 'text' : 'password'}
revealable
revealed={showLLMAPIKey}
onToggleReveal={() => setShowLLMAPIKey((current) => !current)}
onChange={(value) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? { ...current, skill_scanner_config: { ...current.skill_scanner_config, llm_api_key: value } }
: current,
);
}}
/>
<ConfigField
label="LLM Model"
helper={t('securityCenter.scanner.llmModelHelp')}
value={draftConfig.skill_scanner_config.llm_model}
onChange={(value) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? { ...current, skill_scanner_config: { ...current.skill_scanner_config, llm_model: value } }
: current,
);
}}
/>
<ConfigField
label="LLM Base URL"
helper={t('securityCenter.scanner.llmBaseUrlHelp')}
value={draftConfig.skill_scanner_config.llm_base_url}
onChange={(value) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? { ...current, skill_scanner_config: { ...current.skill_scanner_config, llm_base_url: value } }
: current,
);
}}
/>
<ModelImportField
label={t('securityCenter.scanner.metaLlmIntegration')}
helper={t('securityCenter.scanner.metaLlmIntegrationHelp')}
options={llmModelOptions}
onSelect={(model) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? {
...current,
skill_scanner_config: {
...current.skill_scanner_config,
meta_llm_api_key: model.api_key ?? '',
meta_llm_model: model.provider_model_name,
meta_llm_base_url: model.base_url,
},
}
: current,
);
}}
/>
<ConfigField
label="Meta LLM API Key"
helper={t('securityCenter.scanner.metaLlmApiKeyHelp')}
value={draftConfig.skill_scanner_config.meta_llm_api_key}
type={showMetaLLMAPIKey ? 'text' : 'password'}
revealable
revealed={showMetaLLMAPIKey}
onToggleReveal={() => setShowMetaLLMAPIKey((current) => !current)}
onChange={(value) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? { ...current, skill_scanner_config: { ...current.skill_scanner_config, meta_llm_api_key: value } }
: current,
);
}}
/>
<ConfigField
label="Meta LLM Model"
helper={t('securityCenter.scanner.metaLlmModelHelp')}
value={draftConfig.skill_scanner_config.meta_llm_model}
onChange={(value) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? { ...current, skill_scanner_config: { ...current.skill_scanner_config, meta_llm_model: value } }
: current,
);
}}
/>
<ConfigField
label="Meta LLM Base URL"
helper={t('securityCenter.scanner.metaLlmBaseUrlHelp')}
value={draftConfig.skill_scanner_config.meta_llm_base_url}
onChange={(value) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? { ...current, skill_scanner_config: { ...current.skill_scanner_config, meta_llm_base_url: value } }
: current,
);
}}
/>
</div>
</div>
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="text-sm font-semibold text-[#171212]">{t('securityCenter.scanner.currentMode')}</div>
<p className="mt-1 text-xs leading-5 text-[#8f8681]">
{t('securityCenter.scanner.currentModeDesc')}
</p>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
{[
{ value: 'quick', label: t('securityCenter.scanner.quickMode'), description: t('securityCenter.scanner.quickModeDesc') },
{ value: 'deep', label: t('securityCenter.scanner.deepMode'), description: t('securityCenter.scanner.deepModeDesc') },
].map((option) => {
const checked = (draftConfig.active_mode || draftConfig.default_mode) === option.value;
return (
<label
key={option.value}
className={`flex cursor-pointer items-start gap-3 rounded-2xl border px-4 py-4 transition ${
checked ? 'border-[#9fc7ee] bg-[#eef7ff]' : 'border-[#eadfd8] bg-white'
}`}
>
<input
type="radio"
name="active-scan-mode"
checked={checked}
onChange={() => {
setIsDirty(true);
setDraftConfig((current) =>
current ? { ...current, active_mode: option.value, default_mode: option.value } : current,
);
}}
className="mt-1 h-4 w-4 border-[#d7c9bf] text-[#dc2626] focus:ring-[#ef6b4a]"
/>
<div>
<div className="text-sm font-semibold text-[#171212]">{option.label}</div>
<div className="mt-1 text-xs leading-5 text-[#8f8681]">{option.description}</div>
</div>
</label>
);
})}
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<ScanMethodCard
title={t('securityCenter.scanModes.quick')}
timeout={draftConfig.quick_timeout_seconds}
analyzers={draftConfig.quick_analyzers}
analyzerOptions={analyzerOptions}
onTimeoutChange={(value) => {
setIsDirty(true);
setDraftConfig((current) => (current ? { ...current, quick_timeout_seconds: value } : current));
}}
onToggleAnalyzer={(analyzer) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? {
...current,
quick_analyzers: toggleAnalyzer(current.quick_analyzers, analyzer),
}
: current,
);
}}
/>
<ScanMethodCard
title={t('securityCenter.scanModes.deep')}
timeout={draftConfig.deep_timeout_seconds}
analyzers={draftConfig.deep_analyzers}
analyzerOptions={analyzerOptions}
onTimeoutChange={(value) => {
setIsDirty(true);
setDraftConfig((current) => (current ? { ...current, deep_timeout_seconds: value } : current));
}}
onToggleAnalyzer={(analyzer) => {
setIsDirty(true);
setDraftConfig((current) =>
current
? {
...current,
deep_analyzers: toggleAnalyzer(current.deep_analyzers, analyzer),
}
: current,
);
}}
/>
</div>
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] px-4 py-4 text-sm leading-6 text-[#6f6661]">
{t('securityCenter.scanner.applySummary')}
</div>
</div>
) : (
<div className="mt-6 text-sm text-[#8f8681]">{t('securityCenter.scanner.loadingConfig')}</div>
)}
</section>
</div>
</SecurityCenterShell>
);
};
function ScanMethodCard({
title,
timeout,
analyzers,
analyzerOptions,
onTimeoutChange,
onToggleAnalyzer,
}: {
title: string;
timeout: number;
analyzers: string[];
analyzerOptions: Array<{ value: string; label: string; description: string }>;
onTimeoutChange: (value: number) => void;
onToggleAnalyzer: (value: string) => void;
}) {
const { t } = useI18n();
const quickLabel = t('securityCenter.scanModes.quick');
return (
<div className="rounded-2xl border border-[#efe1d8] bg-[#fffaf7] p-4">
<div className="text-sm font-semibold text-[#171212]">{title}</div>
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700">{t('securityCenter.scanner.timeoutSeconds')}</label>
<input
type="number"
min={title === quickLabel ? 5 : 10}
value={timeout}
onChange={(event) => onTimeoutChange(Number(event.target.value) || (title === quickLabel ? 30 : 120))}
className="app-input mt-1 w-full"
/>
</div>
<div className="mt-4">
<div className="text-sm font-medium text-gray-700">{t('securityCenter.scanner.invocationMethod')}</div>
<div className="mt-3 space-y-3">
{analyzerOptions.map((option) => {
const checked = analyzers.includes(option.value);
return (
<label
key={`${title}-${option.value}`}
className="flex cursor-pointer items-start gap-3 rounded-2xl border border-[#eadfd8] bg-white px-4 py-3"
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggleAnalyzer(option.value)}
className="mt-1 h-4 w-4 rounded border-[#d7c9bf] text-[#dc2626] focus:ring-[#ef6b4a]"
/>
<div>
<div className="text-sm font-semibold text-[#171212]">{option.label}</div>
<div className="mt-1 text-xs leading-5 text-[#8f8681]">{option.description}</div>
</div>
</label>
);
})}
</div>
</div>
</div>
);
}
function ConfigField({
label,
helper,
value,
onChange,
type = 'text',
revealable = false,
revealed = false,
onToggleReveal,
}: {
label: string;
helper: string;
value: string;
onChange: (value: string) => void;
type?: 'text' | 'password';
revealable?: boolean;
revealed?: boolean;
onToggleReveal?: () => void;
}) {
const { t } = useI18n();
return (
<div>
<label className="block text-sm font-medium text-gray-700">{label}</label>
<p className="mt-1 text-xs text-[#8f8681]">{helper}</p>
<div className="mt-2 flex items-center gap-3">
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
className="app-input w-full"
/>
{revealable ? (
<button
type="button"
onClick={onToggleReveal}
className="inline-flex min-w-[68px] justify-center rounded-xl border border-[#eadfd8] bg-white px-3 py-2 text-sm font-medium text-[#6f6661] transition hover:border-[#d6c7be] hover:bg-[#fffaf7]"
>
{revealed ? t('securityCenter.scanner.hide') : t('securityCenter.scanner.reveal')}
</button>
) : null}
</div>
</div>
);
}
function ModelImportField({
label,
helper,
options,
onSelect,
}: {
label: string;
helper: string;
options: Array<{ id: string; label: string; model: LLMModel }>;
onSelect: (model: LLMModel) => void;
}) {
const { t } = useI18n();
return (
<div>
<label className="block text-sm font-medium text-gray-700">{label}</label>
<p className="mt-1 text-xs text-[#8f8681]">{helper}</p>
<select
defaultValue=""
onChange={(event) => {
const selected = options.find((item) => item.id === event.target.value);
if (selected) {
onSelect(selected.model);
}
}}
className="app-input mt-2 w-full"
>
<option value="">{t('securityCenter.scanner.selectAiGatewayModel')}</option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</div>
);
}
function toggleAnalyzer(items: string[], target: string): string[] {
if (items.includes(target)) {
return items.filter((item) => item !== target);
}
return [...items, target];
}
export default AdminSecurityScannerConfigPage;
@@ -0,0 +1,375 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import AdminLayout from '../../../components/AdminLayout';
import { useI18n } from '../../../contexts/I18nContext';
import { adminService, type AdminSkillRecord, type SecurityScanConfig, type SecurityScanJob } from '../../../services/adminService';
export interface SecurityCenterSummary {
total: number;
uploaded: number;
discovered: number;
highRisk: number;
mediumRisk: number;
completed: number;
}
export function useSecurityCenterData() {
const { t } = useI18n();
const [skills, setSkills] = useState<AdminSkillRecord[]>([]);
const [jobs, setJobs] = useState<SecurityScanJob[]>([]);
const [config, setConfig] = useState<SecurityScanConfig | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAll = async (mode: 'initial' | 'refresh' = 'initial') => {
try {
if (mode === 'initial') {
setLoading(true);
} else {
setRefreshing(true);
}
setError(null);
const [skillItems, jobItems, configItem] = await Promise.all([
adminService.listSkills(),
adminService.listSecurityScanJobs(20),
adminService.getSecurityConfig(),
]);
setSkills(skillItems);
setJobs(jobItems);
setConfig(configItem);
} catch (err: any) {
setError(err.response?.data?.error || t('securityCenter.errors.loadCenter'));
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void loadAll();
}, [t]);
useEffect(() => {
const hasRunningJob = jobs.some((job) => job.status === 'queued' || job.status === 'running');
if (!hasRunningJob) {
return;
}
const timer = window.setInterval(() => {
void loadAll('refresh');
}, 3000);
return () => window.clearInterval(timer);
}, [jobs]);
const summary = useMemo<SecurityCenterSummary>(() => ({
total: skills.length,
uploaded: skills.filter((item) => item.source_type === 'uploaded').length,
discovered: skills.filter((item) => item.source_type !== 'uploaded').length,
highRisk: skills.filter((item) => item.risk_level === 'high').length,
mediumRisk: skills.filter((item) => item.risk_level === 'medium').length,
completed: skills.filter((item) => item.scan_status === 'completed').length,
}), [skills]);
return {
skills,
jobs,
config,
loading,
refreshing,
error,
setError,
setConfig,
loadAll,
summary,
};
}
export function SecurityCenterShell({
summary: _summary,
children,
}: {
summary: SecurityCenterSummary;
children: React.ReactNode;
}) {
const location = useLocation();
const { t } = useI18n();
const navItems = [
{ path: '/admin/security', label: t('securityCenter.nav.dashboard'), description: t('securityCenter.navDashboardDesc') },
{ path: '/admin/security/reports', label: t('securityCenter.nav.reports'), description: t('securityCenter.navReportsDesc') },
{ path: '/admin/security/scanner', label: t('securityCenter.nav.scanner'), description: t('securityCenter.navScannerDesc') },
];
const isActive = (path: string) => {
if (path === '/admin/security') {
return location.pathname === path;
}
return location.pathname === path || location.pathname.startsWith(`${path}/`);
};
return (
<AdminLayout title={t('securityCenter.title')}>
<div className="space-y-6">
<section className="grid grid-cols-1 gap-6 xl:grid-cols-[176px_minmax(0,1fr)]">
<div className="xl:sticky xl:top-6 xl:self-start">
<div className="rounded-[24px] border border-[#eadfd8] bg-[linear-gradient(180deg,#fffaf7_0%,#ffffff_100%)] p-3 shadow-[0_18px_44px_-36px_rgba(72,44,24,0.35)]">
<div className="flex flex-col gap-2">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`group rounded-[18px] border px-3 py-3 text-left transition ${
isActive(item.path)
? 'border-[#9fc7ee] bg-[#eef7ff] shadow-[inset_0_0_0_1px_rgba(116,173,223,0.35)]'
: 'border-[#eadfd8] bg-[#fffaf7] hover:border-[#d6c7be] hover:bg-white'
}`}
>
<div className="min-w-0">
<div className="whitespace-nowrap text-sm font-semibold text-[#171212]">{item.label}</div>
</div>
</Link>
))}
</div>
</div>
</div>
<div className="min-w-0">{children}</div>
</section>
</div>
</AdminLayout>
);
}
export function AnalyzerGroup({ title, items, emptyLabel }: { title: string; items: string[]; emptyLabel?: string }) {
const { t } = useI18n();
const safeItems = Array.isArray(items) ? items : [];
return (
<div className="rounded-2xl border border-[#f1e2d9] bg-white p-4">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{title}</div>
{safeItems.length === 0 ? (
<div className="mt-3 text-sm text-[#8f8681]">{emptyLabel || t('securityCenter.common.noneYet')}</div>
) : (
<div className="mt-3 flex flex-wrap gap-2">
{safeItems.map((item) => (
<span
key={`${title}-${item}`}
className="inline-flex rounded-full border border-[#d9e0e7] bg-[#f6f8fb] px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] text-[#556070]"
>
{item}
</span>
))}
</div>
)}
</div>
);
}
export function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-2xl border border-[#f1e2d9] bg-white px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#b46c50]">{label}</div>
<div className="mt-2 text-2xl font-semibold text-[#171212]">{value}</div>
</div>
);
}
export function Badge({
children,
tone,
}: {
children: React.ReactNode;
tone: 'green' | 'yellow' | 'orange' | 'red' | 'amber' | 'slate';
}) {
const toneClass =
tone === 'green'
? 'border-[#bde8ca] bg-[#edfdf2] text-[#177245]'
: tone === 'yellow'
? 'border-[#f5df9f] bg-[#fff8dd] text-[#9a6a00]'
: tone === 'orange'
? 'border-[#f7c8a4] bg-[#fff1e6] text-[#b45309]'
: tone === 'red'
? 'border-[#f2c2c2] bg-[#fff0f0] text-[#b42318]'
: tone === 'amber'
? 'border-[#f1d9c7] bg-[#fff6f0] text-[#b46c50]'
: 'border-[#d9e0e7] bg-[#f6f8fb] text-[#556070]';
return (
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${toneClass}`}>
{children}
</span>
);
}
export function splitAnalyzers(value: string): string[] {
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
export function assetTypeLabel(value: string): string {
if (value === 'skill') {
return 'skill';
}
return value;
}
export function sourceLabel(value: string): string {
if (value === 'uploaded') {
return 'uploaded';
}
if (value === 'discovered') {
return 'discovered';
}
return value;
}
export function riskLabel(value: string): string {
switch (value) {
case 'high':
return 'high';
case 'medium':
return 'medium';
case 'low':
return 'low';
case 'none':
return 'SAFE';
case 'completed':
return 'completed';
case 'running':
return 'running';
case 'failed':
return 'failed';
case 'pending':
return 'pending';
case 'queued':
return 'queued';
default:
return value;
}
}
export function jobStatusLabel(value: string): string {
switch (value) {
case 'queued':
return 'queued';
case 'pending':
return 'pending';
case 'running':
return 'running';
case 'completed':
return 'completed';
case 'failed':
return 'failed';
default:
return value;
}
}
export function scanModeLabel(value: string): string {
return value === 'deep' ? 'deep' : 'quick';
}
export function scanScopeLabel(value: string): string {
return value === 'full' ? 'full' : 'incremental';
}
export function formatDateTime(value: string, locale = 'en'): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString(locale, {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
export function riskTone(value: string): 'green' | 'yellow' | 'orange' | 'red' | 'amber' | 'slate' {
switch (value) {
case 'high':
return 'red';
case 'medium':
return 'orange';
case 'low':
return 'yellow';
case 'none':
return 'green';
case 'uploaded':
return 'amber';
default:
return 'slate';
}
}
export function severityTone(value: string): 'green' | 'yellow' | 'orange' | 'red' | 'amber' | 'slate' {
switch (value.toUpperCase()) {
case 'CRITICAL':
case 'HIGH':
return 'red';
case 'MEDIUM':
case 'MODERATE':
return 'orange';
case 'LOW':
case 'WARNING':
return 'yellow';
case 'INFO':
case 'SAFE':
return 'green';
default:
return 'slate';
}
}
export function severityLabel(value: string): string {
switch (value.toUpperCase()) {
case 'CRITICAL':
return 'CRITICAL';
case 'HIGH':
return 'HIGH';
case 'MEDIUM':
case 'MODERATE':
return 'MEDIUM';
case 'LOW':
case 'WARNING':
return 'LOW';
case 'INFO':
return 'INFO';
default:
return value;
}
}
export function scanStatusLabel(value: string): string {
switch (value) {
case 'completed':
return 'completed';
case 'pending':
return 'pending';
case 'failed':
return 'failed';
case 'running':
return 'running';
default:
return value || 'unknown';
}
}
export function scanStatusTone(value: string): 'green' | 'yellow' | 'orange' | 'red' | 'amber' | 'slate' {
switch (value) {
case 'completed':
return 'green';
case 'pending':
return 'amber';
case 'running':
return 'orange';
case 'failed':
return 'red';
default:
return 'slate';
}
}
@@ -4,11 +4,13 @@ import OpenClawConfigPlanSection, { type OpenClawInjectionMode } from '../../com
import UserLayout from '../../components/UserLayout';
import { useAuth } from '../../contexts/AuthContext';
import { instanceService } from '../../services/instanceService';
import { skillService } from '../../services/skillService';
import { userService } from '../../services/userService';
import { INSTANCE_TYPES, PRESET_CONFIGS } from '../../types/instance';
import type { CreateInstanceRequest } from '../../types/instance';
import type { Instance } from '../../types/instance';
import type { OpenClawConfigCompilePreview } from '../../types/openclawConfig';
import type { Skill } from '../../types/skill';
import type { UserQuota } from '../../types/user';
import { useI18n } from '../../contexts/I18nContext';
import { systemSettingsService } from '../../services/systemSettingsService';
@@ -32,6 +34,9 @@ const CreateInstancePage: React.FC = () => {
const [openClawPreview, setOpenClawPreview] = useState<OpenClawConfigCompilePreview | null>(null);
const [openClawPreviewLoading, setOpenClawPreviewLoading] = useState(false);
const [openClawPreviewError, setOpenClawPreviewError] = useState<string | null>(null);
const [availableSkills, setAvailableSkills] = useState<Skill[]>([]);
const [skillLoading, setSkillLoading] = useState(false);
const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([]);
const openClawImportInputRef = useRef<HTMLInputElement | null>(null);
const [formData, setFormData] = useState<CreateInstanceRequest>({
@@ -89,6 +94,22 @@ const CreateInstancePage: React.FC = () => {
loadAvailableTypes();
}, []);
useEffect(() => {
const loadSkills = async () => {
try {
setSkillLoading(true);
const items = await skillService.listSkills();
setAvailableSkills(items.filter((item) => item.status === 'active' && item.risk_level !== 'medium' && item.risk_level !== 'high'));
} catch {
setAvailableSkills([]);
} finally {
setSkillLoading(false);
}
};
void loadSkills();
}, []);
useEffect(() => {
const loadQuotaAndUsage = async () => {
if (!user) {
@@ -125,6 +146,7 @@ const CreateInstancePage: React.FC = () => {
setOpenClawResourceIds([]);
setOpenClawPreview(null);
setOpenClawPreviewError(null);
setSelectedSkillIds([]);
}
setFormData({
...formData,
@@ -160,6 +182,7 @@ const CreateInstancePage: React.FC = () => {
setError(null);
const createPayload: CreateInstanceRequest = {
...formData,
skill_ids: formData.type === 'openclaw' ? selectedSkillIds : undefined,
openclaw_config_plan: formData.type === 'openclaw' && openClawInjectionMode === 'bundle' && openClawBundleId
? { mode: 'bundle', bundle_id: openClawBundleId }
: formData.type === 'openclaw' && openClawInjectionMode === 'manual' && openClawResourceIds.length > 0
@@ -587,6 +610,56 @@ const CreateInstancePage: React.FC = () => {
onPreviewChange={handleOpenClawPreviewChange}
/>
<div className="app-panel p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium text-gray-900">Skill Injection</h2>
<p className="mt-1 text-sm text-gray-500">
Select one or more reusable skills to install into this OpenClaw instance.
</p>
</div>
<span className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
{selectedSkillIds.length} selected
</span>
</div>
<div className="mt-5 grid grid-cols-1 gap-3 md:grid-cols-2">
{skillLoading ? (
<div className="text-sm text-gray-500">Loading skills...</div>
) : availableSkills.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-5 text-sm text-gray-500">
No available skills. Import skills from the resource center first.
</div>
) : availableSkills.map((skill) => {
const checked = selectedSkillIds.includes(skill.id);
return (
<label
key={skill.id}
className={`flex cursor-pointer items-start gap-3 rounded-2xl border px-4 py-3 ${
checked ? 'border-indigo-300 bg-indigo-50' : 'border-gray-200 bg-white'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => setSelectedSkillIds((current) => (
e.target.checked
? [...current, skill.id]
: current.filter((value) => value !== skill.id)
))}
/>
<span className="min-w-0">
<span className="block font-medium text-gray-900">{skill.name}</span>
<span className="mt-1 block text-xs text-gray-500">
{skill.skill_key} · risk {skill.risk_level} · v{skill.current_version_no || 1}
</span>
{skill.description && <span className="mt-2 block text-sm text-gray-600">{skill.description}</span>}
</span>
</label>
);
})}
</div>
</div>
{openClawInjectionMode === 'archive' && (
<div className="app-panel p-6">
<div className="flex items-start justify-between gap-4">
@@ -815,5 +888,3 @@ const CreateInstancePage: React.FC = () => {
};
export default CreateInstancePage;
@@ -2,10 +2,10 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import ConfirmDialog from "../../components/ConfirmDialog";
import { InstanceAccess } from "../../components/InstanceAccess";
import { OpenClawDesktopOverlay } from "../../components/OpenClawDesktopOverlay";
import UserLayout from "../../components/UserLayout";
import { useI18n } from "../../contexts/I18nContext";
import { instanceService } from "../../services/instanceService";
import { skillService } from "../../services/skillService";
import type {
AgentInfo,
Instance,
@@ -14,12 +14,14 @@ import type {
InstanceStatus,
RuntimeStatus,
} from "../../types/instance";
import type { InstanceSkill, Skill } from "../../types/skill";
const META_POLL_INTERVAL_MS = 8000;
const RUNTIME_POLL_INTERVAL_MS = 5000;
const RUNTIME_BURST_POLL_INTERVAL_MS = 1000;
const RUNTIME_BURST_WINDOW_MS = 15000;
const METRIC_WINDOW_MS = 5 * 60 * 1000;
const INSTANCE_SKILL_PAGE_SIZE = 5;
type TimelineItem = {
id: string;
@@ -56,6 +58,11 @@ type MetricCurve = {
preNormalized?: boolean;
};
type TranslateFn = (
key: string,
variables?: Record<string, string | number>,
) => string;
function statusStyle(status: string) {
switch (status) {
case "running":
@@ -94,8 +101,34 @@ function statusStyle(status: string) {
}
}
function skillRiskLabel(t: TranslateFn, riskLevel?: string | null) {
switch ((riskLevel || "").toLowerCase()) {
case "none":
return t("instances.skillRiskNone");
case "low":
return t("instances.skillRiskLow");
case "medium":
return t("instances.skillRiskMedium");
case "high":
return t("instances.skillRiskHigh");
default:
return t("instances.skillRiskUnknown");
}
}
function skillSourceLabel(t: TranslateFn, sourceType?: string | null) {
switch ((sourceType || "").toLowerCase()) {
case "uploaded":
return t("instances.skillSourceUploaded");
case "discovered":
return t("instances.skillSourceDiscovered");
default:
return sourceType || t("instances.notAvailable");
}
}
const InstanceDetailPage: React.FC = () => {
const { t } = useI18n();
const { t, locale } = useI18n();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const instanceId = id ? Number(id) : null;
@@ -121,6 +154,10 @@ const InstanceDetailPage: React.FC = () => {
networkDown: [],
networkUp: [],
});
const [instanceSkills, setInstanceSkills] = useState<InstanceSkill[]>([]);
const [availableSkills, setAvailableSkills] = useState<Skill[]>([]);
const [selectedSkillId, setSelectedSkillId] = useState<number | "">("");
const [instanceSkillPage, setInstanceSkillPage] = useState(1);
const importInputRef = useRef<HTMLInputElement | null>(null);
const timelineScrollRef = useRef<HTMLDivElement | null>(null);
const timelineItemRefs = useRef<Record<string, HTMLDivElement | null>>({});
@@ -247,6 +284,36 @@ const InstanceDetailPage: React.FC = () => {
};
}, [instanceId, t]);
useEffect(() => {
if (!instanceId || Number.isNaN(instanceId)) {
return;
}
const loadSkills = async () => {
try {
const [instanceSkillItems, reusableSkills] = await Promise.all([
skillService.listInstanceSkills(instanceId),
skillService.listSkills(),
]);
setInstanceSkills(instanceSkillItems);
setAvailableSkills(
reusableSkills.filter(
(item) =>
item.status === "active" &&
item.risk_level !== "medium" &&
item.risk_level !== "high",
),
);
} catch (skillError) {
console.error("Failed to load skill data", skillError);
}
};
void loadSkills();
}, [instanceId]);
useEffect(() => {
setInstanceSkillPage(1);
}, [instanceSkills.length]);
useEffect(() => {
if (!instanceId || Number.isNaN(instanceId)) {
return;
@@ -295,6 +362,19 @@ const InstanceDetailPage: React.FC = () => {
};
}, [runtimeBurstUntil]);
const instanceSkillTotalPages = Math.max(
1,
Math.ceil(instanceSkills.length / INSTANCE_SKILL_PAGE_SIZE),
);
const currentInstanceSkillPage = Math.min(
instanceSkillPage,
instanceSkillTotalPages,
);
const paginatedInstanceSkills = instanceSkills.slice(
(currentInstanceSkillPage - 1) * INSTANCE_SKILL_PAGE_SIZE,
currentInstanceSkillPage * INSTANCE_SKILL_PAGE_SIZE,
);
useEffect(() => {
const snapshot = extractMetricSnapshot(runtimeDetails?.runtime?.system_info);
if (!snapshot) {
@@ -416,7 +496,7 @@ const InstanceDetailPage: React.FC = () => {
} catch (err: any) {
alert(
err.response?.data?.error ||
`Failed to queue runtime command: ${command}`,
t("instances.runtimeCommandFailed", { command }),
);
} finally {
setActionLoading(null);
@@ -462,6 +542,47 @@ const InstanceDetailPage: React.FC = () => {
}
};
const refreshSkills = useCallback(async () => {
if (!instanceId || Number.isNaN(instanceId)) {
return;
}
const items = await skillService.listInstanceSkills(instanceId);
setInstanceSkills(items);
}, [instanceId]);
const handleAttachSkill = async () => {
if (!instanceId || Number.isNaN(instanceId) || selectedSkillId === "") {
return;
}
try {
setActionLoading("attach-skill");
await skillService.attachSkillToInstance(instanceId, Number(selectedSkillId));
setSelectedSkillId("");
await refreshSkills();
setRuntimeBurstUntil(Date.now() + RUNTIME_BURST_WINDOW_MS);
} catch (err: any) {
alert(err.response?.data?.error || t("instances.failedToAttachSkill"));
} finally {
setActionLoading(null);
}
};
const handleRemoveSkill = async (skillId: number) => {
if (!instanceId || Number.isNaN(instanceId)) {
return;
}
try {
setActionLoading(`remove-skill-${skillId}`);
await skillService.removeSkillFromInstance(instanceId, skillId);
await refreshSkills();
setRuntimeBurstUntil(Date.now() + RUNTIME_BURST_WINDOW_MS);
} catch (err: any) {
alert(err.response?.data?.error || t("instances.failedToRemoveSkill"));
} finally {
setActionLoading(null);
}
};
if (loading) {
return (
<UserLayout>
@@ -540,6 +661,7 @@ const InstanceDetailPage: React.FC = () => {
networkInfo,
metricHistory,
sessionStartedAt: metricSessionStartedAt,
t,
});
const timelineItems = buildTimelineItems(
instance,
@@ -547,6 +669,8 @@ const InstanceDetailPage: React.FC = () => {
runtime,
agent,
commands,
locale,
t,
);
const canControlGateway = effectiveInstanceStatus === "running";
@@ -596,10 +720,10 @@ const InstanceDetailPage: React.FC = () => {
</div>
<div className="flex flex-wrap items-center gap-2">
<RefreshState
active={metaRefreshing || runtimeRefreshing}
label="Live"
/>
<RefreshState
active={metaRefreshing || runtimeRefreshing}
label={t("instances.live")}
/>
{effectiveInstanceStatus === "running" ? (
<button
onClick={() => handleAction("stop")}
@@ -651,12 +775,16 @@ const InstanceDetailPage: React.FC = () => {
instanceId={instance.id}
instanceName={instance.name}
isRunning={effectiveInstanceStatus === "running"}
/>
<OpenClawDesktopOverlay
gatewayStatus={gatewayStatus}
canControl={canControlGateway}
actionLoading={actionLoading}
onCommand={handleRuntimeCommand}
overlay={
instance.type === "openclaw"
? {
gatewayStatus,
canControl: canControlGateway,
actionLoading,
onCommand: handleRuntimeCommand,
}
: undefined
}
/>
</div>
</section>
@@ -666,14 +794,13 @@ const InstanceDetailPage: React.FC = () => {
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#b09d93]">
Workspace
{t("instances.workspaceSection")}
</p>
<h2 className="mt-2 text-[1.35rem] font-semibold tracking-[-0.03em] text-[#1d1713]">
OpenClaw import and export
{t("instances.openClawWorkspace")}
</h2>
<p className="mt-2 text-sm leading-6 text-[#7a6d66]">
Move the current workspace in or out without leaving this
page.
{t("instances.openClawWorkspaceDesc")}
</p>
</div>
@@ -724,33 +851,31 @@ const InstanceDetailPage: React.FC = () => {
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#b09d93]">
Kubernetes
{t("instances.kubernetesSection")}
</p>
<h2 className="mt-2 text-[1.5rem] font-semibold tracking-[-0.03em] text-[#1d1713]">
Pod and infrastructure status
{t("instances.kubernetesStatusTitle")}
</h2>
</div>
<RefreshState active={metaRefreshing} label="Infra ready" />
<RefreshState active={metaRefreshing} label={t("instances.infrastructureReady")} />
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<DetailCard
label={t("instances.podName")}
value={status?.pod_name || instance.pod_name || "N/A"}
value={status?.pod_name || instance.pod_name || t("instances.notAvailable")}
/>
<DetailCard
label={t("instances.namespace")}
value={
status?.pod_namespace || instance.pod_namespace || "N/A"
}
value={status?.pod_namespace || instance.pod_namespace || t("instances.notAvailable")}
/>
<DetailCard
label={t("instances.podStatus")}
value={status?.pod_status || status?.status || "N/A"}
value={status?.pod_status || status?.status || t("instances.notAvailable")}
/>
<DetailCard
label={t("instances.podIp")}
value={status?.pod_ip || instance.pod_ip || "N/A"}
value={status?.pod_ip || instance.pod_ip || t("instances.notAvailable")}
/>
<DetailCard label={t("common.type")} value={instance.type} />
<DetailCard
@@ -775,19 +900,144 @@ const InstanceDetailPage: React.FC = () => {
</div>
</section>
{instance.type === "openclaw" && (
<section className="app-panel px-6 py-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#b09d93]">
{t("instances.skillsSection")}
</p>
<h2 className="mt-2 text-[1.5rem] font-semibold tracking-[-0.03em] text-[#1d1713]">
{t("instances.skillManagement")}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={selectedSkillId}
onChange={(event) => setSelectedSkillId(event.target.value ? Number(event.target.value) : "")}
className="app-input min-w-[220px]"
>
<option value="">{t("instances.selectSkill")}</option>
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.id}>
{skill.name} ({skill.skill_key})
</option>
))}
</select>
<button
type="button"
onClick={handleAttachSkill}
disabled={selectedSkillId === "" || actionLoading === "attach-skill"}
className="app-button-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{actionLoading === "attach-skill" ? t("instances.installingSkill") : t("instances.installSkill")}
</button>
</div>
</div>
<div className="mt-5 space-y-3">
{instanceSkills.length === 0 ? (
<div className="rounded-[22px] border border-dashed border-[#e7d9d1] bg-[#fffaf7] px-5 py-6 text-sm text-[#7a6d66]">
{t("instances.noSkillsReported")}
</div>
) : (
<>
{paginatedInstanceSkills.map((item) => (
<div
key={`${item.skill_id}-${item.id}`}
className="rounded-[22px] border border-[#efe2d8] bg-[#fffaf7] px-5 py-4 shadow-[0_20px_40px_-36px_rgba(72,44,24,0.42)]"
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-base font-semibold text-[#1d1713]">
{item.skill?.name || t("instances.skillFallback", { id: item.skill_id })}
</span>
<span className="rounded-full border border-[#ead8cf] bg-white px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#8f776b]">
{skillSourceLabel(t, item.source_type)}
</span>
<span className="rounded-full border border-[#ead8cf] bg-white px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em] text-[#8f776b]">
{skillRiskLabel(t, item.skill?.risk_level)}
</span>
</div>
<p className="mt-2 text-sm text-[#6f6158]">
{item.skill?.skill_key || item.skill_id}
{item.install_path ? ` · ${item.install_path}` : ""}
{item.last_seen_at ? ` · ${t("instances.lastSeenAt", { value: formatDateTime(item.last_seen_at, locale, t) })}` : ""}
</p>
</div>
<button
type="button"
onClick={() => handleRemoveSkill(item.skill_id)}
disabled={actionLoading === `remove-skill-${item.skill_id}`}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{actionLoading === `remove-skill-${item.skill_id}` ? t("instances.removingSkill") : t("instances.removeSkill")}
</button>
</div>
</div>
))}
{instanceSkillTotalPages > 1 ? (
<div className="flex flex-col gap-3 border-t border-[#f0e4dc] pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-[#8a7b72]">
{t("instances.skillPagination", {
from: (currentInstanceSkillPage - 1) * INSTANCE_SKILL_PAGE_SIZE + 1,
to: Math.min(
currentInstanceSkillPage * INSTANCE_SKILL_PAGE_SIZE,
instanceSkills.length,
),
total: instanceSkills.length,
})}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
setInstanceSkillPage((current) =>
Math.max(1, current - 1),
)
}
disabled={currentInstanceSkillPage <= 1}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t("instances.previous")}
</button>
<div className="min-w-[76px] text-center text-sm font-medium text-[#5f5957]">
{currentInstanceSkillPage} / {instanceSkillTotalPages}
</div>
<button
type="button"
onClick={() =>
setInstanceSkillPage((current) =>
Math.min(instanceSkillTotalPages, current + 1),
)
}
disabled={currentInstanceSkillPage >= instanceSkillTotalPages}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t("instances.nextPage")}
</button>
</div>
</div>
) : null}
</>
)}
</div>
</section>
)}
<section className="app-panel px-6 py-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#b09d93]">
Timeline
{t("instances.timeline")}
</p>
<h2 className="mt-2 text-[1.5rem] font-semibold tracking-[-0.03em] text-[#1d1713]">
Instance operations and runtime history
{t("instances.timelineSubtitle")}
</h2>
</div>
<RefreshState
active={runtimeRefreshing}
label={`${timelineItems.length} events`}
label={t("instances.timelineEvents", { count: timelineItems.length })}
/>
</div>
@@ -799,7 +1049,7 @@ const InstanceDetailPage: React.FC = () => {
<div className="space-y-4">
{timelineItems.length === 0 ? (
<div className="rounded-[24px] border border-dashed border-[#e7d9d1] bg-[#fffaf7] px-5 py-8 text-sm text-[#7a6d66]">
No runtime activity yet.
{t("instances.noRuntimeActivity")}
</div>
) : (
timelineItems.map((item) => (
@@ -839,7 +1089,7 @@ const InstanceDetailPage: React.FC = () => {
<div className="rounded-[24px] border border-[#efe2d8] bg-[#fffaf7] px-3 py-4">
<p className="text-center text-[11px] font-semibold uppercase tracking-[0.18em] text-[#b09d93]">
Minimap
{t("instances.minimap")}
</p>
<div className="mt-4 flex max-h-[500px] flex-col items-center gap-3 overflow-y-auto">
{timelineItems.map((item, index) => (
@@ -874,13 +1124,13 @@ const InstanceDetailPage: React.FC = () => {
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#b46c50]">
Runtime Summary
{t("instances.runtimeSummary")}
</p>
<h2 className="mt-2 text-[1.55rem] font-semibold tracking-[-0.04em] text-[#1d1713]">
Agent reported status
{t("instances.agentReportedStatus")}
</h2>
</div>
<RefreshState active={runtimeRefreshing} label="Fresh" />
<RefreshState active={runtimeRefreshing} label={t("instances.fresh")} />
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-2">
@@ -891,7 +1141,7 @@ const InstanceDetailPage: React.FC = () => {
<div className="mt-4 grid grid-cols-2 gap-3">
<SummaryMetricCard
label="OS"
label={t("instances.operatingSystemShort")}
value={formatMetricValue(
firstValue(
osInfo?.os_release,
@@ -899,58 +1149,59 @@ const InstanceDetailPage: React.FC = () => {
systemInfo?.hostname,
agent?.host_info?.hostname,
),
t,
)}
/>
<SummaryMetricCard
label="OpenClaw"
value={formatMetricValue(runtime?.openclaw_version)}
label={t("instances.openClawShort")}
value={formatMetricValue(runtime?.openclaw_version, t)}
/>
<SummaryMetricCard
label="Skills"
value={formatCountValue(skillCount)}
label={t("instances.skillsSection")}
value={formatCountValue(skillCount, t)}
/>
<SummaryMetricCard
label="Agents"
value={formatCountValue(agentCount)}
label={t("instances.agents")}
value={formatCountValue(agentCount, t)}
/>
<SummaryMetricCard
label="Channels"
value={formatCountValue(channelCount)}
label={t("instances.channels")}
value={formatCountValue(channelCount, t)}
/>
</div>
<div className="mt-5 rounded-[24px] border border-[#ead8cf] bg-white/82 p-4">
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={`Agent ${agent?.status || runtime?.agent_status || "offline"}`}
label={t("instances.agentStatusLabel", { status: agent?.status || runtime?.agent_status || "offline" })}
status={agent?.status || runtime?.agent_status || "offline"}
/>
<StatusBadge
label={`Gateway ${gatewayStatus}`}
label={t("instances.gatewayStatusLabel", { status: gatewayStatus })}
status={gatewayStatus}
/>
</div>
<dl className="mt-4 grid gap-3 text-sm text-[#4d4039]">
<MetaRow label="Agent ID" value={agent?.agent_id || "N/A"} />
<MetaRow label={t("instances.agentId")} value={agent?.agent_id || t("instances.notAvailable")} />
<MetaRow
label="Agent Version"
value={agent?.agent_version || "N/A"}
label={t("instances.agentVersion")}
value={agent?.agent_version || t("instances.notAvailable")}
/>
<MetaRow
label="Protocol"
value={agent?.protocol_version || "N/A"}
label={t("instances.protocol")}
value={agent?.protocol_version || t("instances.notAvailable")}
/>
<MetaRow
label="Last heartbeat"
value={formatDateTime(agent?.last_heartbeat_at)}
label={t("instances.lastHeartbeat")}
value={formatDateTime(agent?.last_heartbeat_at, locale, t)}
/>
<MetaRow
label="Last report"
value={formatDateTime(runtime?.last_reported_at)}
label={t("instances.lastReport")}
value={formatDateTime(runtime?.last_reported_at, locale, t)}
/>
<MetaRow
label="Pod IP"
value={status?.pod_ip || instance.pod_ip || "N/A"}
label={t("instances.podIp")}
value={status?.pod_ip || instance.pod_ip || t("instances.notAvailable")}
/>
</dl>
</div>
@@ -1304,76 +1555,89 @@ function buildTimelineItems(
runtime: RuntimeStatus | undefined,
agent: AgentInfo | undefined,
commands: InstanceRuntimeCommand[],
locale: string,
t: TranslateFn,
): TimelineItem[] {
const items: TimelineItem[] = [];
items.push({
id: `instance-created-${instance.id}`,
title: "Instance created",
detail: `${instance.name} was provisioned as ${instance.type}.`,
title: t("instances.timelineInstanceCreated"),
detail: t("instances.timelineInstanceCreatedDetail", {
name: instance.name,
type: instance.type,
}),
timestamp: new Date(instance.created_at).getTime(),
stampLabel: formatDateTime(instance.created_at),
stampLabel: formatDateTime(instance.created_at, locale, t),
tone: "bg-[#6366f1]",
section: "instance",
section: t("instances.timelineSectionInstance"),
});
if (instance.started_at) {
items.push({
id: `instance-started-${instance.id}`,
title: "Instance started",
detail: "Infrastructure start was recorded for this instance.",
title: t("instances.timelineInstanceStarted"),
detail: t("instances.timelineInstanceStartedDetail"),
timestamp: new Date(instance.started_at).getTime(),
stampLabel: formatDateTime(instance.started_at),
stampLabel: formatDateTime(instance.started_at, locale, t),
tone: "bg-[#22c55e]",
section: "infra",
section: t("instances.timelineSectionInfra"),
});
}
if (instance.stopped_at) {
items.push({
id: `instance-stopped-${instance.id}`,
title: "Instance stopped",
detail: "Infrastructure stop was recorded for this instance.",
title: t("instances.timelineInstanceStopped"),
detail: t("instances.timelineInstanceStoppedDetail"),
timestamp: new Date(instance.stopped_at).getTime(),
stampLabel: formatDateTime(instance.stopped_at),
stampLabel: formatDateTime(instance.stopped_at, locale, t),
tone: "bg-[#94a3b8]",
section: "infra",
section: t("instances.timelineSectionInfra"),
});
}
if (agent?.registered_at) {
items.push({
id: `agent-registered-${instance.id}`,
title: "Agent registered",
detail: `${agent.agent_id} connected to ClawManager using protocol ${agent.protocol_version}.`,
title: t("instances.timelineAgentRegistered"),
detail: t("instances.timelineAgentRegisteredDetail", {
agentId: agent.agent_id,
protocol: agent.protocol_version,
}),
timestamp: new Date(agent.registered_at).getTime(),
stampLabel: formatDateTime(agent.registered_at),
stampLabel: formatDateTime(agent.registered_at, locale, t),
tone: "bg-[#3b82f6]",
section: "agent",
section: t("instances.timelineSectionAgent"),
});
}
if (runtime?.last_reported_at) {
items.push({
id: `runtime-report-${instance.id}`,
title: "Runtime status reported",
detail: `Agent reported gateway status ${runtime.openclaw_status} and infra status ${runtime.infra_status}.`,
title: t("instances.timelineRuntimeReported"),
detail: t("instances.timelineRuntimeReportedDetail", {
gatewayStatus: runtime.openclaw_status,
infraStatus: runtime.infra_status,
}),
timestamp: new Date(runtime.last_reported_at).getTime(),
stampLabel: formatDateTime(runtime.last_reported_at),
stampLabel: formatDateTime(runtime.last_reported_at, locale, t),
tone: "bg-[#f59e0b]",
section: "runtime",
section: t("instances.timelineSectionRuntime"),
});
}
if (status?.pod_status && status.created_at) {
items.push({
id: `pod-status-${instance.id}`,
title: "Pod status observed",
detail: `Kubernetes currently reports pod status ${status.pod_status}.`,
title: t("instances.timelinePodStatusObserved"),
detail: t("instances.timelinePodStatusObservedDetail", {
status: status.pod_status,
}),
timestamp: new Date(status.created_at).getTime(),
stampLabel: formatDateTime(status.created_at),
stampLabel: formatDateTime(status.created_at, locale, t),
tone: "bg-[#a855f7]",
section: "k8s",
section: t("instances.timelineSectionKubernetes"),
});
}
@@ -1385,14 +1649,20 @@ function buildTimelineItems(
command.issued_at;
items.push({
id: `command-${command.id}`,
title: formatCommandTitle(command.command_type),
title: formatCommandTitle(command.command_type, locale),
detail: command.error_message
? `${command.status}: ${command.error_message}`
: `${command.status} · idempotency ${command.idempotency_key}`,
? t("instances.timelineCommandWithError", {
status: command.status,
error: command.error_message,
})
: t("instances.timelineCommandWithIdempotency", {
status: command.status,
key: command.idempotency_key,
}),
timestamp: new Date(commandTime).getTime(),
stampLabel: formatDateTime(commandTime),
stampLabel: formatDateTime(commandTime, locale, t),
tone: commandTone(command.status),
section: "command",
section: t("instances.timelineSectionCommand"),
});
});
@@ -1401,22 +1671,26 @@ function buildTimelineItems(
.sort((left, right) => right.timestamp - left.timestamp);
}
function formatDateTime(value?: string) {
function formatDateTime(
value: string | undefined,
locale: string,
t: TranslateFn,
) {
if (!value) {
return "N/A";
return t("instances.notAvailable");
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
return date.toLocaleString(locale);
}
function formatCommandTitle(commandType: string) {
function formatCommandTitle(commandType: string, locale: string) {
return commandType
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
.replace(/\b\w/g, (char) => char.toLocaleUpperCase(locale));
}
function commandTone(status: string) {
@@ -1456,7 +1730,7 @@ function firstNumber(...values: unknown[]): number | null {
return null;
}
function formatCountValue(value: unknown): string {
function formatCountValue(value: unknown, t: TranslateFn): string {
if (Array.isArray(value)) {
return `${value.length}`;
}
@@ -1472,7 +1746,7 @@ function formatCountValue(value: unknown): string {
return `${count}`;
}
}
return "N/A";
return t("instances.notAvailable");
}
function asRecord(value: unknown): Record<string, any> | undefined {
@@ -1499,16 +1773,16 @@ function getArray(value: unknown): any[] {
return Array.isArray(value) ? value : [];
}
function bytesToGB(value: number | null): string {
function bytesToGB(value: number | null, t: TranslateFn): string {
if (value === null) {
return "N/A";
return t("instances.notAvailable");
}
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function percentLabel(value: number | null): string {
function percentLabel(value: number | null, t: TranslateFn): string {
if (value === null) {
return "N/A";
return t("instances.notAvailable");
}
return `${Math.round(value)}%`;
}
@@ -1520,6 +1794,7 @@ function buildMetricCurves({
networkInfo,
metricHistory,
sessionStartedAt,
t,
}: {
cpuInfo?: Record<string, any>;
memoryInfo?: Record<string, any>;
@@ -1527,6 +1802,7 @@ function buildMetricCurves({
networkInfo?: Record<string, any>;
metricHistory: MetricHistory;
sessionStartedAt: number;
t: TranslateFn;
}): MetricCurve[] {
const cores = getNumber(cpuInfo?.cores) || 1;
const cpuLoad = asRecord(cpuInfo?.load);
@@ -1615,34 +1891,51 @@ function buildMetricCurves({
return [
{
label: "CPU",
value: percentLabel(cpuCurrent),
detail: `${cores} cores · load 1m ${formatNumber(getNumber(cpuLoad?.["1m"]))} · 5m ${formatNumber(getNumber(cpuLoad?.["5m"]))} · 15m ${formatNumber(getNumber(cpuLoad?.["15m"]))}`,
label: t("instances.metricCpu"),
value: percentLabel(cpuCurrent, t),
detail: t("instances.metricCpuDetail", {
cores,
load1: formatNumber(getNumber(cpuLoad?.["1m"]), t),
load5: formatNumber(getNumber(cpuLoad?.["5m"]), t),
load15: formatNumber(getNumber(cpuLoad?.["15m"]), t),
}),
accent: "#f97316",
points: cpuSeries,
},
{
label: "Memory",
value: percentLabel(memUsedPercent),
detail: `${bytesToGB(memTotal !== null && memAvailable !== null ? memTotal - memAvailable : null)} used / ${bytesToGB(memTotal)}`,
label: t("instances.metricMemory"),
value: percentLabel(memUsedPercent, t),
detail: t("instances.metricMemoryDetail", {
used: bytesToGB(
memTotal !== null && memAvailable !== null ? memTotal - memAvailable : null,
t,
),
total: bytesToGB(memTotal, t),
}),
accent: "#3b82f6",
points: memorySeries,
},
{
label: "Disk",
value: percentLabel(diskUsedPercent),
detail: `${bytesToGB(diskFree)} free / ${bytesToGB(diskTotal)}`,
label: t("instances.metricDisk"),
value: percentLabel(diskUsedPercent, t),
detail: t("instances.metricDiskDetail", {
free: bytesToGB(diskFree, t),
total: bytesToGB(diskTotal, t),
}),
accent: "#d97706",
points: diskSeries,
},
{
label: "Network",
label: t("instances.metricNetwork"),
value:
formatTrafficPair(networkTraffic) ||
formatTrafficPair(networkTraffic, t) ||
`${activeInterfaces}/${interfaceCount || 0}`,
detail:
formatTrafficDetail(networkTraffic) ||
`${totalAddresses} addresses across ${interfaceCount} interfaces`,
formatTrafficDetail(networkTraffic, t) ||
t("instances.metricNetworkDetail", {
addresses: totalAddresses,
interfaces: interfaceCount,
}),
accent: "#14b8a6",
secondaryAccent:
networkTraffic.down !== null || networkTraffic.up !== null
@@ -1655,8 +1948,8 @@ function buildMetricCurves({
legend:
networkTraffic.down !== null || networkTraffic.up !== null
? [
{ label: "Down", accent: "#14b8a6" },
{ label: "Up", accent: "#3b82f6" },
{ label: t("instances.metricLegendDown"), accent: "#14b8a6" },
{ label: t("instances.metricLegendUp"), accent: "#3b82f6" },
]
: undefined,
preNormalized:
@@ -1834,16 +2127,16 @@ function normalizePoints(points: number[]): number[] {
return safe.map((point) => Math.max(point / max, 0.08));
}
function formatNumber(value: number | null): string {
function formatNumber(value: number | null, t: TranslateFn): string {
if (value === null) {
return "N/A";
return t("instances.notAvailable");
}
return value.toFixed(2);
}
function formatBytesCompact(value: number | null): string {
function formatBytesCompact(value: number | null, t: TranslateFn): string {
if (value === null) {
return "N/A";
return t("instances.notAvailable");
}
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -1859,21 +2152,24 @@ function formatBytesCompact(value: number | null): string {
function formatTrafficPair(traffic: {
down: number | null;
up: number | null;
}) {
}, t: TranslateFn) {
if (traffic.down === null && traffic.up === null) {
return "";
}
return `${formatBytesCompact(traffic.down)}${formatBytesCompact(traffic.up)}`;
return `${formatBytesCompact(traffic.down, t)}${formatBytesCompact(traffic.up, t)}`;
}
function formatTrafficDetail(traffic: {
down: number | null;
up: number | null;
}) {
}, t: TranslateFn) {
if (traffic.down === null && traffic.up === null) {
return "";
}
return `Inbound ${formatBytesCompact(traffic.down)} · Outbound ${formatBytesCompact(traffic.up)}`;
return t("instances.metricTrafficDetail", {
inbound: formatBytesCompact(traffic.down, t),
outbound: formatBytesCompact(traffic.up, t),
});
}
function appendMetricSample(
@@ -1962,23 +2258,23 @@ function extractMetricSnapshot(systemInfoValue: unknown) {
};
}
function formatMetricValue(value: unknown): string {
function formatMetricValue(value: unknown, t: TranslateFn): string {
if (value === null || value === undefined) {
return "N/A";
return t("instances.notAvailable");
}
if (typeof value === "string") {
return value.trim() || "N/A";
return value.trim() || t("instances.notAvailable");
}
if (typeof value === "number" || typeof value === "boolean") {
return `${value}`;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return "N/A";
return t("instances.notAvailable");
}
return value
.map((item) => formatMetricValue(item))
.filter((item) => item !== "N/A")
.map((item) => formatMetricValue(item, t))
.filter((item) => item !== t("instances.notAvailable"))
.join(", ");
}
@@ -1996,13 +2292,13 @@ function formatMetricValue(value: unknown): string {
);
if (label !== undefined) {
return formatMetricValue(label);
return formatMetricValue(label, t);
}
try {
return JSON.stringify(record);
} catch {
return "N/A";
return t("instances.notAvailable");
}
}
@@ -7,6 +7,7 @@ import {
OPENCLAW_CHANNEL_TEMPLATES,
} from '../../lib/openclawChannelTemplates';
import { openclawConfigService } from '../../services/openclawConfigService';
import { skillService } from '../../services/skillService';
import type {
OpenClawConfigBundle,
OpenClawConfigBundleItem,
@@ -18,6 +19,7 @@ import type {
UpsertOpenClawConfigResourceRequest,
} from '../../types/openclawConfig';
import { OPENCLAW_RESOURCE_TYPES } from '../../types/openclawConfig';
import type { Skill } from '../../types/skill';
type ConfigCenterTab = 'resources' | 'bundles' | 'injections';
@@ -25,7 +27,8 @@ const CONFIG_CENTER_HIDDEN_RESOURCE_TYPES: OpenClawResourceType[] = ['session_te
const CONFIG_CENTER_RESOURCE_TYPES = OPENCLAW_RESOURCE_TYPES.filter(
(item) => !CONFIG_CENTER_HIDDEN_RESOURCE_TYPES.includes(item.value),
);
const CONFIG_CENTER_CONFIGURABLE_RESOURCE_TYPES: OpenClawResourceType[] = ['channel'];
const CONFIG_CENTER_CONFIGURABLE_RESOURCE_TYPES: OpenClawResourceType[] = ['channel', 'skill'];
const CONFIG_CENTER_PAGE_SIZE = 8;
const RESOURCE_TYPE_I18N_KEYS: Record<OpenClawResourceType, string> = {
channel: 'openClawResourcesPage.resourceTypes.channel',
@@ -575,12 +578,28 @@ const bundleFormFromItem = (item: OpenClawConfigBundle) => ({
itemIds: item.items.map((bundleItem) => bundleItem.resource_id),
});
const skillRiskKey = (riskLevel?: string | null) => {
switch ((riskLevel || '').toLowerCase()) {
case 'none':
return 'none';
case 'low':
return 'low';
case 'medium':
return 'medium';
case 'high':
return 'high';
default:
return 'unknown';
}
};
const OpenClawConfigCenterPage: React.FC = () => {
const { t } = useI18n();
const [tab, setTab] = useState<ConfigCenterTab>('resources');
const [resourceType, setResourceType] = useState<OpenClawResourceType>('channel');
const [resourceSearch, setResourceSearch] = useState('');
const [resources, setResources] = useState<OpenClawConfigResource[]>([]);
const [skills, setSkills] = useState<Skill[]>([]);
const [bundles, setBundles] = useState<OpenClawConfigBundle[]>([]);
const [snapshots, setSnapshots] = useState<OpenClawInjectionSnapshot[]>([]);
const [resourceForm, setResourceForm] = useState(() => newResourceForm('channel'));
@@ -589,23 +608,27 @@ const OpenClawConfigCenterPage: React.FC = () => {
const [selectedBundleId, setSelectedBundleId] = useState<number | undefined>();
const [resourceEditorOpen, setResourceEditorOpen] = useState(false);
const [bundleEditorOpen, setBundleEditorOpen] = useState(false);
const [resourcePage, setResourcePage] = useState(1);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [selectedChannelTemplateId, setSelectedChannelTemplateId] = useState('');
const [channelEditorMode, setChannelEditorMode] = useState<ChannelEditorMode>('form');
const [skillUploadFile, setSkillUploadFile] = useState<File | null>(null);
const loadAll = async () => {
try {
setLoading(true);
setError(null);
const [resourceItems, bundleItems, injectionItems] = await Promise.all([
const [resourceItems, skillItems, bundleItems, injectionItems] = await Promise.all([
openclawConfigService.listResources(),
skillService.listSkills(),
openclawConfigService.listBundles(),
openclawConfigService.listInjections(50),
]);
setResources(resourceItems);
setSkills(skillItems);
setBundles(bundleItems);
setSnapshots(injectionItems);
} catch (err: any) {
@@ -619,6 +642,10 @@ const OpenClawConfigCenterPage: React.FC = () => {
loadAll();
}, []);
useEffect(() => {
setResourcePage(1);
}, [resourceType, resourceSearch, tab]);
const filteredResources = useMemo(() => {
const keyword = resourceSearch.trim().toLowerCase();
return resources.filter((item) => {
@@ -634,6 +661,36 @@ const OpenClawConfigCenterPage: React.FC = () => {
});
}, [resourceSearch, resourceType, resources]);
const filteredSkills = useMemo(() => {
const keyword = resourceSearch.trim().toLowerCase();
if (!keyword) {
return skills;
}
return skills.filter((item) =>
[item.name, item.skill_key, item.description || '', item.risk_level].some((value) =>
value.toLowerCase().includes(keyword),
),
);
}, [resourceSearch, skills]);
const resourceItemTotal = resourceType === 'skill' ? filteredSkills.length : filteredResources.length;
const resourcePageTotal = Math.max(1, Math.ceil(resourceItemTotal / CONFIG_CENTER_PAGE_SIZE));
const currentResourcePage = Math.min(resourcePage, resourcePageTotal);
const paginatedSkills = useMemo(() => {
const start = (currentResourcePage - 1) * CONFIG_CENTER_PAGE_SIZE;
return filteredSkills.slice(start, start + CONFIG_CENTER_PAGE_SIZE);
}, [currentResourcePage, filteredSkills]);
const paginatedResources = useMemo(() => {
const start = (currentResourcePage - 1) * CONFIG_CENTER_PAGE_SIZE;
return filteredResources.slice(start, start + CONFIG_CENTER_PAGE_SIZE);
}, [currentResourcePage, filteredResources]);
useEffect(() => {
if (resourcePage > resourcePageTotal) {
setResourcePage(resourcePageTotal);
}
}, [resourcePage, resourcePageTotal]);
const getResourceTypeLabel = (value: OpenClawResourceType) => t(RESOURCE_TYPE_I18N_KEYS[value]);
const getChannelTemplateLabel = (templateId: string) => (
CHANNEL_TEMPLATE_LABEL_I18N_KEYS[templateId]
@@ -655,6 +712,9 @@ const OpenClawConfigCenterPage: React.FC = () => {
? t(SNAPSHOT_STATUS_I18N_KEYS[status])
: status
);
const getSkillRiskLabel = (riskLevel?: string | null) => (
t(`openClawResourcesPage.risks.${skillRiskKey(riskLevel)}`)
);
const resourceTypeOptions = useMemo(() => (
CONFIG_CENTER_RESOURCE_TYPES.map((item) => ({
...item,
@@ -862,6 +922,57 @@ const OpenClawConfigCenterPage: React.FC = () => {
}
};
const uploadSkillArchive = async () => {
if (!skillUploadFile) {
return;
}
try {
setSaving(true);
setError(null);
setNotice(null);
await skillService.importSkills(skillUploadFile);
setSkillUploadFile(null);
await loadAll();
setNotice(t('openClawResourcesPage.notices.skillArchiveImported'));
} catch (err: any) {
setError(err.response?.data?.error || t('openClawResourcesPage.errors.importSkillArchive'));
} finally {
setSaving(false);
}
};
const removeSkillAsset = async (skillId: number) => {
try {
setSaving(true);
setError(null);
setNotice(null);
await skillService.deleteSkill(skillId);
await loadAll();
setNotice(t('openClawResourcesPage.notices.skillDeleted'));
} catch (err: any) {
setError(err.response?.data?.error || t('openClawResourcesPage.errors.deleteSkill'));
} finally {
setSaving(false);
}
};
const downloadSkillAsset = async (skillId: number, name: string) => {
try {
const blob = await skillService.downloadSkill(skillId);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${name || 'skill'}.zip`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err: any) {
setError(err.response?.data?.error || t('openClawResourcesPage.errors.downloadSkill'));
}
};
const applyChannelTemplate = (templateId: string) => {
const template = findOpenClawChannelTemplate(templateId);
if (!template) {
@@ -1013,51 +1124,187 @@ const OpenClawConfigCenterPage: React.FC = () => {
{resourceTypeIsConfigurable ? (
<div className="app-panel p-4 sm:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<input
value={resourceSearch}
onChange={(e) => setResourceSearch(e.target.value)}
placeholder={t('openClawResourcesPage.searchPlaceholder')}
className="app-input w-full"
/>
<button type="button" onClick={openNewResourceEditor} className="app-button-primary whitespace-nowrap">
{t('openClawResourcesPage.actions.new')}
</button>
</div>
<div className="mt-3 text-sm text-gray-500">
{t('openClawResourcesPage.resourceListHint')}
</div>
<div className="mt-4 space-y-3">
{loading ? (
<div className="text-sm text-gray-500">{t('openClawResourcesPage.loadingResources')}</div>
) : filteredResources.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-500">
{t('openClawResourcesPage.noResources')}
</div>
) : (
filteredResources.map((item) => (
<button
key={item.id}
type="button"
onClick={() => openResourceEditor(item)}
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${
selectedResourceId === item.id ? 'border-indigo-300 bg-indigo-50' : 'border-gray-200 bg-white hover:border-gray-300'
}`}
{resourceType === 'skill' ? (
<>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<label
htmlFor="skill-archive-upload"
className="flex w-full cursor-pointer items-center gap-3 rounded-2xl border border-[#dfd6cf] bg-white px-4 py-3 text-sm text-[#3f3a36] transition hover:border-[#cfc3ba] hover:bg-[#fcfaf8]"
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-gray-900">{item.name}</div>
<div className="mt-1 text-xs text-gray-500">{item.resource_key}</div>
{item.description && <div className="mt-2 line-clamp-2 text-sm text-gray-600">{item.description}</div>}
</div>
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${item.enabled ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{item.enabled ? t('openClawResourcesPage.enabled') : t('openClawResourcesPage.disabled')}
</span>
</div>
<span className="rounded-xl border border-[#d8d0ca] bg-[#f6f3f0] px-3 py-2 text-sm font-medium text-[#2f2a27]">
{t('openClawResourcesPage.skillActions.chooseFile')}
</span>
<span className="min-w-0 truncate text-[#6d655f]">
{skillUploadFile?.name || t('openClawResourcesPage.noFileSelected')}
</span>
<input
id="skill-archive-upload"
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
onChange={(e) => setSkillUploadFile(e.target.files?.[0] || null)}
className="hidden"
/>
</label>
<button
type="button"
onClick={uploadSkillArchive}
disabled={!skillUploadFile || saving}
className="app-button-primary whitespace-nowrap disabled:cursor-not-allowed disabled:opacity-50"
>
{t('openClawResourcesPage.skillActions.uploadArchive')}
</button>
))
)}
</div>
</div>
<div className="mt-3 text-sm text-gray-500">
{t('openClawResourcesPage.skillUploadHint')}
</div>
<div className="mt-4 space-y-3">
{loading ? (
<div className="text-sm text-gray-500">{t('openClawResourcesPage.loadingSkills')}</div>
) : filteredSkills.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-500">
{t('openClawResourcesPage.noSkills')}
</div>
) : (
paginatedSkills.map((item) => (
<div
key={item.id}
className="w-full rounded-2xl border border-gray-200 bg-white px-4 py-4 text-left"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="font-medium text-gray-900">{item.name}</div>
<div className="mt-1 text-xs text-gray-500">
{t('openClawResourcesPage.skillMeta', {
key: item.skill_key,
risk: getSkillRiskLabel(item.risk_level),
count: item.instance_count,
})}
</div>
{item.description && <div className="mt-2 line-clamp-2 text-sm text-gray-600">{item.description}</div>}
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => downloadSkillAsset(item.id, item.name)} className="app-button-secondary">{t('openClawResourcesPage.skillActions.download')}</button>
<button type="button" onClick={() => removeSkillAsset(item.id)} className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100">{t('common.delete')}</button>
</div>
</div>
</div>
))
)}
</div>
{!loading && filteredSkills.length > 0 ? (
<div className="mt-4 flex flex-col gap-3 border-t border-[#f3e7df] pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-[#8f8681]">
{t('openClawResourcesPage.paginationSummary', {
pageSize: CONFIG_CENTER_PAGE_SIZE,
from: (currentResourcePage - 1) * CONFIG_CENTER_PAGE_SIZE + 1,
to: Math.min(currentResourcePage * CONFIG_CENTER_PAGE_SIZE, filteredSkills.length),
})}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setResourcePage((current) => Math.max(1, current - 1))}
disabled={currentResourcePage <= 1}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t('openClawResourcesPage.paginationPrev')}
</button>
<div className="min-w-[88px] text-center text-sm font-medium text-[#5f5957]">
{currentResourcePage} / {resourcePageTotal}
</div>
<button
type="button"
onClick={() => setResourcePage((current) => Math.min(resourcePageTotal, current + 1))}
disabled={currentResourcePage >= resourcePageTotal}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t('openClawResourcesPage.paginationNext')}
</button>
</div>
</div>
) : null}
</>
) : (
<>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<input
value={resourceSearch}
onChange={(e) => setResourceSearch(e.target.value)}
placeholder={t('openClawResourcesPage.searchPlaceholder')}
className="app-input w-full"
/>
<button type="button" onClick={openNewResourceEditor} className="app-button-primary whitespace-nowrap">
{t('openClawResourcesPage.actions.new')}
</button>
</div>
<div className="mt-3 text-sm text-gray-500">
{t('openClawResourcesPage.resourceListHint')}
</div>
<div className="mt-4 space-y-3">
{loading ? (
<div className="text-sm text-gray-500">{t('openClawResourcesPage.loadingResources')}</div>
) : filteredResources.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-300 px-4 py-8 text-sm text-gray-500">
{t('openClawResourcesPage.noResources')}
</div>
) : (
paginatedResources.map((item) => (
<button
key={item.id}
type="button"
onClick={() => openResourceEditor(item)}
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${
selectedResourceId === item.id ? 'border-indigo-300 bg-indigo-50' : 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-gray-900">{item.name}</div>
<div className="mt-1 text-xs text-gray-500">{item.resource_key}</div>
{item.description && <div className="mt-2 line-clamp-2 text-sm text-gray-600">{item.description}</div>}
</div>
<span className={`rounded-full px-2.5 py-1 text-xs font-medium ${item.enabled ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{item.enabled ? t('openClawResourcesPage.enabled') : t('openClawResourcesPage.disabled')}
</span>
</div>
</button>
))
)}
</div>
{!loading && filteredResources.length > 0 ? (
<div className="mt-4 flex flex-col gap-3 border-t border-[#f3e7df] pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm text-[#8f8681]">
{t('openClawResourcesPage.paginationSummary', {
pageSize: CONFIG_CENTER_PAGE_SIZE,
from: (currentResourcePage - 1) * CONFIG_CENTER_PAGE_SIZE + 1,
to: Math.min(currentResourcePage * CONFIG_CENTER_PAGE_SIZE, filteredResources.length),
})}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setResourcePage((current) => Math.max(1, current - 1))}
disabled={currentResourcePage <= 1}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t('openClawResourcesPage.paginationPrev')}
</button>
<div className="min-w-[88px] text-center text-sm font-medium text-[#5f5957]">
{currentResourcePage} / {resourcePageTotal}
</div>
<button
type="button"
onClick={() => setResourcePage((current) => Math.min(resourcePageTotal, current + 1))}
disabled={currentResourcePage >= resourcePageTotal}
className="app-button-secondary disabled:cursor-not-allowed disabled:opacity-50"
>
{t('openClawResourcesPage.paginationNext')}
</button>
</div>
</div>
) : null}
</>
)}
</div>
) : (
<div className="app-panel flex min-h-[420px] items-center justify-center p-6 sm:p-8">
+35
View File
@@ -17,6 +17,9 @@ import InstanceManagementPage from '../pages/admin/InstanceManagementPage';
import AIGatewayPage from '../pages/admin/AIGatewayPage';
import AIAuditPage from '../pages/admin/AIAuditPage';
import CostsPage from '../pages/admin/CostsPage';
import AdminSecurityDashboardPage from '../pages/admin/security/AdminSecurityDashboardPage';
import AdminSecurityReportsPage from '../pages/admin/security/AdminSecurityReportsPage';
import AdminSecurityScannerConfigPage from '../pages/admin/security/AdminSecurityScannerConfigPage';
import RiskRulesPage from '../pages/admin/RiskRulesPage';
import ModelManagementPage from '../pages/admin/ModelManagementPage';
import SystemSettingsPage from '../pages/admin/SystemSettingsPage';
@@ -222,6 +225,38 @@ function AppRoutes() {
</AdminRoute>
}
/>
<Route
path="/admin/security"
element={
<AdminRoute>
<AdminSecurityDashboardPage />
</AdminRoute>
}
/>
<Route
path="/admin/security/reports"
element={
<AdminRoute>
<AdminSecurityReportsPage />
</AdminRoute>
}
/>
<Route
path="/admin/security/scanner"
element={
<AdminRoute>
<AdminSecurityScannerConfigPage />
</AdminRoute>
}
/>
<Route
path="/admin/assets"
element={<Navigate to="/admin/security" replace />}
/>
<Route
path="/admin/skills"
element={<Navigate to="/admin/security" replace />}
/>
<Route
path="/admin/ai-gateway"
element={
+203
View File
@@ -1,5 +1,178 @@
import api from './api';
export interface AdminSkillRecord {
id: number;
external_skill_id: string;
user_id: number;
skill_key: string;
name: string;
status: string;
source_type: string;
risk_level: string;
scan_status: string;
instance_count: number;
risk_reason?: string;
top_findings?: SkillFinding[];
last_scanned_at?: string;
updated_at: string;
}
export interface SkillFinding {
analyzer: string;
severity: string;
category: string;
rule_id: string;
title: string;
description: string;
file_path?: string;
line_number?: number;
remediation: string;
snippet?: string;
}
export interface SecurityScanConfig {
active_mode: string;
default_mode: string;
quick_analyzers: string[];
deep_analyzers: string[];
quick_timeout_seconds: number;
deep_timeout_seconds: number;
allow_fallback: boolean;
scanner_status: {
connected: boolean;
llm_enabled: boolean;
status_label: string;
available_capabilities: string[];
};
skill_scanner_config: {
namespace: string;
deployment_name: string;
llm_api_key: string;
llm_model: string;
llm_base_url: string;
meta_llm_api_key: string;
meta_llm_model: string;
meta_llm_base_url: string;
};
}
export interface SecurityScanJobItem {
id: number;
asset_type: string;
asset_id: number;
asset_name: string;
status: string;
progress_pct: number;
risk_level?: string;
summary?: string;
scan_result_id?: number;
cached_result: boolean;
triggered_analyzers?: string[];
findings?: SkillFinding[];
error_message?: string;
started_at?: string;
finished_at?: string;
}
export interface SecurityScanReport {
job_id: number;
asset_type: string;
scan_mode: string;
scan_scope: string;
status: string;
started_at?: string;
finished_at?: string;
total_items: number;
completed_items: number;
failed_items: number;
risk_counts: Record<string, number>;
findings_summary: Array<{ asset_name: string; summary: string }>;
configured_analyzers: string[];
available_analyzers: string[];
triggered_analyzers: string[];
items: SecurityScanJobItem[];
config: SecurityScanConfig;
}
export interface SecurityScanJob {
id: number;
asset_type: string;
scan_mode: string;
scan_scope: string;
status: string;
requested_by?: number;
total_items: number;
completed_items: number;
failed_items: number;
current_item_name?: string;
progress_pct: number;
started_at?: string;
finished_at?: string;
created_at: string;
updated_at: string;
items?: SecurityScanJobItem[];
report?: SecurityScanReport;
}
function normalizeSecurityScanConfig(config: any): SecurityScanConfig {
return {
active_mode: typeof config?.active_mode === 'string' ? config.active_mode : typeof config?.default_mode === 'string' ? config.default_mode : 'quick',
default_mode: typeof config?.default_mode === 'string' ? config.default_mode : 'quick',
quick_analyzers: Array.isArray(config?.quick_analyzers) ? config.quick_analyzers : [],
deep_analyzers: Array.isArray(config?.deep_analyzers) ? config.deep_analyzers : [],
quick_timeout_seconds: typeof config?.quick_timeout_seconds === 'number' ? config.quick_timeout_seconds : 30,
deep_timeout_seconds: typeof config?.deep_timeout_seconds === 'number' ? config.deep_timeout_seconds : 120,
allow_fallback: Boolean(config?.allow_fallback),
scanner_status: {
connected: Boolean(config?.scanner_status?.connected),
llm_enabled: Boolean(config?.scanner_status?.llm_enabled),
status_label: typeof config?.scanner_status?.status_label === 'string' ? config.scanner_status.status_label : '未启用',
available_capabilities: Array.isArray(config?.scanner_status?.available_capabilities) ? config.scanner_status.available_capabilities : [],
},
skill_scanner_config: {
namespace: typeof config?.skill_scanner_config?.namespace === 'string' ? config.skill_scanner_config.namespace : '',
deployment_name: typeof config?.skill_scanner_config?.deployment_name === 'string' ? config.skill_scanner_config.deployment_name : '',
llm_api_key: typeof config?.skill_scanner_config?.llm_api_key === 'string' ? config.skill_scanner_config.llm_api_key : '',
llm_model: typeof config?.skill_scanner_config?.llm_model === 'string' ? config.skill_scanner_config.llm_model : '',
llm_base_url: typeof config?.skill_scanner_config?.llm_base_url === 'string' ? config.skill_scanner_config.llm_base_url : '',
meta_llm_api_key: typeof config?.skill_scanner_config?.meta_llm_api_key === 'string' ? config.skill_scanner_config.meta_llm_api_key : '',
meta_llm_model: typeof config?.skill_scanner_config?.meta_llm_model === 'string' ? config.skill_scanner_config.meta_llm_model : '',
meta_llm_base_url: typeof config?.skill_scanner_config?.meta_llm_base_url === 'string' ? config.skill_scanner_config.meta_llm_base_url : '',
},
};
}
function normalizeSecurityScanJobItem(item: any): SecurityScanJobItem {
return {
...item,
triggered_analyzers: Array.isArray(item?.triggered_analyzers) ? item.triggered_analyzers : [],
findings: Array.isArray(item?.findings) ? item.findings : [],
};
}
function normalizeSecurityScanReport(report: any): SecurityScanReport {
return {
...report,
scan_scope: typeof report?.scan_scope === 'string' ? report.scan_scope : 'incremental',
risk_counts: report?.risk_counts ?? {},
findings_summary: Array.isArray(report?.findings_summary) ? report.findings_summary : [],
configured_analyzers: Array.isArray(report?.configured_analyzers) ? report.configured_analyzers : [],
available_analyzers: Array.isArray(report?.available_analyzers) ? report.available_analyzers : [],
triggered_analyzers: Array.isArray(report?.triggered_analyzers) ? report.triggered_analyzers : [],
items: Array.isArray(report?.items) ? report.items.map(normalizeSecurityScanJobItem) : [],
config: normalizeSecurityScanConfig(report?.config),
};
}
function normalizeSecurityScanJob(job: any): SecurityScanJob {
return {
...job,
scan_scope: typeof job?.scan_scope === 'string' ? job.scan_scope : 'incremental',
items: Array.isArray(job?.items) ? job.items.map(normalizeSecurityScanJobItem) : undefined,
report: job?.report ? normalizeSecurityScanReport(job.report) : undefined,
};
}
export interface ResourceSummary {
capacity: number;
allocatable: number;
@@ -219,6 +392,36 @@ export interface CostOverview {
}
export const adminService = {
listSkills: async (): Promise<AdminSkillRecord[]> => {
const response = await api.get('/admin/skills');
return response.data.data ?? [];
},
getSecurityConfig: async (): Promise<SecurityScanConfig> => {
const response = await api.get('/admin/security/config');
return normalizeSecurityScanConfig(response.data.data);
},
saveSecurityConfig: async (payload: SecurityScanConfig): Promise<SecurityScanConfig> => {
const response = await api.put('/admin/security/config', payload);
return normalizeSecurityScanConfig(response.data.data);
},
startSecurityScan: async (payload: { asset_type: string; scan_scope?: string; scan_mode?: string; asset_id?: number }): Promise<SecurityScanJob> => {
const response = await api.post('/admin/security/scan-jobs', payload);
return normalizeSecurityScanJob(response.data.data);
},
listSecurityScanJobs: async (limit: number = 20): Promise<SecurityScanJob[]> => {
const response = await api.get('/admin/security/scan-jobs', { params: { limit } });
return Array.isArray(response.data.data) ? response.data.data.map(normalizeSecurityScanJob) : [];
},
getSecurityScanJob: async (jobId: number): Promise<SecurityScanJob> => {
const response = await api.get(`/admin/security/scan-jobs/${jobId}`);
return normalizeSecurityScanJob(response.data.data);
},
getClusterResources: async (): Promise<ClusterResourceOverview> => {
const response = await api.get('/system-settings/cluster-resources');
return response.data.data;
+6
View File
@@ -8,6 +8,7 @@ import type {
InstanceRuntimeDetails,
InstanceConfigRevision,
} from "../types/instance";
import type { InstanceSkill } from "../types/skill";
export const instanceService = {
// Get instance list
@@ -153,4 +154,9 @@ export const instanceService = {
},
});
},
listSkills: async (id: number): Promise<InstanceSkill[]> => {
const response = await api.get(`/instances/${id}/skills`);
return response.data.data;
},
};
+58
View File
@@ -0,0 +1,58 @@
import api from './api';
import type { InstanceSkill, Skill, SkillScanResult, SkillVersion } from '../types/skill';
export const skillService = {
listSkills: async (): Promise<Skill[]> => {
const response = await api.get('/skills');
return response.data.data;
},
importSkills: async (file: File): Promise<Skill[]> => {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/skills/import', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data.data;
},
updateSkill: async (id: number, payload: Pick<Skill, 'name' | 'description' | 'status'>): Promise<Skill> => {
const response = await api.put(`/skills/${id}`, payload);
return response.data.data;
},
deleteSkill: async (id: number): Promise<void> => {
await api.delete(`/skills/${id}`);
},
downloadSkill: async (id: number): Promise<Blob> => {
const response = await api.get(`/skills/${id}/download`, { responseType: 'blob' });
return response.data;
},
listVersions: async (id: number): Promise<SkillVersion[]> => {
const response = await api.get(`/skills/${id}/versions`);
return response.data.data;
},
listScanResults: async (id: number): Promise<SkillScanResult[]> => {
const response = await api.get(`/skills/${id}/scan-results`);
return response.data.data;
},
listInstanceSkills: async (instanceId: number): Promise<InstanceSkill[]> => {
const response = await api.get(`/instances/${instanceId}/skills`);
return response.data.data;
},
attachSkillToInstance: async (instanceId: number, skillId: number): Promise<InstanceSkill> => {
const response = await api.post(`/instances/${instanceId}/skills`, { skill_id: skillId });
return response.data.data;
},
removeSkillFromInstance: async (instanceId: number, skillId: number): Promise<void> => {
await api.delete(`/instances/${instanceId}/skills/${skillId}`);
},
};
+3
View File
@@ -1,4 +1,5 @@
import type { OpenClawConfigPlan } from "./openclawConfig";
import type { InstanceSkill } from "./skill";
export interface Instance {
id: number;
@@ -88,6 +89,7 @@ export interface InstanceRuntimeDetails {
runtime?: RuntimeStatus;
agent?: AgentInfo;
commands: InstanceRuntimeCommand[];
skills?: InstanceSkill[];
}
export interface InstanceConfigRevision {
@@ -119,6 +121,7 @@ export interface CreateInstanceRequest {
image_tag?: string;
storage_class?: string;
openclaw_config_plan?: OpenClawConfigPlan;
skill_ids?: number[];
}
export interface UpdateInstanceRequest {
+57
View File
@@ -0,0 +1,57 @@
export interface Skill {
id: number;
user_id: number;
skill_key: string;
name: string;
description?: string;
status: string;
source_type: string;
risk_level: string;
last_scanned_at?: string;
current_version_id?: number;
current_version_no?: number;
content_hash?: string;
archive_hash?: string;
instance_count: number;
created_at: string;
updated_at: string;
}
export interface SkillVersion {
id: number;
skill_id: number;
blob_id: number;
version_no: number;
source_type: string;
content_hash: string;
archive_hash: string;
object_key: string;
file_name: string;
risk_level: string;
created_at: string;
}
export interface SkillScanResult {
id: number;
blob_id: number;
engine: string;
risk_level: string;
status: string;
summary?: string;
findings?: Record<string, unknown>;
scanned_at?: string;
}
export interface InstanceSkill {
id: number;
instance_id: number;
skill_id: number;
skill_version_id?: number;
source_type: string;
install_path?: string;
observed_hash?: string;
status: string;
last_seen_at?: string;
removed_at?: string;
skill?: Skill;
}