Fix SSE connection handling and optimize Dockerfile

- Fixed SSE connection not being properly closed when pod logs dialog is closed
- Added proper cleanup for EventSource connections in K8sResourceList.tsx
- Added debugging logs to track SSE connection lifecycle
- Optimized Dockerfile to avoid copying frontend files during Go build stage
- Fixed backend handler to properly use context from request for log streaming

🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
loveuer
2025-11-17 10:33:45 +08:00
parent 7a666303be
commit 704d0fe0bf
5 changed files with 221 additions and 48 deletions

63
Dockerfile Normal file
View File

@@ -0,0 +1,63 @@
# Multi-stage build for Cluster application with Go backend and React frontend
# Frontend build stage
FROM node:18 AS frontend-build
WORKDIR /app
# Copy package files
COPY frontend/package.json frontend/pnpm-lock.yaml ./
# Install pnpm globally
RUN npm install -g pnpm
# Install frontend dependencies
RUN pnpm install --frozen-lockfile
# Copy frontend source
COPY frontend/ .
# Build frontend
RUN pnpm run build
# Backend build stage
FROM golang:1.22 AS backend-build
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy only backend source code
COPY main.go ./
COPY internal/ ./internal/
COPY pkg/ ./pkg/
# Build backend
RUN go build -o cluster .
# Final stage - Nginx server
FROM nginx:latest
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy backend binary
COPY --from=backend-build /app/cluster /app/cluster
# Copy frontend build
COPY --from=frontend-build /app/dist /usr/share/nginx/html
# Create data directory
RUN mkdir -p /app/x-storage
# Expose ports
EXPOSE 80
# Start backend and nginx
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]

10
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
# Start the Go backend in the background
/app/cluster -address 127.0.0.1:9119 -data-dir /data &
# Wait a moment for backend to start
sleep 2
# Start nginx in the foreground
nginx -g 'daemon off;'

View File

