diff --git a/api/openapispec/docs.go b/api/openapispec/docs.go index ba3edcc8..edb71689 100644 --- a/api/openapispec/docs.go +++ b/api/openapispec/docs.go @@ -376,6 +376,70 @@ var doc = `{ } } }, + "/insight/issue/interpret/stream": { + "post": { + "description": "This endpoint analyzes scanner issues using AI to provide detailed interpretation and insights", + "consumes": [ + "application/json" + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "insight" + ], + "summary": "Interpret scanner issues using AI", + "parameters": [ + { + "description": "The audit data to interpret", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/scanner.InterpretRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ai.InterpretEvent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, "/insight/yaml/interpret/stream": { "post": { "description": "This endpoint analyzes YAML content using AI to provide detailed interpretation and insights", @@ -413,6 +477,24 @@ var doc = `{ "type": "string" } }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "type": "string" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -969,7 +1051,7 @@ var doc = `{ "200": { "description": "Audit results", "schema": { - "$ref": "#/definitions/scanner.AuditData" + "$ref": "#/definitions/ai.AuditData" } }, "400": { @@ -2067,6 +2149,29 @@ var doc = `{ } } }, + "ai.AuditData": { + "type": "object", + "properties": { + "bySeverity": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "issueGroups": { + "type": "array", + "items": { + "$ref": "#/definitions/ai.IssueGroup" + } + }, + "issueTotal": { + "type": "integer" + }, + "resourceTotal": { + "type": "integer" + } + } + }, "ai.DiagnosisEvent": { "type": "object", "properties": { @@ -2116,6 +2221,20 @@ var doc = `{ } } }, + "ai.IssueGroup": { + "type": "object", + "properties": { + "issue": { + "$ref": "#/definitions/scanner.Issue" + }, + "resourceGroups": { + "type": "array", + "items": { + "$ref": "#/definitions/entity.ResourceGroup" + } + } + } + }, "cluster.ClusterPayload": { "type": "object", "properties": { @@ -2296,26 +2415,16 @@ var doc = `{ } } }, - "scanner.AuditData": { + "scanner.InterpretRequest": { "type": "object", "properties": { - "bySeverity": { - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "issueGroups": { - "type": "array", - "items": { - "$ref": "#/definitions/scanner.IssueGroup" - } - }, - "issueTotal": { - "type": "integer" + "auditData": { + "description": "The audit data to interpret", + "$ref": "#/definitions/ai.AuditData" }, - "resourceTotal": { - "type": "integer" + "language": { + "description": "Language for interpretation", + "type": "string" } } }, @@ -2340,20 +2449,6 @@ var doc = `{ } } }, - "scanner.IssueGroup": { - "type": "object", - "properties": { - "issue": { - "$ref": "#/definitions/scanner.Issue" - }, - "resourceGroups": { - "type": "array", - "items": { - "$ref": "#/definitions/entity.ResourceGroup" - } - } - } - }, "unstructured.Unstructured": { "type": "object", "properties": { diff --git a/api/openapispec/swagger.json b/api/openapispec/swagger.json index 7ce54ade..5262536c 100644 --- a/api/openapispec/swagger.json +++ b/api/openapispec/swagger.json @@ -360,6 +360,70 @@ } } }, + "/insight/issue/interpret/stream": { + "post": { + "description": "This endpoint analyzes scanner issues using AI to provide detailed interpretation and insights", + "consumes": [ + "application/json" + ], + "produces": [ + "text/event-stream" + ], + "tags": [ + "insight" + ], + "summary": "Interpret scanner issues using AI", + "parameters": [ + { + "description": "The audit data to interpret", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/scanner.InterpretRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/ai.InterpretEvent" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "string" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "type": "string" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "string" + } + } + } + } + }, "/insight/yaml/interpret/stream": { "post": { "description": "This endpoint analyzes YAML content using AI to provide detailed interpretation and insights", @@ -397,6 +461,24 @@ "type": "string" } }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "string" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "type": "string" + } + }, "500": { "description": "Internal Server Error", "schema": { @@ -953,7 +1035,7 @@ "200": { "description": "Audit results", "schema": { - "$ref": "#/definitions/scanner.AuditData" + "$ref": "#/definitions/ai.AuditData" } }, "400": { @@ -2051,6 +2133,29 @@ } } }, + "ai.AuditData": { + "type": "object", + "properties": { + "bySeverity": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "issueGroups": { + "type": "array", + "items": { + "$ref": "#/definitions/ai.IssueGroup" + } + }, + "issueTotal": { + "type": "integer" + }, + "resourceTotal": { + "type": "integer" + } + } + }, "ai.DiagnosisEvent": { "type": "object", "properties": { @@ -2100,6 +2205,20 @@ } } }, + "ai.IssueGroup": { + "type": "object", + "properties": { + "issue": { + "$ref": "#/definitions/scanner.Issue" + }, + "resourceGroups": { + "type": "array", + "items": { + "$ref": "#/definitions/entity.ResourceGroup" + } + } + } + }, "cluster.ClusterPayload": { "type": "object", "properties": { @@ -2280,26 +2399,16 @@ } } }, - "scanner.AuditData": { + "scanner.InterpretRequest": { "type": "object", "properties": { - "bySeverity": { - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "issueGroups": { - "type": "array", - "items": { - "$ref": "#/definitions/scanner.IssueGroup" - } - }, - "issueTotal": { - "type": "integer" + "auditData": { + "description": "The audit data to interpret", + "$ref": "#/definitions/ai.AuditData" }, - "resourceTotal": { - "type": "integer" + "language": { + "description": "Language for interpretation", + "type": "string" } } }, @@ -2324,20 +2433,6 @@ } } }, - "scanner.IssueGroup": { - "type": "object", - "properties": { - "issue": { - "$ref": "#/definitions/scanner.Issue" - }, - "resourceGroups": { - "type": "array", - "items": { - "$ref": "#/definitions/entity.ResourceGroup" - } - } - } - }, "unstructured.Unstructured": { "type": "object", "properties": { diff --git a/api/openapispec/swagger.yaml b/api/openapispec/swagger.yaml index b296749a..41e2c8d1 100644 --- a/api/openapispec/swagger.yaml +++ b/api/openapispec/swagger.yaml @@ -27,6 +27,21 @@ definitions: timestamp: type: string type: object + ai.AuditData: + properties: + bySeverity: + additionalProperties: + type: integer + type: object + issueGroups: + items: + $ref: '#/definitions/ai.IssueGroup' + type: array + issueTotal: + type: integer + resourceTotal: + type: integer + type: object ai.DiagnosisEvent: properties: content: @@ -60,6 +75,15 @@ definitions: description: 'Event type: start, chunk, error, complete' type: string type: object + ai.IssueGroup: + properties: + issue: + $ref: '#/definitions/scanner.Issue' + resourceGroups: + items: + $ref: '#/definitions/entity.ResourceGroup' + type: array + type: object cluster.ClusterPayload: properties: description: @@ -189,20 +213,14 @@ definitions: name: type: string type: object - scanner.AuditData: + scanner.InterpretRequest: properties: - bySeverity: - additionalProperties: - type: integer - type: object - issueGroups: - items: - $ref: '#/definitions/scanner.IssueGroup' - type: array - issueTotal: - type: integer - resourceTotal: - type: integer + auditData: + $ref: '#/definitions/ai.AuditData' + description: The audit data to interpret + language: + description: Language for interpretation + type: string type: object scanner.Issue: properties: @@ -221,15 +239,6 @@ definitions: description: Title is a brief summary of the issue. type: string type: object - scanner.IssueGroup: - properties: - issue: - $ref: '#/definitions/scanner.Issue' - resourceGroups: - items: - $ref: '#/definitions/entity.ResourceGroup' - type: array - type: object unstructured.Unstructured: properties: object: @@ -487,6 +496,49 @@ paths: summary: Stream pod logs using Server-Sent Events tags: - insight + /insight/issue/interpret/stream: + post: + consumes: + - application/json + description: This endpoint analyzes scanner issues using AI to provide detailed + interpretation and insights + parameters: + - description: The audit data to interpret + in: body + name: request + required: true + schema: + $ref: '#/definitions/scanner.InterpretRequest' + produces: + - text/event-stream + responses: + "200": + description: OK + schema: + $ref: '#/definitions/ai.InterpretEvent' + "400": + description: Bad Request + schema: + type: string + "401": + description: Unauthorized + schema: + type: string + "404": + description: Not Found + schema: + type: string + "429": + description: Too Many Requests + schema: + type: string + "500": + description: Internal Server Error + schema: + type: string + summary: Interpret scanner issues using AI + tags: + - insight /insight/yaml/interpret/stream: post: consumes: @@ -511,6 +563,18 @@ paths: description: Bad Request schema: type: string + "401": + description: Unauthorized + schema: + type: string + "404": + description: Not Found + schema: + type: string + "429": + description: Too Many Requests + schema: + type: string "500": description: Internal Server Error schema: @@ -883,7 +947,7 @@ paths: "200": description: Audit results schema: - $ref: '#/definitions/scanner.AuditData' + $ref: '#/definitions/ai.AuditData' "400": description: Bad Request schema: diff --git a/docs/api.md b/docs/api.md index 7033582d..b38389af 100644 --- a/docs/api.md +++ b/docs/api.md @@ -77,6 +77,7 @@ Karpor is a brand new Kubernetes visualization tool that focuses on search, insi | GET | /rest-api/v1/insight/topology | [get rest API v1 insight topology](#get-rest-api-v1-insight-topology) | GetTopology returns a topology map for a Kubernetes resource by name, namespace, cluster, apiVersion and kind. | | POST | /insight/aggregator/event/diagnosis/stream | [post insight aggregator event diagnosis stream](#post-insight-aggregator-event-diagnosis-stream) | Diagnose events using AI | | POST | /insight/aggregator/log/diagnosis/stream | [post insight aggregator log diagnosis stream](#post-insight-aggregator-log-diagnosis-stream) | Diagnose pod logs using AI | +| POST | /insight/issue/interpret/stream | [post insight issue interpret stream](#post-insight-issue-interpret-stream) | Interpret scanner issues using AI | | POST | /insight/yaml/interpret/stream | [post insight yaml interpret stream](#post-insight-yaml-interpret-stream) | Interpret YAML using AI | @@ -793,7 +794,7 @@ Status: OK -[ScannerAuditData](#scanner-audit-data) +[AiAuditData](#ai-audit-data) ##### 400 - Bad Request Status: Bad Request @@ -1906,6 +1907,93 @@ Status: Internal Server Error +### Interpret scanner issues using AI (*PostInsightIssueInterpretStream*) + +``` +POST /insight/issue/interpret/stream +``` + +This endpoint analyzes scanner issues using AI to provide detailed interpretation and insights + +#### Consumes + * application/json + +#### Produces + * text/event-stream + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +|------|--------|------|---------|-----------| :------: |---------|-------------| +| request | `body` | [ScannerInterpretRequest](#scanner-interpret-request) | `models.ScannerInterpretRequest` | | ✓ | | The audit data to interpret | + +#### All responses +| Code | Status | Description | Has headers | Schema | +|------|--------|-------------|:-----------:|--------| +| [200](#post-insight-issue-interpret-stream-200) | OK | OK | | [schema](#post-insight-issue-interpret-stream-200-schema) | +| [400](#post-insight-issue-interpret-stream-400) | Bad Request | Bad Request | | [schema](#post-insight-issue-interpret-stream-400-schema) | +| [401](#post-insight-issue-interpret-stream-401) | Unauthorized | Unauthorized | | [schema](#post-insight-issue-interpret-stream-401-schema) | +| [404](#post-insight-issue-interpret-stream-404) | Not Found | Not Found | | [schema](#post-insight-issue-interpret-stream-404-schema) | +| [429](#post-insight-issue-interpret-stream-429) | Too Many Requests | Too Many Requests | | [schema](#post-insight-issue-interpret-stream-429-schema) | +| [500](#post-insight-issue-interpret-stream-500) | Internal Server Error | Internal Server Error | | [schema](#post-insight-issue-interpret-stream-500-schema) | + +#### Responses + + +##### 200 - OK +Status: OK + +###### Schema + + + +[AiInterpretEvent](#ai-interpret-event) + +##### 400 - Bad Request +Status: Bad Request + +###### Schema + + + + + +##### 401 - Unauthorized +Status: Unauthorized + +###### Schema + + + + + +##### 404 - Not Found +Status: Not Found + +###### Schema + + + + + +##### 429 - Too Many Requests +Status: Too Many Requests + +###### Schema + + + + + +##### 500 - Internal Server Error +Status: Internal Server Error + +###### Schema + + + + + ### Interpret YAML using AI (*PostInsightYamlInterpretStream*) ``` @@ -1931,6 +2019,9 @@ This endpoint analyzes YAML content using AI to provide detailed interpretation |------|--------|-------------|:-----------:|--------| | [200](#post-insight-yaml-interpret-stream-200) | OK | OK | | [schema](#post-insight-yaml-interpret-stream-200-schema) | | [400](#post-insight-yaml-interpret-stream-400) | Bad Request | Bad Request | | [schema](#post-insight-yaml-interpret-stream-400-schema) | +| [401](#post-insight-yaml-interpret-stream-401) | Unauthorized | Unauthorized | | [schema](#post-insight-yaml-interpret-stream-401-schema) | +| [404](#post-insight-yaml-interpret-stream-404) | Not Found | Not Found | | [schema](#post-insight-yaml-interpret-stream-404-schema) | +| [429](#post-insight-yaml-interpret-stream-429) | Too Many Requests | Too Many Requests | | [schema](#post-insight-yaml-interpret-stream-429-schema) | | [500](#post-insight-yaml-interpret-stream-500) | Internal Server Error | Internal Server Error | | [schema](#post-insight-yaml-interpret-stream-500-schema) | #### Responses @@ -1954,6 +2045,33 @@ Status: Bad Request +##### 401 - Unauthorized +Status: Unauthorized + +###### Schema + + + + + +##### 404 - Not Found +Status: Not Found + +###### Schema + + + + + +##### 429 - Too Many Requests +Status: Too Many Requests + +###### Schema + + + + + ##### 500 - Internal Server Error Status: Internal Server Error @@ -2556,6 +2674,24 @@ Status: Internal Server Error +### ai.AuditData + + + + + + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +|------|------|---------|:--------:| ------- |-------------|---------| +| bySeverity | map of integer| `map[string]int64` | | | | | +| issueGroups | [][AiIssueGroup](#ai-issue-group)| `[]*AiIssueGroup` | | | | | +| issueTotal | integer| `int64` | | | | | +| resourceTotal | integer| `int64` | | | | | + + + ### ai.DiagnosisEvent @@ -2608,6 +2744,22 @@ Status: Internal Server Error +### ai.IssueGroup + + + + + + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +|------|------|---------|:--------:| ------- |-------------|---------| +| issue | [ScannerIssue](#scanner-issue)| `ScannerIssue` | | | | | +| resourceGroups | [][EntityResourceGroup](#entity-resource-group)| `[]*EntityResourceGroup` | | | | | + + + ### cluster.ClusterPayload @@ -2789,7 +2941,7 @@ of issues across different severity categories. | | -### scanner.AuditData +### scanner.InterpretRequest @@ -2800,10 +2952,8 @@ of issues across different severity categories. | | | Name | Type | Go type | Required | Default | Description | Example | |------|------|---------|:--------:| ------- |-------------|---------| -| bySeverity | map of integer| `map[string]int64` | | | | | -| issueGroups | [][ScannerIssueGroup](#scanner-issue-group)| `[]*ScannerIssueGroup` | | | | | -| issueTotal | integer| `int64` | | | | | -| resourceTotal | integer| `int64` | | | | | +| auditData | [AiAuditData](#ai-audit-data)| `AiAuditData` | | | The audit data to interpret | | +| language | string| `string` | | | Language for interpretation | | @@ -2825,22 +2975,6 @@ of issues across different severity categories. | | -### scanner.IssueGroup - - - - - - -**Properties** - -| Name | Type | Go type | Required | Default | Description | Example | -|------|------|---------|:--------:| ------- |-------------|---------| -| issue | [ScannerIssue](#scanner-issue)| `ScannerIssue` | | | | | -| resourceGroups | [][EntityResourceGroup](#entity-resource-group)| `[]*EntityResourceGroup` | | | | | - - - ### unstructured.Unstructured diff --git a/pkg/core/handler/detail/interpret.go b/pkg/core/handler/detail/interpret.go index aad287f4..27142774 100644 --- a/pkg/core/handler/detail/interpret.go +++ b/pkg/core/handler/detail/interpret.go @@ -19,6 +19,7 @@ import ( "fmt" "net/http" + "github.com/KusionStack/karpor/pkg/core/handler" "github.com/KusionStack/karpor/pkg/core/manager/ai" "github.com/KusionStack/karpor/pkg/util/ctxutil" "k8s.io/apiserver/pkg/server" @@ -40,6 +41,9 @@ type InterpretRequest struct { // @Param request body InterpretRequest true "The YAML content to interpret" // @Success 200 {object} ai.InterpretEvent // @Failure 400 {string} string "Bad Request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 429 {string} string "Too Many Requests" +// @Failure 404 {string} string "Not Found" // @Failure 500 {string} string "Internal Server Error" // @Router /insight/yaml/interpret/stream [post] func InterpretYAML(aiMgr *ai.AIManager, c *server.CompletedConfig) http.HandlerFunc { @@ -48,16 +52,19 @@ func InterpretYAML(aiMgr *ai.AIManager, c *server.CompletedConfig) http.HandlerF ctx := r.Context() logger := ctxutil.GetLogger(ctx) + // Begin the interpretation process, logging the start + logger.Info("Starting YAML interpretation in handler ...") + // Parse request body var req InterpretRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf("invalid request format: %v", err), http.StatusBadRequest) + handler.FailureRender(ctx, w, r, fmt.Errorf("invalid request format: %v", err)) return } // Validate request if req.YAML == "" { - http.Error(w, "YAML content is required", http.StatusBadRequest) + handler.FailureRender(ctx, w, r, fmt.Errorf("YAML content is required")) return } if req.Language == "" { @@ -73,7 +80,7 @@ func InterpretYAML(aiMgr *ai.AIManager, c *server.CompletedConfig) http.HandlerF flusher, ok := w.(http.Flusher) if !ok { - http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + handler.FailureRender(ctx, w, r, fmt.Errorf("streaming unsupported")) return } diff --git a/pkg/core/handler/scanner/interpret.go b/pkg/core/handler/scanner/interpret.go new file mode 100644 index 00000000..2f1610ba --- /dev/null +++ b/pkg/core/handler/scanner/interpret.go @@ -0,0 +1,110 @@ +// Copyright The Karpor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scanner + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/KusionStack/karpor/pkg/core/handler" + "github.com/KusionStack/karpor/pkg/core/manager/ai" + "github.com/KusionStack/karpor/pkg/util/ctxutil" + "k8s.io/apiserver/pkg/server" +) + +// InterpretRequest represents the request body for issue interpretation +type InterpretRequest struct { + AuditData *ai.AuditData `json:"auditData"` // The audit data to interpret + Language string `json:"language"` // Language for interpretation +} + +// InterpretIssues returns an HTTP handler function that performs AI interpretation on scanner issues +// +// @Summary Interpret scanner issues using AI +// @Description This endpoint analyzes scanner issues using AI to provide detailed interpretation and insights +// @Tags insight +// @Accept json +// @Produce text/event-stream +// @Param request body InterpretRequest true "The audit data to interpret" +// @Success 200 {object} ai.InterpretEvent +// @Failure 400 {string} string "Bad Request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 429 {string} string "Too Many Requests" +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal Server Error" +// @Router /insight/issue/interpret/stream [post] +func InterpretIssues(aiMgr *ai.AIManager, c *server.CompletedConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Extract the context and logger from the request + ctx := r.Context() + logger := ctxutil.GetLogger(ctx) + + // Begin the interpretation process, logging the start + logger.Info("Starting issue interpretation in handler ...") + + // Parse request body + var req InterpretRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + handler.FailureRender(ctx, w, r, fmt.Errorf("invalid request format: %v", err)) + return + } + + // Log successful decoding of the request body + logger.Info("Successfully decoded the request body", "auditData", req.AuditData) + + // Validate request + if req.AuditData == nil { + handler.FailureRender(ctx, w, r, fmt.Errorf("audit data is required")) + return + } + if req.Language == "" { + req.Language = "English" // Default to English if language not specified + } + + // Set headers for SSE + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("X-Accel-Buffering", "no") + + flusher, ok := w.(http.Flusher) + if !ok { + handler.FailureRender(ctx, w, r, fmt.Errorf("streaming unsupported")) + return + } + + // Create channel for interpretation events + eventChan := make(chan *ai.InterpretEvent, 10) + go func() { + if err := aiMgr.InterpretIssues(ctx, req.AuditData, req.Language, eventChan); err != nil { + logger.Error(err, "Failed to interpret issues") + // Error will be sent through eventChan + } + }() + + // Stream events to client + for event := range eventChan { + data, err := json.Marshal(event) + if err != nil { + logger.Error(err, "Failed to marshal event") + continue + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + } +} diff --git a/pkg/core/handler/scanner/scanner.go b/pkg/core/handler/scanner/scanner.go index 3621fe04..1ae7328b 100644 --- a/pkg/core/handler/scanner/scanner.go +++ b/pkg/core/handler/scanner/scanner.go @@ -20,8 +20,8 @@ import ( "github.com/KusionStack/karpor/pkg/core/entity" "github.com/KusionStack/karpor/pkg/core/handler" + _ "github.com/KusionStack/karpor/pkg/core/manager/ai" "github.com/KusionStack/karpor/pkg/core/manager/insight" - _ "github.com/KusionStack/karpor/pkg/infra/scanner" "github.com/KusionStack/karpor/pkg/util/ctxutil" ) @@ -31,18 +31,18 @@ import ( // @Description This endpoint audits based on the specified resource group. // @Tags insight // @Produce json -// @Param cluster query string false "The specified cluster name, such as 'example-cluster'" -// @Param apiVersion query string false "The specified apiVersion, such as 'apps/v1'" -// @Param kind query string false "The specified kind, such as 'Deployment'" -// @Param namespace query string false "The specified namespace, such as 'default'" -// @Param name query string false "The specified resource name, such as 'foo'" -// @Param forceNew query bool false "Switch for forced scanning, default is 'false'" -// @Success 200 {object} AuditData "Audit results" -// @Failure 400 {string} string "Bad Request" -// @Failure 401 {string} string "Unauthorized" -// @Failure 429 {string} string "Too Many Requests" -// @Failure 404 {string} string "Not Found" -// @Failure 500 {string} string "Internal Server Error" +// @Param cluster query string false "The specified cluster name, such as 'example-cluster'" +// @Param apiVersion query string false "The specified apiVersion, such as 'apps/v1'" +// @Param kind query string false "The specified kind, such as 'Deployment'" +// @Param namespace query string false "The specified namespace, such as 'default'" +// @Param name query string false "The specified resource name, such as 'foo'" +// @Param forceNew query bool false "Switch for forced scanning, default is 'false'" +// @Success 200 {object} ai.AuditData "Audit results" +// @Failure 400 {string} string "Bad Request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 429 {string} string "Too Many Requests" +// @Failure 404 {string} string "Not Found" +// @Failure 500 {string} string "Internal Server Error" // @Router /rest-api/v1/insight/audit [get] func Audit(insight *insight.InsightManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/core/handler/scanner/types.go b/pkg/core/handler/scanner/types.go deleted file mode 100644 index 2e8f7bc4..00000000 --- a/pkg/core/handler/scanner/types.go +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright The Karpor Authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package scanner - -import ( - "github.com/KusionStack/karpor/pkg/core/entity" - "github.com/KusionStack/karpor/pkg/infra/scanner" -) - -// AuditData represents the aggregated data of scanner issues, including the -// original list of issues and their aggregated count based on title. -type AuditData struct { - IssueTotal int `json:"issueTotal"` - ResourceTotal int `json:"resourceTotal"` - BySeverity map[string]int `json:"bySeverity"` - IssueGroups []*IssueGroup `json:"issueGroups"` -} - -// IssueGroup represents a group of resourceGroups tied to a specific issue. -type IssueGroup struct { - Issue scanner.Issue `json:"issue"` - ResourceGroups []entity.ResourceGroup `json:"resourceGroups"` -} diff --git a/pkg/core/handler/scanner/util.go b/pkg/core/handler/scanner/util.go index 97e09268..e223db06 100644 --- a/pkg/core/handler/scanner/util.go +++ b/pkg/core/handler/scanner/util.go @@ -18,19 +18,20 @@ import ( "sort" "github.com/KusionStack/karpor/pkg/core/entity" + "github.com/KusionStack/karpor/pkg/core/manager/ai" "github.com/KusionStack/karpor/pkg/infra/scanner" ) // convertScanResultToAuditData converts the scanner.ScanResult to an AuditData // structure containing aggregated issue and resource data. -func convertScanResultToAuditData(sr scanner.ScanResult) *AuditData { - issueGroups := make([]*IssueGroup, 0, len(sr.ByIssue())) +func convertScanResultToAuditData(sr scanner.ScanResult) *ai.AuditData { + issueGroups := make([]*ai.IssueGroup, 0, len(sr.ByIssue())) bySeverity := map[string]int{} // Iterate through each issue in the ScanResult and create corresponding // IssueGroup entries. for issue, resources := range sr.ByIssue() { - issueGroup := &IssueGroup{ + issueGroup := &ai.IssueGroup{ Issue: issue, ResourceGroups: []entity.ResourceGroup{}, } @@ -59,7 +60,7 @@ func convertScanResultToAuditData(sr scanner.ScanResult) *AuditData { }) // Construct the AuditData structure. - return &AuditData{ + return &ai.AuditData{ IssueTotal: sr.IssueTotal(), ResourceTotal: len(sr.ByResource()), BySeverity: bySeverity, diff --git a/pkg/core/manager/ai/issue.go b/pkg/core/manager/ai/issue.go new file mode 100644 index 00000000..6bc7d240 --- /dev/null +++ b/pkg/core/manager/ai/issue.go @@ -0,0 +1,134 @@ +// Copyright The Karpor Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ai + +import ( + "context" + "fmt" + "strings" + + "github.com/KusionStack/karpor/pkg/core/entity" + "github.com/KusionStack/karpor/pkg/infra/scanner" +) + +// AuditData represents the aggregated data of scanner issues, including the +// original list of issues and their aggregated count based on title. +type AuditData struct { + IssueTotal int `json:"issueTotal"` + ResourceTotal int `json:"resourceTotal"` + BySeverity map[string]int `json:"bySeverity"` + IssueGroups []*IssueGroup `json:"issueGroups"` +} + +// IssueGroup represents a group of resourceGroups tied to a specific issue. +type IssueGroup struct { + Issue scanner.Issue `json:"issue"` + ResourceGroups []entity.ResourceGroup `json:"resourceGroups"` +} + +// InterpretIssues performs AI interpretation of scanner issues and sends events through the channel +func (a *AIManager) InterpretIssues(ctx context.Context, auditData *AuditData, language string, eventChan chan<- *InterpretEvent) error { + defer close(eventChan) + + // Send start event + eventChan <- &InterpretEvent{Type: "start"} + + // Validate input + if auditData == nil || len(auditData.IssueGroups) == 0 { + eventChan <- &InterpretEvent{ + Type: "error", + Content: "No issues to interpret", + } + return fmt.Errorf("no issues to interpret") + } + + // Build issue summary + var summary strings.Builder + summary.WriteString(fmt.Sprintf("Total Issues: %d\n", auditData.IssueTotal)) + summary.WriteString(fmt.Sprintf("Total Resources: %d\n", auditData.ResourceTotal)) + summary.WriteString("\nSeverity Distribution:\n") + for severity, count := range auditData.BySeverity { + summary.WriteString(fmt.Sprintf("- %s: %d\n", severity, count)) + } + + // Build issue details + summary.WriteString("\nIssue Details:\n") + for _, group := range auditData.IssueGroups { + summary.WriteString("\n## Issue\n") + summary.WriteString(fmt.Sprintf("Title: %s\n", group.Issue.Title)) + summary.WriteString(fmt.Sprintf("Severity: %s\n", group.Issue.Severity)) + summary.WriteString(fmt.Sprintf("Scanner: %s\n", group.Issue.Scanner)) + if group.Issue.Message != "" { + summary.WriteString(fmt.Sprintf("Message: %s\n", group.Issue.Message)) + } + + summary.WriteString("\nAffected Resources:\n") + for _, rg := range group.ResourceGroups { + summary.WriteString(fmt.Sprintf("- %s/%s (%s)\n", rg.Namespace, rg.Name, rg.Kind)) + } + } + + // Build prompt from template + prompt := fmt.Sprintf(ServicePromptMap[IssueInterpretType], language, summary.String()) + + // Get AI service client + if a.client == nil { + eventChan <- &InterpretEvent{ + Type: "error", + Content: "AI service not configured", + } + return fmt.Errorf("AI service not configured") + } + + // Stream completion from AI service + stream, err := a.client.GenerateStream(ctx, prompt) + if err != nil { + eventChan <- &InterpretEvent{ + Type: "error", + Content: fmt.Sprintf("Failed to start AI service: %v", err), + } + return fmt.Errorf("failed to start AI service: %w", err) + } + + // Process stream + var fullContent strings.Builder + for chunk := range stream { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if strings.HasPrefix(chunk, "ERROR:") { + eventChan <- &InterpretEvent{ + Type: "error", + Content: fmt.Sprintf("AI service error: %v", strings.TrimPrefix(chunk, "ERROR: ")), + } + return fmt.Errorf("AI service error: %v", chunk) + } + + fullContent.WriteString(chunk) + eventChan <- &InterpretEvent{ + Type: "chunk", + Content: chunk, + } + } + } + + // Send complete event + eventChan <- &InterpretEvent{ + Type: "complete", + Content: fullContent.String(), + } + return nil +} diff --git a/pkg/core/manager/ai/prompt.go b/pkg/core/manager/ai/prompt.go index cf8f7dda..a731c849 100644 --- a/pkg/core/manager/ai/prompt.go +++ b/pkg/core/manager/ai/prompt.go @@ -30,6 +30,8 @@ const ( EventDiagnosisType PromptType = "event_diagnosis" // YAMLInterpretType represents the prompt type for YAML interpretation YAMLInterpretType PromptType = "yaml_interpret" + // IssueInterpretType represents the prompt type for issue interpretation + IssueInterpretType PromptType = "issue_interpret" ) var ServicePromptMap = map[PromptType]string{ @@ -181,4 +183,20 @@ Note: - Format your response with clear sections using markdown headings (##) and bullet points - Do NOT wrap your entire response in a markdown code block - Use code blocks only for YAML examples or specific configuration snippets`, + + IssueInterpretType: `You are a Kubernetes expert specialized in analyzing security issues and providing solutions. +Please analyze the following issues and provide your insights in %s. + +Issues Summary: +%s + +Please provide a concise analysis focusing on: +1. Brief summary of the most critical issues (1-2 sentences) +2. Detailed solutions with specific examples, including: + - Exact code or configuration changes needed + - Before and after examples + - Common pitfalls to avoid +3. Best practices and preventive measures + +Note: Format your response with clear sections using markdown headings (##) and bullet points. Do NOT wrap your entire response in a markdown code block.`, } diff --git a/pkg/core/route/route.go b/pkg/core/route/route.go index e4766897..dc79aaec 100644 --- a/pkg/core/route/route.go +++ b/pkg/core/route/route.go @@ -176,6 +176,7 @@ func setupRestAPIV1( r.Post("/aggregator/log/diagnosis/stream", aggregatorhandler.DiagnosePodLogs(aiMgr, genericConfig)) r.Post("/aggregator/event/diagnosis/stream", aggregatorhandler.DiagnoseEvents(aiMgr, genericConfig)) r.Post("/yaml/interpret/stream", detailhandler.InterpretYAML(aiMgr, genericConfig)) + r.Post("/issue/interpret/stream", scannerhandler.InterpretIssues(aiMgr, genericConfig)) }) r.Route("/resource-group-rule", func(r chi.Router) { diff --git a/ui/src/components/yaml/index.tsx b/ui/src/components/yaml/index.tsx index af332ba2..7b15650f 100644 --- a/ui/src/components/yaml/index.tsx +++ b/ui/src/components/yaml/index.tsx @@ -401,13 +401,6 @@ const Yaml = (props: IProps) => { clear: 'both', }} /> -
= 0 ? styles.yaml_content_streamingIndicatorFixed : ''}`} - > - - - -
) : interpretStatus === 'error' ? ( { {interpret} )} + {interpretStatus === 'streaming' && interpret && ( +
= 0 ? styles.yaml_content_streamingIndicatorFixed : ''}`} + > + + + +
+ )} )} diff --git a/ui/src/components/yaml/styles.module.less b/ui/src/components/yaml/styles.module.less index 47d2b6ab..47676b2c 100644 --- a/ui/src/components/yaml/styles.module.less +++ b/ui/src/components/yaml/styles.module.less @@ -204,7 +204,6 @@ } } - .yaml_content_diagnosisPanel { box-sizing: border-box; width: 400px; @@ -332,7 +331,6 @@ box-sizing: border-box; border-radius: 0 0 12px 12px; - &::-webkit-scrollbar { width: 8px; height: 8px; @@ -366,34 +364,7 @@ box-sizing: border-box; word-wrap: break-word; - .yaml_content_streamingIndicator { - display: flex; - align-items: center; - gap: 4px; - - .dot { - width: 6px; - height: 6px; - background-color: #4447c3; - border-radius: 50%; - opacity: 0.3; - animation: dotPulse 1.4s infinite; - - &:nth-child(2) { - animation-delay: 0.2s; - } - - &:nth-child(3) { - animation-delay: 0.4s; - } - } - } - .yaml_content_streamingIndicatorFixed { - position: fixed; - bottom: 16px; - left: 16px; - } .yaml_content_diagnosisLoading { height: 100%; @@ -624,10 +595,38 @@ } } - } + .yaml_content_streamingIndicator { + display: flex; + align-items: center; + gap: 4px; + padding: 16px; - } + .dot { + width: 6px; + height: 6px; + background-color: #4447c3; + border-radius: 50%; + opacity: 0.3; + animation: dotPulse 1.4s infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } + } + .yaml_content_streamingIndicatorFixed { + position: sticky; + bottom: 0; + left: 0; + right: 0; + } + } + } } .resizeHandle { diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index 311bc937..a3fb2dc0 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -196,5 +196,30 @@ } }, "Enabled": "Aktiviert", - "Disabled": "Deaktiviert" + "Disabled": "Deaktiviert", + "ExceptionList": { + "NoIssues": "Keine Probleme gefunden", + "NoIssuesFound": "Keine Probleme gefunden", + "InterpretConnectionError": "Diagnoseverbindung fehlgeschlagen", + "FailedToDiagnoseLogs": "Protokolldiagnose fehlgeschlagen", + "Diagnose": "KI-Diagnose", + "InterpretResult": "KI-Diagnoseergebnis", + "StopInterpret": "Diagnose stoppen", + "InterpretInProgress": "Analyse läuft...", + "InterpretFailed": "Diagnose fehlgeschlagen", + "TryAgainLater": "Bitte versuchen Sie es später erneut", + "Rescan": "Erneut scannen", + "Collapse": "Einklappen", + "Expand": "Ausklappen", + "CheckAllIssues": "Alle Probleme prüfen", + "ViewIssueDetail": "Problemdetails anzeigen", + "Occur": "Tritt auf", + "Times": "mal", + "CollectedFrom": "Gesammelt von", + "Tool": "Werkzeug", + "Description": "Beschreibung", + "High": "Hoch", + "Medium": "Mittel", + "Low": "Niedrig" + } } diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index c5eb8fbe..2aed243c 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -200,5 +200,30 @@ } }, "Enabled": "Enabled", - "Disabled": "Disabled" + "Disabled": "Disabled", + "ExceptionList": { + "NoIssues": "No Issues Found", + "NoIssuesFound": "No Issues Found", + "InterpretConnectionError": "Interpret connection failed", + "FailedToInterpretLogs": "Failed to interpret logs", + "Interpret": "AI Interpret", + "InterpretResult": "AI Interpret Result", + "StopInterpret": "Stop Interpret", + "InterpretInProgress": "Analyzing...", + "InterpretFailed": "Interpret Failed", + "TryAgainLater": "Please try again later", + "Rescan": "Re-scan", + "Collapse": "Collapse", + "Expand": "Expand", + "CheckAllIssues": "Check All Issues", + "ViewIssueDetail": "View Issue Detail", + "Occur": "Occurs", + "Times": "times", + "CollectedFrom": "Collected from", + "Tool": "Tool", + "Description": "Description", + "High": "High", + "Medium": "Medium", + "Low": "Low" + } } diff --git a/ui/src/locales/pt.json b/ui/src/locales/pt.json index 9b8be3c4..f8e5524d 100644 --- a/ui/src/locales/pt.json +++ b/ui/src/locales/pt.json @@ -198,5 +198,30 @@ } }, "Enabled": "Ativado", - "Disabled": "Desativado" + "Disabled": "Desativado", + "ExceptionList": { + "NoIssues": "Nenhum problema encontrado", + "NoIssuesFound": "Nenhum problema encontrado", + "InterpretConnectionError": "Falha na conexão do diagnóstico", + "FailedToDiagnoseLogs": "Falha no diagnóstico dos logs", + "Diagnose": "Diagnóstico IA", + "InterpretResult": "Resultado do Diagnóstico IA", + "StopInterpret": "Parar Diagnóstico", + "InterpretInProgress": "Analisando...", + "InterpretFailed": "Falha no diagnóstico", + "TryAgainLater": "Por favor, tente novamente mais tarde", + "Rescan": "Reescanear", + "Collapse": "Recolher", + "Expand": "Expandir", + "CheckAllIssues": "Verificar Todos os Problemas", + "ViewIssueDetail": "Ver Detalhes do Problema", + "Occur": "Ocorre", + "Times": "vezes", + "CollectedFrom": "Coletado de", + "Tool": "Ferramenta", + "Description": "Descrição", + "High": "Alto", + "Medium": "Médio", + "Low": "Baixo" + } } diff --git a/ui/src/locales/zh.json b/ui/src/locales/zh.json index 56aaafef..7cada4ba 100644 --- a/ui/src/locales/zh.json +++ b/ui/src/locales/zh.json @@ -200,5 +200,30 @@ "DefaultTag": "默认标签", "FailedToParsePodDetails": "解析 Pod 详情失败", "Enabled": "已启用", - "Disabled": "未启用" + "Disabled": "未启用", + "ExceptionList": { + "NoIssues": "未发现问题", + "NoIssuesFound": "未发现问题", + "InterpretConnectionError": "诊断连接失败", + "FailedToInterpretLogs": "日志解读失败", + "Interpret": "AI 解读", + "InterpretResult": "AI 解读结果", + "StopInterpret": "停止诊断", + "InterpretInProgress": "分析中...", + "InterpretFailed": "诊断失败", + "TryAgainLater": "请稍后重试", + "Rescan": "重新扫描", + "Collapse": "收起", + "Expand": "展开", + "CheckAllIssues": "查看所有问题", + "ViewIssueDetail": "查看问题详情", + "Occur": "出现", + "Times": "次", + "CollectedFrom": "收集自", + "Tool": "工具", + "Description": "描述", + "High": "高", + "Medium": "中", + "Low": "低" + } } diff --git a/ui/src/pages/insightDetail/components/eventAggregator/index.tsx b/ui/src/pages/insightDetail/components/eventAggregator/index.tsx index f0692610..5b36cb01 100644 --- a/ui/src/pages/insightDetail/components/eventAggregator/index.tsx +++ b/ui/src/pages/insightDetail/components/eventAggregator/index.tsx @@ -425,19 +425,19 @@ const EventAggregator: React.FC = ({ ) : ( <> {diagnosis} - {diagnosisStatus === 'streaming' && ( -
= 0 ? styles.events_content_streamingIndicatorFixed : ''}`} - > - - - -
- )}
)}
+ {diagnosisStatus === 'streaming' && diagnosis && ( +
= 0 ? styles.events_content_streamingIndicatorFixed : ''}`} + > + + + +
+ )} ) diff --git a/ui/src/pages/insightDetail/components/eventAggregator/styles.module.less b/ui/src/pages/insightDetail/components/eventAggregator/styles.module.less index 6d48767c..1c16b9c8 100644 --- a/ui/src/pages/insightDetail/components/eventAggregator/styles.module.less +++ b/ui/src/pages/insightDetail/components/eventAggregator/styles.module.less @@ -247,7 +247,18 @@ flex-direction: column; backdrop-filter: blur(8px); z-index: 10; - background-image: radial-gradient(ellipse 489px 674px at 6px 0px, #fcf8ff 0%, #ebd7ff 100%), radial-gradient(ellipse 587px 672px at 433px 513px, #dae4ffa1 0%, #e1d2e704 100%), radial-gradient(ellipse 346px 396px at 15px 506px, #dae4ffa3 0%, #e1d2e704 100%), radial-gradient(ellipse 583px 668px at 436px 8px, #f8f5ff 0%, #f3e7f904 100%); + background-image: radial-gradient(ellipse 489px 674px at 6px 0px, + #fcf8ff 0%, + #ebd7ff 100%), + radial-gradient(ellipse 587px 672px at 433px 513px, + #dae4ffa1 0%, + #e1d2e704 100%), + radial-gradient(ellipse 346px 396px at 15px 506px, + #dae4ffa3 0%, + #e1d2e704 100%), + radial-gradient(ellipse 583px 668px at 436px 8px, + #f8f5ff 0%, + #f3e7f904 100%); animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); .events_content_diagnosisHeader { @@ -448,34 +459,6 @@ } } - .events_content_streamingIndicator { - display: flex; - align-items: center; - gap: 4px; - - .dot { - width: 6px; - height: 6px; - background-color: #4447c3; - border-radius: 50%; - opacity: 0.3; - animation: dotPulse 1.4s infinite; - - &:nth-child(2) { - animation-delay: 0.2s; - } - - &:nth-child(3) { - animation-delay: 0.4s; - } - } - } - - .events_content_streamingIndicatorFixed { - position: fixed; - bottom: 16px; - left: 16px; - } .events_content_diagnosisLoading { display: flex; @@ -646,13 +629,41 @@ } } - } + .events_content_streamingIndicator { + display: flex; + align-items: center; + gap: 4px; + padding: 16px; + + .dot { + width: 6px; + height: 6px; + background-color: #4447c3; + border-radius: 50%; + opacity: 0.3; + animation: dotPulse 1.4s infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + &:nth-child(3) { + animation-delay: 0.4s; + } + } + } + + .events_content_streamingIndicatorFixed { + position: sticky; + bottom: 0; + left: 0; + right: 0; + } + } } } } - .tag { display: inline-flex; align-items: center; diff --git a/ui/src/pages/insightDetail/components/exceptionList/index.tsx b/ui/src/pages/insightDetail/components/exceptionList/index.tsx index c282699c..ae4beef8 100644 --- a/ui/src/pages/insightDetail/components/exceptionList/index.tsx +++ b/ui/src/pages/insightDetail/components/exceptionList/index.tsx @@ -1,12 +1,23 @@ -import React, { useEffect, useState } from 'react' -import { Button, Empty, Tag } from 'antd' +import React, { useEffect, useState, useCallback, useRef } from 'react' +import { Button, Empty, Tag, Space, Tooltip, Alert, Spin } from 'antd' import { useTranslation } from 'react-i18next' -import { ArrowRightOutlined, UpOutlined, DownOutlined } from '@ant-design/icons' +import { + ArrowRightOutlined, + UpOutlined, + DownOutlined, + PoweroffOutlined, + CloseOutlined, +} from '@ant-design/icons' import Loading from '@/components/loading' import { SEVERITY_MAP } from '@/utils/constants' import ExceptionStat from '../exceptionStat' - +import { useSelector } from 'react-redux' +import axios from 'axios' +import { debounce } from 'lodash' +import Markdown from 'react-markdown' +import aiSummarySvg from '@/assets/ai-summary.svg' import styles from './style.module.less' +import classNames from 'classnames' type IProps = { exceptionList: any @@ -28,9 +39,24 @@ const ExceptionList = ({ const [selectedEventId, setSelectedEventId] = useState() const [top5List, setTop5list] = useState([]) const [currentKey, setCurrentKey] = useState('All') - const { t } = useTranslation() + const { t, i18n } = useTranslation() const [isShowList, setIsShowList] = useState(true) + // AI interpret states + const [interpret, setInterpret] = useState('') + const [interpretStatus, setInterpretStatus] = useState< + 'idle' | 'loading' | 'streaming' | 'complete' | 'error' + >('idle') + const [isStreaming, setStreaming] = useState(false) + const interpretEndRef = useRef(null) + const abortControllerRef = useRef(null) + + const { aiOptions } = useSelector((state: any) => state.globalSlice) + const isAIEnabled = aiOptions?.AIModel && aiOptions?.AIAuthToken + const exceptionRef = useRef(null) + const contentRef = useRef(null) + const interpretBodyRef = useRef(null) + useEffect(() => { setIsShowList( exceptionList?.issueGroups && exceptionList?.issueGroups?.length > 0, @@ -54,120 +80,404 @@ const ExceptionList = ({ setIsShowList(!isShowList) } const iconStyle = { marginLeft: 5, color: '#646566' } - return ( -
-
-
- + + const debouncedInterpret = useCallback( + debounce(async () => { + try { + if (!top5List?.length) { + // message.warning(t('ExceptionList.NoIssues')) + return + } + + // Reset interpret state + setInterpret('') + setInterpretStatus('loading') + setStreaming(true) + + // Cancel any existing SSE connection + if (abortControllerRef.current) { + abortControllerRef.current.abort() + } + + // Create new AbortController for this request + const abortController = new AbortController() + abortControllerRef.current = abortController + + // Create new fetch request for interpret + const url = `${axios.defaults.baseURL}/rest-api/v1/insight/issue/interpret/stream` + + // Send POST request and handle SSE response + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + body: JSON.stringify({ + auditData: { + issueTotal: exceptionList.issueTotal, + resourceTotal: exceptionList.resourceTotal, + bySeverity: exceptionList.bySeverity, + issueGroups: top5List.map(item => ({ + issue: { + scanner: item.issue.scanner, + severity: severityMap[item.issue.severity] ?? 0, + title: item.issue.title, + message: item.issue.message, + }, + resourceGroups: item.resourceGroups, + })), + }, + language: i18n.language, + }), + signal: abortController.signal, + }) + + if (!response.ok) { + throw new Error(response.statusText) + } + + // Create a reader from the response body stream + const reader = response.body?.getReader() + const decoder = new TextDecoder() + + if (!reader) { + throw new Error('No response body') + } + + // Read the stream + const processStream = async () => { + try { + let buffer = '' + let streaming = true + while (streaming) { + const { done, value } = await reader.read() + + if (done) { + streaming = false + setInterpretStatus('complete') + setStreaming(false) + break + } + + // Decode the chunk and add to buffer + buffer += decoder.decode(value, { stream: true }) + + // Process complete events in buffer + const lines = buffer.split('\n\n') + buffer = lines.pop() || '' // Keep the last incomplete event in buffer + + for (const line of lines) { + if (!line.trim()) continue + + try { + const eventData = line.replace('data: ', '') + const diagEvent = JSON.parse(eventData) + + switch (diagEvent.type) { + case 'start': + setInterpretStatus('streaming') + break + case 'chunk': + setInterpret(prev => prev + diagEvent.content) + // Scroll to bottom of interpret + if (interpretEndRef.current) { + const interpretBodyScrollHeight = + interpretBodyRef.current.scrollHeight + interpretBodyRef.current.scrollTo({ + top: interpretBodyScrollHeight, + behavior: 'smooth', + }) + } + break + case 'error': + streaming = false + setInterpretStatus('error') + setStreaming(false) + // message.error(t('ExceptionList.InterpretConnectionError')) + reader.cancel() + return + case 'complete': + streaming = false + setInterpretStatus('complete') + setStreaming(false) + reader.cancel() + return + } + } catch (error) { + console.error('Failed to parse interpret event:', error) + } + } + } + } catch (error) { + if (error.name === 'AbortError') { + console.log('Interpret stream aborted') + } else { + console.error('Error reading stream:', error) + setInterpretStatus('error') + setStreaming(false) + // message.error(t('ExceptionList.InterpretConnectionError')) + } + } + } + + processStream() + } catch (error) { + console.error('Failed to start interpret:', error) + setInterpretStatus('error') + setStreaming(false) + // message.error(t('ExceptionList.FailedToInterpretLogs')) + } + }, 500), + [top5List, t, i18n.language], + ) + + const startInterpret = useCallback(() => { + debouncedInterpret() + }, [debouncedInterpret]) + + const contentToTopHeight = contentRef.current?.getBoundingClientRect()?.top + const dotToTopHeight = interpretEndRef.current?.getBoundingClientRect()?.top + + const renderInterpretWindow = () => { + if (interpretStatus === 'idle') { + return null + } + + return ( +
+
+ +
+ ai summary +
+ {t('ExceptionList.InterpretResult')} +
+ + {isStreaming && ( + +
-
- -
- {isShowList ? ( - - {t('Collapse')} - - +
+
+ {interpretStatus === 'loading' || + (interpretStatus === 'streaming' && !interpret) ? ( +
+ +

{t('ExceptionList.InterpretInProgress')}

+
+ ) : interpretStatus === 'error' ? ( + ) : ( - - {t('Expand')} - - + <> + {interpret} +
+ )}
+ {interpretStatus === 'streaming' && interpret && ( +
= 0 ? styles.streaming_indicatorFixed : ''}`} + > + + + +
+ )}
- {isShowList && ( -
- {auditLoading ? ( -
- + ) + } + + const severityMap = { + SAFE: 0, + LOW: 1, + MEDIUM: 2, + HIGH: 3, + CRITICAL: 5, + } + + return ( +
+
+
+
+ +
+
+ {isAIEnabled && ( + + +
+ {isShowList ? ( + + {t('ExceptionList.Collapse')} + + + ) : ( + + {t('ExceptionList.Expand')} + + + )}
- ) : top5List && top5List?.length > 0 ? ( - <> - {top5List?.map(item => { - const uniqueKey = `${item?.issue?.title}_${item?.issue?.message}_${item?.issue?.scanner}_${item?.issue?.severity}` - return ( -
setSelectedEventId(uniqueKey)} - onMouseOut={() => setSelectedEventId(undefined)} - onClick={() => onItemClick(item)} - > - {selectedEventId === uniqueKey && ( -
- {t('ViewIssueDetail')} -
- )} - -
-
- - {t(SEVERITY_MAP?.[item?.issue?.severity]?.text)} - -
-
-
-
- - {item?.issue?.title} - - - {t('Occur')}  - - {item?.resourceGroups?.length} - -  {t('Times')} - -  | -  {t('CollectedFrom')} -
-
- -  {item?.issue?.scanner} {t('Tool')} +
+
+ {isShowList && ( +
+ {auditLoading ? ( +
+ +
+ ) : top5List && top5List?.length > 0 ? ( +
+
+ {top5List?.map(item => { + const uniqueKey = `${item?.issue?.title}_${item?.issue?.message}_${item?.issue?.scanner}_${item?.issue?.severity}` + return ( +
setSelectedEventId(uniqueKey)} + onMouseOut={() => setSelectedEventId(undefined)} + onClick={() => onItemClick(item)} + > + {selectedEventId === uniqueKey && ( +
+ {t('ExceptionList.ViewIssueDetail')}
-
-
-
- {t('Description')}:  + )} + +
+
+ + {t( + `ExceptionList.${SEVERITY_MAP?.[item?.issue?.severity]?.text}`, + )} +
-
- {item?.issue?.message} +
+
+
+ + {item?.issue?.title} + + + {t('ExceptionList.Occur')}  + + {item?.resourceGroups?.length} + +  {t('ExceptionList.Times')} + +  | +  {t('ExceptionList.CollectedFrom')} +
+
+ +  {item?.issue?.scanner}  + {t('ExceptionList.Tool')} +
+
+
+
+ {t('ExceptionList.Description')}:  +
+
+ {item?.issue?.message} +
+
-
+ ) + })} +
+ + {t('ExceptionList.CheckAllIssues')} + +
- ) - })} -
- - {t('CheckAllIssues')} - - +
- - ) : ( -
- -
- )} -
- )} + ) : ( +
+ +
+ )} +
+ )} +
+ {renderInterpretWindow()}
) } diff --git a/ui/src/pages/insightDetail/components/exceptionList/style.module.less b/ui/src/pages/insightDetail/components/exceptionList/style.module.less index 68d584c1..f5965333 100644 --- a/ui/src/pages/insightDetail/components/exceptionList/style.module.less +++ b/ui/src/pages/insightDetail/components/exceptionList/style.module.less @@ -1,196 +1,760 @@ -.exception { - width: 100%; - padding: 24px; - background: #fff; - border-radius: 8px; - box-sizing: border-box; - - .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; +.exceptionContainer { + display: flex; + gap: 16px; + + .exception { + flex: 1; + padding: 24px; + background: #fff; + border-radius: 8px; + box-sizing: border-box; - .header_left { + .header { display: flex; - font-size: 14px; - font-weight: 400; - line-height: 22px; - color: rgb(0 0 0 / 65%); + justify-content: space-between; align-items: center; + margin-bottom: 10px; - .title { + .header_left { display: flex; - margin-right: 20px; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: rgb(0 0 0 / 65%); align-items: center; + + .title { + display: flex; + margin-right: 20px; + align-items: center; + } + + .num { + padding: 2px 10px; + margin-left: 5px; + font-size: 12px; + background-color: rgb(0 0 0 / 3%); + border-radius: 10px; + transform: scale(0.8); + } + } + + .header_right { + display: flex; + align-items: center; + gap: 8px; + + .header_right_action { + margin-left: 10px; + color: #646566; + cursor: pointer; + } + + &_ai_button { + height: 28px; + width: 28px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + border-radius: 6px; + transition: all 0.2s ease; + + .magic_wand { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 20px; + background: linear-gradient(135deg, #722ed1 0%, #1890ff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + transform: rotate(-15deg); + transition: all 0.3s ease; + filter: drop-shadow(0 0 2px rgba(114, 46, 209, 0.3)); + position: relative; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #722ed1 0%, #1890ff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + opacity: 0.5; + filter: blur(4px); + transform: scale(1.2); + z-index: -1; + } + } + + &:not(:disabled) { + &:hover { + background: rgba(114, 46, 209, 0.08); + + .magic_wand { + transform: rotate(0deg) scale(1.2); + filter: drop-shadow(0 0 4px rgba(114, 46, 209, 0.5)); + + &::before { + opacity: 0.8; + transform: scale(1.4); + filter: blur(6px); + } + } + } + + &:active { + background: rgba(114, 46, 209, 0.12); + + .magic_wand { + transform: rotate(-25deg) scale(0.95); + filter: drop-shadow(0 0 2px rgba(114, 46, 209, 0.3)); + + &::before { + opacity: 0.3; + transform: scale(1.1); + filter: blur(3px); + } + } + } + } + + &[disabled] { + color: rgba(0, 0, 0, 0.25); + background: transparent; + cursor: not-allowed; + } + + &.ant-btn-loading .magic_wand { + opacity: 0; + } + } + } + } + + .body { + .loading_box { + display: flex; + align-items: center; + } + + .item { + box-sizing: border-box; + position: relative; + padding: 10px 8px; + font-size: 14px; + font-weight: 400; + color: rgb(0 10 26 / 45%); + cursor: pointer; + border-bottom: 1px solid #f7f7f7; + + .itme_tip { + position: absolute; + top: -55px; + right: 200px; + padding: 0 10px; + height: 38px; + font-size: 14px; + line-height: 38px; + color: #fff; + text-align: center; + background-color: #000; + border-radius: 8px; + box-shadow: + 0 3px 6px -4px rgb(0 0 0 / 12%), + 0 6px 16px 0 rgb(0 0 0 / 8%), + 0 9px 28px 8px rgb(0 0 0 / 5%); + + &::before { + position: absolute; + bottom: 0; + left: 30px; + width: 0; + height: 0; + margin-bottom: -15px; + border: 10px solid transparent; + border-radius: 8px; + content: ''; + border-top-color: #000; + } + + &::after { + position: absolute; + bottom: 0; + left: 30px; + width: 0; + height: 0; + margin-bottom: -15px; + border: 10px solid transparent; + border-radius: 8px; + content: ''; + border-top-color: #000; + } + } + + .itme_content { + display: flex; + + .left { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + .title { + margin-right: 8px; + font-size: 14px; + font-weight: 500; + color: rgb(0 10 26 / 89%); + } + } + + .right { + display: flex; + flex-direction: column; + flex: 1; + + .right_top { + display: flex; + align-items: center; + + .date, + .times, + .tool { + margin: 0 8px; + font-size: 14px; + font-weight: 400; + color: rgb(0 10 26 / 89%); + } + } + + .right_bottom { + display: flex; + width: 100%; + font-size: 12px; + box-sizing: border-box; + align-items: center; + + .label { + font-weight: 400; + line-height: 24px; + color: rgb(0 10 26 / 45%); + } + + .value { + width: 0; + overflow: hidden; + font-weight: 400; + color: rgb(0 10 26 / 89%); + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + } + } + } } - .num { - padding: 2px 10px; - margin-left: 5px; - font-size: 12px; - background-color: rgb(0 0 0 / 3%); - border-radius: 10px; - transform: scale(0.8); + .item:hover { + box-sizing: border-box; + box-shadow: + 0 1px 2px -2px rgb(0 0 0 / 16%), + 0 3px 6px 0 rgb(0 0 0 / 12%), + 0 5px 12px 4px rgb(0 0 0 / 9%); + border-radius: 8px; + } + + .content_empty { + display: flex; + justify-content: center; + align-items: center; } } - .header_right { + .content_wrapper { display: flex; - align-items: center; + gap: 24px; + } - .header_right_action { - margin-left: 10px; - color: #646566; + .list_container { + width: 100%; + transition: width 0.3s ease; + } + + .footer { + margin-top: 16px; + font-size: 14px; + font-weight: 500; + color: #2f54eb; + + .btn { cursor: pointer; } } } - .body { - .loading_box { + .interpret_panel { + width: 400px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + box-sizing: border-box; + background-image: radial-gradient(ellipse 489px 674px at 6px 0px, + #fcf8ff 0%, + #ebd7ff 100%), + radial-gradient(ellipse 587px 672px at 433px 513px, + #dae4ffa1 0%, + #e1d2e704 100%), + radial-gradient(ellipse 346px 396px at 15px 506px, + #dae4ffa3 0%, + #e1d2e704 100%), + radial-gradient(ellipse 583px 668px at 436px 8px, + #f8f5ff 0%, + #f3e7f904 100%); + + .interpret_header { + padding: 12px; + border-bottom: 1px solid rgba(147, 112, 219, 0.2); display: flex; + justify-content: space-between; align-items: center; - } + border-top-left-radius: 12px; + border-top-right-radius: 12px; + font-size: 16px; + font-weight: 500; + color: #000; + backdrop-filter: blur(4px); - .item { - position: relative; - padding: 10px 8px; - font-size: 14px; - font-weight: 400; - color: rgb(0 10 26 / 45%); - cursor: pointer; - border-bottom: 1px solid #f7f7f7; - - .itme_tip { - position: absolute; - top: -55px; - right: 200px; - width: 100px; - height: 38px; - font-size: 14px; - line-height: 38px; - color: #fff; - text-align: center; - background-color: #000; - border-radius: 8px; - box-shadow: - 0 3px 6px -4px rgb(0 0 0 / 12%), - 0 6px 16px 0 rgb(0 0 0 / 8%), - 0 9px 28px 8px rgb(0 0 0 / 5%); + .interpret_title { + font-weight: 500; + color: #000; + display: flex; + align-items: center; + gap: 8px; + + .interpret_icon { + width: 18px; + height: 18px; + + img { + width: 100%; + height: 100%; + } + } + } + + .interpret_header_stopButton { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + margin-right: 8px; + position: relative; + overflow: hidden; &::before { + content: ''; position: absolute; + top: 0; + left: 0; + right: 0; bottom: 0; - left: 30px; - width: 0; - height: 0; - margin-bottom: -15px; - border: 10px solid transparent; - border-radius: 8px; - content: ''; - border-top-color: #000; + background: rgba(255, 77, 79, 0); + transition: background 0.3s ease; + z-index: 0; } - &::after { - position: absolute; - bottom: 0; - left: 30px; - width: 0; - height: 0; - margin-bottom: -15px; - border: 10px solid transparent; - border-radius: 8px; - content: ''; - border-top-color: #000; + :global(.anticon) { + font-size: 16px; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + z-index: 1; + } + + &:hover { + color: #ff4d4f; + transform: scale(1.05); + + &::before { + background: rgba(255, 77, 79, 0.15); + } + + :global(.anticon) { + transform: rotate(180deg) scale(1.1); + } + } + + &:active { + transform: scale(0.95); + + &::before { + background: rgba(255, 77, 79, 0.25); + } } } - .itme_content { + button { + transition: all 0.3s ease; + border-radius: 6px; + width: 28px; + height: 28px; display: flex; + align-items: center; + justify-content: center; - .left { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + &:hover { + color: #fff; + background: rgba(147, 112, 219, 0.2); + transform: scale(1.05); + } - .title { - margin-right: 8px; - font-size: 14px; - font-weight: 500; - color: rgb(0 10 26 / 89%); - } + &:active { + transform: scale(0.95); } + } + + .interpret_close { + cursor: pointer; + color: #999; + transition: all 0.3s ease; + padding: 4px; + border-radius: 4px; - .right { + &:hover { + color: #000; + background: rgba(0, 0, 0, 0.06); + } + } + } + + .interpret_body { + position: relative; + overflow-y: auto; + height: 100%; + box-sizing: border-box; + border-radius: 0 0 12px 12px; + + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-track { + background: rgba(147, 112, 219, 0.05); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(147, 112, 219, 0.2); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; + + &:hover { + background: rgba(147, 112, 219, 0.3); + border: 2px solid transparent; + background-clip: padding-box; + } + } + + .interpret_content { + color: #000; + border-radius: 0 0 12px 12px; + padding: 20px 16px; + font-size: 14px; + line-height: 1.6; + box-sizing: border-box; + word-wrap: break-word; + + .interpret_loading { display: flex; flex-direction: column; - flex: 1; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; + text-align: center; + color: rgba(230, 230, 250, 0.85); - .right_top { - display: flex; - align-items: center; + :global(.ant-spin) { + .ant-spin-dot-item { + background-color: #9370db; + } + } + + p { + font-size: 14px; + margin-top: 16px; + color: rgba(230, 230, 250, 0.85); + } + } + + .interpret_error { + margin: 16px; + border-radius: 6px; + } + + h1, + h2, + h3 { + color: #000; + margin-bottom: 16px; + font-weight: 600; + letter-spacing: -0.01em; + } + + h1 { + font-size: 20px; + } + + h2 { + font-size: 18px; + margin-top: 24px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(147, 112, 219, 0.2); + } + + h3 { + font-size: 16px; + margin-top: 20px; + } + + p { + margin-bottom: 16px; + line-height: 1.7; + } + + ul, + ol { + padding-left: 24px; + margin-bottom: 16px; + } + + li { + margin-bottom: 8px; + } + + code { + background: rgba(147, 112, 219, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 13px; + } + + pre { + background: #000; + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; + overflow-x: auto; + border: 1px solid rgba(147, 112, 219, 0.1); + + code { + background: none; + padding: 0; + color: #fff; + } + } + + :global { + .markdown-body { + color: #d4d4d4; + background: transparent; + padding: 16px; + + h1, + h2, + h3, + h4, + h5, + h6 { + color: rgba(230, 230, 250, 0.95); + border-bottom: 1px solid rgba(147, 112, 219, 0.2); + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; + } + + h1 { + font-size: 24px; + } - .date, - .times, - .tool { - margin: 0 8px; + h2 { + font-size: 20px; + } + + h3 { + font-size: 18px; + } + + h4 { + font-size: 16px; + } + + h5 { font-size: 14px; - font-weight: 400; - color: rgb(0 10 26 / 89%); } - } - .right_bottom { - display: flex; - width: 100%; - font-size: 12px; - box-sizing: border-box; - align-items: center; + h6 { + font-size: 13px; + } + + pre { + background: rgba(0, 0, 0, 0.4); + border: 1px solid rgba(147, 112, 219, 0.5); + border-radius: 6px; + padding: 12px; + margin: 12px 0; + overflow-x: auto; + + code { + background: transparent; + padding: 0; + color: #f0e6ff; + } + } - .label { - font-weight: 400; - line-height: 24px; - color: rgb(0 10 26 / 45%); + code { + background: rgba(147, 112, 219, 0.2); + padding: 2px 6px; + border-radius: 4px; + color: #f0e6ff; + font-family: 'Menlo', 'Monaco', 'Courier New', monospace; } - .value { - width: 0; + ul, + ol { + padding-left: 24px; + margin: 8px 0; + + li { + margin: 4px 0; + } + } + + blockquote { + color: rgba(230, 230, 250, 0.85); + border-left: 4px solid rgba(147, 112, 219, 0.4); + background: rgba(147, 112, 219, 0.1); + margin: 16px 0; + padding: 12px 16px; + border-radius: 0 8px 8px 0; + } + + a { + color: #9370db; + text-decoration: none; + transition: all 0.2s ease; + border-bottom: 1px solid transparent; + + &:hover { + color: lighten(#9370db, 10%); + border-bottom-color: currentColor; + } + } + + table { + border-collapse: separate; + border-spacing: 0; + width: 100%; + margin: 16px 0; + border-radius: 8px; + border: 1px solid rgba(147, 112, 219, 0.2); overflow: hidden; - font-weight: 400; - color: rgb(0 10 26 / 89%); - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; + + th, + td { + border: 1px solid rgba(147, 112, 219, 0.2); + padding: 12px; + } + + th { + background: rgba(147, 112, 219, 0.1); + font-weight: 600; + text-align: left; + } + + tr { + background-color: transparent; + transition: background-color 0.2s ease; + + &:nth-child(2n) { + background-color: rgba(147, 112, 219, 0.05); + } + + &:hover { + background-color: rgba(147, 112, 219, 0.1); + } + } + } + + hr { + border: none; + height: 1px; + background: linear-gradient(to right, + rgba(147, 112, 219, 0.1), + rgba(147, 112, 219, 0.4), + rgba(147, 112, 219, 0.1)); + margin: 24px 0; } } } } - } - .item:hover { - box-shadow: - 0 1px 2px -2px rgb(0 0 0 / 16%), - 0 3px 6px 0 rgb(0 0 0 / 12%), - 0 5px 12px 4px rgb(0 0 0 / 9%); - border-radius: 8px; - } + .streaming_indicator { + display: flex; + align-items: center; + gap: 4px; + padding: 16px; - .content_empty { - display: flex; - justify-content: center; - align-items: center; + .dot { + width: 6px; + height: 6px; + background-color: #4447c3; + border-radius: 50%; + opacity: 0.3; + animation: dotPulse 1.4s infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } + } + + .streaming_indicatorFixed { + position: sticky; + bottom: 0; + left: 0; + right: 0; + } } } +} - .footer { - margin-top: 16px; - font-size: 14px; - font-weight: 500; - color: #2f54eb; +@keyframes dotPulse { + 0% { + opacity: 0.3; + } - .btn { - cursor: pointer; - } + 50% { + opacity: 1; + } + + 100% { + opacity: 0.3; } } diff --git a/ui/src/pages/insightDetail/components/podLogs/index.tsx b/ui/src/pages/insightDetail/components/podLogs/index.tsx index ce5c45e7..670ee890 100644 --- a/ui/src/pages/insightDetail/components/podLogs/index.tsx +++ b/ui/src/pages/insightDetail/components/podLogs/index.tsx @@ -848,13 +848,6 @@ const PodLogs: React.FC = ({ ) : diagnosisStatus === ('streaming' as DiagnosisStatus) ? (
{diagnosis} -
= 0 ? styles.streamingIndicatorFixed : ''}`} - > - - - -
= ({ {diagnosis} )}
+ {diagnosisStatus === ('streaming' as DiagnosisStatus) && + diagnosis && ( +
= 0 ? styles.streamingIndicatorFixed : ''}`} + > + + + +
+ )}
)} diff --git a/ui/src/pages/insightDetail/components/podLogs/styles.module.less b/ui/src/pages/insightDetail/components/podLogs/styles.module.less index db4d0b19..98312110 100644 --- a/ui/src/pages/insightDetail/components/podLogs/styles.module.less +++ b/ui/src/pages/insightDetail/components/podLogs/styles.module.less @@ -176,8 +176,6 @@ cursor: not-allowed; } - - &:not(:disabled) { &:hover { background: rgba(114, 46, 209, 0.08); @@ -233,7 +231,7 @@ right: 15px; top: 15px; color: #222; - background: rgba(255, 255, 255, .9); + background: rgba(255, 255, 255, 0.9); &:hover { background: #fff; @@ -415,7 +413,6 @@ } } - button { color: rgba(230, 230, 250, 0.85); transition: all 0.3s ease; @@ -479,34 +476,7 @@ box-sizing: border-box; word-wrap: break-word; - .streamingIndicator { - display: flex; - align-items: center; - gap: 4px; - - .dot { - width: 6px; - height: 6px; - background-color: #4447c3; - border-radius: 50%; - opacity: 0.3; - animation: dotPulse 1.4s infinite; - - &:nth-child(2) { - animation-delay: 0.2s; - } - - &:nth-child(3) { - animation-delay: 0.4s; - } - } - } - .streamingIndicatorFixed { - position: fixed; - bottom: 16px; - left: 16px; - } .diagnosisLoading, .diagnosisError { @@ -738,6 +708,37 @@ } } } + + .streamingIndicator { + display: flex; + align-items: center; + gap: 4px; + padding: 16px; + + .dot { + width: 6px; + height: 6px; + background-color: #4447c3; + border-radius: 50%; + opacity: 0.3; + animation: dotPulse 1.4s infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } + } + + .streamingIndicatorFixed { + position: sticky; + bottom: 0; + left: 0; + right: 0; + } } } }