This commit is contained in:
217
internal/httpserver/export.go
Normal file
217
internal/httpserver/export.go
Normal file
@@ -0,0 +1,217 @@
|
||||
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(`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">`)
|
||||
b.WriteString(`<rect width="100%" height="100%" fill="#f5f7fb"/>`)
|
||||
for _, edge := range resp.Edges {
|
||||
s, sok := coords[edge.Source]
|
||||
t, tok := coords[edge.Target]
|
||||
if !sok || !tok {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(`<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="#9ca3af" stroke-width="1.5"/>`, 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(`<circle cx="%.1f" cy="%.1f" r="20" fill="%s" opacity="0.9"/>`, p.X, p.Y, fill))
|
||||
b.WriteString(fmt.Sprintf(`<text x="%.1f" y="%.1f" font-size="11" fill="#111827" text-anchor="middle">%s</text>`, p.X, p.Y+34, label))
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user