package visualizer
import (
"bufio"
"context"
_ "embed"
"encoding/gob"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"regexp"
"slices"
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/andybalholm/brotli"
"github.com/dominikbraun/graph"
"github.com/pancsta/asyncmachine-go/tools/debugger/server"
amgraph "github.com/pancsta/asyncmachine-go/pkg/graph"
am "github.com/pancsta/asyncmachine-go/pkg/machine"
ssrpc "github.com/pancsta/asyncmachine-go/pkg/rpc/states"
ssam "github.com/pancsta/asyncmachine-go/pkg/states"
"github.com/pancsta/asyncmachine-go/tools/visualizer/states"
)
var ss = states .VisualizerStates
var HtmlDiagram []byte
func PresetSingle (r *Renderer ) {
r .RenderDefaults ()
r .RenderStart = false
r .RenderDistance = 0
r .RenderDepth = 0
r .RenderStates = true
r .RenderDetailedPipes = true
r .RenderRelations = true
r .RenderInherited = true
r .RenderConns = true
r .RenderParentRel = true
r .RenderHalfConns = true
r .RenderHalfPipes = true
}
func PresetBird (r *Renderer ) {
PresetSingle (r )
r .RenderDistance = 3
r .RenderInherited = false
}
func PresetMap (r *Renderer ) {
r .RenderDefaults ()
r .RenderNestSubmachines = true
r .RenderStates = false
r .RenderPipes = false
r .RenderStart = false
r .RenderReady = false
r .RenderException = false
r .RenderTags = false
r .RenderDepth = 0
r .RenderRelations = false
r .OutputElk = false
}
type Visualizer struct {
Mach *am .Machine
R *Renderer
graph *amgraph .Graph
}
func New (ctx context .Context , name string ) (*Visualizer , error ) {
mach , err := am .NewCommon (ctx , "vis-" +name , states .VisualizerSchema ,
ss .Names (), nil , nil , nil )
if err != nil {
return nil , err
}
gob .Register (server .Exportable {})
gob .Register (am .Relation (0 ))
g , err := amgraph .New (mach )
if err != nil {
return nil , err
}
vis := &Visualizer {
R : NewRenderer (g , mach .Log ),
Mach : mach ,
graph : g ,
}
return vis , nil
}
func (v *Visualizer ) ClientMsgEnter (e *am .Event ) bool {
return true
}
func (v *Visualizer ) ClientMsgState (e *am .Event ) {
}
func (v *Visualizer ) ConnectEventEnter (e *am .Event ) bool {
return true
}
func (v *Visualizer ) ConnectEventState (e *am .Event ) {
}
func (v *Visualizer ) InitClientState (e *am .Event ) {
id := e .Args ["id" ].(string )
c , ok := v .graph .Clients [id ]
if !ok {
panic ("client not found " + id )
}
err := v .graph .G .AddVertex (&amgraph .Vertex {
MachId : c .Id ,
})
if err != nil {
panic (err )
}
_ = v .graph .Map .AddVertex (&amgraph .Vertex {
MachId : c .Id ,
})
if c .MsgSchema .Parent != "" {
err = v .graph .G .AddEdge (c .Id , c .MsgSchema .Parent ,
func (e *graph .EdgeProperties ) {
e .Data = &amgraph .EdgeData {MachChildOf : true }
})
if err != nil {
when := v .Mach .WhenArgs (ss .InitClient ,
am .A {"id" : c .MsgSchema .Parent }, nil )
go func () {
<-when
err = v .graph .G .AddEdge (c .Id , c .MsgSchema .Parent ,
func (e *graph .EdgeProperties ) {
e .Data = &amgraph .EdgeData {MachChildOf : true }
})
if err == nil {
_ = v .graph .Map .AddEdge (c .Id , c .MsgSchema .Parent )
}
}()
} else {
_ = v .graph .Map .AddEdge (c .Id , c .MsgSchema .Parent )
}
}
for name , props := range c .MsgSchema .States {
err = v .graph .G .AddVertex (&amgraph .Vertex {
MachId : id ,
StateName : name ,
})
if err != nil {
panic (err )
}
_ = v .graph .Map .AddVertex (&amgraph .Vertex {
MachId : id ,
StateName : name ,
})
err = v .graph .G .AddEdge (id , id +":" +name ,
func (e *graph .EdgeProperties ) {
e .Data = &amgraph .EdgeData {
MachHas : &amgraph .MachineHas {
Auto : props .Auto ,
Multi : props .Multi ,
Inherited : "" ,
},
}
})
if err != nil {
panic (err )
}
_ = v .graph .Map .AddEdge (id , id +":" +name )
}
type relation struct {
States am .S
RelType am .Relation
}
for name , state := range c .MsgSchema .States {
toAdd := []relation {
{States : state .Require , RelType : am .RelationRequire },
{States : state .Add , RelType : am .RelationAdd },
{States : state .Remove , RelType : am .RelationRemove },
}
for _ , item := range toAdd {
for _ , relState := range item .States {
from := id + ":" + name
to := id + ":" + relState
if edge , err := v .graph .G .Edge (from , to ); err == nil {
data := edge .Properties .Data .(*amgraph .EdgeData )
data .StateRelation = append (data .StateRelation ,
&amgraph .StateRelation {
RelType : item .RelType ,
})
err = v .graph .G .UpdateEdge (from , to , func (e *graph .EdgeProperties ) {
e .Data = data
})
if err != nil {
panic (err )
}
continue
}
err = v .graph .G .AddEdge (from , to , func (e *graph .EdgeProperties ) {
e .Data = &amgraph .EdgeData {
StateRelation : []*amgraph .StateRelation {
{RelType : item .RelType },
},
}
})
if err != nil {
panic (err )
}
_ = v .graph .Map .AddEdge (from , to )
}
}
}
}
func (v *Visualizer ) GoToMachAddrState (e *am .Event ) {
}
func (v *Visualizer ) HImportData (filename string ) error {
v .Mach .Log ("Importing data from %s\n" , filename )
var reader *bufio .Reader
u , err := url .Parse (filename )
if err == nil && u .Host != "" {
resp , err := http .Get (filename )
if err != nil {
return err
}
reader = bufio .NewReader (resp .Body )
} else {
fr , err := os .Open (filename )
if err != nil {
return err
}
defer fr .Close ()
reader = bufio .NewReader (fr )
}
brReader := brotli .NewReader (reader )
decoder := gob .NewDecoder (brReader )
var res []*server .Exportable
err = decoder .Decode (&res )
if err != nil {
return err
}
for _ , data := range res {
err := v .graph .AddClient (data .MsgStruct )
if err != nil {
return err
}
}
for _ , data := range res {
id := data .MsgStruct .ID
for i := range data .MsgTxs {
v .graph .ParseMsg (id , data .MsgTxs [i ])
}
}
return nil
}
func (v *Visualizer ) Clients () map [string ]amgraph .Client {
ret := make (map [string ]amgraph .Client )
for k , c := range v .graph .Clients {
ret [k ] = *c
}
return ret
}
var pkgStates = []am .S {
ssam .BasicStates .Names (),
ssam .DisposedStates .Names (),
ssam .ConnectedStates .Names (),
ssam .DisposedStates .Names (),
ssrpc .SharedStates .Names (),
}
type Renderer struct {
graph *amgraph .Graph
RenderMachs []string
RenderAllowlist am .S
RenderMachsRe []*regexp .Regexp
RenderSkipMachs []string
RenderDistance int
RenderDepth int
RenderStates bool
RenderStart bool
RenderException bool
RenderReady bool
RenderPipeStates bool
RenderPipes bool
RenderHalfPipes bool
RenderDetailedPipes bool
RenderRelations bool
RenderActive bool
RenderParentRel bool
RenderNestSubmachines bool
RenderTags bool
RenderConns bool
RenderHalfConns bool
RenderHalfHierarchy bool
RenderInherited bool
RenderMarkInherited bool
OutputFilename string
OutputD2Svg bool
OutputMermaidSvg bool
OutputElk bool
OutputD2 bool
OutputMermaid bool
shortIdMap map [string ]string
lastId string
buf strings .Builder
adjMap map [string ]map [string ]graph .Edge [string ]
renderedPipes map [string ]struct {}
renderedConns map [string ]struct {}
renderedParents map [string ]struct {}
renderedMachs map [string ]struct {}
adjsMachsToRender []string
log func (msg string , args ...any )
}
func NewRenderer (
graph *amgraph .Graph , logger func (msg string , args ...any ),
) *Renderer {
vis := &Renderer {
log : logger ,
graph : graph ,
OutputD2 : true ,
OutputMermaid : true ,
OutputD2Svg : true ,
OutputElk : true ,
}
vis .RenderDefaults ()
return vis
}
func (r *Renderer ) RenderDefaults () {
r .RenderReady = true
r .RenderStart = true
r .RenderException = true
r .RenderPipeStates = true
r .RenderActive = true
r .RenderStates = true
r .RenderRelations = true
r .RenderParentRel = true
r .RenderPipes = true
r .RenderTags = true
r .RenderConns = true
r .RenderInherited = true
r .RenderMarkInherited = true
r .RenderHalfConns = true
r .RenderHalfHierarchy = true
r .RenderHalfConns = true
r .RenderNestSubmachines = false
r .RenderDetailedPipes = false
r .RenderDistance = -1
r .RenderDepth = 10
r .RenderMachs = nil
r .RenderMachsRe = nil
}
func (r *Renderer ) shortId (longId string ) string {
if _ , ok := r .shortIdMap [longId ]; ok {
return r .shortIdMap [longId ]
}
shortId := genId (r .lastId )
r .lastId = shortId
r .shortIdMap [longId ] = shortId
return r .lastId
}
func (r *Renderer ) GenDiagrams (ctx context .Context ) error {
if r .OutputFilename == "" {
r .OutputFilename = "am-vis"
}
r .log ("DIAGRAM %s" , r .OutputFilename )
if r .OutputMermaid {
if err := r .outputMermaid (ctx ); err != nil {
return fmt .Errorf ("failed to generate mermaid: %w" , err )
}
}
if r .OutputD2 {
if err := r .outputD2 (ctx ); err != nil {
return fmt .Errorf ("failed to generate D2: %w" , err )
}
}
r .log ("Done %s" , r .OutputFilename )
return nil
}
func (r *Renderer ) outputMermaid (ctx context .Context ) error {
r .log ("Generating mermaid\n" )
r .cleanBuffer ()
if r .OutputElk {
r .buf .WriteString (
"%%{init: {'flowchart': {'defaultRenderer': 'elk'}} }%%\n" )
}
r .buf .WriteString ("flowchart LR\n" )
graphMap , err := r .graph .G .AdjacencyMap ()
if err != nil {
return fmt .Errorf ("failed to get adjacency map: %w" , err )
}
if r .RenderActive {
r .buf .WriteString ("\tclassDef _active color:black,fill:yellow;\n" )
}
for src , targets := range graphMap {
src , err := r .graph .G .Vertex (src )
if err != nil {
return fmt .Errorf ("failed to get vertex for source %s: %w" , src , err )
}
if src .StateName == "" {
_ = r .outputMermaidMach (ctx , src .MachId , targets )
}
}
err = os .WriteFile (r .OutputFilename +".mermaid" , []byte (r .buf .String ()), 0o644 )
if err != nil {
return fmt .Errorf ("failed to write mermaid file: %w" , err )
}
if r .OutputMermaidSvg {
r .log ("Generating SVG\n%s\n" )
cmd := exec .CommandContext (ctx , "mmdc" , "-i" , r .OutputFilename +".mermaid" ,
"-o" , r .OutputFilename +".svg" , "-b" , "black" , "-t" , "dark" , "-c" ,
"am-vis.mermaid.json" )
err = cmd .Run ()
if err != nil {
return fmt .Errorf ("failed to execute mmdc command for SVG: %w" , err )
}
}
r .log ("Done" )
return nil
}
func (r *Renderer ) outputMermaidMach (
ctx context .Context , machId string , targets map [string ]graph .Edge [string ],
) error {
if slices .Contains (r .RenderSkipMachs , machId ) {
return nil
}
if !r .isMachWhitelisted (machId ) && !r .isMachCloseEnough (machId ) {
return nil
}
c := r .graph .Clients [machId ]
tags := ""
if r .RenderTags && len (c .MsgSchema .Tags ) > 0 {
tags = "<br>#" + strings .Join (c .MsgSchema .Tags , " #" )
}
r .buf .WriteString ("\tsubgraph " + r .shortId (machId ) + "[" + machId +
tags + "]\n" )
r .buf .WriteString ("\t\tdirection TB\n" )
parent := ""
pipes := ""
conns := ""
for _ , edge := range targets {
if ctx .Err () != nil {
return nil
}
target , err := r .graph .G .Vertex (edge .Target )
if err != nil {
return fmt .Errorf ("failed to get vertex for target %s: %w" , edge .Target ,
err )
}
data := edge .Properties .Data .(*amgraph .EdgeData )
shortIdTarget := r .shortId (edge .Target )
if r .RenderStates && data .MachHas != nil {
r .buf .WriteString ("\t\t" + shortIdTarget + "([" + target .StateName +
"])\n" )
if r .RenderRelations {
state := c .MsgSchema .States [target .StateName ]
for _ , relState := range state .Require {
r .buf .WriteString ("\t\t" + shortIdTarget + " --o " +
r .shortId (machId +":" +relState ) + "\n" )
}
for _ , relState := range state .Add {
r .buf .WriteString ("\t\t" + shortIdTarget + " --> " +
r .shortId (machId +":" +relState ) + "\n" )
}
for _ , relState := range state .Remove {
shortRelId := r .shortId (machId + ":" + relState )
if shortRelId == shortIdTarget {
continue
}
r .buf .WriteString ("\t\t" + shortIdTarget + " --x " +
shortRelId + "\n" )
}
}
}
if r .RenderParentRel && data .MachChildOf {
parent = "\t" + r .shortId (edge .Source ) + " ==o " + shortIdTarget + "\n"
}
if r .RenderPipes {
for _ , mp := range data .MachPipesTo {
sym := ">"
if mp .MutType == am .MutationRemove {
sym = "x"
}
if r .RenderDetailedPipes {
pipes += "\t%% " + edge .Source + ":" + mp .FromState +
" --" + sym + " " + edge .Target + ":" + mp .ToState + "\n"
pipes += "\t" + r .shortId (edge .Source +":" +mp .FromState ) +
" --" + sym + " " + r .shortId (edge .Target +":" +mp .ToState ) + "\n"
} else {
tmp := "\t" + r .shortId (edge .Source ) +
" --> " + r .shortId (edge .Target ) + "\n"
if !strings .Contains (pipes , tmp ) {
pipes += tmp
}
}
}
}
if r .RenderConns && data .MachConnectedTo {
conns += "\t" + r .shortId (edge .Source ) +
" .-> " + shortIdTarget + "\n"
}
}
if r .RenderActive {
var active am .S
for idx , tick := range c .LatestClock {
if !am .IsActiveTick (tick ) {
continue
}
name := c .MsgSchema .StatesIndex [idx ]
shortId := r .shortId (machId + ":" + name )
active = append (active , shortId )
}
if len (active ) > 0 {
r .buf .WriteString (
"\t\tclass " + strings .Join (active , "," ) + " _active;\n" )
}
}
r .buf .WriteString ("\tend\n" )
if parent != "" {
r .buf .WriteString (parent )
}
if pipes != "" {
r .buf .WriteString (pipes )
}
if conns != "" {
r .buf .WriteString (conns )
}
r .buf .WriteString ("\n\n" )
return nil
}
func (r *Renderer ) stateHasRenderedPipes (machId , stateName string ) bool {
if !r .RenderPipeStates || !r .RenderDetailedPipes {
return false
}
for _ , edge := range r .adjMap [machId ] {
for _ , mp := range edge .Properties .Data .(*amgraph .EdgeData ).MachPipesTo {
if r .shouldRenderState (edge .Target , mp .ToState ) {
return true
}
}
}
return false
}
func (r *Renderer ) cleanBuffer () {
r .buf = strings .Builder {}
r .lastId = ""
r .shortIdMap = make (map [string ]string )
r .renderedPipes = map [string ]struct {}{}
r .renderedConns = map [string ]struct {}{}
r .renderedMachs = map [string ]struct {}{}
r .renderedParents = map [string ]struct {}{}
r .adjsMachsToRender = nil
}
func (r *Renderer ) fullIdPath (machId string , shorten bool ) []string {
ret := []string {machId }
if shorten {
ret [0 ] = r .shortId (machId )
}
mach := r .graph .Clients [machId ]
for mach != nil && mach .MsgSchema != nil && mach .MsgSchema .Parent != "" {
parent := mach .MsgSchema .Parent
if shorten {
parent = r .shortId (parent )
}
ret = slices .Concat ([]string {parent }, ret )
mach = r .graph .Clients [mach .MsgSchema .Parent ]
}
return ret
}
func (r *Renderer ) shouldRenderMach (machId string ) bool {
if r .isMachWhitelisted (machId ) {
return true
}
if r .isMachCloseEnough (machId ) {
return true
}
if r .isMachShallowEnough (machId ) {
return true
}
return false
}
func (r *Renderer ) shouldRenderState (machId , state string ) bool {
if !r .shouldRenderMach (machId ) {
return false
}
allow := r .RenderAllowlist
if !r .RenderStates {
if r .RenderStart && state == ssam .BasicStates .Start {
return true
}
if r .RenderReady && state == ssam .BasicStates .Ready {
return true
}
if r .RenderException && state == am .StateException {
return true
}
}
if r .RenderStates {
if !r .RenderStart && state == ssam .BasicStates .Start {
return false
}
if !r .RenderReady && state == ssam .BasicStates .Ready {
return false
}
if !r .RenderException && state == am .StateException {
return false
}
if len (allow ) > 0 && !slices .Contains (allow , state ) {
return false
}
}
statesIndex := r .graph .Clients [machId ].MsgSchema .StatesIndex
if !r .RenderInherited && r .isStateInherited (state , statesIndex ) {
if r .RenderStart && state == ssam .BasicStates .Start {
return true
}
if r .RenderReady && state == ssam .BasicStates .Ready {
return true
}
if r .RenderException && state == am .StateException {
return true
}
return false
}
return r .RenderStates
}
func (r *Renderer ) isStateInherited (state string , machStates am .S ) bool {
if state == am .StateException {
return true
}
for _ , states := range pkgStates {
if !slices .Contains (states , state ) {
continue
}
if len (am .DiffStates (states , machStates )) == 0 {
return true
}
}
return false
}
func (r *Renderer ) isMachCloseEnough (machId string ) bool {
if r .RenderDistance == -1 {
return true
}
for _ , renderMachId := range r .renderMachIds () {
path , err := graph .ShortestPath (r .graph .Map , machId , renderMachId )
if err == nil && len (path ) <= r .RenderDistance +1 {
return true
}
}
return false
}
func (r *Renderer ) isMachShallowEnough (machId string ) bool {
if r .RenderDepth < 1 {
return false
}
for _ , renderMachId := range r .renderMachIds () {
fullId := r .fullIdPath (machId , false )
idxRender := slices .Index (fullId , renderMachId )
idxMach := slices .Index (fullId , machId )
if idxRender != -1 && idxMach -idxRender <= r .RenderDepth {
return true
}
}
if len (r .RenderMachs ) > 0 || len (r .RenderMachsRe ) > 0 {
return false
}
depth := len (r .fullIdPath (machId , false ))
return depth <= r .RenderDepth
}
func (r *Renderer ) isMachWhitelisted (id string ) bool {
if slices .Contains (r .renderMachIds (), id ) {
return true
}
return len (r .RenderMachs ) == 0 && len (r .RenderMachsRe ) == 0
}
func (r *Renderer ) renderMachIds () []string {
ret := slices .Clone (r .RenderMachs )
for _ , re := range r .RenderMachsRe {
for _ , client := range r .graph .Clients {
if re .MatchString (client .Id ) {
ret = append (ret , client .Id )
}
}
}
return ret
}
var characters = "abcdefghijklmnopqrstuvwxyz0123456789"
func genId(lastId string ) string {
if lastId == "" {
return string (characters [0 ])
}
runes := []rune (lastId )
idx := len (runes ) - 1
for {
charPos := strings .IndexRune (characters , runes [idx ])
if charPos < len (characters )-1 {
runes [idx ] = rune (characters [charPos +1 ])
return string (runes )
}
runes [idx ] = rune (characters [0 ])
idx --
if idx < 0 {
return string (characters [0 ]) + string (runes )
}
}
}
type Fragment struct {
MachId string
States am .S
Active am .S
}
func UpdateCache (
ctx context .Context , filepath string , dom *goquery .Document ,
fragments ...*Fragment ,
) error {
for _ , sel := range fragments {
for _ , state := range sel .States {
if ctx .Err () != nil {
return nil
}
isActive := slices .Contains (sel .Active , state )
isStart := state == ssam .BasicStates .Start
isReady := state == ssam .BasicStates .Ready
isErr := state == am .StateException ||
strings .HasPrefix (state , am .PrefixErr )
fillOuter := "#CDD6F4"
classOuter := "text-bold fill-N1"
strokeInner := "white"
fillInner := "#45475A"
classInner := "fill-B5"
if isActive {
fillOuter = "black"
classOuter = "text-bold"
strokeInner = "#5F5C5C"
fillInner = "yellow"
classInner = "stroke-B1"
if isReady {
fillInner = "deepskyblue"
} else if isStart {
fillInner = "#329241"
} else if isErr {
fillInner = "red"
}
}
root := dom .Find ("g > text:contains(" + state + ")" ).
FilterFunction (func (i int , s *goquery .Selection ) bool {
return s .Text () == state
})
root .
SetAttr ("fill" , fillOuter ).
SetAttr ("class" , classOuter ).
Prev ().Children ().SetAttr ("stroke" , strokeInner ).
SetAttr ("class" , classInner ).
First ().SetAttr ("fill" , fillInner )
}
}
if ctx .Err () != nil {
return nil
}
html , err := goquery .OuterHtml (dom .Selection )
if err != nil {
return err
}
return os .WriteFile (filepath , []byte (html ), 0o644 )
}
The pages are generated with Golds v0.8.2 . (GOOS=linux GOARCH=amd64)
Golds is a Go 101 project developed by Tapir Liu .
PR and bug reports are welcome and can be submitted to the issue list .
Please follow @zigo_101 (reachable from the left QR code) to get the latest news of Golds .