Mackerelのカスタムダッシュボードでリソース確認がしやすくなった

リリースされてから少し経ちますが、Mackerelのダッシュボードが新しくなりました。
これまでのダッシュボードはレガシーとして扱われ、新しいものはカスタムダッシュボードとされています。

まだレガシーからカスタムダッシュボードに置き換えて間もないですが、意外と良いかもと思ったので、そのことに関して書きます。

# どんなダッシュボードか

見たほうが手っ取り早いので、新旧並べてみます。

**古いほう**
![old_dashboard.png](https://qiita-image-store.s3.amazonaws.com/0/143313/b81a306f-633e-61c6-d20d-5af0edf63593.png)

**新しいほう**
![new_dashboard.png](https://qiita-image-store.s3.amazonaws.com/0/143313/a8c83c47-8caa-2fc2-2f6c-00bbd30c06c7.png)



# 私のユースケース

一般的に便利！というと語弊があるので、私が求めていたものを書いておきます。
ダッシュボードなので、各組織ごと見たいものは違うでしょう。

私は何か問題が発生したとき、原因特定のために利用しようとしています。

* ユーザ体験が悪化していた期間の特定
* その期間において各ホストのグラフにおかしなパターンがないか確認
* おかしなパターンがあれば原因の仮設を立てる

あとはログを見たりなどして仮設が正しいかどうかの確認を繰り返しです。
間違っていれば再度仮設を立てます。

難しく書きましたが、要はグラフで問題のありそうなところにあたりをつける、ということです。

# ダッシュボードで何をしようとしたか

問題の原因確認中、グラフの期間指定のパターンとしては以下のパターンがあることに気づきました。

* 短/中/長期的な観点で、問題の発生している期間を特定する
* 特定した期間だけを切り取ったグラフを確認する
* 特定した期間のグラフの中で短/中/長期的に見ておかしな値を示しているものを特定する
* 問題の原因または原因によって引き起こされた異常値として仮説に組み込む

このようなパターンが決まってるなら、わざわざタイムレンジを頻繁に更新せずとも一覧できるようにしたいと思いました。

絵にするとこんな感じです。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/143313/4321859d-7f33-c2f8-d6ad-a6bd0579e35f.png)

これはレガシーのダッシュボードでは無理でした(たぶん)。

# カスタムダッシュボードで嬉しくなったこと

なんと言っても期間指定がダッシュボード上でできるようになったことです。

1つのグラフ上からタイムレンジを指定すると、他のグラフのタイムレンジも変わってくれます。

しかし、期間固定をしているとタイムレンジが変わりません。

結果、ダッシュボードでしようとしていたことが実現できるようになりました。


# ままならないこと

ダッシュボードが大量にできました。

このような表のようにグラフを並べると、グラフの数が多くて表示させるのに時間がかかります。

1ダッシュボードあたりのグラフの量を少なくするためにダッシュボードを分けます。

どれがなんのダッシュボードだよ・・・という流れです。

そもそもダッシュボードの在り方を見直したほうが良いのかもしれないなとは思いましたが、
とりあえず運用してみようと思います。

# まとめ

正直なところレガシーからカスタムに移す工数かける理由がないなと思っていました。
ただ、おすすめされて使ってみたら昔出来なかったことができるようになっていて、やはりとりあえず試すべきだなと思いました。

このようなやり方ではなくdrill downダッシュボードのほうが良いかもしれないと思ったりもしたので、まだまだ改善の余地はありそうです。


# [おまけ]GUIから作ると

大変なので、Rubyでスクリプト書きました。

Web上からはマークダウンより遥かに楽に作れるようになったのですが、それでも表示させるグラフが多いとGUIつらい・・・となります。

作る際はAPIを利用することをおすすめします。

なぐり書きしたものを載せておきます。

※コードは読み飛ばして問題ありません

```ruby:dashboard.rb
require 'dotenv'
require 'yaml'
require 'json'
require 'faraday'

Dotenv.load

module Mackerel
  class ObjectCache
    @@hash_objects = {}
    def self.write(key, object)
      @@hash_objects[key] = object
    end

    def self.read(key)
      @@hash_objects[key]
    end
  end

  class Client
    ENDPOINT = "https://api.mackerelio.com/"

    def create_dashboard(payload)
      res = connection.post do |req|
        req.url '/api/v0/dashboards'
        req.headers['X-Api-Key'] = ENV['MACKEREL_APIKEY']
        req.headers['Content-Type'] = 'application/json'
        req.body = payload
      end
      res.body
    end

    def delete_dashboard(dashboard_id)
      res = connection.delete do |req|
        req.url "/api/v0/dashboards/#{dashboard_id}"
        req.headers['X-Api-Key'] = ENV['MACKEREL_APIKEY']
        req.headers['Content-Type'] = 'application/json'
      end
      res.body
    end

    def search_dashboard_id_by_urlpath(url_path)
      dashboards = ObjectCache::read('dashboards')
      if dashboards.nil?
        res = connection.get do |req|
          req.url '/api/v0/dashboards'
          req.headers['X-Api-Key'] = ENV['MACKEREL_APIKEY']
          req.headers['Content-Type'] = 'application/json'
        end
        dashboards = JSON.parse(res.body)
        ObjectCache::write('dashboards', dashboards)
      end

      ids = dashboards["dashboards"].select{|d| d["urlPath"] == url_path }.map{|d| d["id"]}
      ids.first
    end

    def list_hosts_with_role(role_fullname)
      res = connection.get do |req|
          req.url '/api/v0/hosts'
          req.headers['X-Api-Key'] = ENV['MACKEREL_APIKEY']
          req.headers['Content-Type'] = 'application/json'
          req.params['status'] = ["working", "standby", "maintenance", "poweroff"]
      end
      hosts = JSON.parse(res.body)
      hosts["hosts"].select! do |host|
        host["roles"].map do |s, roles|
          roles.map{|r| "#{s}:#{r}"}
        end.flatten.include?(role_fullname)
      end
    end

    def connection
      @conn ||= Faraday::Connection.new(:url => ENDPOINT) do |builder|
        builder.use Faraday::Request::UrlEncoded
        builder.use Faraday::Adapter::NetHttp
        builder.options.params_encoder = Faraday::FlatParamsEncoder
      end
    end
  end

  class BulkDashboardMaker
    def load_config
      @yaml = YAML.load_file("config.yml")
    end

    def bulk_create
      load_config
      client = Client.new
      old_id = client.search_dashboard_id_by_urlpath(@yaml['urlPath'])
      client.delete_dashboard(old_id) unless old_id.nil?
      payload = Dashboard.new.build(@yaml)
      client.create_dashboard(payload)
    end
  end

  class Dashboard
    attr_reader :title
    attr_reader :urlPath
    attr_reader :memo
    attr_reader :widgets

    def build(yaml)
      @title   = yaml["title"] || ""
      @urlPath = yaml["urlPath"] || "error"
      @memo    = yaml["memo"] || ""
      @widgets = []

      y = 0

      wgf = WidgetGroupFactory.new
      yaml["widget_params"].each do |param, i|
        group = wgf.create(param["type"])
        group.build(y, param).each do |widget|
          @widgets << widget
        end
        y += group.height
      end

      self.to_json
    end

    def to_json
      {
        "title" => self.title,
        "urlPath" => self.urlPath,
        "memo" => self.memo,
        "widgets" => self.widgets
      }.to_json
    end
  end

  class WidgetGroupFactory
    def create(name)
      case name
      when "role" then RoleGroup.new
      when "host" then HostGroup.new
      when "header" then HeaderGroup.new
      end
    end
  end

  class WidgetGroup
    MAX_COLUMN = 24
    attr_reader :row

    def build(y, param)
    end

    def max_width
      (MAX_COLUMN / ranges.size).to_i
    end

    def height
      6 #default
    end

    def row_height
      6 #All type adopted
    end

    def make_layout(y, i)
      {
        "x" => max_width * i,
        "y" => y,
        "height" => row_height,
        "width" => 6
      }
    end

    def ranges
      @ranges ||= [
        {},
        {"type" => "relative", "period" => 21600, "offset" => 0},
        {"type" => "relative", "period" => 259200, "offset" => 0},
        {"type" => "relative", "period" => 2592000, "offset" => 0}
      ]
    end
  end

  class HeaderGroup < WidgetGroup
    def build(y, param)
      param['markdowns'].map.with_index do |mkd, i|
        {
          "type" => "markdown",
          "title" => "",
          "layout" => make_layout(y, i),
          "markdown" => mkd
        }
      end
    end

    def height
      2
    end

    def row_height
      2
    end
  end

  class RoleGroup < WidgetGroup
    def build(y, param)
      ranges.map.with_index do |range, i|
        _tmp_obj = {
          "type" => "graph",
          "title" => "",
          "layout" => make_layout(y, i),
          "graph" => param,
        }
        _tmp_obj['range'] = range unless range.empty?
        _tmp_obj
      end
    end

    def height
      6
    end
    def row_height
      6
    end
  end

  class HostGroup < WidgetGroup
    def build(y, param)
      get_host_ids(param['roleFullname']).map.with_index do |id, i|
        ranges.map.with_index do |range, j|
          _tmp_obj = {
            "type" => "graph",
            "title" => "",
            "layout" => make_layout(y + HEIGHT * i, j),
            "graph" => {
              "type" => "host",
              "hostId" => id,
              "name" => param["name"]
            }
          }
          _tmp_obj['range'] = range unless range.empty?
          _tmp_obj
        end
      end.flatten
    end

    HEIGHT = 6
    def height
      @ids.nil? ? raise("Please call after #get_host_ids") : @ids.size * HEIGHT
    end

    def row_height
      HEIGHT
    end

    def get_host_ids(role_fullname)
      @ids = ObjectCache::read(role_fullname)
      if @ids.nil?
        hosts = Client.new.list_hosts_with_role(role_fullname)
        @ids = hosts.sort{|a,b| a["name"] <=> b["name"]}.map{|host| host['id']}
        ObjectCache::write(role_fullname, @ids)
      end
      @ids
    end
  end
end

Mackerel::BulkDashboardMaker.new.bulk_create
```

ダッシュボードの定義

```yaml:config.yml
title: えびばでぃれっつごー
urlPath: aft_sch_clm_grl
memo: yeah yeah
widget_params:
    - type: header
      markdowns:
        - 自由
        - 6時間
        - 3日間
        - 1ヶ月
    - type: role
      roleFullname: dev:web
      name: custom.multicore.loadavg_per_core.loadavg5
      isStacked: false
    - type: host
      roleFullname: dev:web
      name: memory
```

使う

```ruby:Gemfile
source "https://rubygems.org"

gem "dotenv"
gem "faraday"
gem "json"
```

```
export MACKEREL_APIKEY="XXXXXXX"
bundle install
bundle exec ruby dashboard.rb
```