feat: added routing tools to amtool (#1511)
Signed-off-by: Martin Chodur <m.chodur@seznam.cz>
This commit is contained in:
parent
e220c00a01
commit
5d222bce55
|
@ -1,6 +1,7 @@
|
|||
## Next release
|
||||
|
||||
* [CHANGE] Revert Alertmanager working directory changes in Docker image back to `/alertmanager` (#1435)
|
||||
* [FEATURE] [amtool] Added `config routes` tools for vizualization and testing routes (#1511)
|
||||
|
||||
## 0.15.2 / 2018-08-14
|
||||
|
||||
|
|
16
README.md
16
README.md
|
@ -286,6 +286,22 @@ output: extended
|
|||
receiver: team-X-pager
|
||||
```
|
||||
|
||||
### Routes
|
||||
|
||||
Amtool allows you to vizualize the routes of your configuration in form of text tree view.
|
||||
Also you can use it to test the routing by passing it label set of an alert
|
||||
and it prints out all receivers the alert would match ordered and separated by `,`.
|
||||
(If you use `--verify.receivers` amtool returns error code 1 on mismatch)
|
||||
|
||||
Example of usage:
|
||||
```
|
||||
# View routing tree of remote Alertmanager
|
||||
amtool config routes --alertmanager.url=http://localhost:9090
|
||||
|
||||
# Test if alert matches expected receiver
|
||||
./amtool config routes test --config.file=doc/examples/simple.yml --tree --verify.receivers=team-X-pager service=database owner=team-X
|
||||
```
|
||||
|
||||
## High Availability
|
||||
|
||||
> Warning: High Availability is under active development
|
||||
|
|
|
@ -17,11 +17,9 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/prometheus/client_golang/api"
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
|
||||
"github.com/prometheus/alertmanager/cli/format"
|
||||
"github.com/prometheus/alertmanager/client"
|
||||
)
|
||||
|
||||
const configHelp = `View current config.
|
||||
|
@ -34,17 +32,13 @@ The amount of output is controlled by the output selection flag:
|
|||
|
||||
// configCmd represents the config command
|
||||
func configureConfigCmd(app *kingpin.Application) {
|
||||
app.Command("config", configHelp).Action(execWithTimeout(queryConfig)).PreAction(requireAlertManagerURL)
|
||||
|
||||
configCmd := app.Command("config", configHelp)
|
||||
configCmd.Command("show", configHelp).Default().Action(execWithTimeout(queryConfig)).PreAction(requireAlertManagerURL)
|
||||
configureRoutingCmd(configCmd)
|
||||
}
|
||||
|
||||
func queryConfig(ctx context.Context, _ *kingpin.ParseContext) error {
|
||||
c, err := api.NewClient(api.Config{Address: alertmanagerURL.String()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
statusAPI := client.NewStatusAPI(c)
|
||||
status, err := statusAPI.Get(ctx)
|
||||
status, err := getRemoteAlertmanagerConfigStatus(ctx, alertmanagerURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
// Copyright 2018 Prometheus Team
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/xlab/treeprint"
|
||||
|
||||
"github.com/prometheus/alertmanager/client"
|
||||
"github.com/prometheus/alertmanager/dispatch"
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
)
|
||||
|
||||
type routingShow struct {
|
||||
configFile string
|
||||
labels []string
|
||||
expectedReceivers string
|
||||
tree treeprint.Tree
|
||||
debugTree bool
|
||||
}
|
||||
|
||||
const (
|
||||
routingHelp = `Prints alert routing tree
|
||||
|
||||
Will print whole routing tree in form of ASCII tree view.
|
||||
|
||||
Routing is loaded from a local configuration file or a running Alertmanager configuration.
|
||||
Specifying --config.file takes precedence over --alertmanager.url.
|
||||
|
||||
Example:
|
||||
|
||||
./amtool config routes [show] --config.file=doc/examples/simple.yml
|
||||
|
||||
`
|
||||
branchSlugSeparator = " "
|
||||
)
|
||||
|
||||
func configureRoutingCmd(app *kingpin.CmdClause) {
|
||||
var (
|
||||
c = &routingShow{}
|
||||
routingCmd = app.Command("routes", routingHelp)
|
||||
routingShowCmd = routingCmd.Command("show", routingHelp).Default()
|
||||
configFlag = routingCmd.Flag("config.file", "Config file to be tested.")
|
||||
)
|
||||
configFlag.ExistingFileVar(&c.configFile)
|
||||
routingShowCmd.Action(execWithTimeout(c.routingShowAction))
|
||||
configureRoutingTestCmd(routingCmd, c)
|
||||
}
|
||||
|
||||
func (c *routingShow) routingShowAction(ctx context.Context, _ *kingpin.ParseContext) error {
|
||||
// Load configuration form file or URL.
|
||||
cfg, err := loadAlertmanagerConfig(ctx, alertmanagerURL, c.configFile)
|
||||
if err != nil {
|
||||
kingpin.Fatalf("%s", err)
|
||||
return err
|
||||
}
|
||||
route := dispatch.NewRoute(cfg.Route, nil)
|
||||
tree := treeprint.New()
|
||||
convertRouteToTree(route, tree)
|
||||
fmt.Println("Routing tree:")
|
||||
fmt.Println(tree.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRouteTreeSlug(route *dispatch.Route, showContinue bool, showReceiver bool) string {
|
||||
var branchSlug bytes.Buffer
|
||||
if route.Matchers.Len() == 0 {
|
||||
branchSlug.WriteString("default-route")
|
||||
} else {
|
||||
branchSlug.WriteString(route.Matchers.String())
|
||||
}
|
||||
if route.Continue && showContinue {
|
||||
branchSlug.WriteString(branchSlugSeparator)
|
||||
branchSlug.WriteString("continue: true")
|
||||
}
|
||||
if showReceiver {
|
||||
branchSlug.WriteString(branchSlugSeparator)
|
||||
branchSlug.WriteString("receiver: ")
|
||||
branchSlug.WriteString(route.RouteOpts.Receiver)
|
||||
}
|
||||
return branchSlug.String()
|
||||
}
|
||||
|
||||
func convertRouteToTree(route *dispatch.Route, tree treeprint.Tree) {
|
||||
branch := tree.AddBranch(getRouteTreeSlug(route, true, true))
|
||||
for _, r := range route.Routes {
|
||||
convertRouteToTree(r, branch)
|
||||
}
|
||||
}
|
||||
|
||||
func getMatchingTree(route *dispatch.Route, tree treeprint.Tree, lset client.LabelSet) {
|
||||
final := true
|
||||
branch := tree.AddBranch(getRouteTreeSlug(route, false, false))
|
||||
for _, r := range route.Routes {
|
||||
if r.Matchers.Match(convertClientToCommonLabelSet(lset)) == true {
|
||||
getMatchingTree(r, branch, lset)
|
||||
final = false
|
||||
if r.Continue != true {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if final == true {
|
||||
branch.SetValue(getRouteTreeSlug(route, false, true))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
// Copyright 2018 Prometheus Team
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus/alertmanager/client"
|
||||
"github.com/prometheus/alertmanager/dispatch"
|
||||
"github.com/xlab/treeprint"
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
)
|
||||
|
||||
const routingTestHelp = `Test alert routing
|
||||
|
||||
Will return receiver names which the alert with given labels resolves to.
|
||||
If the labelset resolves to multiple receivers, they are printed out in order as defined in the routing tree.
|
||||
|
||||
Routing is loaded from a local configuration file or a running Alertmanager configuration.
|
||||
Specifying --config.file takes precedence over --alertmanager.url.
|
||||
|
||||
Example:
|
||||
|
||||
./amtool config routes test --config.file=doc/examples/simple.yml --verify.receivers=team-DB-pager service=database
|
||||
|
||||
`
|
||||
|
||||
func configureRoutingTestCmd(cc *kingpin.CmdClause, c *routingShow) {
|
||||
var routingTestCmd = cc.Command("test", routingTestHelp)
|
||||
|
||||
routingTestCmd.Flag("verify.receivers", "Checks if specified receivers matches resolved receivers. The command fails if the labelset does not route to the specified receivers.").StringVar(&c.expectedReceivers)
|
||||
routingTestCmd.Flag("tree", "Prints out matching routes tree.").BoolVar(&c.debugTree)
|
||||
routingTestCmd.Arg("labels", "List of labels to be tested against the configured routes.").StringsVar(&c.labels)
|
||||
routingTestCmd.Action(execWithTimeout(c.routingTestAction))
|
||||
}
|
||||
|
||||
// resolveAlertReceivers returns list of receiver names which given LabelSet resolves to.
|
||||
func resolveAlertReceivers(mainRoute *dispatch.Route, labels *client.LabelSet) ([]string, error) {
|
||||
var (
|
||||
finalRoutes []*dispatch.Route
|
||||
receivers []string
|
||||
)
|
||||
finalRoutes = mainRoute.Match(convertClientToCommonLabelSet(*labels))
|
||||
for _, r := range finalRoutes {
|
||||
receivers = append(receivers, r.RouteOpts.Receiver)
|
||||
}
|
||||
return receivers, nil
|
||||
}
|
||||
|
||||
func printMatchingTree(mainRoute *dispatch.Route, ls client.LabelSet) {
|
||||
tree := treeprint.New()
|
||||
getMatchingTree(mainRoute, tree, ls)
|
||||
fmt.Println("Matching routes:")
|
||||
fmt.Println(tree.String())
|
||||
fmt.Print("\n")
|
||||
}
|
||||
|
||||
func (c *routingShow) routingTestAction(ctx context.Context, _ *kingpin.ParseContext) error {
|
||||
cfg, err := loadAlertmanagerConfig(ctx, alertmanagerURL, c.configFile)
|
||||
if err != nil {
|
||||
kingpin.Fatalf("%v\n", err)
|
||||
return err
|
||||
}
|
||||
|
||||
mainRoute := dispatch.NewRoute(cfg.Route, nil)
|
||||
|
||||
// Parse lables to LabelSet.
|
||||
ls, err := parseLabels(c.labels)
|
||||
if err != nil {
|
||||
kingpin.Fatalf("Failed to parse labels: %v\n", err)
|
||||
}
|
||||
|
||||
if c.debugTree == true {
|
||||
printMatchingTree(mainRoute, ls)
|
||||
}
|
||||
|
||||
receivers, err := resolveAlertReceivers(mainRoute, &ls)
|
||||
receiversSlug := strings.Join(receivers, ",")
|
||||
fmt.Printf("%s\n", receiversSlug)
|
||||
|
||||
if c.expectedReceivers != "" && c.expectedReceivers != receiversSlug {
|
||||
fmt.Printf("WARNING: Expected receivers did not match resolved receivers.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
// Copyright 2018 Prometheus Team
|
||||
// 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 cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/alertmanager/client"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/dispatch"
|
||||
)
|
||||
|
||||
type routingTestDefinition struct {
|
||||
alert client.LabelSet
|
||||
expectedReceivers []string
|
||||
configFile string
|
||||
}
|
||||
|
||||
func checkResolvedReceivers(mainRoute *dispatch.Route, ls client.LabelSet, expectedReceivers []string) error {
|
||||
resolvedReceivers, err := resolveAlertReceivers(mainRoute, &ls)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !reflect.DeepEqual(expectedReceivers, resolvedReceivers) {
|
||||
return fmt.Errorf("Unexpected routing result want: `%s`, got: `%s`", strings.Join(expectedReceivers, ","), strings.Join(resolvedReceivers, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRoutingTest(t *testing.T) {
|
||||
tests := []*routingTestDefinition{
|
||||
&routingTestDefinition{configFile: "testdata/conf.routing.yml", alert: client.LabelSet{"test": "1"}, expectedReceivers: []string{"test1"}},
|
||||
&routingTestDefinition{configFile: "testdata/conf.routing.yml", alert: client.LabelSet{"test": "2"}, expectedReceivers: []string{"test1", "test2"}},
|
||||
&routingTestDefinition{configFile: "testdata/conf.routing-reverted.yml", alert: client.LabelSet{"test": "2"}, expectedReceivers: []string{"test2", "test1"}},
|
||||
&routingTestDefinition{configFile: "testdata/conf.routing.yml", alert: client.LabelSet{"test": "volovina"}, expectedReceivers: []string{"default"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
cfg, _, err := config.LoadFile(test.configFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load test configuration: %v", err)
|
||||
}
|
||||
mainRoute := dispatch.NewRoute(cfg.Route, nil)
|
||||
err = checkResolvedReceivers(mainRoute, test.alert, test.expectedReceivers)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
fmt.Println(" OK")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
global:
|
||||
smtp_smarthost: 'localhost:25'
|
||||
|
||||
templates:
|
||||
- '/etc/alertmanager/template/*.tmpl'
|
||||
|
||||
route:
|
||||
receiver: default
|
||||
routes:
|
||||
- match:
|
||||
test: 2
|
||||
receiver: test2
|
||||
continue: true
|
||||
- match_re:
|
||||
test: ^[12]$
|
||||
receiver: test1
|
||||
continue: true
|
||||
|
||||
receivers:
|
||||
- name: default
|
||||
- name: test1
|
||||
- name: test2
|
|
@ -0,0 +1,21 @@
|
|||
global:
|
||||
smtp_smarthost: 'localhost:25'
|
||||
|
||||
templates:
|
||||
- '/etc/alertmanager/template/*.tmpl'
|
||||
|
||||
route:
|
||||
receiver: default
|
||||
routes:
|
||||
- match_re:
|
||||
test: ^[12]$
|
||||
receiver: test1
|
||||
continue: true
|
||||
- match:
|
||||
test: 2
|
||||
receiver: test2
|
||||
|
||||
receivers:
|
||||
- name: default
|
||||
- name: test1
|
||||
- name: test2
|
54
cli/utils.go
54
cli/utils.go
|
@ -18,9 +18,12 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/prometheus/alertmanager/client"
|
||||
amconfig "github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/client_golang/api"
|
||||
kingpin "gopkg.in/alecthomas/kingpin.v2"
|
||||
|
||||
"github.com/prometheus/alertmanager/pkg/parse"
|
||||
|
@ -70,6 +73,57 @@ func parseMatchers(inputMatchers []string) ([]labels.Matcher, error) {
|
|||
return matchers, nil
|
||||
}
|
||||
|
||||
// getRemoteAlertmanagerConfigStatus returns status responsecontaining configuration from remote Alertmanager
|
||||
func getRemoteAlertmanagerConfigStatus(ctx context.Context, alertmanagerURL *url.URL) (*client.ServerStatus, error) {
|
||||
c, err := api.NewClient(api.Config{Address: alertmanagerURL.String()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statusAPI := client.NewStatusAPI(c)
|
||||
status, err := statusAPI.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func checkRoutingConfigInputFlags(alertmanagerURL *url.URL, configFile string) {
|
||||
if alertmanagerURL != nil && configFile != "" {
|
||||
fmt.Fprintln(os.Stderr, "Warning: --config.file flag overrides the --alertmanager.url.")
|
||||
}
|
||||
if alertmanagerURL == nil && configFile == "" {
|
||||
kingpin.Fatalf("You have to specify one of --config.file or --alertmanager.url flags.")
|
||||
}
|
||||
}
|
||||
|
||||
func loadAlertmanagerConfig(ctx context.Context, alertmanagerURL *url.URL, configFile string) (*amconfig.Config, error) {
|
||||
checkRoutingConfigInputFlags(alertmanagerURL, configFile)
|
||||
if configFile != "" {
|
||||
cfg, _, err := amconfig.LoadFile(configFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
if alertmanagerURL != nil {
|
||||
status, err := getRemoteAlertmanagerConfigStatus(ctx, alertmanagerURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return status.ConfigJSON, nil
|
||||
}
|
||||
return nil, errors.New("Failed to get Alertmanager configuration.")
|
||||
}
|
||||
|
||||
// convertClientToCommonLabelSet converts client.LabelSet to model.Labelset
|
||||
func convertClientToCommonLabelSet(cls client.LabelSet) model.LabelSet {
|
||||
mls := make(model.LabelSet, len(cls))
|
||||
for ln, lv := range cls {
|
||||
mls[model.LabelName(ln)] = model.LabelValue(lv)
|
||||
}
|
||||
return mls
|
||||
}
|
||||
|
||||
// Parse a list of labels (cli arguments)
|
||||
func parseLabels(inputLabels []string) (client.LabelSet, error) {
|
||||
labelSet := make(client.LabelSet, len(inputLabels))
|
||||
|
|
|
@ -75,6 +75,7 @@ route:
|
|||
- match:
|
||||
owner: team-X
|
||||
receiver: team-X-pager
|
||||
continue: true
|
||||
- match:
|
||||
owner: team-Y
|
||||
receiver: team-Y-pager
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
The MIT License (MIT)
|
||||
Copyright © 2016 Maxim Kupriianov <max@kc.vc>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
|
@ -0,0 +1,128 @@
|
|||
treeprint [![GoDoc](https://godoc.org/github.com/xlab/treeprint?status.svg)](https://godoc.org/github.com/xlab/treeprint) ![test coverage](https://img.shields.io/badge/coverage-68.6%25-green.svg)
|
||||
=========
|
||||
|
||||
Package `treeprint` provides a simple ASCII tree composing tool.
|
||||
|
||||
<a href="https://upload.wikimedia.org/wikipedia/commons/5/58/ENC_SYSTEME_FIGURE.jpeg"><img alt="SYSTEME FIGURE" src="https://upload.wikimedia.org/wikipedia/commons/thumb/5/58/ENC_SYSTEME_FIGURE.jpeg/896px-ENC_SYSTEME_FIGURE.jpeg" align="left" width="300"></a>
|
||||
|
||||
If you are familiar with the [tree](http://mama.indstate.edu/users/ice/tree/) utility that is a recursive directory listing command that produces a depth indented listing of files, then you have the idea of what it would look like.
|
||||
|
||||
On my system the command yields the following
|
||||
|
||||
```
|
||||
$ tree
|
||||
.
|
||||
├── LICENSE
|
||||
├── README.md
|
||||
├── treeprint.go
|
||||
└── treeprint_test.go
|
||||
|
||||
0 directories, 4 files
|
||||
```
|
||||
|
||||
and I'd like to have the same format for my Go data structures when I print them.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
$ go get github.com/xlab/treeprint
|
||||
```
|
||||
|
||||
## Concept of work
|
||||
|
||||
The general idea is that you initialise a new tree with `treeprint.New()` and then add nodes and
|
||||
branches into it. Use `AddNode()` when you want add a node on the same level as the target or
|
||||
use `AddBranch()` when you want to go a level deeper. So `tree.AddBranch().AddNode().AddNode()` would
|
||||
create a new level with two distinct nodes on it. So `tree.AddNode().AddNode()` is a flat thing and
|
||||
`tree.AddBranch().AddBranch().AddBranch()` is a high thing. Use `String()` or `Bytes()` on a branch
|
||||
to render a subtree, or use it on the root to print the whole tree.
|
||||
|
||||
The utility will yield Unicode-friendly trees. The output is predictable and there is no platform-dependent exceptions, so if you have issues with displaying the tree in the console, all platform-related transformations can be done after the tree has been rendered: [an example](https://github.com/xlab/treeprint/issues/2#issuecomment-324944141) for Asian locales.
|
||||
|
||||
## Use cases
|
||||
|
||||
When you want to render a complex data structure:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
tree := treeprint.New()
|
||||
|
||||
// create a new branch in the root
|
||||
one := tree.AddBranch("one")
|
||||
|
||||
// add some nodes
|
||||
one.AddNode("subnode1").AddNode("subnode2")
|
||||
|
||||
// create a new sub-branch
|
||||
one.AddBranch("two").
|
||||
AddNode("subnode1").AddNode("subnode2"). // add some nodes
|
||||
AddBranch("three"). // add a new sub-branch
|
||||
AddNode("subnode1").AddNode("subnode2") // add some nodes too
|
||||
|
||||
// add one more node that should surround the inner branch
|
||||
one.AddNode("subnode3")
|
||||
|
||||
// add a new node to the root
|
||||
tree.AddNode("outernode")
|
||||
|
||||
fmt.Println(tree.String())
|
||||
}
|
||||
```
|
||||
|
||||
Will give you:
|
||||
|
||||
```
|
||||
.
|
||||
├── one
|
||||
│ ├── subnode1
|
||||
│ ├── subnode2
|
||||
│ ├── two
|
||||
│ │ ├── subnode1
|
||||
│ │ ├── subnode2
|
||||
│ │ └── three
|
||||
│ │ ├── subnode1
|
||||
│ │ └── subnode2
|
||||
│ └── subnode3
|
||||
└── outernode
|
||||
```
|
||||
|
||||
Another case, when you have to make a tree where any leaf may have some meta-data (as `tree` is capable of it):
|
||||
|
||||
```go
|
||||
func main {
|
||||
tree := treeprint.New()
|
||||
|
||||
tree.AddNode("Dockerfile")
|
||||
tree.AddNode("Makefile")
|
||||
tree.AddNode("aws.sh")
|
||||
tree.AddMetaBranch(" 204", "bin").
|
||||
AddNode("dbmaker").AddNode("someserver").AddNode("testtool")
|
||||
tree.AddMetaBranch(" 374", "deploy").
|
||||
AddNode("Makefile").AddNode("bootstrap.sh")
|
||||
tree.AddMetaNode("122K", "testtool.a")
|
||||
|
||||
fmt.Println(tree.String())
|
||||
}
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
.
|
||||
├── Dockerfile
|
||||
├── Makefile
|
||||
├── aws.sh
|
||||
├── [ 204] bin
|
||||
│ ├── dbmaker
|
||||
│ ├── someserver
|
||||
│ └── testtool
|
||||
├── [ 374] deploy
|
||||
│ ├── Makefile
|
||||
│ └── bootstrap.sh
|
||||
└── [122K] testtool.a
|
||||
```
|
||||
|
||||
Yay! So it works.
|
||||
|
||||
## License
|
||||
MIT
|
|
@ -0,0 +1,47 @@
|
|||
package treeprint
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isEmpty(v *reflect.Value) bool {
|
||||
switch v.Kind() {
|
||||
case reflect.Array, reflect.Map, reflect.Slice, reflect.String:
|
||||
return v.Len() == 0
|
||||
case reflect.Bool:
|
||||
return !v.Bool()
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return v.Int() == 0
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
|
||||
return v.Uint() == 0
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return v.Float() == 0
|
||||
case reflect.Interface, reflect.Ptr:
|
||||
return v.IsNil()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tagSpec(tag string) (name string, omit bool) {
|
||||
parts := strings.Split(tag, ",")
|
||||
if len(parts) < 2 {
|
||||
return tag, false
|
||||
}
|
||||
if parts[1] == "omitempty" {
|
||||
return parts[0], true
|
||||
}
|
||||
return parts[0], false
|
||||
}
|
||||
|
||||
func filterTags(tag reflect.StructTag) string {
|
||||
tags := strings.Split(string(tag), " ")
|
||||
filtered := make([]string, 0, len(tags))
|
||||
for i := range tags {
|
||||
if strings.HasPrefix(tags[i], "tree:") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, tags[i])
|
||||
}
|
||||
return strings.Join(filtered, " ")
|
||||
}
|
|
@ -0,0 +1,322 @@
|
|||
package treeprint
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StructTreeOption int
|
||||
|
||||
const (
|
||||
StructNameTree StructTreeOption = iota
|
||||
StructValueTree
|
||||
StructTagTree
|
||||
StructTypeTree
|
||||
StructTypeSizeTree
|
||||
)
|
||||
|
||||
func FromStruct(v interface{}, opt ...StructTreeOption) (Tree, error) {
|
||||
var treeOpt StructTreeOption
|
||||
if len(opt) > 0 {
|
||||
treeOpt = opt[0]
|
||||
}
|
||||
switch treeOpt {
|
||||
case StructNameTree:
|
||||
tree := New()
|
||||
err := nameTree(tree, v)
|
||||
return tree, err
|
||||
case StructValueTree:
|
||||
tree := New()
|
||||
err := valueTree(tree, v)
|
||||
return tree, err
|
||||
case StructTagTree:
|
||||
tree := New()
|
||||
err := tagTree(tree, v)
|
||||
return tree, err
|
||||
case StructTypeTree:
|
||||
tree := New()
|
||||
err := typeTree(tree, v)
|
||||
return tree, err
|
||||
case StructTypeSizeTree:
|
||||
tree := New()
|
||||
err := typeSizeTree(tree, v)
|
||||
return tree, err
|
||||
default:
|
||||
err := fmt.Errorf("treeprint: invalid StructTreeOption %v", treeOpt)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
type FmtFunc func(name string, v interface{}) (string, bool)
|
||||
|
||||
func FromStructWithMeta(v interface{}, fmtFunc FmtFunc) (Tree, error) {
|
||||
if fmtFunc == nil {
|
||||
tree := New()
|
||||
err := nameTree(tree, v)
|
||||
return tree, err
|
||||
}
|
||||
tree := New()
|
||||
err := metaTree(tree, v, fmtFunc)
|
||||
return tree, err
|
||||
}
|
||||
|
||||
func Repr(v interface{}) string {
|
||||
tree := New()
|
||||
vType := reflect.TypeOf(v)
|
||||
vValue := reflect.ValueOf(v)
|
||||
_, val, isStruct := getValue(vType, &vValue)
|
||||
if !isStruct {
|
||||
return fmt.Sprintf("%+v", val.Interface())
|
||||
}
|
||||
err := valueTree(tree, val.Interface())
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return tree.String()
|
||||
}
|
||||
|
||||
func nameTree(tree Tree, v interface{}) error {
|
||||
typ, val, err := checkType(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := typ.NumField()
|
||||
for i := 0; i < fields; i++ {
|
||||
field := typ.Field(i)
|
||||
fieldValue := val.Field(i)
|
||||
name, skip, omit := getMeta(field.Name, field.Tag)
|
||||
if skip || omit && isEmpty(&fieldValue) {
|
||||
continue
|
||||
}
|
||||
typ, val, isStruct := getValue(field.Type, &fieldValue)
|
||||
if !isStruct {
|
||||
tree.AddNode(name)
|
||||
continue
|
||||
} else if subNum := typ.NumField(); subNum == 0 {
|
||||
tree.AddNode(name)
|
||||
continue
|
||||
}
|
||||
branch := tree.AddBranch(name)
|
||||
if err := nameTree(branch, val.Interface()); err != nil {
|
||||
err := fmt.Errorf("%v on struct branch %s", err, name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getMeta(fieldName string, tag reflect.StructTag) (name string, skip, omit bool) {
|
||||
if tagStr := tag.Get("tree"); len(tagStr) > 0 {
|
||||
name, omit = tagSpec(tagStr)
|
||||
}
|
||||
if name == "-" {
|
||||
return fieldName, true, omit
|
||||
}
|
||||
if len(name) == 0 {
|
||||
name = fieldName
|
||||
} else if trimmed := strings.TrimSpace(name); len(trimmed) == 0 {
|
||||
name = fieldName
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func valueTree(tree Tree, v interface{}) error {
|
||||
typ, val, err := checkType(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := typ.NumField()
|
||||
for i := 0; i < fields; i++ {
|
||||
field := typ.Field(i)
|
||||
fieldValue := val.Field(i)
|
||||
name, skip, omit := getMeta(field.Name, field.Tag)
|
||||
if skip || omit && isEmpty(&fieldValue) {
|
||||
continue
|
||||
}
|
||||
typ, val, isStruct := getValue(field.Type, &fieldValue)
|
||||
if !isStruct {
|
||||
tree.AddMetaNode(val.Interface(), name)
|
||||
continue
|
||||
} else if subNum := typ.NumField(); subNum == 0 {
|
||||
tree.AddMetaNode(val.Interface(), name)
|
||||
continue
|
||||
}
|
||||
branch := tree.AddBranch(name)
|
||||
if err := valueTree(branch, val.Interface()); err != nil {
|
||||
err := fmt.Errorf("%v on struct branch %s", err, name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tagTree(tree Tree, v interface{}) error {
|
||||
typ, val, err := checkType(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := typ.NumField()
|
||||
for i := 0; i < fields; i++ {
|
||||
field := typ.Field(i)
|
||||
fieldValue := val.Field(i)
|
||||
name, skip, omit := getMeta(field.Name, field.Tag)
|
||||
if skip || omit && isEmpty(&fieldValue) {
|
||||
continue
|
||||
}
|
||||
filteredTag := filterTags(field.Tag)
|
||||
typ, val, isStruct := getValue(field.Type, &fieldValue)
|
||||
if !isStruct {
|
||||
tree.AddMetaNode(filteredTag, name)
|
||||
continue
|
||||
} else if subNum := typ.NumField(); subNum == 0 {
|
||||
tree.AddMetaNode(filteredTag, name)
|
||||
continue
|
||||
}
|
||||
branch := tree.AddMetaBranch(filteredTag, name)
|
||||
if err := tagTree(branch, val.Interface()); err != nil {
|
||||
err := fmt.Errorf("%v on struct branch %s", err, name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func typeTree(tree Tree, v interface{}) error {
|
||||
typ, val, err := checkType(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := typ.NumField()
|
||||
for i := 0; i < fields; i++ {
|
||||
field := typ.Field(i)
|
||||
fieldValue := val.Field(i)
|
||||
name, skip, omit := getMeta(field.Name, field.Tag)
|
||||
if skip || omit && isEmpty(&fieldValue) {
|
||||
continue
|
||||
}
|
||||
typ, val, isStruct := getValue(field.Type, &fieldValue)
|
||||
typename := fmt.Sprintf("%T", val.Interface())
|
||||
if !isStruct {
|
||||
tree.AddMetaNode(typename, name)
|
||||
continue
|
||||
} else if subNum := typ.NumField(); subNum == 0 {
|
||||
tree.AddMetaNode(typename, name)
|
||||
continue
|
||||
}
|
||||
branch := tree.AddMetaBranch(typename, name)
|
||||
if err := typeTree(branch, val.Interface()); err != nil {
|
||||
err := fmt.Errorf("%v on struct branch %s", err, name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func typeSizeTree(tree Tree, v interface{}) error {
|
||||
typ, val, err := checkType(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := typ.NumField()
|
||||
for i := 0; i < fields; i++ {
|
||||
field := typ.Field(i)
|
||||
fieldValue := val.Field(i)
|
||||
name, skip, omit := getMeta(field.Name, field.Tag)
|
||||
if skip || omit && isEmpty(&fieldValue) {
|
||||
continue
|
||||
}
|
||||
typ, val, isStruct := getValue(field.Type, &fieldValue)
|
||||
typesize := typ.Size()
|
||||
if !isStruct {
|
||||
tree.AddMetaNode(typesize, name)
|
||||
continue
|
||||
} else if subNum := typ.NumField(); subNum == 0 {
|
||||
tree.AddMetaNode(typesize, name)
|
||||
continue
|
||||
}
|
||||
branch := tree.AddMetaBranch(typesize, name)
|
||||
if err := typeSizeTree(branch, val.Interface()); err != nil {
|
||||
err := fmt.Errorf("%v on struct branch %s", err, name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func metaTree(tree Tree, v interface{}, fmtFunc FmtFunc) error {
|
||||
typ, val, err := checkType(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields := typ.NumField()
|
||||
for i := 0; i < fields; i++ {
|
||||
field := typ.Field(i)
|
||||
fieldValue := val.Field(i)
|
||||
name, skip, omit := getMeta(field.Name, field.Tag)
|
||||
if skip || omit && isEmpty(&fieldValue) {
|
||||
continue
|
||||
}
|
||||
typ, val, isStruct := getValue(field.Type, &fieldValue)
|
||||
formatted, show := fmtFunc(name, val.Interface())
|
||||
if !isStruct {
|
||||
if show {
|
||||
tree.AddMetaNode(formatted, name)
|
||||
continue
|
||||
}
|
||||
tree.AddNode(name)
|
||||
continue
|
||||
} else if subNum := typ.NumField(); subNum == 0 {
|
||||
if show {
|
||||
tree.AddMetaNode(formatted, name)
|
||||
continue
|
||||
}
|
||||
tree.AddNode(name)
|
||||
continue
|
||||
}
|
||||
var branch Tree
|
||||
if show {
|
||||
branch = tree.AddMetaBranch(formatted, name)
|
||||
} else {
|
||||
branch = tree.AddBranch(name)
|
||||
}
|
||||
if err := metaTree(branch, val.Interface(), fmtFunc); err != nil {
|
||||
err := fmt.Errorf("%v on struct branch %s", err, name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getValue(typ reflect.Type, val *reflect.Value) (reflect.Type, *reflect.Value, bool) {
|
||||
switch typ.Kind() {
|
||||
case reflect.Ptr:
|
||||
typ = typ.Elem()
|
||||
if typ.Kind() == reflect.Struct {
|
||||
elem := val.Elem()
|
||||
return typ, &elem, true
|
||||
}
|
||||
case reflect.Struct:
|
||||
return typ, val, true
|
||||
}
|
||||
return typ, val, false
|
||||
}
|
||||
|
||||
func checkType(v interface{}) (reflect.Type, *reflect.Value, error) {
|
||||
typ := reflect.TypeOf(v)
|
||||
val := reflect.ValueOf(v)
|
||||
switch typ.Kind() {
|
||||
case reflect.Ptr:
|
||||
typ = typ.Elem()
|
||||
if typ.Kind() != reflect.Struct {
|
||||
err := fmt.Errorf("treeprint: %T is not a struct we could work with", v)
|
||||
return nil, nil, err
|
||||
}
|
||||
val = val.Elem()
|
||||
case reflect.Struct:
|
||||
default:
|
||||
err := fmt.Errorf("treeprint: %T is not a struct we could work with", v)
|
||||
return nil, nil, err
|
||||
}
|
||||
return typ, &val, nil
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
// Package treeprint provides a simple ASCII tree composing tool.
|
||||
package treeprint
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type Value interface{}
|
||||
type MetaValue interface{}
|
||||
|
||||
// Tree represents a tree structure with leaf-nodes and branch-nodes.
|
||||
type Tree interface {
|
||||
// AddNode adds a new node to a branch.
|
||||
AddNode(v Value) Tree
|
||||
// AddMetaNode adds a new node with meta value provided to a branch.
|
||||
AddMetaNode(meta MetaValue, v Value) Tree
|
||||
// AddBranch adds a new branch node (a level deeper).
|
||||
AddBranch(v Value) Tree
|
||||
// AddMetaBranch adds a new branch node (a level deeper) with meta value provided.
|
||||
AddMetaBranch(meta MetaValue, v Value) Tree
|
||||
// Branch converts a leaf-node to a branch-node,
|
||||
// applying this on a branch-node does no effect.
|
||||
Branch() Tree
|
||||
// FindByMeta finds a node whose meta value matches the provided one by reflect.DeepEqual,
|
||||
// returns nil if not found.
|
||||
FindByMeta(meta MetaValue) Tree
|
||||
// FindByValue finds a node whose value matches the provided one by reflect.DeepEqual,
|
||||
// returns nil if not found.
|
||||
FindByValue(value Value) Tree
|
||||
// returns the last node of a tree
|
||||
FindLastNode() Tree
|
||||
// String renders the tree or subtree as a string.
|
||||
String() string
|
||||
// Bytes renders the tree or subtree as byteslice.
|
||||
Bytes() []byte
|
||||
|
||||
SetValue(value Value)
|
||||
}
|
||||
|
||||
type node struct {
|
||||
Root *node
|
||||
Meta MetaValue
|
||||
Value Value
|
||||
Nodes []*node
|
||||
}
|
||||
|
||||
func (n *node) FindLastNode() Tree {
|
||||
ns := n.Nodes
|
||||
n = ns[len(ns)-1]
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *node) AddNode(v Value) Tree {
|
||||
n.Nodes = append(n.Nodes, &node{
|
||||
Root: n,
|
||||
Value: v,
|
||||
})
|
||||
if n.Root != nil {
|
||||
return n.Root
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *node) AddMetaNode(meta MetaValue, v Value) Tree {
|
||||
n.Nodes = append(n.Nodes, &node{
|
||||
Root: n,
|
||||
Meta: meta,
|
||||
Value: v,
|
||||
})
|
||||
if n.Root != nil {
|
||||
return n.Root
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *node) AddBranch(v Value) Tree {
|
||||
branch := &node{
|
||||
Value: v,
|
||||
}
|
||||
n.Nodes = append(n.Nodes, branch)
|
||||
return branch
|
||||
}
|
||||
|
||||
func (n *node) AddMetaBranch(meta MetaValue, v Value) Tree {
|
||||
branch := &node{
|
||||
Meta: meta,
|
||||
Value: v,
|
||||
}
|
||||
n.Nodes = append(n.Nodes, branch)
|
||||
return branch
|
||||
}
|
||||
|
||||
func (n *node) Branch() Tree {
|
||||
n.Root = nil
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *node) FindByMeta(meta MetaValue) Tree {
|
||||
for _, node := range n.Nodes {
|
||||
if reflect.DeepEqual(node.Meta, meta) {
|
||||
return node
|
||||
}
|
||||
if v := node.FindByMeta(meta); v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *node) FindByValue(value Value) Tree {
|
||||
for _, node := range n.Nodes {
|
||||
if reflect.DeepEqual(node.Value, value) {
|
||||
return node
|
||||
}
|
||||
if v := node.FindByMeta(value); v != nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *node) Bytes() []byte {
|
||||
buf := new(bytes.Buffer)
|
||||
level := 0
|
||||
var levelsEnded []int
|
||||
if n.Root == nil {
|
||||
buf.WriteString(fmt.Sprintf("%v",n.Value))
|
||||
buf.WriteByte('\n')
|
||||
} else {
|
||||
edge := EdgeTypeMid
|
||||
if len(n.Nodes) == 0 {
|
||||
edge = EdgeTypeEnd
|
||||
levelsEnded = append(levelsEnded, level)
|
||||
}
|
||||
printValues(buf, 0, levelsEnded, edge, n.Meta, n.Value)
|
||||
}
|
||||
if len(n.Nodes) > 0 {
|
||||
printNodes(buf, level, levelsEnded, n.Nodes)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func (n *node) String() string {
|
||||
return string(n.Bytes())
|
||||
}
|
||||
|
||||
func (n *node) SetValue(value Value){
|
||||
n.Value = value
|
||||
}
|
||||
|
||||
func printNodes(wr io.Writer,
|
||||
level int, levelsEnded []int, nodes []*node) {
|
||||
|
||||
for i, node := range nodes {
|
||||
edge := EdgeTypeMid
|
||||
if i == len(nodes)-1 {
|
||||
levelsEnded = append(levelsEnded, level)
|
||||
edge = EdgeTypeEnd
|
||||
}
|
||||
printValues(wr, level, levelsEnded, edge, node.Meta, node.Value)
|
||||
if len(node.Nodes) > 0 {
|
||||
printNodes(wr, level+1, levelsEnded, node.Nodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printValues(wr io.Writer,
|
||||
level int, levelsEnded []int, edge EdgeType, meta MetaValue, val Value) {
|
||||
|
||||
for i := 0; i < level; i++ {
|
||||
if isEnded(levelsEnded, i) {
|
||||
fmt.Fprint(wr, " ")
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(wr, "%s ", EdgeTypeLink)
|
||||
}
|
||||
if meta != nil {
|
||||
fmt.Fprintf(wr, "%s [%v] %v\n", edge, meta, val)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(wr, "%s %v\n", edge, val)
|
||||
}
|
||||
|
||||
func isEnded(levelsEnded []int, level int) bool {
|
||||
for _, l := range levelsEnded {
|
||||
if l == level {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type EdgeType string
|
||||
|
||||
var (
|
||||
EdgeTypeLink EdgeType = "│"
|
||||
EdgeTypeMid EdgeType = "├──"
|
||||
EdgeTypeEnd EdgeType = "└──"
|
||||
)
|
||||
|
||||
func New() Tree {
|
||||
return &node{Value: "."}
|
||||
}
|
|
@ -323,6 +323,12 @@
|
|||
"revision": "1e01b2bdbae8edb393fcf555732304f34d192fc9",
|
||||
"revisionTime": "2016-11-21T14:22:35Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "Oen67XBkt5voJj1wya8kIakvdT4=",
|
||||
"path": "github.com/xlab/treeprint",
|
||||
"revision": "d6fb6747feb6e7cfdc44682a024bddf87ef07ec2",
|
||||
"revisionTime": "2018-06-15T14:19:04Z"
|
||||
},
|
||||
{
|
||||
"checksumSHA1": "hKZGpbFuCrkE4s1vxubKK1aFlfI=",
|
||||
"path": "golang.org/x/net/context",
|
||||
|
|
Loading…
Reference in New Issue