218 lines
5.3 KiB
Go
218 lines
5.3 KiB
Go
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)
|
|
}
|