feat(skill-management): add skill management and skill-scanner integration
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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;
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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 = ¤tName
|
||||
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 = ¤tName
|
||||
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
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
+603
-3
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">
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user