Skip to content

Commit

Permalink
View the SVG using a web server instead of PNG on the local FS (#4)
Browse files Browse the repository at this point in the history
This requires zero setup for the user, because everyone has a browser ;)

Added 3 flags:
```console
$ flux-graph --help
Processes a Flux Kustomization tree and generates a graph

Usage:
  flux-graph [flags]

Flags:
  -d, --direction string   Specify direction of graph (TB,BT,LR,RL) (default "TB")
  -f, --file string        Specify input file
  -h, --help               help for flux-graph
  -n, --no-serve           Don't serve the graph on a web server
  -o, --output string      Specify output file (default "graph.svg")
  -p, --port string        Specify web server port (default "9000")
```

Also refactored the code by making it a bit more modular and testable (still not there yet)
  • Loading branch information
rishinair11 authored Aug 3, 2024
1 parent 230a178 commit 9aa0651
Show file tree
Hide file tree
Showing 10 changed files with 381 additions and 45 deletions.
64 changes: 21 additions & 43 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,80 +1,58 @@
package main

import (
"io"
"log"
"os"

graphviz "github.com/goccy/go-graphviz"
"github.com/rishinair11/flux-ks-graph/pkg/graph"
"github.com/rishinair11/flux-ks-graph/pkg/resource"
"github.com/rishinair11/flux-ks-graph/pkg/serve"
"github.com/rishinair11/flux-ks-graph/pkg/util"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

func main() {
var inputFile string
var outputFile string
var (
inputFile string
outputFile string
serverPort string
graphDirection string
noServe bool
)

rootCmd := &cobra.Command{
Use: "flux-graph",
Short: "Processes a Flux Kustomization tree and generates a graph",
Run: func(cmd *cobra.Command, _ []string) {
var yamlBytes []byte
var err error

// Read YAML input
yamlBytes, err = readInput(inputFile)
rt, err := resource.NewResourceTree(inputFile)
if err != nil {
log.Fatalf("Failed to read YAML: %v", err)
}

// Unmarshal YAML into ResourceTree
t := &resource.ResourceTree{}
if err := yaml.Unmarshal(yamlBytes, t); err != nil {
log.Fatalf("Failed to unmarshal YAML: %v", err)
log.Fatalf("Failed to initialize ResourceTree: %v", err)
}

// Process the graph
graph, err := graph.ProcessGraph(t)
graph, err := graph.ProcessGraph(rt, graphDirection)
if err != nil {
log.Fatalf("Failed to construct graph: %v", err)
}

gvGraph, err := graphviz.ParseBytes([]byte(graph.String()))
if err != nil {
log.Fatalf("Failed to parse graph dot string: %v", err)
if err := util.GenerateGraphSVG(graph, outputFile); err != nil {
log.Fatalf("Failed to generate graph SVG: %v", err)
}
defer gvGraph.Close()

f, err := os.Create(outputFile)
if err != nil {
log.Fatalf("Failed to create output file: %v", err)
}
defer f.Close()
log.Println("Generated graph:", outputFile)

if err := graphviz.New().RenderFilename(gvGraph, graphviz.PNG, f.Name()); err != nil {
log.Fatalf("Failed to write output graph image: %v", err)
if !noServe {
serve.ServeAssets(outputFile, serverPort)
}

log.Println("Generated graph:", outputFile)
},
}

rootCmd.Flags().StringVarP(&inputFile, "file", "f", "", "Specify input file")
rootCmd.Flags().StringVarP(&outputFile, "output", "o", "graph.png", "Specify output file")
rootCmd.Flags().StringVarP(&graphDirection, "direction", "d", "TB", "Specify direction of graph (https://graphviz.gitlab.io/docs/attrs/rankdir)")
rootCmd.Flags().StringVarP(&outputFile, "output", "o", "graph.svg", "Specify output file")
rootCmd.Flags().StringVarP(&serverPort, "port", "p", "9000", "Specify web server port")
rootCmd.Flags().BoolVarP(&noServe, "no-serve", "n", false, "Don't serve the graph on a web server")

if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}

// readInput reads the YAML input from a file or stdin
func readInput(inputFile string) ([]byte, error) {
if inputFile != "" {
log.Println("Reading from file:", inputFile)
return os.ReadFile(inputFile)
}
log.Println("Reading from STDIN...")
return io.ReadAll(os.Stdin)
}
4 changes: 2 additions & 2 deletions pkg/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import (
)

// ProcessGraph creates a Graphviz graph from the given ResourceTree
func ProcessGraph(t *resource.ResourceTree) (*gographviz.Graph, error) {
func ProcessGraph(t *resource.ResourceTree, rankDir string) (*gographviz.Graph, error) {
log.Println("Processing tree...")

graphName := "fluxgraph"
g := gographviz.NewGraph()
g.SetDir(true) //nolint errcheck 'always returns nil'
g.SetName(graphName) //nolint errcheck 'always returns nil'
if err := g.Attrs.Add(string(gographviz.RankDir), "LR"); err != nil {
if err := g.Attrs.Add(string(gographviz.RankDir), rankDir); err != nil {
log.Fatal(err)
}
if err := g.Attrs.Add(string(gographviz.RankSep), "5.0"); err != nil {
Expand Down
20 changes: 20 additions & 0 deletions pkg/resource/resource.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
package resource

import (
"github.com/rishinair11/flux-ks-graph/pkg/util"
"gopkg.in/yaml.v3"
)

type ResourceTree struct {
Resource Resource `yaml:"resource"`
Resources []ResourceTree `yaml:"resources"`
}

// NewResourceTree parses a Flux 'tree' YAML file and returns a ResourceTree
func NewResourceTree(fileName string) (*ResourceTree, error) {
yamlBytes, err := util.ReadInput(fileName)
if err != nil {
return nil, err
}

rt := &ResourceTree{}
if err := yaml.Unmarshal(yamlBytes, rt); err != nil {
return nil, err
}

return rt, nil
}

type Resource struct {
GroupKind GroupKind `yaml:"GroupKind"`
Name string `yaml:"Name"`
Expand Down
43 changes: 43 additions & 0 deletions pkg/resource/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,46 @@ func TestResource_GetKind(t *testing.T) {
})
}
}

func TestNewResourceTree(t *testing.T) {
testPath := "testdata/test.yaml"

want := &ResourceTree{
Resource: Resource{
GroupKind: GroupKind{
Group: "parent-group",
Kind: "parent-kind",
},
Name: "parent-name",
Namespace: "parent-namespace",
},
Resources: []ResourceTree{
{
Resource: Resource{
GroupKind: GroupKind{
Group: "child-group",
Kind: "child-kind",
},
Name: "child-name",
Namespace: "child-namespace",
},
Resources: []ResourceTree{
{
Resource: Resource{
GroupKind: GroupKind{
Group: "grandchild-group",
Kind: "grandchild-kind",
},
Name: "grandchild-name",
Namespace: "grandchild-namespace",
},
},
},
},
},
}

got, err := NewResourceTree(testPath)
assert.NoError(t, err)
assert.Equal(t, want, got)
}
20 changes: 20 additions & 0 deletions pkg/resource/testdata/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
resource:
GroupKind:
Group: parent-group
Kind: parent-kind
Name: parent-name
Namespace: parent-namespace
resources:
- resource:
GroupKind:
Group: child-group
Kind: child-kind
Name: child-name
Namespace: child-namespace
resources:
- resource:
GroupKind:
Group: grandchild-group
Kind: grandchild-kind
Name: grandchild-name
Namespace: grandchild-namespace
37 changes: 37 additions & 0 deletions pkg/serve/serve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package serve

import (
"embed"
"log"
"net/http"
"path/filepath"
)

//go:embed static/*.html
var staticFS embed.FS

// ServeAssets starts a web server to serve the generated graph.html containing the SVG
func ServeAssets(filePath string, port string) {
http.HandleFunc("/", handleGraphSVG)

http.HandleFunc("/svg", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filePath)
})

log.Println("Serving your graph at http://localhost:" + port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

// handleGraphSVG serves graph.html, which contains the graph SVG, for "/" path.
func handleGraphSVG(w http.ResponseWriter, r *http.Request) {
data, err := staticFS.ReadFile(filepath.Join("static", "graph.html"))
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}

w.Header().Set("Content-Type", "text/html")
w.Write(data) //nolint:errcheck
}
32 changes: 32 additions & 0 deletions pkg/serve/serve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package serve

import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
)

func TestHandleGraphSVG(t *testing.T) {
// test server
req := httptest.NewRequest(http.MethodGet, "/graph.html", nil)
w := httptest.NewRecorder()

handleGraphSVG(w, req)

resp := w.Result()
assert.Equal(t, http.StatusOK, resp.StatusCode)

// response body should match the static file
expectedBody, err := os.ReadFile(filepath.Join("static", "graph.html"))
assert.NoError(t, err)

body, err := io.ReadAll(resp.Body)
assert.NoError(t, err)

assert.Equal(t, string(expectedBody), string(body))
}
105 changes: 105 additions & 0 deletions pkg/serve/static/graph.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flux Graph</title>
<link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" rel="stylesheet">
<style>
body, html {
height: 100%;
margin: 0;
font-size: calc(1vw + 1vh + 0.5vmin);
overflow: hidden;
}
.container-fluid {
display: flex;
flex-direction: column;
height: 100vh;
margin: 0;
}
#svg-container {
flex: 1;
margin: 10px;
border: 1px solid #ccc;
overflow: hidden;
position: relative;
}
#svg-object {
width: 100%;
height: 100%;
display: block;
}
#svg-controls {
margin: 10px;
text-align: center;
}
.btn {
font-size: 0.5rem;
margin: 5px;
padding: 0.25rem 0.5rem;
}
.pan-cursor{
cursor: move !important;
}
</style>
</head>
<body>
<div class="container-fluid">
<div id="svg-container" class="pan-cursor">
<object id="svg-object" type="image/svg+xml" data="/svg"></object>
</div>
<div id="svg-controls">
<button id="zoom-in" class="btn btn-dark">Zoom +</button>
<button id="zoom-out" class="btn btn-dark">Zoom -</button>
<button id="reset" class="btn btn-dark">Reset</button>
</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js" crossorigin="anonymous"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
var svgPanZoomInstance = null;
var svgObject = document.getElementById('svg-object');
var svgContainer = document.getElementById('svg-container');

svgObject.addEventListener('load', function() {
var svg = svgObject.contentDocument.querySelector('svg');

if (svg) {
svgPanZoomInstance = svgPanZoom(svg, {
zoomEnabled: true,
fit: true,
center: true,
minZoom: 0.1,
maxZoom: 100,
zoomScaleSensitivity: 0.5,
controlIconsEnabled: false,
customControls: true
});

document.getElementById('zoom-in').addEventListener('click', function() {
if (svgPanZoomInstance) svgPanZoomInstance.zoomIn();
});

document.getElementById('zoom-out').addEventListener('click', function() {
if (svgPanZoomInstance) svgPanZoomInstance.zoomOut();
});

document.getElementById('reset').addEventListener('click', function() {
if (svgPanZoomInstance) {
svgPanZoomInstance.reset();
svgPanZoomInstance.center();
}
});
} else {
console.error("SVG element not found.");
}
});
});
</script>
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js"></script>
</body>
</html>
Loading

0 comments on commit 9aa0651

Please sign in to comment.