Grafanaプラグインを読んでいく – Clock plugin

最も単純そうなプラグインを読んでいくシリーズ。
プラグインは Clock plugin。Panelプラグイン。配布はここ

最も単純そうなDataSourceプラグインを読む記事は以下。

ダッシュボードに時計を表示できる。
ダッシュボードから設定をおこない表示に反映する機能を備えていて、
PanelプラグインのHelloWorldには良い感じ。

The Clock Panel can show the current time or a countdown and updates every second.
Show the time in another office or show a countdown to an important event.

肝心のデータプロットに関する機能は無いので別途違うコンポーネントを読む。

インストール、ビルド

公式からインストールすると src が含まれない。
ソースコードをclone、buildすることにする。
初回だけgrafana-serverのrestartが必要。


# clone repository
$ cd ~/
$ git clone https://github.com/grafana/clock-panel.git
$ mv clock-panek /var/lib/grafana/plugins
# install plugin
$ yarn install
$ yarn build
# restart grafana-server
$ sudo service grafana-server restart

ディレクトリ・ファイル構成

ディレクトリ・ファイル構成は以下の通り。


clock-panel/
   src/
      ClockPanel.tsx ... プラグイン本体
      module.ts ... プラグインのエントリポイント
      options.tsx
      plugin.json ... プラグインの設定ファイル
      types.ts ... TypeScript型定義
      img/ ... 画像リソース
          clock.svg
          countdown1.png
          screenshot-clock-options.png
          screenshot-clocks.png
          screenshot-showcase.png
      external/ ... 外部ライブラリ
          moment-duration-formant.js

エントリポイント

./module.ts の内容は以下の通り。 ClockPanel.tsxで定義済みのClockPanelクラスをexportしている。
options.tsxに記述したオプション画面関連のクラスを.setPanelOptions()を介して設定する。


import { PanelPlugin } from '@grafana/data';

import { ClockPanel } from './ClockPanel';
import { ClockOptions } from './types';
import { optionsBuilder } from './options';

export const plugin = new PanelPlugin(ClockPanel).setNoPadding().setPanelOptions(optionsBuilder);

本体 (ClockPanel.tsx)

./ClockPanel.tsxを読んでいく。React+TypeScript。


import React, { PureComponent } from 'react';
import { PanelProps } from '@grafana/data';
import { ClockOptions, ClockType, ZoneFormat, ClockMode } from './types';
import { css } from 'emotion';

// eslint-disable-next-line
import moment, { Moment } from 'moment';
import './external/moment-duration-format';

interface Props extends PanelProps {}
interface State {
  // eslint-disable-next-line
  now: Moment;
}

export function getTimeZoneNames(): string[] {
  return (moment as any).tz.names();
}

// PureComponentクラスを派生させることでプラグイン用のパネルクラスを定義できる。
// パネルのプロパティは PanelProps型だが 当プラグイン用にProps型に拡張している。
export class ClockPanel extends PureComponent {
  timerID?: any;
  state = { now: this.getTZ(), timezone: '' };

  //Componentのインスタンスが生成されDOMに挿入されるときに呼ばれる
  //DOM挿入後,1秒間隔で this.tick()の実行を開始する。
  componentDidMount() {
    this.timerID = setInterval(
      () => this.tick(),
      1000 // 1 second
    );
  }
  //[非推奨] DOMから削除されるときに呼ばれる。古いのかな。
  //this.tick()の実行を停止する。
  componentWillUnmount() {
    clearInterval(this.timerID);
  }

  //DOM挿入後1秒間隔で呼ばれる。
  //stateを更新する。
  tick() {
    const { timezone } = this.props.options;
    this.setState({ now: this.getTZ(timezone) });
  }

  //時刻フォーマットを取得する。
  //時刻フォーマットはオプション設定画面で設定され props.optionsに渡される。
  //渡される変数は clockType と timeSettings である。 clockTypeは 12時間/24時間のいずれか。
  //12時間なら h:mm:ss A, 24時間なら HH:mm:ss。
  //カスタムの場合,clockTypeがClockType.Customになる。
  getTimeFormat() {
    const { clockType, timeSettings } = this.props.options;

    if (clockType === ClockType.Custom && timeSettings.customFormat) {
      return timeSettings.customFormat;
    }

    if (clockType === ClockType.H12) {
      return 'h:mm:ss A';
    }

    return 'HH:mm:ss';
  }

