最も単純そうなプラグインを読んでいくシリーズ。
プラグインは 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が必要。
1 2 3 4 5 6 7 8 9 |
# 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 |
ディレクトリ・ファイル構成
ディレクトリ・ファイル構成は以下の通り。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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()を介して設定する。
1 2 3 4 5 6 7 |
import { PanelPlugin } from '@grafana/data'; import { ClockPanel } from './ClockPanel'; import { ClockOptions } from './types'; import { optionsBuilder } from './options'; export const plugin = new PanelPlugin<ClockOptions>(ClockPanel).setNoPadding().setPanelOptions(optionsBuilder); |
本体 (ClockPanel.tsx)
./ClockPanel.tsxを読んでいく。React+TypeScript。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 |
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<ClockOptions> {} interface State { // eslint-disable-next-line now: Moment; } export function getTimeZoneNames(): string[] { return (moment as any).tz.names(); } // PureComponentクラスを派生させることでプラグイン用のパネルクラスを定義できる。 // パネルのプロパティは PanelProps<ClockOptions>型だが 当プラグイン用にProps型に拡張している。 export class ClockPanel extends PureComponent<Props, State> { 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 ( <h4 className={clazz}> {zone} {zoneFormat === ZoneFormat.nameOffset && ( <> <br />({now.format('Z z')}) </> )} </h4> ); } //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 ( <span> <h3 className={clazz}>{disp}</h3> </span> ); } //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 <h2 className={clazz}>{disp}</h2>; } //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 ( <div className={clazz} style={{ width, height, }} > {dateSettings.showDate && this.renderDate()} {this.renderTime()} {timezoneSettings.showTimezone && this.renderZone()} </div> ); } } |
設定 (options.tsx)
設定画面側を読んでいく。
PanelOptionsEditorBuilder
機能実装というのは、つまり、ラジオボタンを追加したり、カスタムエディタを追加したり、など。
この実装で以下のような設定画面が表示される。(README.mdは古いので注意)。
Modeとして、時間をそのまま表示するTimeモードか、カウンドダウンモードかを二者択一で設定する。
背景色(BackgroundColor)をカラーピッカーで設定する。(ちなみにGrafanaV7では機能しない様子)。
addTimeFormat()メソッドにより、24h表示/12h表示/カスタム表示,FontSize,FontWeightの設定機能を追加する。
addTimeZone()メソッドにより,TimeZoneと表示有無の設定機能を追加する。
カウントダウンモードに設定すると、カウントダウン設定をおこなえるが,
addCountdown()メソッドにより,カウントダウン設定を追加する。
決められた構文にしたがって欲しい機能を追加していくだけなので、
設定画面の実装が必要になったら必要な構文を調べて追加していくことになりそう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
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<ClockOptions>) => { // 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 = <Icon className={styles.trashIcon} name="trash-alt" onClick={() => props.onChange(undefined)} />; } prefix = ( <div className={styles.inputPrefix}> <div className={styles.colorPicker}> <ColorPicker color={props.value || config.theme.colors.panelBg} onChange={props.onChange} enableNamedColors={true} /> </div> </div> ); return ( <div> <Input type="text" value={props.value || 'Pick Color'} onBlur={(v: any) => { console.log('CLICK'); }} prefix={prefix} suffix={suffix} /> </div> ); }, defaultValue: '', }); // TODO: refreshSettings.syncWithDashboard addCountdown(builder); addTimeFormat(builder); addTimeZone(builder); addDateFormat(builder); }; //--------------------------------------------------------------------- // COUNTDOWN //--------------------------------------------------------------------- function addCountdown(builder: PanelOptionsEditorBuilder<ClockOptions>) { 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<ClockOptions>) { 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<ClockOptions>) { 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<ClockOptions>) { 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}; } `, }; }); |