| // Copyright 2016 The LUCI Authors. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package main |
| |
| import ( |
| "bytes" |
| "context" |
| "fmt" |
| "os" |
| "os/exec" |
| "os/signal" |
| "sort" |
| "strings" |
| "sync" |
| "time" |
| |
| "github.com/golang/protobuf/proto" |
| "github.com/maruel/subcommands" |
| "go.chromium.org/luci/client/flagpb" |
| "go.chromium.org/luci/common/cli" |
| "go.chromium.org/luci/common/clock" |
| "go.chromium.org/luci/common/errors" |
| "go.chromium.org/luci/common/lhttp" |
| "go.chromium.org/luci/common/logging" |
| dm "go.chromium.org/luci/dm/api/service/v1" |
| "go.chromium.org/luci/grpc/prpc" |
| ) |
| |
| var cmdVisQuery = &subcommands.Command{ |
| UsageLine: `vis [options] [args for rpc command]`, |
| ShortDesc: "Runs a DM WalkGraph query and visualizes the result to a .dot file.", |
| LongDesc: `This command runs a WalkGraph query repeatedly, overwriting the .dot |
| file specified with a visual representation of the graph.`, |
| CommandRun: func() subcommands.CommandRun { |
| r := &visQueryRun{} |
| r.registerOptions() |
| return r |
| }, |
| } |
| |
| type visQueryRun struct { |
| cmdRun |
| host string |
| path string |
| sequence bool |
| includeAll bool |
| } |
| |
| func (r *visQueryRun) registerOptions() { |
| r.Flags.StringVar(&r.host, "host", ":8080", |
| "The host to connect to") |
| r.Flags.StringVar(&r.path, "path", "", |
| "The output path for the .dot file. Leave empty to query once and print the result stdout.") |
| r.Flags.BoolVar(&r.sequence, "sequence", false, |
| "Use `path` as the base path for a sequence of pngs. `path` must not be empty.") |
| r.Flags.BoolVar(&r.includeAll, "include-all", false, |
| "Modify query to have all 'Include' options.") |
| } |
| |
| var alphabet = []rune{ |
| // lower greek |
| 'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', |
| 'π', 'ρ', 'ς', 'τ', 'υ', 'φ', 'χ', 'ψ', 'ω', |
| |
| // lower english |
| //'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', |
| //'q', 'r', 's', 't', 'w', 'y', 'z', |
| |
| // upper greek |
| 'Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ', 'Η', 'Θ', 'Ι', 'Κ', 'Λ', 'Μ', 'Ν', 'Ξ', 'Ο', |
| 'Π', 'Ρ', 'Σ', 'Τ', 'Υ', 'Φ', 'Χ', 'Ψ', 'Ω', |
| |
| // upper english |
| //'C', 'D', 'F', 'G', 'J', 'L', 'Q', 'R', 'S', 'U', 'V', 'W', |
| } |
| |
| type name uint |
| |
| var alphabetLen = name(len(alphabet)) |
| |
| func (n name) runes() []rune { |
| if n < alphabetLen { |
| return alphabet[n : n+1] |
| } |
| return append((n/alphabetLen - 1).runes(), alphabet[n%alphabetLen]) |
| } |
| |
| func (n name) String() string { |
| return string(n.runes()) |
| } |
| |
| var alphabetLock = sync.Mutex{} |
| var curAlphabetName = name(0) |
| var alphabetMap = map[string]name{} |
| |
| func getColor(a *dm.Attempt) string { |
| switch a.Data.State() { |
| case dm.Attempt_SCHEDULING: |
| return "cadetblue1" |
| case dm.Attempt_EXECUTING: |
| return "darkorchid" |
| case dm.Attempt_WAITING: |
| return "gold" |
| case dm.Attempt_FINISHED: |
| return "chartreuse" |
| case dm.Attempt_ABNORMAL_FINISHED: |
| return "crimson" |
| } |
| return "deeppink" |
| } |
| |
| func nameFor(qid string) string { |
| alphabetLock.Lock() |
| defer alphabetLock.Lock() |
| if curName, ok := alphabetMap[qid]; ok { |
| return curName.String() |
| } |
| ret := curAlphabetName |
| alphabetMap[qid] = ret |
| curAlphabetName++ |
| return ret.String() |
| } |
| |
| func renderDotFile(gdata *dm.GraphData) string { |
| buf := &bytes.Buffer{} |
| |
| indent := 0 |
| idt := func() string { return strings.Repeat(" ", indent) } |
| p := func(format string, args ...interface{}) { |
| indent -= strings.Count(format, "}") |
| fmt.Fprintf(buf, idt()+format+"\n", args...) |
| indent += strings.Count(format, "{") |
| } |
| |
| var edges []string |
| |
| p("digraph {") |
| |
| sortedQids := make([]string, 0, len(gdata.Quests)) |
| for qid := range gdata.Quests { |
| sortedQids = append(sortedQids, qid) |
| } |
| sort.Strings(sortedQids) |
| |
| for _, qid := range sortedQids { |
| q := gdata.Quests[qid] |
| qName := nameFor(qid) |
| p("subgraph cluster_%s {", qName) |
| p("label=%q", qName) |
| for aid, a := range q.Attempts { |
| if a.DNE { |
| continue |
| } |
| color := getColor(a) |
| p(`"%s:%d" [style=filled label="%d" fillcolor=%s]`, qName, aid, aid, color) |
| if a.FwdDeps != nil { |
| for depQid, aNums := range a.FwdDeps.To { |
| depQName := nameFor(depQid) |
| for _, num := range aNums.Nums { |
| edges = append(edges, fmt.Sprintf(`"%s:%d" -> "%s:%d"`, qName, aid, depQName, num)) |
| } |
| } |
| } |
| } |
| p("}") |
| } |
| |
| sort.Strings(edges) |
| for _, e := range edges { |
| p(e) |
| } |
| p("}") |
| |
| return buf.String() |
| } |
| |
| func runQuery(c context.Context, dc dm.DepsClient, query *dm.WalkGraphReq) (ret *dm.GraphData, err error) { |
| query = proto.Clone(query).(*dm.WalkGraphReq) |
| ret = &dm.GraphData{} |
| for c.Err() == nil { |
| newRet := (*dm.GraphData)(nil) |
| newRet, err = dc.WalkGraph(c, query) |
| if err != nil || newRet.HadErrors { |
| return |
| } |
| ret.UpdateWith(newRet) |
| if !newRet.HadMore { |
| return |
| } |
| query.Query = ret.ToQuery() |
| } |
| return |
| } |
| |
| func (r *visQueryRun) Run(a subcommands.Application, args []string, env subcommands.Env) int { |
| r.cmd = cmdVisQuery |
| |
| c, cancel := context.WithCancel(cli.GetContext(a, r, env)) |
| |
| if r.path == "" && r.sequence { |
| return r.argErr("path is required for sequence") |
| } |
| |
| sigChan := make(chan os.Signal, 1) |
| signal.Notify(sigChan, os.Interrupt) |
| go func() { |
| <-sigChan |
| signal.Stop(sigChan) |
| cancel() |
| }() |
| |
| query := &dm.WalkGraphReq{} |
| if err := flagpb.UnmarshalMessage(args, flagpb.NewResolver(dm.FileDescriptorSet()), query); err != nil { |
| logging.WithError(err).Errorf(c, "could not construct query") |
| return 1 |
| } |
| if r.includeAll { |
| query.Include = dm.MakeWalkGraphIncludeAll() |
| } |
| |
| client := &prpc.Client{ |
| Host: r.host, |
| Options: prpc.DefaultOptions(), |
| } |
| client.Options.Insecure = lhttp.IsLocalHost(r.host) |
| dc := dm.NewDepsPRPCClient(client) |
| |
| prev := "" |
| seq := 0 |
| |
| for c.Err() == nil { |
| gdata, err := runQuery(c, dc, query) |
| if err != nil { |
| if errors.Any(err, func(err error) bool { return err == context.Canceled }) { |
| return 0 |
| } |
| logging.WithError(err).Errorf(c, "error running query") |
| return 1 |
| } |
| |
| if gdata != nil { |
| newVal := renderDotFile(gdata) |
| if prev != newVal { |
| prev = newVal |
| |
| if r.sequence { |
| outfile := fmt.Sprintf("%s%d.png", r.path, seq) |
| seq++ |
| cmd := exec.CommandContext(c, "dot", |
| "-Gdpi=300", "-Glwidth=6", "-Glheight=17.6", |
| "-Tpng", "-o"+outfile) |
| cmd.Stdin = strings.NewReader(newVal) |
| err := cmd.Run() |
| if err != nil { |
| logging.WithError(err).Errorf(c, "error running dot") |
| return 1 |
| } |
| } else { |
| ofile := os.Stdout |
| if r.path != "" { |
| ofile, err = os.Create(r.path) |
| if err != nil { |
| logging.Fields{ |
| logging.ErrorKey: err, |
| "outfile": r.path, |
| }.Errorf(c, "error opening output file") |
| return 1 |
| } |
| } |
| ofile.WriteString(newVal) |
| ofile.Close() |
| if r.path == "" { |
| return 0 |
| } |
| } |
| } |
| } |
| |
| clock.Sleep(c, time.Second/4) |
| } |
| return 0 |
| } |