  // Return a new moment instnce in the selected timezone
  // eslint-disable-next-line
  getTZ(tz?: string): Moment {
    if (!tz) {
      tz = (moment as any).tz.guess();
    }
    return (moment() as any).tz(tz);
  }

  //カウントダウン文字列を得る
  //設定値countdownSettings, timezone は props.options から得られる。
  //
  getCountdownText(): string {
    const { now } = this.state;
    const { countdownSettings, timezone } = this.props.options;

    //カウントダウン終了時 設定された文字列 countdownSettings.endText を返す
    if (!countdownSettings.endCountdownTime) {
      return countdownSettings.endText;
    }

    //残り時間を計算。
    const timeLeft = moment.duration(
      moment(countdownSettings.endCountdownTime)
        .utcOffset(this.getTZ(timezone).format('Z'), true)
        .diff(now)
    );
    let formattedTimeLeft = '';

    //計算した残り時間が0以下であれば、設定された文字列 countdownSettings.endText を返す。
    if (timeLeft.asSeconds() <= 0) {
      return countdownSettings.endText;
    }

    //書式設定が customFormatで,かつ auto の場合、自動書式 をかませて残り時間を返す。
    if (countdownSettings.customFormat === 'auto') {
      return (timeLeft as any).format();
    }
    //書式設定が customFormatで,かつ auto 以外であれば,その書式をかませて返す。
    if (countdownSettings.customFormat) {
      return (timeLeft as any).format(countdownSettings.customFormat);
    }

    //以下,customFormatでない場合
    let previous = '';

    // X years (or X year)
    if (timeLeft.years() > 0) {
      formattedTimeLeft = timeLeft.years() === 1 ? '1 year, ' : timeLeft.years() + ' years, ';
      previous = 'years';
    }
    // Y months (or Y month)
    if (timeLeft.months() > 0 || previous === 'years') {
      formattedTimeLeft += timeLeft.months() === 1 ? '1 month, ' : timeLeft.months() + ' months, ';
      previous = 'months';
    }
    // Z days (or Z day)
    if (timeLeft.days() > 0 || previous === 'months') {
      formattedTimeLeft += timeLeft.days() === 1 ? '1 day, ' : timeLeft.days() + ' days, ';
      previous = 'days';
    }
    // A hours (or A hour)
    if (timeLeft.hours() > 0 || previous === 'days') {
      formattedTimeLeft += timeLeft.hours() === 1 ? '1 hour, ' : timeLeft.hours() + ' hours, ';
      previous = 'hours';
    }
    // B minutes (or B minute)
    if (timeLeft.minutes() > 0 || previous === 'hours') {
      formattedTimeLeft += timeLeft.minutes() === 1 ? '1 minute, ' : timeLeft.minutes() + ' minutes, ';
    }
    // C minutes (or C minute)
    formattedTimeLeft += timeLeft.seconds() === 1 ? '1 second ' : timeLeft.seconds() + ' seconds';
    return formattedTimeLeft;
  }

  //Zoneを表示するh4タグを作成して返す。Reactっぽい。
  renderZone() {
    const { now } = this.state;
    const { timezoneSettings } = this.props.options;
    const { zoneFormat } = timezoneSettings;

    // ReactでCSSを書く作法。ヒアドキュメント
    // ロジックコードの中にHTML生成コードが混在して非常に見辛い。
    const clazz = css`
      font-size: ${timezoneSettings.fontSize};
      font-weight: ${timezoneSettings.fontWeight};
      line-height: 1.4;
    `;

    let zone = this.props.options.timezone || '';

    switch (zoneFormat) {
      case ZoneFormat.offsetAbbv:
        zone = now.format('Z z');
        break;
      case ZoneFormat.offset:
        zone = now.format('Z');
        break;
      case ZoneFormat.abbv:
        zone = now.format('z');
        break;
      default:
        try {
          zone = (this.getTZ(zone) as any)._z.name;
        } catch (e) {
          console.log('Error getting timezone', e);
        }
    }
    return (
      

{zone} {zoneFormat === ZoneFormat.nameOffset && ( <>
({now.format('Z z')}) )}

); } //Dateを表示するh3タグを返す。 renderDate() { const { now } = this.state; const { dateSettings } = this.props.options; const clazz = css` font-size: ${dateSettings.fontSize}; font-weight: ${dateSettings.fontWeight}; `; const disp = now.locale(dateSettings.locale || '').format(dateSettings.dateFormat); return (

{disp}

); } //Timeを返すh2タグを返す。 renderTime() { const { now } = this.state; const { timeSettings, mode } = this.props.options; const clazz = css` font-size: ${timeSettings.fontSize}; font-weight: ${timeSettings.fontWeight}; `; const disp = mode === ClockMode.countdown ? this.getCountdownText() : now.format(this.getTimeFormat()); return