@@ -76,6 +76,7 @@ export default function K8sResourceList() {
const [logsDialogOpen, setLogsDialogOpen] = useState(false)
const [logs, setLogs] = useState<string[]>([])
const [selectedPod, setSelectedPod] = useState<{ name: string; namespace: string } | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<{ name: string; namespace: string } | null>(null)
const [deleting, setDeleting] = useState(false)
@@ -98,6 +99,17 @@ export default function K8sResourceList() {
}
}, [selectedKind, namespace, nameFilter])
// Clean up SSE connection on component unmount
useEffect(() => {
return () => {
if (eventSourceRef.current) {
console.log('Cleaning up SSE connection on component unmount')
eventSourceRef.current.close()
eventSourceRef.current = null
}
}
}, [])
const fetchKubeconfig = async () => {
try {
const res = await fetch('/api/v1/k8s/config')
@@ -171,24 +183,62 @@ export default function K8sResourceList() {
}
const handleViewLogs = (podName: string, podNamespace: string) => {
console.log('handleViewLogs called with:', { podName, podNamespace })
setSelectedPod({ name: podName, namespace: podNamespace })
setLogs([])
setLogsDialogOpen(true)
// Close any existing connection
if (eventSourceRef.current) {
console.log('Closing existing EventSource connection')
eventSourceRef.current.close()
eventSourceRef.current = null
}
const eventSource = new EventSource(
`/api/v1/k8s/pod/logs?name=${encodeURIComponent(podName)}&namespace=${encodeURIComponent(podNamespace)}&tail=1000&follow=true`
)
eventSource.onmessage = (event) => {
setLogs((prev) => [...prev, event.data])
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
}
// Save reference to the EventSource
eventSourceRef.current = eventSource
// Listen for the specific event type 'pod-logs'
eventSource.addEventListener('pod-logs', (event: MessageEvent) => {
try {
const message = JSON.parse(event.data)
if (message.type === 'log') {
setLogs((prev) => [...prev, message.data])
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
} else if (message.type === 'EOF') {
// Handle end of stream if needed
} else if (message.type === 'error') {
setLogs((prev) => [...prev, `Error: ${message.data}`])
}
} catch (e) {
// If parsing fails, treat as plain text (fallback)
setLogs((prev) => [...prev, event.data])
setTimeout(() => logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
}
})
eventSource.onerror = () => {
eventSource.close()
console.log('EventSource error occurred')
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
}
}
return () => eventSource.close()
const handleCloseLogsDialog = () => {
console.log('handleCloseLogsDialog called')
// Close the EventSource connection if it exists
if (eventSourceRef.current) {
console.log('Closing EventSource connection')
eventSourceRef.current.close()
eventSourceRef.current = null
}
setLogsDialogOpen(false)
}
const handleDeleteResource = async () => {
@@ -843,7 +893,7 @@ export default function K8sResourceList() {
<Dialog
open={logsDialogOpen}
onClose={() => setLogsDialogOpen(false)}
onClose={handleCloseLogsDialog}
maxWidth="lg"
fullWidth
>
@@ -852,7 +902,7 @@ export default function K8sResourceList() {
<Typography variant="h6">
Pod : {selectedPod?.name} ({selectedPod?.namespace})
</Typography>
<IconButton onClick={() => setLogsDialogOpen(false)}>
<IconButton onClick={handleCloseLogsDialog}>
<CloseIcon />
</IconButton>
</Box>

View File

@@ -15,6 +15,7 @@ import (
"gorm.io/gorm"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
@@ -22,7 +23,6 @@ import (
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"sigs.k8s.io/yaml"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func getK8sClient(db *gorm.DB) (*kubernetes.Clientset, error) {
@@ -386,25 +386,25 @@ func K8sResourceGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.H
func getResourceName(kind string) string {
kindToResource := map[string]string{
"Namespace": "namespaces",
"Deployment": "deployments",
"StatefulSet": "statefulsets",
"Service": "services",
"ConfigMap": "configmaps",
"Pod": "pods",
"PersistentVolume": "persistentvolumes",
"PersistentVolumeClaim": "persistentvolumeclaims",
"Secret": "secrets",
"Ingress": "ingresses",
"DaemonSet": "daemonsets",
"Job": "jobs",
"CronJob": "cronjobs",
"ReplicaSet": "replicasets",
"ServiceAccount": "serviceaccounts",
"Role": "roles",
"RoleBinding": "rolebindings",
"ClusterRole": "clusterroles",
"ClusterRoleBinding": "clusterrolebindings",
"Namespace": "namespaces",
"Deployment": "deployments",
"StatefulSet": "statefulsets",
"Service": "services",
"ConfigMap": "configmaps",
"Pod": "pods",
"PersistentVolume": "persistentvolumes",
"PersistentVolumeClaim": "persistentvolumeclaims",
"Secret": "secrets",
"Ingress": "ingresses",
"DaemonSet": "daemonsets",
"Job": "jobs",
"CronJob": "cronjobs",
"ReplicaSet": "replicasets",
"ServiceAccount": "serviceaccounts",
"Role": "roles",
"RoleBinding": "rolebindings",
"ClusterRole": "clusterroles",
"ClusterRoleBinding": "clusterrolebindings",
}
if resource, ok := kindToResource[kind]; ok {
@@ -726,21 +726,22 @@ func K8sPodLogs(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handl
req := clientset.CoreV1().Pods(namespace).GetLogs(podName, podLogOpts)
logCtx, cancel := context.WithCancel(context.Background())
defer cancel()
logCtx, cancel := context.WithCancel(c.Context())
stream, err := req.Stream(logCtx)
if err != nil {
cancel()
return resp.R500(c, "", nil, fmt.Errorf("failed to get pod logs: %w", err))
}
defer stream.Close()
// Use the existing SSE manager from resp package
manager := resp.SSE(c, "pod-logs")
// Start streaming logs in a goroutine
go func() {
defer stream.Close()
defer manager.Close()
defer cancel()
reader := bufio.NewReader(stream)
for {
@@ -751,20 +752,18 @@ func K8sPodLogs(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handl
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
manager.Send("[EOF]")
manager.JSON(map[string]any{"type": "EOF"})
return
}
manager.Send(fmt.Sprintf("error: %v", err))
manager.JSON(map[string]any{"type": "error", "data": err.Error()})
return
}
manager.Send(line)
manager.JSON(map[string]any{"data": line, "type": "log"})
}
}
}()
// Return nil since we're handling the response directly
c.Context().SetBodyStreamWriter(manager.Writer())
return nil
return c.SendStreamWriter(manager.Writer())
}
}

51
nginx.conf Normal file
View File

@@ -0,0 +1,51 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream backend {
server 127.0.0.1:9119;
}
server {
listen 80;
server_name localhost;
# Serve static files
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy OCI registry v2 requests to backend
location /v2/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy registry requests to backend
location /registry/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}