Skip to content

Instantly share code, notes, and snippets.

@dcvezzani
Created June 9, 2017 15:46
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dcvezzani/c35f519433949c5cfbec84cab2c6c56a to your computer and use it in GitHub Desktop.
Save dcvezzani/c35f519433949c5cfbec84cab2c6c56a to your computer and use it in GitHub Desktop.
Generate state diagrams using Graphviz and JSON summary

Requirements

Install Ruby 2.3.1

Install Graphviz

brew install graphviz

Install JQ (command line JSON parser)

brew install jq

Usage

Sample JSON input.

The root of the object should include a key representing the name of the state and a hash of event handlers as the value.

Simple event handler definitions are associated with an array that include states (token proceded by a plus symbol (+)) and other key methods or attributes that might provide insight into what the handler does.

If the event handler is a case statement that encapsulates constants (perhaps these are from a different state machine?), then the value is a hash instead of an array. The keys represent the foreign states (separated by a comma followed by a space (', ')) and the value is a single value representing an internal state.

{
  "pausing": {
    "onEndSession": [
      "_stopSession"
    ],
    "onHangup": [
      "+hanging_up",
      "uuid",
      "_endCall"
    ],
    "onHangupLead": [
      "_removePhoneByLeadId"
    ],
    "onResume": [
      "+dialing"
    ],
    "onSettings": [
      "_resize"
    ],
    "onTargetCallTimeout": [
      "_redial"
    ],
    "onTargetUpdate": [
      "+bridged",
      "+callback_burn_down",
      "+paused",
      "_clearCallTimeout",
      "call.status",
      "call.leavingCallback",
      "call.isEnded",
      "this.dialingLines",
      "self.reload",
      "_dialingLinesEmpty"
    ],
    "onRemovePhone": [
      "+paused",
      "_removePhoneByLeadId",
      "_dialingLinesEmpty"
    ],
    "onUserOrVmUpdate": {
      "DIALING, PARKED_USER": "nothing",
      "BRIDGED": "bridged",
      "ENDED": "ended",
      "DEFAULT": "error"
    }
  }
}

Must be compressed to a single line before passing to script

{"pausing":{"onEndSession":["_stopSession"],"onHangup":["+hanging_up","uuid","_endCall"],"onHangupLead":["_removePhoneByLeadId"],"onResume":["+dialing"],"onSettings":["_resize"],"onTargetCallTimeout":["_redial"],"onTargetUpdate":["+bridged","+callback_burn_down","+paused","_clearCallTimeout","call.status","call.leavingCallback","call.isEnded","this.dialingLines","self.reload","_dialingLinesEmpty"],"onRemovePhone":["+paused","_removePhoneByLeadId","_dialingLinesEmpty"],"onUserOrVmUpdate":{"DIALING, PARKED_USER":"nothing","BRIDGED":"bridged","ENDED":"ended","DEFAULT":"error"}}}

Example call.

The script finished by opening the generated Graphviz dot file and png diagram using mvim and open (which typically opens the png in Preview). If you don't use mvim (MacVim), then edit the script to open the dot file in your preferred editor.

json='{"pausing":{"onEndSession":["_stopSession"],"onHangup":["+hanging_up","uuid","_endCall"],"onHangupLead":["_removePhoneByLeadId"],"onResume":["+dialing"],"onSettings":["_resize"],"onTargetCallTimeout":["_redial"],"onTargetUpdate":["+bridged","+callback_burn_down","+paused","_clearCallTimeout","call.status","call.leavingCallback","call.isEnded","this.dialingLines","self.reload","_dialingLinesEmpty"],"onRemovePhone":["+paused","_removePhoneByLeadId","_dialingLinesEmpty"],"onUserOrVmUpdate":{"DIALING, PARKED_USER":"nothing","BRIDGED":"bridged","ENDED":"ended","DEFAULT":"error"}}}'

./generate_diagram.sh "$json"

Ruby can automate the compression.

