package httpserver import ( "fmt" "image" "image/color" "image/draw" "image/png" "math" "net/http" "sort" "strings" "kubeviz/internal/analyze" "kubeviz/internal/graph" "kubeviz/internal/model" ) type point struct { X float64 Y float64 } func (s *Server) handleExportSVG(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } resp, ok := s.exportGraph(w, r) if !ok { return } svg := renderSVG(resp) w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Content-Disposition", "inline; filename=graph.svg") _, _ = w.Write([]byte(svg)) } func (s *Server) handleExportPNG(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } resp, ok := s.exportGraph(w, r) if !ok { return } img := renderPNG(resp) w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Disposition", "inline; filename=graph.png") _ = png.Encode(w, img) } func (s *Server) exportGraph(w http.ResponseWriter, r *http.Request) (model.GraphResponse, bool) { sid, err := s.sessionID(w, r) if err != nil { http.Error(w, "session error", http.StatusInternalServerError) return model.GraphResponse{}, false } dataset := s.store.GetDataset(sid) if dataset == nil { http.Error(w, "no manifests uploaded", http.StatusNotFound) return model.GraphResponse{}, false } filters := graph.Filters{ Namespace: r.URL.Query().Get("namespace"), Kind: r.URL.Query().Get("kind"), Query: r.URL.Query().Get("q"), FocusID: r.URL.Query().Get("focus"), Relations: parseRelations(r.URL.Query().Get("relations")), GroupBy: r.URL.Query().Get("groupBy"), CollapsedGroups: parseCSVSet(r.URL.Query().Get("collapsed")), } checkCfg := analyze.DefaultConfig() if raw := r.URL.Query().Get("checkRules"); raw != "" { checkCfg = analyze.ConfigFromEnabled(parseCSVSet(raw)) } _, healthHints := analyze.AnalyzeDataset(dataset, checkCfg) return graph.BuildGraph(dataset, filters, healthHints), true } func layout(nodes []model.GraphNode) map[string]point { out := map[string]point{} sorted := make([]model.GraphNode, len(nodes)) copy(sorted, nodes) sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) n := len(sorted) if n == 0 { return out } centerX, centerY := 640.0, 360.0 radius := 220.0 if n > 40 { radius = 280 } for i, node := range sorted { angle := 2 * math.Pi * float64(i) / float64(n) out[node.ID] = point{ X: centerX + radius*math.Cos(angle), Y: centerY + radius*math.Sin(angle), } } return out } func renderSVG(resp model.GraphResponse) string { coords := layout(resp.Nodes) var b strings.Builder b.WriteString(``) b.WriteString(``) for _, edge := range resp.Edges { s, sok := coords[edge.Source] t, tok := coords[edge.Target] if !sok || !tok { continue } b.WriteString(fmt.Sprintf(``, s.X, s.Y, t.X, t.Y)) } for _, node := range resp.Nodes { p := coords[node.ID] fill := "#2563eb" if node.IsSensitive { fill = "#dc2626" } label := escapeXML(fmt.Sprintf("%s/%s", node.Kind, node.Name)) b.WriteString(fmt.Sprintf(``, p.X, p.Y, fill)) b.WriteString(fmt.Sprintf(`%s`, p.X, p.Y+34, label)) } b.WriteString(``) return b.String() } func renderPNG(resp model.GraphResponse) image.Image { img := image.NewRGBA(image.Rect(0, 0, 1280, 720)) draw.Draw(img, img.Bounds(), &image.Uniform{C: color.RGBA{245, 247, 251, 255}}, image.Point{}, draw.Src) coords := layout(resp.Nodes) edgeColor := color.RGBA{156, 163, 175, 255} for _, edge := range resp.Edges { s, sok := coords[edge.Source] t, tok := coords[edge.Target] if !sok || !tok { continue } drawLine(img, int(s.X), int(s.Y), int(t.X), int(t.Y), edgeColor) } for _, node := range resp.Nodes { p := coords[node.ID] fill := color.RGBA{37, 99, 235, 255} if node.IsSensitive { fill = color.RGBA{220, 38, 38, 255} } drawCircle(img, int(p.X), int(p.Y), 20, fill) } return img } func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.Color) { dx := abs(x1 - x0) sx := -1 if x0 < x1 { sx = 1 } dy := -abs(y1 - y0) sy := -1 if y0 < y1 { sy = 1 } err := dx + dy for { if image.Pt(x0, y0).In(img.Bounds()) { img.Set(x0, y0, c) } if x0 == x1 && y0 == y1 { break } e2 := 2 * err if e2 >= dy { err += dy x0 += sx } if e2 <= dx { err += dx y0 += sy } } } func drawCircle(img *image.RGBA, cx, cy, r int, c color.Color) { for y := -r; y <= r; y++ { for x := -r; x <= r; x++ { if x*x+y*y <= r*r { px := cx + x py := cy + y if image.Pt(px, py).In(img.Bounds()) { img.Set(px, py, c) } } } } } func abs(v int) int { if v < 0 { return -v } return v } func escapeXML(s string) string { replacer := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) return replacer.Replace(s) }