{disp}

; } //React componentとしてrender()メソッドを実装する必要がある。 //CSSを整形してZone,Date,Timeを設定して返す。 render() { const { options, width, height } = this.props; const { bgColor, dateSettings, timezoneSettings } = options; const clazz = css` display: flex; align-items: center; justify-content: center; flex-direction: column; background-color: ${bgColor ?? ''}; text-align: center; `; return (
{dateSettings.showDate && this.renderDate()} {this.renderTime()} {timezoneSettings.showTimezone && this.renderZone()}
); } }

設定 (options.tsx)

設定画面側を読んでいく。
PanelOptionsEditorBuilder型の引数を取り、builderに対して機能実装していく。
機能実装というのは、つまり、ラジオボタンを追加したり、カスタムエディタを追加したり、など。
この実装で以下のような設定画面が表示される。(README.mdは古いので注意)。

Modeとして、時間をそのまま表示するTimeモードか、カウンドダウンモードかを二者択一で設定する。
背景色(BackgroundColor)をカラーピッカーで設定する。(ちなみにGrafanaV7では機能しない様子)。
addTimeFormat()メソッドにより、24h表示/12h表示/カスタム表示,FontSize,FontWeightの設定機能を追加する。
addTimeZone()メソッドにより,TimeZoneと表示有無の設定機能を追加する。
カウントダウンモードに設定すると、カウントダウン設定をおこなえるが,
addCountdown()メソッドにより,カウントダウン設定を追加する。

決められた構文にしたがって欲しい機能を追加していくだけなので、
設定画面の実装が必要になったら必要な構文を調べて追加していくことになりそう。


import React from 'react';
import { PanelOptionsEditorBuilder, GrafanaTheme, dateTime } from '@grafana/data';
import { ColorPicker, Input, Icon, stylesFactory } from '@grafana/ui';
import { css } from 'emotion';
import { config } from '@grafana/runtime';

import { ClockOptions, ClockMode, ClockType, FontWeight, ZoneFormat } from './types';
import { getTimeZoneNames } from './ClockPanel';

