├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── app.go ├── cmd └── mkrg │ └── main.go ├── fetcher.go ├── go.mod ├── go.sum ├── graph.go ├── image.go ├── iterm2ui.go ├── metrics.go ├── sixelui.go ├── tui.go ├── ui.go └── viewer.go /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ${{ matrix.os }} 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v3 19 | - name: Setup Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.x 23 | - name: Test 24 | run: make test 25 | - name: Lint 26 | run: make lint 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | - name: Setup Go 16 | uses: actions/setup-go@v3 17 | with: 18 | go-version: 1.x 19 | - name: Cross build 20 | run: make cross 21 | - name: Create Release 22 | id: create_release 23 | uses: actions/create-release@v1 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | with: 27 | tag_name: ${{ github.ref }} 28 | release_name: Release ${{ github.ref }} 29 | - name: Upload 30 | run: make upload 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /mkrg 2 | /goxz 3 | /CREDITS 4 | *.exe 5 | *.test 6 | *.out 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2022 itchyny 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN := mkrg 2 | VERSION := $$(make -s show-version) 3 | VERSION_PATH := cmd/$(BIN) 4 | BUILD_LDFLAGS := "-s -w" 5 | GOBIN ?= $(shell go env GOPATH)/bin 6 | 7 | .PHONY: all 8 | all: build 9 | 10 | .PHONY: build 11 | build: 12 | go build -ldflags=$(BUILD_LDFLAGS) -o $(BIN) ./cmd/$(BIN) 13 | 14 | .PHONY: install 15 | install: 16 | go install -ldflags=$(BUILD_LDFLAGS) ./... 17 | 18 | .PHONY: show-version 19 | show-version: $(GOBIN)/gobump 20 | @gobump show -r $(VERSION_PATH) 21 | 22 | $(GOBIN)/gobump: 23 | @go install github.com/x-motemen/gobump/cmd/gobump@latest 24 | 25 | .PHONY: cross 26 | cross: $(GOBIN)/goxz CREDITS 27 | goxz -n $(BIN) -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) ./cmd/$(BIN) 28 | 29 | $(GOBIN)/goxz: 30 | go install github.com/Songmu/goxz/cmd/goxz@latest 31 | 32 | CREDITS: $(GOBIN)/gocredits go.sum 33 | go mod tidy 34 | gocredits -w . 35 | 36 | $(GOBIN)/gocredits: 37 | go install github.com/Songmu/gocredits/cmd/gocredits@latest 38 | 39 | .PHONY: test 40 | test: build 41 | go test -v -race ./... 42 | 43 | .PHONY: lint 44 | lint: $(GOBIN)/staticcheck 45 | go vet ./... 46 | staticcheck -checks all,-ST1000 ./... 47 | 48 | $(GOBIN)/staticcheck: 49 | go install honnef.co/go/tools/cmd/staticcheck@latest 50 | 51 | .PHONY: clean 52 | clean: 53 | rm -rf $(BIN) goxz CREDITS 54 | go clean 55 | 56 | .PHONY: bump 57 | bump: $(GOBIN)/gobump 58 | ifneq ($(shell git status --porcelain),) 59 | $(error git workspace is dirty) 60 | endif 61 | ifneq ($(shell git rev-parse --abbrev-ref HEAD),main) 62 | $(error current branch is not main) 63 | endif 64 | @gobump up -w "$(VERSION_PATH)" 65 | git commit -am "bump up version to $(VERSION)" 66 | git tag "v$(VERSION)" 67 | git push origin main 68 | git push origin "refs/tags/v$(VERSION)" 69 | 70 | .PHONY: upload 71 | upload: $(GOBIN)/ghr 72 | ghr "v$(VERSION)" goxz 73 | 74 | $(GOBIN)/ghr: 75 | go install github.com/tcnksm/ghr@latest 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mkrg 2 | [![CI Status](https://github.com/itchyny/mkrg/workflows/CI/badge.svg)](https://github.com/itchyny/mkrg/actions) 3 | 4 | [Mackerel](https://mackerel.io) graph viewer in terminal 5 | 6 | ## Screenshots 7 | On iTerm2, export `MKRG_VIEWER=iTerm2` to get the graphical viewer (exporting the environment variable is required when you're using ssh). 8 | ![mkrg](https://user-images.githubusercontent.com/375258/47090208-65696e80-d25d-11e8-936a-3fe80879ebe7.png) 9 | 10 | Sixel viewer is implemented but does not fit to the terminal width (patches welcolme). 11 | 12 | The command has simple graph viewer using Braille. 13 | ![mkrg](https://user-images.githubusercontent.com/375258/47095115-8c2ca280-d267-11e8-99de-85dfb7401798.png) 14 | 15 | ## Installation 16 | ### Homebrew 17 | ```sh 18 | brew install itchyny/tap/mkrg 19 | ``` 20 | 21 | ### Build from source 22 | ```sh 23 | go install github.com/itchyny/mkrg/cmd/mkrg@latest 24 | ``` 25 | 26 | ## Bug Tracker 27 | Report bug at [Issues・itchyny/mkrg - GitHub](https://github.com/itchyny/mkrg/issues). 28 | 29 | ## Author 30 | itchyny (https://github.com/itchyny) 31 | 32 | ## License 33 | This software is released under the MIT License, see LICENSE. 34 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | "time" 7 | 8 | "github.com/mackerelio/mackerel-client-go" 9 | "golang.org/x/sync/errgroup" 10 | "golang.org/x/term" 11 | ) 12 | 13 | // App ... 14 | type App struct { 15 | client *mackerel.Client 16 | hostID string 17 | fetcher *fetcher 18 | } 19 | 20 | // NewApp creates a new app. 21 | func NewApp(client *mackerel.Client, hostID string) *App { 22 | return &App{ 23 | client: client, 24 | hostID: hostID, 25 | fetcher: newFetcher(client), 26 | } 27 | } 28 | 29 | // Run the app. 30 | func (app *App) Run() error { 31 | metricNamesMap, err := app.getMetricNamesMap() 32 | if err != nil { 33 | return err 34 | } 35 | termWidth, _, err := term.GetSize(0) 36 | if err != nil { 37 | return err 38 | } 39 | var maxColumn int 40 | if termWidth > 160 { 41 | maxColumn = 3 42 | } else if termWidth > 80 { 43 | maxColumn = 2 44 | } else { 45 | maxColumn = 1 46 | } 47 | width := (termWidth+4)/maxColumn - 4 48 | height := width / 8 * 3 49 | until := time.Now().Round(time.Minute) 50 | from := until.Add(-time.Duration(width*3) * time.Minute) 51 | var ui ui 52 | if os.Getenv("TERM_PROGRAM") == "iTerm.app" && os.Getenv("MKRG_VIEWER") == "" || 53 | os.Getenv("MKRG_VIEWER") == "iTerm2" { 54 | ui = newIterm2UI(height, width, maxColumn, from, until) 55 | } else if os.Getenv("MKRG_VIEWER") == "Sixel" { 56 | ui = newSixel(height, width, maxColumn, from, until) 57 | } else { 58 | from = until.Add(-time.Duration(width) * time.Minute) 59 | ui = newTui(height, width, maxColumn, until) 60 | } 61 | eg, mu := errgroup.Group{}, new(sync.Mutex) 62 | orderChs := make([]chan struct{}, len(systemGraphs)) 63 | for i := range orderChs { 64 | orderChs[i] = make(chan struct{}) 65 | } 66 | var j int 67 | for _, graph := range systemGraphs { 68 | graph := graph 69 | var metricNames []string 70 | for _, metric := range graph.metrics { 71 | metricNames = append(metricNames, filterMetricNames(metricNamesMap, metric.name)...) 72 | } 73 | if len(metricNames) == 0 { 74 | continue 75 | } 76 | k := j 77 | j++ 78 | eg.Go(func() error { 79 | ms, err := app.fetchMetrics(graph, metricNames, from, until) 80 | if err != nil { 81 | return err 82 | } 83 | if k > 0 { 84 | <-orderChs[k-1] 85 | } 86 | mu.Lock() 87 | defer func() { 88 | mu.Unlock() 89 | close(orderChs[k]) 90 | }() 91 | return ui.output(graph, ms) 92 | }) 93 | } 94 | if err := eg.Wait(); err != nil { 95 | return err 96 | } 97 | mu.Lock() 98 | defer mu.Unlock() 99 | return ui.cleanup() 100 | } 101 | 102 | func (app *App) fetchMetrics(graph graph, metricNames []string, from, until time.Time) (metricsByName, error) { 103 | eg, mu := errgroup.Group{}, new(sync.Mutex) 104 | ms := make(metricsByName, len(metricNames)) 105 | for _, metricName := range metricNames { 106 | metricName := metricName 107 | eg.Go(func() error { 108 | metrics, err := app.fetcher.fetchMetric(app.hostID, metricName, from, until) 109 | if err != nil { 110 | return err 111 | } 112 | mu.Lock() 113 | defer mu.Unlock() 114 | ms.Add(metricName, metrics) 115 | return nil 116 | }) 117 | } 118 | if err := eg.Wait(); err != nil { 119 | return nil, err 120 | } 121 | ms.AddMemorySwapUsed() 122 | ms.Stack(graph) 123 | return ms, nil 124 | } 125 | 126 | func (app *App) getMetricNamesMap() (map[string]bool, error) { 127 | metricNames, err := app.client.ListHostMetricNames(app.hostID) 128 | if err != nil { 129 | return nil, err 130 | } 131 | metricNamesMap := make(map[string]bool, len(metricNames)) 132 | for _, metricName := range metricNames { 133 | metricNamesMap[metricName] = true 134 | } 135 | return metricNamesMap, nil 136 | } 137 | 138 | func filterMetricNames(metricNamesMap map[string]bool, name string) []string { 139 | if metricNamesMap[name] { 140 | return []string{name} 141 | } 142 | if name == "memory.swap_used" { 143 | if metricNamesMap["memory.swap_total"] && metricNamesMap["memory.swap_free"] { 144 | return []string{"memory.swap_free"} 145 | } 146 | } 147 | namePattern := metricNamePattern(name) 148 | var metricNames []string 149 | for metricName := range metricNamesMap { 150 | if namePattern.MatchString(metricName) { 151 | metricNames = append(metricNames, metricName) 152 | } 153 | } 154 | return metricNames 155 | } 156 | -------------------------------------------------------------------------------- /cmd/mkrg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "runtime" 9 | "strings" 10 | 11 | "github.com/mackerelio/mackerel-agent/config" 12 | "github.com/mackerelio/mackerel-client-go" 13 | "github.com/pkg/errors" 14 | "github.com/urfave/cli" 15 | 16 | "github.com/itchyny/mkrg" 17 | ) 18 | 19 | const ( 20 | cmdName = "mkrg" 21 | description = "Mackerel graph viewer in terminal" 22 | version = "0.0.4" 23 | author = "itchyny" 24 | ) 25 | 26 | func main() { 27 | if run(os.Args) != nil { 28 | os.Exit(1) 29 | } 30 | } 31 | 32 | func run(args []string) error { 33 | app := cli.NewApp() 34 | app.Name = cmdName 35 | app.HelpName = cmdName 36 | app.Usage = description 37 | app.Version = version 38 | app.Author = author 39 | app.Flags = []cli.Flag{ 40 | cli.StringFlag{ 41 | Name: "host", 42 | Usage: "host id", 43 | }, 44 | cli.BoolFlag{ 45 | Name: "help, h", 46 | Usage: "show help", 47 | }, 48 | } 49 | app.HideHelp = true 50 | app.Action = func(ctx *cli.Context) error { 51 | if ctx.GlobalBool("help") { 52 | return cli.ShowAppHelp(ctx) 53 | } 54 | client, hostID, err := setupClientHostID(ctx) 55 | if err != nil { 56 | fmt.Fprintf(os.Stderr, "%s: %s\n", cmdName, err) 57 | return err 58 | } 59 | err = mkrg.NewApp(client, hostID).Run() 60 | if err != nil { 61 | fmt.Fprintf(os.Stderr, "%s: %s\n", cmdName, err) 62 | } 63 | return err 64 | } 65 | return app.Run(args) 66 | } 67 | 68 | func setupClientHostID(ctx *cli.Context) (*mackerel.Client, string, error) { 69 | conf, err := config.LoadConfig(config.DefaultConfig.Conffile) 70 | if runtime.GOOS == "darwin" && err != nil && os.IsNotExist(err) { 71 | out, err := exec.Command("brew", "--prefix").Output() 72 | if err != nil { 73 | return nil, "", err 74 | } 75 | brewPrefix, _, _ := strings.Cut(string(out), "\n") 76 | conffile := filepath.Join(brewPrefix, "etc", "mackerel-agent.conf") 77 | conf, _ = config.LoadConfig(conffile) 78 | } 79 | 80 | apiKey, apiBase := os.Getenv("MACKEREL_APIKEY"), "" 81 | if apiKey == "" { 82 | if conf == nil { 83 | return nil, "", errors.New("MACKEREL_APIKEY not set") 84 | } 85 | apiKey = conf.Apikey 86 | if apiKey == "" { 87 | return nil, "", errors.New("MACKEREL_APIKEY not set") 88 | } 89 | apiBase = conf.Apibase 90 | } 91 | if apiBase == "" { 92 | apiBase = config.DefaultConfig.Apibase 93 | } 94 | client, err := mackerel.NewClientWithOptions(apiKey, apiBase, false) 95 | if err != nil { 96 | return nil, "", err 97 | } 98 | 99 | hostID := ctx.GlobalString("host") 100 | if hostID == "" { 101 | if conf == nil { 102 | return nil, "", errors.New("specify host id") 103 | } 104 | hostID, err = loadHostID(conf.Root) 105 | if err != nil { 106 | return nil, "", errors.New("specify host id") 107 | } 108 | } 109 | 110 | return client, hostID, nil 111 | } 112 | 113 | func loadHostID(root string) (string, error) { 114 | cnt, err := os.ReadFile(filepath.Join(root, "id")) 115 | if err != nil { 116 | return "", err 117 | } 118 | return string(cnt), nil 119 | } 120 | -------------------------------------------------------------------------------- /fetcher.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mackerelio/mackerel-client-go" 7 | ) 8 | 9 | type fetcher struct { 10 | client *mackerel.Client 11 | sem chan struct{} 12 | } 13 | 14 | func newFetcher(client *mackerel.Client) *fetcher { 15 | return &fetcher{client, make(chan struct{}, 5)} 16 | } 17 | 18 | func (f *fetcher) fetchMetric(hostID, metricName string, from, until time.Time) ([]mackerel.MetricValue, error) { 19 | f.sem <- struct{}{} 20 | metrics, err := f.client.FetchHostMetricValues(hostID, metricName, from.Unix(), until.Unix()) 21 | <-f.sem 22 | return metrics, err 23 | } 24 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/itchyny/mkrg 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/mackerelio/mackerel-agent v0.72.13 7 | github.com/mackerelio/mackerel-client-go v0.21.0 8 | github.com/mattn/go-sixel v0.0.2-0.20211227053453-f4e95b245312 9 | github.com/pkg/errors v0.9.1 10 | github.com/urfave/cli v1.22.9 11 | golang.org/x/image v0.0.0-20220601225756-64ec528b34cd 12 | golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f 13 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 14 | ) 15 | 16 | require ( 17 | github.com/BurntSushi/toml v1.1.0 // indirect 18 | github.com/Songmu/timeout v0.4.0 // indirect 19 | github.com/Songmu/wrapcommander v0.1.0 // indirect 20 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 21 | github.com/mackerelio/golib v1.2.1 // indirect 22 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 23 | github.com/soniakeys/quant v1.0.0 // indirect 24 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect 25 | golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d // indirect 26 | golang.org/x/text v0.3.7 // indirect 27 | golang.org/x/tools v0.1.11 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= 3 | github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 4 | github.com/Songmu/timeout v0.4.0 h1:7qUlKeO2neby/Htk9bYYd9w6VSj5MDkE6jnwGZV5zmU= 5 | github.com/Songmu/timeout v0.4.0/go.mod h1:lS4MuG+s4DJ+RvC+lmvhPTRjIRbZfqdP7K4NURzZVcg= 6 | github.com/Songmu/wrapcommander v0.1.0 h1:y8/yk9/PHT983weH+ehZIOJ7JtwAlI1AkfUpUNCj1SY= 7 | github.com/Songmu/wrapcommander v0.1.0/go.mod h1:EC2y4OnN8PkdMnaCwcSzItewq+f0yqUvS30kcS4vmn0= 8 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 9 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 11 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 12 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 13 | github.com/mackerelio/golib v1.2.1 h1:SDcDn6Jw3p9bi1N0bg1Z/ilG5qcBB23qL8xNwrU0gg4= 14 | github.com/mackerelio/golib v1.2.1/go.mod h1:b8ZaapsHGH1FlEJlCqfD98CqafLeyMevyATDlID2BsM= 15 | github.com/mackerelio/mackerel-agent v0.72.13 h1:mxwKn9pqZh+/VySr7IHyaY25ErzsGCIBkcbrr0eerc4= 16 | github.com/mackerelio/mackerel-agent v0.72.13/go.mod h1:4yajwbSgB3LmnYlKZcTNcI21ji9AuihOm2jgmz1Zsrg= 17 | github.com/mackerelio/mackerel-client-go v0.21.0 h1:7s0GBxpHqHsUMm1hRz1HSHxhcFT15LnWIYar77Hnd1I= 18 | github.com/mackerelio/mackerel-client-go v0.21.0/go.mod h1:/GNOj+y1eFsd3CK8c6IQ/uS38/GT0+NWImk5YGJs5Lk= 19 | github.com/mattn/go-sixel v0.0.2-0.20211227053453-f4e95b245312 h1:SksuxIUmgDiyU1ymPvlefLL8yBRx0oKZ81XLINBVb94= 20 | github.com/mattn/go-sixel v0.0.2-0.20211227053453-f4e95b245312/go.mod h1:ryQshZ54pudq7jsSYlxDbYviNpiF6PwWhm5N/YbEg0Y= 21 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 22 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 25 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 26 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 27 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 28 | github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= 29 | github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= 30 | github.com/urfave/cli v1.22.9 h1:cv3/KhXGBGjEXLC4bH0sLuJ9BewaAbpk5oyMOveu4pw= 31 | github.com/urfave/cli v1.22.9/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 32 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 33 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 34 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 35 | golang.org/x/image v0.0.0-20220601225756-64ec528b34cd h1:9NbNcTg//wfC5JskFW4Z3sqwVnjmJKHxLAol1bW2qgw= 36 | golang.org/x/image v0.0.0-20220601225756-64ec528b34cd/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY= 37 | golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 38 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= 39 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 40 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 41 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 42 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 43 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 44 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 45 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 46 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 47 | golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8= 48 | golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 49 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 50 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 51 | golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d h1:Zu/JngovGLVi6t2J3nmAf3AoTDwuzw85YZ3b9o4yU7s= 52 | golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= 54 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 55 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 56 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 57 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 58 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 59 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 60 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 61 | golang.org/x/tools v0.0.0-20200612022331-742c5eb664c2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 62 | golang.org/x/tools v0.1.11 h1:loJ25fNOEhSXfHrpoGj91eCUThwdNX6u24rO1xnNteY= 63 | golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= 64 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 65 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 66 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 69 | -------------------------------------------------------------------------------- /graph.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | type graph struct { 9 | name string 10 | metrics []metric 11 | } 12 | 13 | type metric struct { 14 | name string 15 | stacked bool 16 | } 17 | 18 | var systemGraphs = []graph{ 19 | { 20 | name: "loadavg", 21 | metrics: []metric{ 22 | {"loadavg1", false}, 23 | {"loadavg5", false}, 24 | {"loadavg15", false}, 25 | }, 26 | }, 27 | { 28 | name: "cpu", 29 | metrics: []metric{ 30 | {"cpu.user.percentage", true}, 31 | {"cpu.nice.percentage", true}, 32 | {"cpu.system.percentage", true}, 33 | {"cpu.irq.percentage", true}, 34 | {"cpu.softirq.percentage", true}, 35 | {"cpu.iowait.percentage", true}, 36 | {"cpu.steal.percentage", true}, 37 | {"cpu.guest.percentage", true}, 38 | {"cpu.idle.percentage", true}, 39 | }, 40 | }, 41 | { 42 | name: "memory", 43 | metrics: []metric{ 44 | {"memory.used", true}, 45 | {"memory.mem_available", true}, 46 | {"memory.buffers", true}, 47 | {"memory.cached", true}, 48 | {"memory.total", false}, 49 | {"memory.free", true}, 50 | {"memory.pagefile_total", false}, 51 | {"memory.swap_used", false}, 52 | {"memory.swap_cached", false}, 53 | {"memory.pagefile_free", false}, 54 | {"memory.swap_total", false}, 55 | }, 56 | }, 57 | { 58 | name: "disk", 59 | metrics: []metric{ 60 | {"disk.#.reads.delta", false}, 61 | {"disk.#.writes.delta", false}, 62 | }, 63 | }, 64 | { 65 | name: "interface", 66 | metrics: []metric{ 67 | {"interface.#.rxBytes.delta", false}, 68 | {"interface.#.txBytes.delta", false}, 69 | }, 70 | }, 71 | { 72 | name: "filesystem", 73 | metrics: []metric{ 74 | {"filesystem.#.used", false}, 75 | {"filesystem.#.size", false}, 76 | }, 77 | }, 78 | } 79 | 80 | func metricNamePattern(name string) *regexp.Regexp { 81 | return regexp.MustCompile( 82 | `\A` + strings.Replace(name, "#", `([-a-zA-Z0-9_]+)`, -1) + `\z`, 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /image.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/color" 7 | "math" 8 | "time" 9 | 10 | "golang.org/x/image/draw" 11 | "golang.org/x/image/font" 12 | "golang.org/x/image/font/inconsolata" 13 | "golang.org/x/image/math/fixed" 14 | ) 15 | 16 | var ( 17 | borderColor = color.RGBA{0xff, 0xff, 0xff, 0x88} 18 | axisColor = color.RGBA{0xff, 0xff, 0xff, 0xff} 19 | tickColor = color.RGBA{0xff, 0xff, 0xff, 0xaa} 20 | seriesColors = []color.RGBA{ 21 | {0x63, 0xba, 0xc6, 0xff}, 22 | {0xcc, 0x99, 0x00, 0xff}, 23 | {0x81, 0x71, 0xb3, 0xff}, 24 | {0x80, 0x9e, 0x10, 0xff}, 25 | {0xb2, 0x66, 0x32, 0xff}, 26 | {0x36, 0x99, 0x7d, 0xff}, 27 | {0xb7, 0x95, 0x69, 0xff}, 28 | {0x32, 0x6e, 0xc6, 0xff}, 29 | {0x9c, 0x91, 0x00, 0xff}, 30 | {0x53, 0x7c, 0x48, 0xff}, 31 | {0xc9, 0x5b, 0x75, 0xff}, 32 | {0x00, 0x5c, 0x9b, 0xff}, 33 | {0x96, 0x75, 0x5a, 0xff}, 34 | {0x67, 0xb0, 0x7d, 0xff}, 35 | {0x5f, 0x83, 0xb8, 0xff}, 36 | {0xa3, 0xa3, 0xe2, 0xff}, 37 | {0x83, 0x9b, 0x4d, 0xff}, 38 | {0xba, 0x55, 0x9b, 0xff}, 39 | {0x3a, 0x8c, 0x86, 0xff}, 40 | {0xb5, 0x83, 0x13, 0xff}, 41 | {0x9e, 0x7f, 0x68, 0xff}, 42 | {0x56, 0x54, 0xaf, 0xff}, 43 | } 44 | ) 45 | 46 | type imageWithMargins struct { 47 | img draw.Image 48 | topMargin, leftMargin int 49 | } 50 | 51 | func (img *imageWithMargins) Set(x, y int, c color.Color) { 52 | img.img.Set(x+img.leftMargin, y+img.topMargin, c) 53 | } 54 | func (img *imageWithMargins) ColorModel() color.Model { 55 | return img.img.ColorModel() 56 | } 57 | func (img *imageWithMargins) Bounds() image.Rectangle { 58 | return img.img.Bounds() 59 | } 60 | func (img *imageWithMargins) At(x, y int) color.Color { 61 | return img.img.At(x+img.leftMargin+img.topMargin, y) 62 | } 63 | 64 | func printImage(img draw.Image, graph graph, ms metricsByName, height, width int, from, until time.Time) error { 65 | drawGraph(img, graph, ms, height, width, from, until) 66 | drawBorder(img, height, width) 67 | drawTitle(img, width, graph.name) 68 | return nil 69 | } 70 | 71 | func drawGraph(img draw.Image, graph graph, ms metricsByName, height, width int, from, until time.Time) { 72 | graphLeftMargin, bottomMargin := 48, 26 73 | maxValue := math.Max(ms.MaxValue(), 1.0) * 1.1 74 | drawAxisX(img, height-bottomMargin, width, graphLeftMargin, from, until) 75 | drawAxisY(img, height-bottomMargin, width, graphLeftMargin, from, until, maxValue) 76 | drawSeries(&imageWithMargins{img, 0, graphLeftMargin}, graph, ms, height-bottomMargin, width-graphLeftMargin, from, until, maxValue) 77 | } 78 | 79 | func drawSeries(img draw.Image, graph graph, ms metricsByName, height, width int, from, until time.Time, maxValue float64) { 80 | imgSet := func(x, y int, c color.RGBA) { 81 | pointSize := 2 82 | for i := 0; i < pointSize; i++ { 83 | for j := 0; j < pointSize; j++ { 84 | img.Set(x+i, height-(y+j), c) 85 | } 86 | } 87 | } 88 | for i, metricName := range ms.ListMetricNames(graph) { 89 | prevPrevTime, prevTime, nextTime, prevX, prevY := int64(0), int64(0), int64(0), -1.0, 0.0 90 | metrics, seriesColor := ms[metricName], seriesColors[i%len(seriesColors)] 91 | for i, m := range metrics { 92 | x := float64(m.Time-from.Unix()) * float64(width) / float64(until.Sub(from)/time.Second) 93 | y := m.Value.(float64) / maxValue * float64(height) 94 | if 0 <= x { 95 | if i < len(metrics)-1 { 96 | nextTime = metrics[i+1].Time 97 | } 98 | start, step := 0.0, math.Min(2.0/math.Sqrt((x-prevX)*(x-prevX)+(y-prevY)*(y-prevY)), 0.2) 99 | if prevX < 0 || prevTime < m.Time-3*60 && (prevTime-3*60 < prevPrevTime || prevPrevTime == 0 && nextTime-3*60 < m.Time) { 100 | start = 1.0 101 | } 102 | prevPrevTime, prevTime = prevTime, m.Time 103 | for p := start; p <= 1.0; p += step { 104 | imgSet(int(prevX*(1.0-p)+x*p), int(prevY*(1.0-p)+y*p), seriesColor) 105 | } 106 | } 107 | prevX, prevY = x, y 108 | } 109 | } 110 | } 111 | 112 | func drawAxisX(img draw.Image, height, width, graphLeftMargin int, from, until time.Time) { 113 | for i := 0; i < height; i++ { 114 | img.Set(graphLeftMargin, i, axisColor) 115 | } 116 | stepX := 30 * time.Minute 117 | for t := from.Truncate(stepX).Add(stepX); !until.Before(t); t = t.Add(stepX) { 118 | offset := int(float64(t.Sub(from)) / float64(until.Sub(from)) * float64(width-graphLeftMargin)) 119 | for i := 0; i < height; i++ { 120 | img.Set(graphLeftMargin+offset, i, tickColor) 121 | } 122 | diffX := -19 123 | if t.Hour() < 10 { 124 | diffX = -23 125 | } 126 | d := &font.Drawer{ 127 | Dst: img, 128 | Src: image.NewUniform(axisColor), 129 | Face: inconsolata.Bold8x16, 130 | Dot: fixed.P(graphLeftMargin+offset+diffX, height+17), 131 | } 132 | d.DrawString(fmt.Sprintf("%2d:%02d", t.Hour(), t.Minute())) 133 | } 134 | for i := 0; i < 40; i++ { 135 | for j := 0; j < 20; j++ { 136 | img.Set(i+width, height+j+5, color.Alpha{0x00}) 137 | } 138 | } 139 | } 140 | 141 | func drawAxisY(img draw.Image, height, width, graphLeftMargin int, from, until time.Time, maxValue float64) { 142 | for i := graphLeftMargin; i < width; i++ { 143 | img.Set(i, height, axisColor) 144 | } 145 | tick := getTick(maxValue) 146 | format, scale := formatAxisY(tick, maxValue) 147 | for y := 0.0; y < maxValue; y += tick { 148 | posY := height - int(y/maxValue*float64(height)) 149 | for i := graphLeftMargin + 1; 0.0 < y && i < width; i++ { 150 | img.Set(i, posY, tickColor) 151 | } 152 | d := &font.Drawer{ 153 | Dst: img, 154 | Src: image.NewUniform(axisColor), 155 | Face: inconsolata.Bold8x16, 156 | Dot: fixed.P(8, posY+5), 157 | } 158 | if y == 0.0 { 159 | d.DrawString(" 0") 160 | } else if y/maxValue < 0.985 { 161 | d.DrawString(fmt.Sprintf("%4s", fmt.Sprintf(format, y/scale))) 162 | } 163 | } 164 | } 165 | 166 | func getTick(maxValue float64) float64 { 167 | tick := math.Pow10(int(math.Floor(math.Log10(maxValue / 5.0)))) 168 | if maxValue/tick > 12 { 169 | tick *= 5 170 | } else if maxValue/tick > 6 { 171 | tick *= 2 172 | } 173 | return tick 174 | } 175 | 176 | func formatAxisY(tick, maxValue float64) (string, float64) { 177 | var suffix string 178 | scale := 1.0 179 | if maxValue >= 1e12 { 180 | suffix, scale = "T", 1e12 181 | } else if maxValue >= 1e9 { 182 | suffix, scale = "G", 1e9 183 | } else if maxValue >= 1e6 { 184 | suffix, scale = "M", 1e6 185 | } else if maxValue >= 1e3 { 186 | suffix, scale = "K", 1e3 187 | } 188 | digits := int(math.Ceil(math.Max(1.0-math.Log10(maxValue/scale), 0.0))) 189 | return fmt.Sprintf("%%.%df%s", digits, suffix), scale 190 | } 191 | 192 | func drawBorder(img draw.Image, height, width int) { 193 | for i := 0; i < width; i++ { 194 | img.Set(i, 0, borderColor) 195 | img.Set(i, height-1, borderColor) 196 | } 197 | for i := 0; i < height; i++ { 198 | img.Set(0, i, borderColor) 199 | img.Set(width-1, i, borderColor) 200 | } 201 | } 202 | 203 | func drawTitle(img draw.Image, width int, title string) { 204 | x, y := width/2-len(title)*4, 20 205 | for i := -3; i < len(title)*8+3; i++ { 206 | for j := -4; j < 15; j++ { 207 | img.Set(x+i, y-j, color.Alpha{0x00}) 208 | } 209 | } 210 | d := &font.Drawer{ 211 | Dst: img, 212 | Src: image.NewUniform(axisColor), 213 | Face: inconsolata.Bold8x16, 214 | Dot: fixed.P(x, y), 215 | } 216 | d.DrawString(title) 217 | } 218 | -------------------------------------------------------------------------------- /iterm2ui.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "image" 8 | "image/png" 9 | "os" 10 | "time" 11 | ) 12 | 13 | type iterm2UI struct { 14 | height, width int 15 | column, maxColumn int 16 | from, until time.Time 17 | img *image.RGBA 18 | } 19 | 20 | func newIterm2UI(height, width, maxColumn int, from, until time.Time) *iterm2UI { 21 | return &iterm2UI{height, width, 0, maxColumn, from, until, nil} 22 | } 23 | 24 | func (ui *iterm2UI) output(graph graph, ms metricsByName) error { 25 | imgHeight, imgWidth, padding := ui.height*20, ui.width*12, ui.width/5 26 | if ui.column == 0 { 27 | ui.img = image.NewRGBA(image.Rect(0, 0, (imgWidth+padding)*ui.maxColumn-padding, imgHeight+padding*2)) 28 | } 29 | printImage(&imageWithMargins{ui.img, padding, (imgWidth + padding) * ui.column}, graph, ms, imgHeight, imgWidth, ui.from, ui.until) 30 | if ui.column == ui.maxColumn-1 { 31 | if err := ui.cleanup(); err != nil { 32 | return err 33 | } 34 | ui.column = 0 35 | } else { 36 | ui.column++ 37 | } 38 | return nil 39 | } 40 | 41 | func (ui *iterm2UI) cleanup() error { 42 | if ui.column > 0 { 43 | buf := new(bytes.Buffer) 44 | if err := png.Encode(buf, ui.img); err != nil { 45 | return err 46 | } 47 | printOSC() 48 | fmt.Print("1337;File=inline=1;preserveAspectRatio=1;width=100%:") 49 | fmt.Print(base64.StdEncoding.EncodeToString(buf.Bytes())) 50 | printST() 51 | } 52 | return nil 53 | } 54 | 55 | func printOSC() { 56 | if os.Getenv("TMUX") != "" { 57 | fmt.Print("\x1bPtmux;\x1b\x1b]") 58 | } else { 59 | fmt.Print("\x1b]") 60 | } 61 | } 62 | 63 | func printST() { 64 | if os.Getenv("TMUX") != "" { 65 | fmt.Print("\x07\x1b\\\n") 66 | } else { 67 | fmt.Print("\x07\n") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /metrics.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | 7 | "github.com/mackerelio/mackerel-client-go" 8 | ) 9 | 10 | type metricsByName map[string][]mackerel.MetricValue 11 | 12 | func (ms metricsByName) Add(metricName string, metricValues []mackerel.MetricValue) { 13 | ms[metricName] = metricValues 14 | } 15 | 16 | func (ms metricsByName) MaxValue() float64 { 17 | maxValue := 0.0 18 | for _, metrics := range ms { 19 | for _, m := range metrics { 20 | v := m.Value.(float64) 21 | if v > maxValue { 22 | maxValue = v 23 | } 24 | } 25 | } 26 | return maxValue 27 | } 28 | 29 | func (ms metricsByName) Stack(graph graph) { 30 | stackedValue := make(map[int64]float64) 31 | for _, metric := range graph.metrics { 32 | if !metric.stacked { 33 | continue 34 | } 35 | if metrics, ok := ms[metric.name]; ok { 36 | for i, m := range metrics { 37 | w := metrics[i].Value.(float64) 38 | if v, ok := stackedValue[m.Time]; ok { 39 | stackedValue[m.Time] = v + w 40 | metrics[i].Value = v + w 41 | } else { 42 | stackedValue[m.Time] = w 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | func (ms metricsByName) ListMetricNames(graph graph) []string { 50 | metricNames := make([]string, 0, len(ms)) 51 | for name := range ms { 52 | metricNames = append(metricNames, name) 53 | } 54 | var groupNames []string 55 | groupNameByName := make(map[string]string, len(ms)) 56 | metricPriorityByName := make(map[string]int, len(ms)) 57 | for i, metric := range graph.metrics { 58 | if strings.ContainsRune(metric.name, '#') { 59 | namePattern := metricNamePattern(metric.name) 60 | for _, name := range metricNames { 61 | match := namePattern.FindStringSubmatch(name) 62 | if len(match) > 1 { 63 | newGroupName, found := match[1], false 64 | groupNameByName[name] = newGroupName 65 | metricPriorityByName[name] = i 66 | for _, groupName := range groupNames { 67 | if groupName == newGroupName { 68 | found = true 69 | break 70 | } 71 | } 72 | if !found { 73 | groupNames = append(groupNames, newGroupName) 74 | } 75 | } 76 | } 77 | } else if _, ok := ms[metric.name]; ok { 78 | metricPriorityByName[metric.name] = i 79 | } 80 | } 81 | sort.Strings(groupNames) 82 | priorityByGroupName := make(map[string]int, len(groupNames)) 83 | for i, groupName := range groupNames { 84 | priorityByGroupName[groupName] = i 85 | } 86 | priorityByName := make(map[string]int, len(ms)) 87 | for _, metricName := range metricNames { 88 | priorityByName[metricName] = metricPriorityByName[metricName] 89 | if groupName, ok := groupNameByName[metricName]; ok { 90 | priorityByName[metricName] += priorityByGroupName[groupName] * 100 91 | } 92 | } 93 | sort.Slice(metricNames, func(i, j int) bool { 94 | return priorityByName[metricNames[i]] < priorityByName[metricNames[j]] 95 | }) 96 | return metricNames 97 | } 98 | 99 | func (ms metricsByName) AddMemorySwapUsed() { 100 | if totalMetrics, ok := ms["memory.swap_total"]; ok { 101 | if freeMetrics, ok := ms["memory.swap_free"]; ok { 102 | usedMetrics := make([]mackerel.MetricValue, 0, len(totalMetrics)) 103 | for i, j := 0, 0; i < len(totalMetrics) && j < len(freeMetrics); i++ { 104 | for j < len(freeMetrics) && totalMetrics[i].Time > freeMetrics[j].Time { 105 | j++ 106 | } 107 | if totalMetrics[i].Time == freeMetrics[j].Time { 108 | usedMetrics = append(usedMetrics, mackerel.MetricValue{ 109 | Time: totalMetrics[i].Time, 110 | Value: totalMetrics[i].Value.(float64) - freeMetrics[j].Value.(float64), 111 | }) 112 | } 113 | for j < len(freeMetrics) && totalMetrics[i].Time >= freeMetrics[j].Time { 114 | j++ 115 | } 116 | } 117 | delete(ms, "memory.swap_free") 118 | ms.Add("memory.swap_used", usedMetrics) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /sixelui.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | import ( 4 | "image" 5 | "os" 6 | "time" 7 | 8 | "github.com/mattn/go-sixel" 9 | ) 10 | 11 | type sixelUI struct { 12 | height, width int 13 | column, maxColumn int 14 | from, until time.Time 15 | img *image.RGBA 16 | } 17 | 18 | func newSixel(height, width, maxColumn int, from, until time.Time) *sixelUI { 19 | return &sixelUI{height, width, 0, maxColumn, from, until, nil} 20 | } 21 | 22 | // Needs improvements because I don't have checked the behavior. 23 | func (ui *sixelUI) output(graph graph, ms metricsByName) error { 24 | imgHeight, imgWidth, padding := ui.height*20, ui.width*12, ui.width/5 25 | if ui.column == 0 { 26 | ui.img = image.NewRGBA(image.Rect(0, 0, (imgWidth+padding)*ui.maxColumn-padding, imgHeight+padding*2)) 27 | } 28 | printImage(&imageWithMargins{ui.img, padding, (imgWidth + padding) * ui.column}, graph, ms, imgHeight, imgWidth, ui.from, ui.until) 29 | if ui.column == ui.maxColumn-1 { 30 | if err := ui.cleanup(); err != nil { 31 | return err 32 | } 33 | ui.column = 0 34 | } else { 35 | ui.column++ 36 | } 37 | return nil 38 | } 39 | 40 | func (ui *sixelUI) cleanup() error { 41 | if ui.column > 0 { 42 | if err := sixel.NewEncoder(os.Stdout).Encode(ui.img); err != nil { 43 | return err 44 | } 45 | } 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /tui.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type tui struct { 9 | height, width int 10 | column, maxColumn int 11 | until time.Time 12 | lines []string 13 | } 14 | 15 | func newTui(height, width, maxColumn int, until time.Time) *tui { 16 | return &tui{height, width, 0, maxColumn, until, make([]string, height)} 17 | } 18 | 19 | func (ui *tui) output(graph graph, ms metricsByName) error { 20 | v := newViewer(graph, ui.height, ui.width) 21 | for i, l := range v.GetLines(ms, ui.until) { 22 | ui.lines[i] += l 23 | if ui.column < ui.maxColumn-1 { 24 | ui.lines[i] += " " 25 | } 26 | } 27 | if ui.column == ui.maxColumn-1 { 28 | for i := range ui.lines { 29 | fmt.Println(ui.lines[i]) 30 | ui.lines[i] = "" 31 | } 32 | ui.column = 0 33 | } else { 34 | ui.column++ 35 | } 36 | return nil 37 | } 38 | 39 | func (ui *tui) cleanup() error { 40 | if ui.column > 0 { 41 | for _, l := range ui.lines { 42 | fmt.Println(l) 43 | } 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | type ui interface { 4 | output(graph, metricsByName) error 5 | cleanup() error 6 | } 7 | -------------------------------------------------------------------------------- /viewer.go: -------------------------------------------------------------------------------- 1 | package mkrg 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type viewer struct { 11 | graph graph 12 | height, width int 13 | } 14 | 15 | func newViewer(graph graph, height, width int) *viewer { 16 | return &viewer{graph, height, width} 17 | } 18 | 19 | func (v *viewer) GetLines(ms metricsByName, until time.Time) []string { 20 | h, w := (v.height-3)*4, (v.width-6)*2 21 | dots := make([][]int, h) 22 | for i := range dots { 23 | dots[i] = make([]int, w) 24 | } 25 | maxValue := math.Max(ms.MaxValue(), 1.0) * 1.1 26 | tick := getTick(maxValue) 27 | format, scale := formatAxisY(tick, maxValue) 28 | from := until.Add(-time.Duration(w/2) * time.Minute) 29 | for _, metrics := range ms { 30 | prevPrevTime, prevTime, nextTime, prevX, prevY := int64(0), int64(0), int64(0), -1.0, 0.0 31 | for i, m := range metrics { 32 | x := float64((m.Time - from.Unix()) / 30) 33 | y := m.Value.(float64) / maxValue * float64(h) 34 | if 0 <= x { 35 | if i < len(metrics)-1 { 36 | nextTime = metrics[i+1].Time 37 | } 38 | start, step := 0.0, math.Min(1.0/math.Sqrt((x-prevX)*(x-prevX)+(y-prevY)*(y-prevY)), 1.0) 39 | if prevX < 0 || prevTime < m.Time-3*60 && (prevTime-3*60 < prevPrevTime || prevPrevTime == 0 && nextTime-3*60 < m.Time) { 40 | start = 1.0 41 | } 42 | prevPrevTime, prevTime = prevTime, m.Time 43 | for p := start; p <= 1.0; p += step { 44 | dots[int(prevY*(1.0-p)+y*p)][int(prevX*(1.0-p)+x*p)] = 1 45 | } 46 | } 47 | prevX, prevY = x, y 48 | } 49 | } 50 | lines := make([]string, v.height) 51 | leftPadding := int(math.Max(float64((v.width-len(v.graph.name)+1)/2), 0)) 52 | lines[0] = strings.Repeat(" ", leftPadding) + v.graph.name 53 | lines[0] += strings.Repeat(" ", int(math.Max(float64(v.width-len(lines[0])), 0))) 54 | for y := 0.0; y < maxValue; y += tick { 55 | if y > 0.0 { 56 | posY := v.height - int(math.Round(y/maxValue*float64(v.height-3))) - 2 57 | lines[posY] = fmt.Sprintf("%4s +", fmt.Sprintf(format, y/scale)) 58 | } 59 | } 60 | line := make([]rune, w/2) 61 | for i := h - 4; i >= 0; i -= 4 { 62 | for j := 0; j < w; j += 2 { 63 | line[j/2] = rune(0x2800 | dots[i+3][j] | dots[i+2][j]<<1 | dots[i+1][j]<<2 | dots[i+3][j+1]<<3 | 64 | dots[i+2][j+1]<<4 | dots[i+1][j+1]<<5 | dots[i][j]<<6 | dots[i][j+1]<<7) 65 | } 66 | y := (h - i) / 4 67 | if lines[y] == "" { 68 | lines[y] = " |" 69 | } 70 | lines[y] += string(line) 71 | } 72 | axisX := []rune(" 0 +" + strings.Repeat("-", v.width-6)) 73 | stepX := 15 * time.Minute 74 | var axisXLabels string 75 | for t := from.Truncate(stepX); !until.Before(t); t = t.Add(stepX) { 76 | offset := int(float64(t.Sub(from))/float64(until.Sub(from))*float64(v.width-6)) + 6 77 | if offset < 5 || len(axisX) <= offset { 78 | continue 79 | } 80 | axisX[offset] = '+' 81 | axisXLabels += strings.Repeat(" ", int(math.Max(float64(offset-len(axisXLabels)-2), 0))) 82 | axisXLabels += fmt.Sprintf("%1d:%02d", t.Hour(), t.Minute()) 83 | } 84 | axisXLabels += strings.Repeat(" ", int(math.Max(float64(v.width-len(axisXLabels)), 0))) 85 | lines[v.height-2] += string(axisX) 86 | lines[v.height-1] = axisXLabels[:v.width] 87 | return lines 88 | } 89 | --------------------------------------------------------------------------------