PostgreSQLの統計情報を可視化（フロントエンド編）

#はじめに
「PostgreSQLの統計情報を可視化」の3章です。この章では2章の[「バックエンド編」](https://qiita.com/atmitani/items/8140f1735f74e82a6cc3)で作成したAPIを使ってPostgreSQLの統計情報をグラフ化する、フロントエンドにフォーカスして話を進めます。

# フロントエンドのモジュール構成
Nuxt.jsのフロントエンド側を大雑把に分けると、**画面構成**モジュールと、**データ更新・保持**モジュールがあります。まずはインターフェース仕様が決まっている「データ更新・保持モジュール」から作成します。

## データ更新・保持モジュール
データを更新・保持するモジュールには、動的なデータの読み出し、書込み、保持を定義する**store**モジュール、静的なデータを保持する**assets**モジュールがあります
バックエンドとの動的なデータ送受信には**axios**を使います。
動的なデータの読み書き、保持を行いますので、storeモジュールに記述します。
###storeモジュール
storeモジュールは以下の4つの定義から構成されます。

|名称|役割|
|:--:|:--:|
|state|保持するデータを定義|
|getters|データ読み出し処理|
|mutations|データ書込み処理|
|actions|データ取得・操作|

バックエンドのAPIを呼び出して統計情報を取得・保持するsoteモジュールのプログラムは以下の様になります。

```store/statistics.js
/**
* 統計情報取得Storeモジュール
*/
import Vue from 'vue'
import Vuex from 'vuex'
import * as api from './api'
Vue.use(Vuex)

export const state = () => ({
  dbSize: [],         // DBサイズ
  slowQuery: [],      // スロークエリ
  sqlCall: null       // SQL実行回数
})
// Getters
export const getters = {
  getDbSize (state) {
    return state.dbSize
  },
  getSlowQuery (state) {
    return state.slowQuery
  },
  getSqlCall (state) {
    return state.sqlCall
  }
}
// Mutations
export const mutations = {
  setDbSize (state, dbSize) {
    state.dbSize = dbSize
  },
  setSlowQuery (state, slowQuery) {
    state.slowQuery = slowQuery
  },
  setSqlCall (state, sqlCall) {
    state.sqlCall = sqlCall
  }
}
// Actions
export const actions = {
  // DBサイズ取得
  async doGetDbSize (params = null) {
    const res = await api.getApi('/statistics/getDbSize')
    if (res.status === 200) {
      this.commit('statistics/setDbSize', res.data.value)
    } else {
      this.commit('statistics/setDbSize', [])
    }
  },
  // SQL実行回数取得
  async doGetSqlCall (params = null) {
    const res = await api.getApi('/statistics/getSqlCalls')
    if (res.status === 200) {
      this.commit('statistics/setSqlCall', res.data)
    } else {
      this.commit('statistics/setSqlCall', null)
    }
  },
  // スロークエリ取得
  async doGetSlowQuery (params = null, data) {
    const threshold = data.threshold || 1000
    const limit = data.limit || 20
    const arg = 'threshold=' + threshold + '&limit=' + limit
    const res = await api.getApi('/statistics/getSlowQuery', arg)
    if (res.status === 200) {
      this.commit('statistics/setSlowQuery', res.data.value)
    } else {
      this.commit('statistics/setSlowQuery', [])
    }
  }
}
```

ここではaxsiosを使ってAPIの送受信処理を、共通モジュールのapi.jsとして定義しています。

```store/api.js
/**
* API共通モジュール
*   POSTとGETのインターフェース共通化
*/
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
Vue.use(Vuex)

// POST呼び出し共通処理
export const postApi = async function(uri, data = null) {
  try {
    var res = await axios.post(process.env.apiUrl + uri , data)
    return res
  } catch (e) {
    return {status: 500}
  }
}

//GET呼び出し共通処理
export const getApi = async function(uri, options = null) {
  var param = (options != null) ? '?' + options : ''
  try {
    var res = await axios.get(process.env.apiUrl + uri + param)
    return res
  } catch (e) {
    return {status: 500}
  }
}
```

##画面を構成するモジュール
さて、いよいよ画面作成です。
Nuxtの画面を構成するモジュールには、画面の共通的な外観を定義するlayoutsモジュール、画面の構成を定義するpagesモジュール、画面の共通部品を定義するcomponentsモジュールがあります。
今回は可視化の為にグラフ作成を行いますので、グラフ描画コンポーネントを自動読込するためにpluginsモジュールも使います。

描画コンポーネントにはマテリアルコンポーネントフレームワークのvuetify.jsを使います。
・Vuetifyの公式サイト [https://vuetifyjs.com/ja/](https://vuetifyjs.com/ja/)

vuetifyのマテリアルデザインアイコンには種類が豊富なMaterial Design Iconsを使います。
・Material Design Iconsの公式サイト [https://materialdesignicons.com/](https://materialdesignicons.com/)

グラフ作成のパッケージは色々ありますが、今回は直感的に使いやすいApexchartsとvue用のコンポーネントであるVue-Apexchartsを使います。
・Apexchartsの公式サイト [https://apexcharts.com/](https://apexcharts.com/)
・Vue Apexchartsの公式サイト [https://vuejsprojects.com/vue-apexcharts](https://vuejsprojects.com/vue-apexcharts)

###pluginsモジュール
画面描画用のvuetifyとvue-apexchartsのコンポーネント読み込み用のコードをプラグインに作成します。

```plugins/vuetify.js
import Vue from 'vue'
import Vuetify from 'vuetify'
import 'vuetify/dist/vuetify.min.css'
import '@mdi/font/css/materialdesignicons.css'

Vue.use(Vuetify)

export default (ctx) => {
  const vuetify = new Vuetify({
    theme: {
      dark: false // darkのテーマも格好いいですが、グラフの色が見づらくなるのでfalseにしました
    },
    icons: {
      iconfont: 'mdi' // iconは種類が豊富なmdiを使います
    }
  })

  ctx.app.vuetify = vuetify
  ctx.$vuetify = vuetify.framework
}
```

```plugins/vue-apexcharts.js
import Vue from 'vue'
import VueApexCharts from 'vue-apexcharts'

Vue.use({
  install (Vue, options) {
    Vue.component('apexchart', VueApexCharts)
  }
})
```

作成したプラグインをnuxt.config.jsに登録します。

```nuxt.config.js
（省略）
  plugins: [
    '@plugins/vuetify',
    {
      src: '@plugins/vue-apexcharts',
      ssr: false
    }
  ],
（省略）
```

###layoutsモジュール
全画面共通のレイアウトに以下のメニューを用意して画面が切り替わるようにしたいと思います。

|メニュー|mdiアイコン|表示ページ|
|:--:|:--:|:--:|
|データベース容量|mdi-database|/db_size|
|アクセス負荷|mdi-align-vertical-bottom|/access_load|
|スロークエリ|mdi-tortoise|/slow_query|


```layouts/monitor.vue
<template>
  <v-app>
    <v-navigation-drawer
      v-model="drawer"
      :mini-variant="miniVariant"
      :clipped="clipped"
      fixed
      app
    >
      <v-list>
        <v-list-item
          v-for="(item, i) in items"
          :key="i"
          :to="item.to"
          router
          exact
        >
          <v-list-item-action>
            <v-icon>{{ item.icon }}</v-icon>
          </v-list-item-action>
          <v-list-item-content>
            <v-list-item-title v-text="item.title" />
          </v-list-item-content>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>
    <v-app-bar
      :clipped-left="clipped"
      fixed
      app
    >
      <v-app-bar-nav-icon @click.stop="drawer = !drawer" />
      <v-toolbar-title v-text="title" />
    </v-app-bar>
    <v-content>
      <v-container>
        <nuxt />
      </v-container>
    </v-content>
    <v-footer
      :fixed="fixed"
      app
    >
      <span>&copy; 2019</span>
    </v-footer>
  </v-app>
</template>
<script>
import Vue from 'vue'
import Vuetify from 'vuetify'
import 'vuetify/dist/vuetify.min.css'
Vue.use(Vuetify)
export default {
  data () {
    const $t = this.$t.bind(this)
    return {
      clipped: false,
      drawer: false,
      fixed: false,
      miniVariant: false,
      right: true,
      rightDrawer: false,
      title: $t('label.pgbmon'),
      items: [
        {
          icon: 'mdi-database',
          title: $t('label.dbSize'),
          to: '/db_size'
        },
        {
          icon: 'mdi-align-vertical-bottom',
          title: $t('label.accessLoad'),
          to: '/access_load'
        },
        {
          icon: 'mdi-tortoise',
          title: $t('label.slowQuery'),
          to: '/slow_query'
        }
      ]
    }
  }
}
</script>
```

国際化対応ファイルに日本語の文字列を登録します。

```lang/ja.json
{
  "label": {
    "accessLoad": "アクセス負荷",
    "db": "データベース",
    "dbSize": "データベース容量",
    "homePage": "トップページ",
    "host": "ホスト",
    "otherError": "エラー",
    "pageNotFound": "ページはありません",
    "pgbmon": "Pg バランスモニター",
    "slowQuery": "スロークエリ"
  },
  "button": {},
  "tooltip": {
    "menu": "メニュー"
  },
  "message": {
    "otherError": "エラーが発生しました",
    "pageNotFound": "指定のページはありません"
  }
}
```
こんな感じのメニューになります。
![menu.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309509/223ed2c2-bbb3-3632-3f8c-fbd2dab7e68a.jpeg)

### pagesモジュール
とりあえず、空のindx.vueを用意します。

```pages/index.vue
<template>
  <v-layout column justify-center align-center>
    <v-flex xs12 sm8 md6>
      <v-card />
    </v-flex>
  </v-layout>
</template>

<script>
export default {
  layout: 'monitor'
}
</script>
```

###DBサイズの可視化画面
ここでは、個々のDBサイズを見るのではなく、突出して肥大化したDBはないか確認のため、DB間のサイズのバランスを可視化したいと思います。

```pages/db_size.vue
<template>
  <v-container fluid fill-height>
    <v-layout
      align-center
      row wrap
    >
      <v-flex xs12 md6>
        <v-card class="ma-2 pa-3">
          <apexchart type="donut" height="380" :options="chartOptions" :series="series" />
        </v-card>
      </v-flex>
    </v-layout>
  </v-container>
</template>

<script>
export default {
  name: 'DbSizeChart',
  layout: 'monitor',
  data () {
    const $t = this.$t.bind(this)
    return {
      series: [],
      chartOptions: {
        labels: this.getLabel(),
        dataLabels: {
          enable: false
        },
        plotOptions: {
          pie: {
            donut: {
              labels: {
                show: true,
                value: {
                  formatter: function (val) {
                    return String(val).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,')
                  }
                }
              }
            }
          }
        },
        title: {
          text: $t('label.dbSize')
        },
        responsive: [{
          breakpoint: 480,
          options: {
            chart: {
              width: 200
            },
            legend: {
              position: 'bottom'
            }
          }
        }]
      }
    }
  },
  async created () {
    await this.initialize()
  },
  methods: {
    async initialize () {
      await this.$store.dispatch('statistics/doGetDbSize')
      this.series = this.getVal()
      this.chartOptions.labels = this.getLabel()
    },
    getVal () {
      const dbSize = this.$store.getters['statistics/getDbSize']
      const val = []
      for (let i = 0; i < dbSize.length; i++) {
        val.push(Number(dbSize[i].result[0].pg_database_size))
      }
      return val
    },
    getLabel () {
      const dbSize = this.$store.getters['statistics/getDbSize']
      const label = []
      for (let i = 0; i < dbSize.length; i++) {
        label.push(dbSize[i].db + '@' + dbSize[i].host)
      }
      return label
    }
  }

}
</script>
```

こんな感じでDB間のDBサイズがドーナツグラフで表示されます。
![db_size.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309509/2ff12e5a-a137-ca4e-7951-769282663156.jpeg)

多少、偏りが見られるようです。。。


###クエリの種類と数の可視化画面
次に、DB間でSELECT/INSERT/UPDATE等のクエリに偏りがないか、確認するためのグラフを作成したいと思います。

```pages/access_load.vue
<template>
  <v-container fluid fill-height>
    <v-layout
      align-center
      row wrap
    >
      <v-flex v-for="(item, i) in series"
              :key="i"
              xs12 md4
      >
        <v-card class="item ma-2 pa-3">
          <apexchart type="radar" height="350" :options="chartOptions[i]" :series="item" />
        </v-card>
      </v-flex>
    </v-layout>
  </v-container>
</template>

<script>
export default {
  name: 'AccessLoadChart',
  layout: 'monitor',
  data () {
    return {
      // グラフの色を5種類用意します。
      color: ['#008FFB', '#00E396', '#FEB019', '#FF4560', '#775DD0'],
      series: [],
      chartOptions: []
    }
  },
  async created () {
    await this.initialize()
  },
  methods: {
    async initialize () {
      await this.$store.dispatch('statistics/doGetSqlCall')
      const data = this.$store.getters['statistics/getSqlCall']
      var series = []
      const label = []
      for (let i = 0; i < data.value.length; i++) {
        label.push(data.value[i].db + '@' + data.value[i].host)
        const val = data.value[i].result
        for (let j = 0; j < val.length; j++) {
          var call = []
          if (Array.isArray(series[val[j].sql])) {
            call = series[val[j].sql]
          }
          if (val[j].call !== null) {
            call.push(Number(val[j].call))
          } else {
            call.push(0)
          }
          series[val[j].sql] = call
        }
      }
      this.series = []
      this.chartOptions = []
      var i = 0
      for (var key in series) {
        var val = series[key]
        var index = i %  this.color.length
        this.series.push([{ name: key, data: val }])
        var option = {
          title: { text: key },
          labels: label,
          fill: {
            colors: [this.color[index]]
          }
        }
        this.chartOptions.push(option)
        i++
      }
    }
  }
}
</script>
```

こんな感じでDB間のSELECT/INSERT/UPDATEクエリの実行数がレーダーチャート表示されます。
![access_cnt.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309509/3ff4b9e7-6b42-9b1e-5e1b-4d60b89ebeaf.jpeg)
クエリはバランスよく分散されているようです。


###スロークエリの可視化
最後にDBによって処理負荷に偏りがないか、DB間のスロークエリを比較するグラフを作成したいと思います。

```pages/slow_query.vue
<template>
  <v-container fluid fill-height>
    <v-layout align-center row wrap>
      <v-flex xs12 md12>
        <v-card class="item ma-2 pa-3">
          <p class="font-weight-black">{{ title }}</p>
          <apexchart type="bar" height="400" :options="chartOptions" :series="series" />
        </v-card>
      </v-flex>
    </v-layout>
  </v-container>
</template>
<script>
export default {
  layout: 'monitor',
  data () {
    return {
      threshold: process.env.threshold,
      limit: process.env.limit,
      series: [],
      chartOptions: {
        plotOptions: {
          bar: {
            barHeight: '100%',
            horizontal: false
          }
        },
        dataLabels: {
          enabled: false
        },
        xaxis: {
          labels: { show: false }
        }
      },
      title: 'slow query'
    }
  },
  async created () {
    await this.initialize()
  },
  methods: {
    async initialize () {
      this.series = []
      await this.$store.dispatch('statistics/doGetSlowQuery', { threshold: this.threshold, limit: this.limit })
      const data = this.$store.getters['statistics/getSlowQuery']
      for (let i = 0; i < data.length; i++) {
        var maxTime = []
        var label = []
        const val = data[i].result
        for (let j = 0; j < val.length; j++) {
          maxTime.push(Math.round(val[j].max_time))
          label.push(String(val[j].calls))
        }
        var name = data[i].db + '@' + data[i].host
        this.series.push({ name: name, data: maxTime })
      }
    }
  }
}

</script>

```
こんな感じで登録したDBのスロークエリーの時間が棒グラフで表示されます。
![slow_query.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/309509/664881db-13fa-f55a-3613-a45ef8112b53.jpeg)

全部バランスよく、遅いクエリーがあるようです。。。

##まとめ
ここまで簡単ですが、PostgreSQLの統計情報を可視化するコードを書いてみました。
統計情報の可視化は以前から興味がありましたので、軽い気持ちで始めてみましたが、思いのほか簡単に綺麗なグラフが出る事に驚きました。
今後、DBのデモなどに使えると良いなと思っています。

単体テストをやってみたい方は次の章にお進みください。
[>>PostgreSQLの統計情報を可視化(単体でスト編)](https://qiita.com/atmitani/items/50073872588d78eaf830)

なお、コード全体をご覧になりたい方はGitHubからダウンロードできるようにしてみましたので、ご利用ください。
GitHub [https://github.com/at-mitani/pgbmon](https://github.com/at-mitani/pgbmon)