export const optionsBuilder = (builder: PanelOptionsEditorBuilder) => {
  // Global options
  builder
    //ClockModeの二者択一。TimeかCountdownを選ばせる。
    .addRadio({
      path: 'mode',
      name: 'Mode',
      settings: {
        options: [
          { value: ClockMode.time, label: 'Time' },
          { value: ClockMode.countdown, label: 'Countdown' },
        ],
      },
      defaultValue: ClockMode.time,
    })
    //背景色のカスタムエディタ。カラーピッカーから色を選ばせる。
    .addCustomEditor({
      id: 'bgColor',
      path: 'bgColor',
      name: 'Background Color',
      editor: props => {
        const styles = getStyles(config.theme);
        let prefix: React.ReactNode = null;
        let suffix: React.ReactNode = null;
        if (props.value) {
          suffix =  props.onChange(undefined)} />;
        }

        prefix = (
          
); return (
{ console.log('CLICK'); }} prefix={prefix} suffix={suffix} />
); }, defaultValue: '', }); // TODO: refreshSettings.syncWithDashboard addCountdown(builder); addTimeFormat(builder); addTimeZone(builder); addDateFormat(builder); }; //--------------------------------------------------------------------- // COUNTDOWN //--------------------------------------------------------------------- function addCountdown(builder: PanelOptionsEditorBuilder) { const category = ['Countdown']; builder .addTextInput({ category, path: 'countdownSettings.endCountdownTime', name: 'End Time', settings: { placeholder: 'ISO 8601 or RFC 2822 Date time', }, defaultValue: dateTime(Date.now()) .add(6, 'h') .format(), showIf: o => o.mode === ClockMode.countdown, }) .addTextInput({ category, path: 'countdownSettings.endText', name: 'End Text', defaultValue: '00:00:00', showIf: o => o.mode === ClockMode.countdown, }) .addTextInput({ category, path: 'countdownSettings.customFormat', name: 'Custom format', settings: { placeholder: 'optional', }, defaultValue: undefined, showIf: o => o.mode === ClockMode.countdown, }); } //--------------------------------------------------------------------- // TIME FORMAT //--------------------------------------------------------------------- function addTimeFormat(builder: PanelOptionsEditorBuilder) { const category = ['Time Format']; builder .addRadio({ category, path: 'clockType', name: 'Clock Type', settings: { options: [ { value: ClockType.H24, label: '24 Hour' }, { value: ClockType.H12, label: '12 Hour' }, { value: ClockType.Custom, label: 'Custom' }, ], }, defaultValue: ClockType.H24, }) .addTextInput({ category, path: 'timeSettings.customFormat', name: 'Time Format', description: 'the date formatting pattern', settings: { placeholder: 'date format', }, defaultValue: undefined, showIf: opts => opts.clockType === ClockType.Custom, }) .addTextInput({ category, path: 'timeSettings.fontSize', name: 'Font size', settings: { placeholder: 'date format', }, defaultValue: '12px', }) .addRadio({ category, path: 'timeSettings.fontWeight', name: 'Font weight', settings: { options: [ { value: FontWeight.normal, label: 'Normal' }, { value: FontWeight.bold, label: 'Bold' }, ], }, defaultValue: FontWeight.normal, }); } //--------------------------------------------------------------------- // TIMEZONE //--------------------------------------------------------------------- function addTimeZone(builder: PanelOptionsEditorBuilder) { const category = ['Timezone']; const timezones = getTimeZoneNames().map(n => { return { label: n, value: n }; }); timezones.unshift({ label: 'Default', value: '' }); builder .addSelect({ category, path: 'timezone', name: 'Timezone', settings: { options: timezones, }, defaultValue: '', }) .addBooleanSwitch({ category, path: 'timezoneSettings.showTimezone', name: 'Show Timezone', defaultValue: false, }) .addSelect({ category, path: 'timezoneSettings.zoneFormat', name: 'Display Format', settings: { options: [ { value: ZoneFormat.name, label: 'Normal' }, { value: ZoneFormat.nameOffset, label: 'Name + Offset' }, { value: ZoneFormat.offsetAbbv, label: 'Offset + Abbreviation' }, { value: ZoneFormat.offset, label: 'Offset' }, { value: ZoneFormat.abbv, label: 'Abbriviation' }, ], }, defaultValue: ZoneFormat.offsetAbbv, showIf: s => s.timezoneSettings?.showTimezone, }) .addTextInput({ category, path: 'timezoneSettings.fontSize', name: 'Font size', settings: { placeholder: 'font size', }, defaultValue: '12px', showIf: s => s.timezoneSettings?.showTimezone, }) .addRadio({ category, path: 'timezoneSettings.fontWeight', name: 'Font weight', settings: { options: [ { value: FontWeight.normal, label: 'Normal' }, { value: FontWeight.bold, label: 'Bold' }, ], }, defaultValue: FontWeight.normal, showIf: s => s.timezoneSettings?.showTimezone, }); } //--------------------------------------------------------------------- // DATE FORMAT //--------------------------------------------------------------------- function addDateFormat(builder: PanelOptionsEditorBuilder) { const category = ['Date Options']; builder .addBooleanSwitch({ category, path: 'dateSettings.showDate', name: 'Show Date', defaultValue: false, }) .addTextInput({ category, path: 'dateSettings.dateFormat', name: 'Date Format', settings: { placeholder: 'Enter date format', }, defaultValue: 'YYYY-MM-DD', showIf: s => s.dateSettings?.showDate, }) .addTextInput({ category, path: 'dateSettings.locale', name: 'Locale', settings: { placeholder: 'Enter locale: de, fr, es, ... (default: en)', }, defaultValue: '', showIf: s => s.dateSettings?.showDate, }) .addTextInput({ category, path: 'dateSettings.fontSize', name: 'Font size', settings: { placeholder: 'date format', }, defaultValue: '20px', showIf: s => s.dateSettings?.showDate, }) .addRadio({ category, path: 'dateSettings.fontWeight', name: 'Font weight', settings: { options: [ { value: FontWeight.normal, label: 'Normal' }, { value: FontWeight.bold, label: 'Bold' }, ], }, defaultValue: FontWeight.normal, showIf: s => s.dateSettings?.showDate, }); } const getStyles = stylesFactory((theme: GrafanaTheme) => { return { colorPicker: css` padding: 0 ${theme.spacing.sm}; `, inputPrefix: css` display: flex; align-items: center; `, trashIcon: css` color: ${theme.colors.textWeak}; cursor: pointer; &:hover { color: ${theme.colors.text}; } `, }; });