json=$(ruby -e 'require "json"
puts({pausing: {
  onEndSession: %w{_stopSession},
  onHangup: %w{+hanging_up uuid _endCall },
  onHangupLead: %w{_removePhoneByLeadId},
  onResume: %w{+dialing},
  onSettings: %w{_resize},
  onTargetCallTimeout: %w{_redial},
  onTargetUpdate: %w{+bridged +callback_burn_down +paused _clearCallTimeout call.status call.leavingCallback call.isEnded this.dialingLines self.reload _dialingLinesEmpty},
  onRemovePhone: %w{+paused _removePhoneByLeadId _dialingLinesEmpty},
  onUserOrVmUpdate: {
    "DIALING, PARKED_USER" => :nothing, 
    "BRIDGED" => :bridged, 
    "ENDED" => :ended, 
    "DEFAULT" => :error
  }
}}.to_json)') && ./generate_diagram.sh "$json"
#!/bin/bash
#generate_diagram.sh
#working
json="$1"
#working
state_name=$(echo "$json" | jq -r '(. | to_entries | first | .key as $state | $state)')
file_name="/Users/dcvezzani/Documents/journal/current/${state_name}_state_transitions.viz"
#working
echo -e "# ${state_name}_state_transitions.viz\ndigraph G {\n rankdir=\"LR\";" > $file_name
echo '' >> $file_name
#json='{"pausing":{"onEndSession":["_stopSession"],"onHangup":["+hanging_up","uuid","_endCall"],"onHangupLead":["_removePhoneByLeadId"],"onResume":["+dialing"],"onSettings":["_resize"],"onTargetCallTimeout":["_redial"],"onTargetUpdate":["+bridged","+callback_burn_down","+paused","_clearCallTimeout","call.status","call.leavingCallback","call.isEnded","this.dialingLines","self.reload","_dialingLinesEmpty"],"onRemovePhone":["+paused","_removePhoneByLeadId","_dialingLinesEmpty"],"onUserOrVmUpdate":{"DIALING, PARKED_USER":"nothing","BRIDGED":"bridged","ENDED":"ended","DEFAULT":"error"}}}'
#working
echo "$json" | jq -r '(. | to_entries | first | .key as $state | .value | to_entries | [((map(.key as $event_handler | .value | if type == "array" then null elif type == "object" then (((. | to_entries | map("\(.value); "))) as $states | $states) else null end)),((map(.value as $potential_state | $potential_state) | flatten | map(select(((type == "string") and (. | test("^\\+")))))) | map("\(.); " | gsub("^\\+"; ""))))] | flatten | map(select(. != null)) | unique | join("") as $all_states | " # states\n {\n node [style=filled; color=black; fillcolor=\"#FFF8D6\"];\n \($state); \($all_states)\n }")' >> $file_name
echo '' >> $file_name
#working
echo "$json" | jq -r '(. | to_entries | first | .key as $state | .value | to_entries | (map(.key as $event_handler | "\($state)_\($event_handler)") | join("; ")) as $event_handlers | " # event handlers\n {\n node [style=filled; color=black; fillcolor=\"#C8FFD2\"];\n \($event_handlers)\n }")' >> $file_name
echo '' >> $file_name
#working
# e.g., recording_onEndSession [ label="onEndSession" ];
echo "$json" | jq -r '(. | to_entries | first | .key as $state | .value | to_entries | map(.key as $event_handler | " \($state)_\($event_handler) [ label=\"\($event_handler)\" ]; ") | join("\n"))' >> $file_name
#working
# e.g., log_error [ label="log.error" ];
echo "$json" | jq -r '((. | to_entries | first | .key as $state | .value | to_entries | map(.value) | flatten | map(select(type == "string")) | map(select(. | test("\\.")))) as $dot_notations | $dot_notations | map(" \(. | gsub("\\."; "_")) [ label=\"\(.)\" ]; ") | join("\n"))' >> $file_name
echo '' >> $file_name
# working
# e.g., subgraph cluster_recording {
echo "$json" | jq -r '(. | to_entries | first | .key as $state | .value | to_entries | map(.key as $event_handler | .value | if type == "array" then (map(" \($state)_\($event_handler) -> \(. | gsub("\\."; "_") | gsub("\\+"; ""));")) elif type == "object" then (. | to_entries | map(" \($state)_" + $event_handler + " -> \(.value) [ label=\"status:\\n" + (.key | gsub(", "; "\\n")) + "\" ]" )) else "unsupported type" end) | flatten | join("\n")) as $all | " subgraph cluster_recording {\n fixedsize=true; width = 5;\n\n\($all)\n\n color=gray;\n }"' >> $file_name
echo '' >> $file_name
#working
# e.g., recording -> recording_onEndSession;
echo "$json" | jq -r '(. | to_entries | first | .key as $state | .value | to_entries | map(.key as $event_handler | " \($state) -> \($state)_\($event_handler);") | join("\n"))' >> $file_name
echo -e "\n}" >> $file_name
dot -Tpng "$file_name" -o "$state_name.png" && open "$state_name.png"
mvim "${state_name}_state_transitions.viz"
open "$state_name.png"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment