diff --git a/pkg/core/handler/aggregator/log.go b/pkg/core/handler/aggregator/log.go new file mode 100644 index 00000000..b6d899d2 --- /dev/null +++ b/pkg/core/handler/aggregator/log.go @@ -0,0 +1,150 @@ +// 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 aggregator + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/KusionStack/karpor/pkg/core/manager/cluster" + "github.com/KusionStack/karpor/pkg/infra/multicluster" + "github.com/KusionStack/karpor/pkg/util/ctxutil" + "github.com/go-chi/chi/v5" + corev1 "k8s.io/api/core/v1" + "k8s.io/apiserver/pkg/server" + "k8s.io/utils/pointer" +) + +// LogEntry represents a single log entry with timestamp and content +type LogEntry struct { + Timestamp string `json:"timestamp"` + Content string `json:"content"` + Error string `json:"error,omitempty"` +} + +// GetPodLogs returns an HTTP handler function that streams Pod logs using Server-Sent Events +// +// @Summary Stream pod logs using Server-Sent Events +// @Description This endpoint streams pod logs in real-time using SSE. It supports container selection and automatic reconnection. +// @Tags insight +// @Produce text/event-stream +// @Param cluster path string true "The cluster name" +// @Param namespace path string true "The namespace name" +// @Param name path string true "The pod name" +// @Param container query string false "The container name (optional if pod has only one container)" +// @Success 200 {object} LogEntry +// @Failure 400 {string} string "Bad Request" +// @Failure 401 {string} string "Unauthorized" +// @Failure 404 {string} string "Not Found" +// @Router /insight/aggregator/pod/{cluster}/{namespace}/{name}/log [get] +func GetPodLogs(clusterMgr *cluster.ClusterManager, c *server.CompletedConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Set SSE headers + 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", "*") + + // Extract the context and logger from the request + ctx := r.Context() + logger := ctxutil.GetLogger(ctx) + + // Get parameters from URL path and query + cluster := chi.URLParam(r, "cluster") + namespace := chi.URLParam(r, "namespace") + name := chi.URLParam(r, "name") + container := r.URL.Query().Get("container") + + if cluster == "" || namespace == "" || name == "" { + writeSSEError(w, "cluster, namespace and name are required") + return + } + + // Build multi-cluster client + client, err := multicluster.BuildMultiClusterClient(ctx, c.LoopbackClientConfig, cluster) + if err != nil { + writeSSEError(w, fmt.Sprintf("failed to build multi-cluster client: %v", err)) + return + } + // Get single cluster clientset + clusterClient := client.ClientSet + + logger.Info("Getting pod logs...", "cluster", cluster, "namespace", namespace, "pod", name, "container", container) + + // Configure log streaming options + opts := &corev1.PodLogOptions{ + Container: container, + Follow: true, + TailLines: pointer.Int64(1000), + } + + // Get log stream from the pod + req := clusterClient.CoreV1().Pods(namespace).GetLogs(name, opts) + stream, err := req.Stream(ctx) + if err != nil { + writeSSEError(w, fmt.Sprintf("failed to get pod logs: %v", err)) + return + } + defer stream.Close() + + // Create a done channel to handle client disconnection + done := r.Context().Done() + go func() { + <-done + stream.Close() + }() + + // Read and send logs + scanner := bufio.NewScanner(stream) + for scanner.Scan() { + select { + case <-done: + return + default: + logEntry := LogEntry{ + Timestamp: time.Now().Format(time.RFC3339Nano), + Content: scanner.Text(), + } + + data, err := json.Marshal(logEntry) + if err != nil { + writeSSEError(w, fmt.Sprintf("failed to marshal log entry: %v", err)) + return + } + + fmt.Fprintf(w, "data: %s\n\n", data) + w.(http.Flusher).Flush() + } + } + + if err := scanner.Err(); err != nil { + writeSSEError(w, fmt.Sprintf("error reading log stream: %v", err)) + } + } +} + +// writeSSEError writes an error message to the SSE stream +func writeSSEError(w http.ResponseWriter, errMsg string) { + logEntry := LogEntry{ + Timestamp: time.Now().Format(time.RFC3339Nano), + Error: errMsg, + } + data, _ := json.Marshal(logEntry) + fmt.Fprintf(w, "data: %s\n\n", data) + w.(http.Flusher).Flush() +} diff --git a/pkg/core/route/route.go b/pkg/core/route/route.go index 4816a100..ee0d6578 100644 --- a/pkg/core/route/route.go +++ b/pkg/core/route/route.go @@ -19,6 +19,7 @@ import ( "expvar" docs "github.com/KusionStack/karpor/api/openapispec" + aggregatorhandler "github.com/KusionStack/karpor/pkg/core/handler/aggregator" authnhandler "github.com/KusionStack/karpor/pkg/core/handler/authn" clusterhandler "github.com/KusionStack/karpor/pkg/core/handler/cluster" detailhandler "github.com/KusionStack/karpor/pkg/core/handler/detail" @@ -170,6 +171,7 @@ func setupRestAPIV1( r.Get("/summary", summaryhandler.GetSummary(insightMgr, genericConfig)) r.Get("/events", eventshandler.GetEvents(insightMgr, genericConfig)) r.Get("/detail", detailhandler.GetDetail(clusterMgr, insightMgr, genericConfig)) + r.Get("/aggregator/pod/{cluster}/{namespace}/{name}/log", aggregatorhandler.GetPodLogs(clusterMgr, genericConfig)) }) r.Route("/resource-group-rule", func(r chi.Router) { diff --git a/ui/package.json b/ui/package.json index 4a0bee63..6b865366 100644 --- a/ui/package.json +++ b/ui/package.json @@ -92,6 +92,7 @@ "@babel/plugin-transform-private-property-in-object": "^7.23.4", "@craco/craco": "^7.1.0", "@craco/types": "^7.1.0", + "@types/js-yaml": "^4.0.9", "@types/react-grid-layout": "^1.3.2", "compression-webpack-plugin": "^11.0.0", "craco-css-modules": "^1.0.5", diff --git a/ui/src/components/layout/style.module.less b/ui/src/components/layout/style.module.less index 4646885f..622ecdc5 100644 --- a/ui/src/components/layout/style.module.less +++ b/ui/src/components/layout/style.module.less @@ -159,32 +159,32 @@ } .github_corner:hover .octo_arm { - animation: octocat-wave 560ms ease-in-out + animation: octocat-wave 560ms ease-in-out; } @keyframes octocat-wave { 0%, 100% { - transform: rotate(0) + transform: rotate(0); } 20%, 60% { - transform: rotate(-25deg) + transform: rotate(-25deg); } 40%, 80% { - transform: rotate(10deg) + transform: rotate(10deg); } } @media (width <=500px) { .github_corner .octo_arm { - animation: octocat-wave 560ms ease-in-out + animation: octocat-wave 560ms ease-in-out; } .github_corner:hover .octo_arm { - animation: none + animation: none; } } diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json index c6dee3a8..19123620 100644 --- a/ui/src/locales/de.json +++ b/ui/src/locales/de.json @@ -28,6 +28,7 @@ "CheckAllIssues": "Alle Fehler prüfen", "ResourceTopology": "Ressourcen Topologie", "KubernetesEvents": "Kubernetes Ereignisse", + "LogAggregator": "Log-Aggregator", "Name": "Name", "Times": "Mal", "FilterByName": "Nach Name filtern", @@ -122,5 +123,12 @@ "InputToken": "Geben Sie bitte den Token ein", "SearchByNaturalLanguage": "Suche mit natürlicher Sprache", "CannotBeEmpty": "Darf nicht leer sein", - "DefaultTag": "Standard-Tag" + "DefaultTag": "Standard-Tag", + "ResumeLogs": "Logs fortsetzen", + "PauseLogs": "Logs pausieren", + "ClearLogs": "Logs löschen", + "Connected": "Verbunden", + "Disconnected": "Getrennt", + "FailedToParsePodDetails": "Pod-Details konnten nicht analysiert werden", + "SelectContainer": "Container auswählen" } diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json index 40b02524..ca116011 100644 --- a/ui/src/locales/en.json +++ b/ui/src/locales/en.json @@ -28,6 +28,7 @@ "CheckAllIssues": "Check All Issues", "ResourceTopology": "Resource Topology", "KubernetesEvents": "Kubernetes Events", + "LogAggregator": "Log", "Name": "Name", "Times": "Times", "FilterByName": "Filter by Name", @@ -122,5 +123,12 @@ "InputToken": "Please Enter the Token", "SearchByNaturalLanguage": "Search By Natural Language", "CannotBeEmpty": "Cannot be empty", - "DefaultTag": "default tag" + "DefaultTag": "default tag", + "SelectContainer": "Select container", + "ResumeLogs": "Resume logs", + "PauseLogs": "Pause logs", + "ClearLogs": "Clear logs", + "Connected": "Connected", + "Disconnected": "Disconnected", + "FailedToParsePodDetails": "Failed to parse pod details" } diff --git a/ui/src/locales/pt.json b/ui/src/locales/pt.json index 67965cc0..6eb94aaf 100644 --- a/ui/src/locales/pt.json +++ b/ui/src/locales/pt.json @@ -28,6 +28,7 @@ "CheckAllIssues": "Verificar Todos os Problemas", "ResourceTopology": "Topologia de Recursos", "KubernetesEvents": "Eventos do Kubernetes", + "LogAggregator": "Agregador de Logs", "Name": "Nome", "Times": "Vezes", "FilterByName": "Filtrado por nome", @@ -122,5 +123,12 @@ "InputToken": "Por favor, insira o token", "SearchByNaturalLanguage": "Procure por linguagem natural", "CannotBeEmpty": "Não pode estar vazio", - "DefaultTag": "Tag padrão" + "DefaultTag": "Tag padrão", + "ResumeLogs": "Retomar logs", + "PauseLogs": "Pausar logs", + "ClearLogs": "Limpar logs", + "Connected": "Conectado", + "Disconnected": "Desconectado", + "FailedToParsePodDetails": "Falha ao analisar detalhes do Pod", + "SelectContainer": "Selecionar container" } diff --git a/ui/src/locales/zh.json b/ui/src/locales/zh.json index bd61e526..7a29e71f 100644 --- a/ui/src/locales/zh.json +++ b/ui/src/locales/zh.json @@ -28,6 +28,7 @@ "CheckAllIssues": "查看全部风险", "ResourceTopology": "资源拓扑", "KubernetesEvents": "Kubernetes 事件", + "LogAggregator": "日志", "Name": "名称", "Times": "次", "FilterByName": "请输入名称", @@ -119,8 +120,15 @@ "LoginSuccess": "登录成功", "Login": "登录", "LogoutSuccess": "登出成功", - "InputToken": "请输入 token", + "InputToken": "请输入 Token", "SearchByNaturalLanguage": "自然语言搜索", "CannotBeEmpty": "不能为空", - "DefaultTag": "默认标签" + "DefaultTag": "默认标签", + "ResumeLogs": "继续日志", + "PauseLogs": "暂停日志", + "ClearLogs": "清除日志", + "Connected": "已连接", + "Disconnected": "已断开", + "FailedToParsePodDetails": "解析 Pod 详情失败", + "SelectContainer": "选择容器" } diff --git a/ui/src/pages/cluster/add/styles.module.less b/ui/src/pages/cluster/add/styles.module.less index e718c258..ed979f9c 100644 --- a/ui/src/pages/cluster/add/styles.module.less +++ b/ui/src/pages/cluster/add/styles.module.less @@ -10,7 +10,7 @@ align-items: center; .page_title { - margin: 0 + margin: 0; } } diff --git a/ui/src/pages/insightDetail/components/podLogs/index.tsx b/ui/src/pages/insightDetail/components/podLogs/index.tsx new file mode 100644 index 00000000..b6c9ed8d --- /dev/null +++ b/ui/src/pages/insightDetail/components/podLogs/index.tsx @@ -0,0 +1,177 @@ +import React, { useEffect, useRef, useState } from 'react' +import { Select, Space, Button, Alert, Badge, Tooltip } from 'antd' +import { + PauseCircleOutlined, + PlayCircleOutlined, + ClearOutlined, +} from '@ant-design/icons' +import { useTranslation } from 'react-i18next' +import yaml from 'js-yaml' +import styles from './styles.module.less' + +interface LogEntry { + timestamp: string + content: string + error?: string +} + +interface PodLogsProps { + cluster: string + namespace: string + podName: string + yamlData?: string +} + +const PodLogs: React.FC = ({ + cluster, + namespace, + podName, + yamlData, +}) => { + const { t } = useTranslation() + const [container, setContainer] = useState('') + const [containers, setContainers] = useState([]) + const [logs, setLogs] = useState([]) + const [isPaused, setPaused] = useState(false) + const [isConnected, setConnected] = useState(false) + const [error, setError] = useState(null) + const eventSourceRef = useRef(null) + const logsEndRef = useRef(null) + + useEffect(() => { + if (yamlData) { + try { + const podSpec = yaml.load(yamlData) as any + + let containerList: string[] = [] + if (podSpec?.spec?.containers) { + containerList = podSpec.spec.containers.map((c: any) => c.name) + } + + setContainers(containerList) + if (containerList.length > 0 && !container) { + setContainer(containerList[0]) + } + } catch (error) { + console.error('Failed to parse pod details:', error) + setError(t('FailedToParsePodDetails')) + } + } + }, [yamlData, container, t]) + + useEffect(() => { + if (!container || isPaused) { + return + } + + // Clean up previous connection + if (eventSourceRef.current) { + eventSourceRef.current.close() + setLogs([]) // Clear logs when switching containers or reconnecting + } + + const url = `/rest-api/v1/insight/aggregator/pod/${cluster}/${namespace}/${podName}/log?container=${container}` + const eventSource = new EventSource(url) + eventSourceRef.current = eventSource + + eventSource.onopen = () => { + setConnected(true) + setError(null) + } + + eventSource.onmessage = event => { + try { + const logEntry: LogEntry = JSON.parse(event.data) + + if (logEntry.error) { + setError(logEntry.error) + return + } + + setLogs(prev => [...prev, logEntry]) + + // Auto-scroll to bottom + if (logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }) + } + } catch (error) { + console.error('Failed to parse log entry:', error) + } + } + + eventSource.onerror = err => { + console.error('EventSource error:', err) + setConnected(false) + // SSE will automatically reconnect, no manual handling needed + } + + return () => { + eventSource.close() + } + }, [cluster, namespace, podName, container, isPaused]) + + const handlePause = () => { + setPaused(!isPaused) + } + + const handleClear = () => { + setLogs([]) + setError(null) + } + + return ( +
+
+ + + +
+ + {error && ( + setError(null)} + /> + )} + +
+ {logs.map((log, index) => ( +
+ {log.content} +
+ ))} +
+
+
+ ) +} + +export default PodLogs diff --git a/ui/src/pages/insightDetail/components/podLogs/styles.module.less b/ui/src/pages/insightDetail/components/podLogs/styles.module.less new file mode 100644 index 00000000..e4dcc424 --- /dev/null +++ b/ui/src/pages/insightDetail/components/podLogs/styles.module.less @@ -0,0 +1,67 @@ +.podLogs { + display: flex; + flex-direction: column; + height: 100%; + padding: 16px; + + .toolbar { + margin-bottom: 16px; + } + + .error { + margin-bottom: 16px; + } + + .logsContainer { + flex: 1; + background: #1e1e1e; + border-radius: 4px; + overflow: auto; + padding: 16px; + font-family: 'Courier New', Courier, monospace; + font-size: 13px; + line-height: 1.5; + + .logEntry { + display: flex; + margin-bottom: 4px; + color: #fff; + + .timestamp { + color: #888; + margin-right: 12px; + flex-shrink: 0; + user-select: none; + } + + .content { + flex: 1; + white-space: pre-wrap; + word-break: break-all; + } + + &:hover { + background: rgba(255, 255, 255, 0.05); + } + } + + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-track { + background: #2e2e2e; + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: #555; + border-radius: 4px; + + &:hover { + background: #666; + } + } + } +} diff --git a/ui/src/pages/insightDetail/resource/index.tsx b/ui/src/pages/insightDetail/resource/index.tsx index cc8675f1..1fdfbf24 100644 --- a/ui/src/pages/insightDetail/resource/index.tsx +++ b/ui/src/pages/insightDetail/resource/index.tsx @@ -16,6 +16,7 @@ import EventDetail from '../components/eventDetail' import K8sEvent from '../components/k8sEvent' import K8sEventDrawer from '../components/k8sEventDrawer' import SummaryCard from '../components/summaryCard' +import PodLogs from '../components/podLogs' import styles from './styles.module.less' @@ -62,9 +63,19 @@ const ClusterDetail = () => { }) setTabList(tmp) } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [urlParams?.deleted]) + useEffect(() => { + if (kind === 'Pod') { + setTabList(prev => { + if (!prev.find(tab => tab.value === 'Log')) { + return [...prev, { value: 'Log', label: t('LogAggregator') }] + } + return prev + }) + } + }, [kind]) + function handleTabChange(value: string) { setCurrentTab(value) } @@ -380,6 +391,16 @@ const ClusterDetail = () => { if (currentTab === 'YAML') { return } + if (currentTab === 'Log' && kind === 'Pod') { + return ( + + ) + } if (currentTab === 'K8s') { return (