1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【#3】電力監視システムをモダンWebに載せた話 PLC × Rust × Zabbix × React

1
Last updated at Posted at 2026-04-17

【第3回】React + Tailwind で「Zabbix にログインさせない」監視画面を作る

デモ画面デマンド電力グラフ_Qiitaトップ画像用.png

前回のおさらい

第1回では Rust によるマルチプロトコルデータ収集と DB を経由しないリアルタイムパスの設計を、第2回では「時系列 DB だけでは足りなかった理由」と Zabbix を管理者 UI として採用した設計を解説しました。最終回では、利用者が実際に触れる 「Zabbix にログインさせない」監視画面 の構築を掘り下げます。

核心は、Rust から WebSocket 経由で届く 10 秒周期の JSON を、DB に一切触れずにそのまま画面に反映する フロントエンド設計にあります。

システムのデモ版について
こちらから本システムのデモ版にアクセスいただけます。ぜひご覧ください。
(第1回で掲載しているものと同じです)
以下のユーザー名/パスワードで閲覧できます。

  • ユーザー名:demo01
  • パスワード:ResnBNVvrHObS8GD

「Zabbix にログインさせない」ための UI 設計

Zabbix ダッシュボードの課題

Zabbix の標準 UI は多機能ゆえに、非 IT ユーザーにとっては情報過多です。

  • 左ペインのナビゲーション(ホスト、テンプレート、トリガー…)が専門用語の羅列
  • グラフ表示はシンプルだが、「どのホストのどのアイテムか」を辿る導線が深い
  • レスポンシブ非対応で、現場でタブレットから確認する用途には不向き

本システムのフロントエンドが解決すべき課題は明確でした。

  1. 「今の電力量はいくらか」が開いた瞬間にわかる ── 10 秒更新
  2. 建物 → フロア → 区画の階層で直感的に辿れる
  3. スマートフォン・タブレットでも快適に使える
  4. Zabbix の存在を一切意識させない

技術選定 ── React + Tailwind CSS を選んだ監視システム固有の理由

React と Tailwind CSS を選んだ理由は、一般的な Web 開発の文脈とは少し異なります。
監視システム特有の要件が選定を決めました。

React:10 秒ごとに WebSocket で届く snapshot を state に入れるだけで、サマリーカード・グラフ・フロアマップが宣言的に再レンダリングされます。全コンポーネントが同一の snapshot を参照するから時刻ずれが起きない──これは監視画面では致命的に重要で、コンポーネントごとに API を叩く構成では実現しにくい点です。

Tailwind CSS:監視画面ではデザインの自由度よりも「統一感」「視認性」「ダークモード」が優先されます。dark: プレフィックスで監視室の暗い環境に対応し、tabular-nums で数値の桁ずれを防ぎ、transition-colors duration-500 でステータス変化を視覚化する──こうしたユーティリティの組み合わせが、監視 UI のスタイリングに適していました。


データフローの全体像 ── 「DB を経由しない」リアルタイムパス

React アプリケーションは、中間 API サーバーを通じて 2 系統のデータ を受け取ります。
そして、この 2 系統の最大の違いはDB が介在するかどうか です。

React App

[DB なしパス ── 日常の監視はすべてここで完結]
├── WebSocket 接続 (/ws/realtime)
│   └── PLC → Rust → JSON(メモリ) → WebSocket → ここ
│       → サマリーカード       ← DB なし
│       → リアルタイムグラフ    ← DB なし
│       → フロアマップ         ← DB なし
│       → 接続状態表示         ← DB なし

[DB ありパス ── 過去を振り返るときだけ]
├── REST API (/api/power/trend, /api/power/history)
│   └── Zabbix DB → Zabbix API → 中間 API → ここ
│       → 日別・月別トレンドグラフ
│       → 統計テーブル

[静的データ]
└── REST API (/api/locations)
    └── 拠点メタ情報 JSON → フロアマップの座標・ラベル定義

フロントエンド開発者の視点で言うと、画面上の「今」を表示するコンポーネントは、一切 DB に触っていません。Rust モジュールがメモリ上で生成した JSON がそのまま WebSocket で届き、React の state に入るだけです。API のレスポンス待ちもなく、DB のクエリレイテンシもゼロ。だからこそ 10 秒周期の更新が、閲覧者が何人いようとストレスなく動きます。


WebSocket によるリアルタイムデータの受信 ── App 直下で 1 本だけ接続する

Rust → 中間 API → React の WebSocket 接続は、App のルートで 1 本だけ張り、Context で全コンポーネントに配布します。コンポーネントごとに接続を張ると WebSocket が乱立し、データの到着タイミングもずれてしまうため、監視画面では避けるべきパターンです。

カスタムフック

import { useState, useEffect, useRef, useCallback } from 'react';

function useRealtimePower(wsUrl) {
  const [snapshot, setSnapshot] = useState(null);
  const [connected, setConnected] = useState(false);
  const wsRef = useRef(null);
  const reconnectTimer = useRef(null);

  const connect = useCallback(() => {
    const ws = new WebSocket(wsUrl);

    ws.onopen = () => {
      setConnected(true);
    };

    ws.onmessage = (event) => {
      try {
        setSnapshot(JSON.parse(event.data));
      } catch (e) {
        console.error('JSON parse error:', e);
      }
    };

    ws.onclose = () => {
      setConnected(false);
      reconnectTimer.current = setTimeout(connect, 5000);
    };

    ws.onerror = () => ws.close();
    wsRef.current = ws;
  }, [wsUrl]);

  useEffect(() => {
    connect();
    return () => {
      wsRef.current?.close();
      clearTimeout(reconnectTimer.current);
    };
  }, [connect]);

  return { snapshot, connected };
}

Context による配布

import { createContext, useContext } from 'react';

const RealtimeContext = createContext({ snapshot: null, connected: false });

// ── App 直下で 1 回だけ接続し、Context に流す ──
function App() {
  const realtime = useRealtimePower('wss://api.example.com/ws/realtime');

  return (
    <RealtimeContext.Provider value={realtime}>
      <Header />
      <DashboardLayout />
      <Footer />
    </RealtimeContext.Provider>
  );
}

// ── 各コンポーネントは Context から snapshot を受け取るだけ ──
function useSnapshot() {
  return useContext(RealtimeContext);
}

この設計のポイント:

  • WebSocket 接続は App 全体で 1 本だけ。Rust の 10 秒周期がそのまま onmessage の発火間隔になる
  • すべてのコンポーネントが 同一の snapshot オブジェクトを同一タイミングで参照 する。サマリーカードとフロアマップとグラフで時刻がずれることがない
  • コンポーネントの追加・削除が接続数に影響しない。新しい表示を足しても useSnapshot() を呼ぶだけ

コンポーネント設計

フロントエンドは以下のコンポーネントツリーで構成しています。
App で 1 本だけ WebSocket を張り、RealtimeContext.Provider で全コンポーネントに配布する のがポイントです。

<App>                              ← useRealtimePower() はここだけ
├── <RealtimeContext.Provider>     ← snapshot を全子孫に配布
│   ├── <Header />                 // 拠点選択・接続状態表示
│   ├── <DashboardLayout>
│   │   ├── <PowerSummaryCards />  // useSnapshot() で現在値取得
│   │   ├── <RealtimeChart />      // useSnapshot() + ブラウザ内バッファ
│   │   ├── <FloorMap />           // useSnapshot() + 拠点 JSON
│   │   ├── <TrendChart />         // Zabbix API(REST)
│   │   └── <StatsTable />         // Zabbix API(REST)
│   └── <Footer />
└── </RealtimeContext.Provider>

useSnapshot() を呼ぶコンポーネントは WebSocket の存在を意識しません。
snapshot が 10 秒ごとに更新されるたびに自動的に再レンダリングされるだけです。

接続状態インジケーター

WebSocket の接続状態を画面上部に表示します。Rust モジュールや VPN に障害が発生した場合、利用者がすぐに気づけるようにするためです。

function ConnectionIndicator({ connected }) {
  return (
    <div className="flex items-center gap-2">
      <div className={`h-2.5 w-2.5 rounded-full ${
        connected
          ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.6)]'
          : 'bg-red-500 animate-pulse'
      }`} />
      <span className="text-xs text-gray-500 dark:text-gray-400">
        {connected ? 'リアルタイム接続中' : '再接続中...'}
      </span>
    </div>
  );
}

サマリーカード ── 「開いた瞬間」の体験

ダッシュボード最上部に配置する、現在の状態を一目で把握するためのカードです。useSnapshot() で Context から snapshot を受け取ります。

function PowerSummaryCard({ label, value, unit, trend, status }) {
  const statusColor = {
    normal: 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950',
    warning: 'border-amber-500 bg-amber-50 dark:bg-amber-950',
    critical: 'border-red-500 bg-red-50 dark:bg-red-950',
  }[status];

  const trendIcon = trend > 0 ? '' : trend < 0 ? '' : '';

  return (
    <div className={`rounded-xl border-l-4 p-5 shadow-sm
                     transition-all duration-300 ${statusColor}`}>
      <p className="text-sm font-medium text-gray-500 dark:text-gray-400">
        {label}
      </p>
      <div className="mt-2 flex items-baseline gap-2">
        <span className="text-3xl font-bold text-gray-900 dark:text-white
                         tabular-nums transition-[color] duration-300">
          {value.toLocaleString()}
        </span>
        <span className="text-sm text-gray-500">{unit}</span>
      </div>
      <p className="mt-1 text-xs text-gray-400">
        前回比 {trendIcon} {Math.abs(trend)}%
      </p>
    </div>
  );
}
// 使用例:Context から snapshot を受け取って表示
function SummarySection() {
  const { snapshot } = useSnapshot();  // WebSocket は App で 1 本だけ接続済み

  if (!snapshot) return <LoadingSkeleton />;

  const eastZone = snapshot.readings.find(r => r.zone_id === '1f-east');

  return (
    <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
      <PowerSummaryCard
        label="現在デマンド(東側)"
        value={eastZone?.value ?? 0}
        unit="kW"
        trend={-3.2}
        status="normal"
      />
      {/* 他のカード... */}
    </div>
  );
}

transition-all duration-300 により、10 秒ごとの値更新がなめらかなアニメーションで反映されます。tabular-nums は数字の桁がずれないようにする OpenType 機能で、更新時の視覚的なチラつきを抑えます。

リアルタイムグラフ ── WebSocket データの時系列バッファリング

Highcharts / ApexCharts を使い、WebSocket で届くデータを時系列バッファに溜めながら折れ線グラフを描画します。

import { useState, useEffect, useRef } from 'react';
import Highcharts from 'highcharts';

function RealtimeChart({ snapshot, zoneId, maxPoints = 60 }) {
  const chartRef = useRef(null);
  const [buffer, setBuffer] = useState([]);

  // スナップショットが届くたびにバッファに追加
  useEffect(() => {
    if (!snapshot) return;

    const reading = snapshot.readings.find(r => r.zone_id === zoneId);
    if (!reading) return;

    setBuffer(prev => {
      const next = [...prev, {
        x: new Date(snapshot.timestamp).getTime(),
        y: reading.value,
      }];
      return next.slice(-maxPoints);
    });
  }, [snapshot, zoneId, maxPoints]);

  // Highcharts でグラフを描画
  useEffect(() => {
    if (!chartRef.current || buffer.length === 0) return;

    Highcharts.chart(chartRef.current, {
      chart: { type: 'line', animation: { duration: 300 } },
      title: { text: null },
      xAxis: { type: 'datetime' },
      yAxis: { title: { text: 'kW' } },
      series: [{
        name: 'リアルタイム電力',
        data: buffer,
        color: '#10b981',
      }],
      credits: { enabled: false },
    });
  }, [buffer]);

  return (
    <div className="rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800">
      <h3 className="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">
        リアルタイム電力 (kW)
      </h3>
      <div ref={chartRef} style={{ height: 300 }} />
    </div>
  );
}

従来の HTTP ポーリング方式との違い:

項目 HTTP ポーリング(当社従来構成) WebSocket(本構成)
データ到着タイミング フロントエンド側の setInterval に依存 Rust の 10 秒周期に同期
リクエスト数 10 秒ごとに HTTP リクエスト発生 初回接続のみ、以降はプッシュ
DB 介在 あり(毎回 DB から SELECT) なし(メモリ上の JSON を直送)
レイテンシ API 処理 + DB クエリ Rust → WebSocket 直送(数百 ms)
閲覧者増加時の DB 負荷 人数 × リクエスト回数で増大 ゼロ(DB を経由しない)
データ整合性 リクエストタイミングのずれで不整合 全コンポーネントが同一スナップショットを参照

すべてのリアルタイムコンポーネント(サマリーカード、グラフ、フロアマップ)が 同一の snapshot オブジェクト を参照するため、画面内でデータの時刻が食い違うことがありません。

フロアマップ ── JSON 駆動の拠点可視化

第2回で紹介した拠点メタ情報 JSON の map_position を使い、フロアの平面図上に各区画の電力値をオーバーレイ表示します。

function FloorMap({ snapshot, zones, floorImageUrl }) {
  return (
    <div className="relative rounded-xl bg-white p-6 shadow-sm dark:bg-gray-800">
      <h3 className="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">
        フロアマップ
      </h3>
      <div className="relative mx-auto" style={{ maxWidth: 600 }}>
        <img
          src={floorImageUrl}
          alt="フロア平面図"
          className="w-full rounded-lg opacity-80"
        />

        {zones.map(zone => {
          // WebSocket のスナップショットから該当ゾーンの値を取得
          const reading = snapshot?.readings?.find(
            r => r.zone_id === zone.zone_id
          );
          const value = reading?.value ?? '--';
          const status = getStatus(reading?.value, zone.thresholds);

          return (
            <div
              key={zone.zone_id}
              className="absolute flex flex-col items-center
                         transition-all duration-500"
              style={{
                left: `${zone.map_position.x}px`,
                top: `${zone.map_position.y}px`,
                transform: 'translate(-50%, -50%)',
              }}
            >
              <div className={`
                rounded-lg px-3 py-2 text-center shadow-lg backdrop-blur-sm
                transition-colors duration-500
                ${status === 'normal'
                  ? 'bg-emerald-500/90 text-white'
                  : status === 'warning'
                    ? 'bg-amber-500/90 text-white'
                    : 'bg-red-500/90 text-white animate-pulse'
                }
              `}>
                <p className="text-xs font-medium">{zone.label}</p>
                <p className="text-lg font-bold tabular-nums">{value} kW</p>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

10 秒ごとにマップ上の数値と色が transition-colors duration-500 でなめらかに切り替わり、施設全体の電力状況がひと目でわかるヒートマップ のような体験を提供します。
異常時は animate-pulse で点滅させ、注意を引きます。

拠点が増えたときの対応が JSON 追加のみで完結 する点がこの設計の強みです。
フロントエンドのコードはデプロイし直す必要がありません。


ダークモード・レスポンシブ ── 監視システム固有の要件

一般的な Web アプリでは「あると嬉しい」レベルの機能ですが、監視システムでは必須要件になります。

ダークモード:24 時間稼働の監視室では画面の明るさが作業者の目の負担に直結します。darkMode: 'media' で OS 設定に自動追従し、全コンポーネントで bg-white dark:bg-gray-800 を統一しています。

レスポンシブ:管理事務所の PC(lg:)、現場巡回のタブレット(md:)、外出先のスマートフォン(デフォルト)──利用シーンごとにデバイスが異なります。Tailwind のレスポンシブプレフィックスで 1 コードベース対応しています。

{/* サマリーカード:スマホ2列 → PC4列 */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">

{/* メインコンテンツ:スマホ縦積み → PC横並び */}
<div className="grid gap-6 lg:grid-cols-3">
  <div className="lg:col-span-2">
    <RealtimeChart />
  </div>
  <div>
    <StatsTable />
  </div>
</div>

3 層のユーザーモデル ── 運用設計

本シリーズを通じた設計の根幹を改めて整理します。
本システムには 3種類のユーザー がいて、それぞれ最適な画面を使い分けます。

┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│  利用者(報告閲覧)  │ │ 現地管理者(設備担当)│ │   開発者(当社)     │
│                    │ │                    │ │                    │
│ React ダッシュボード │ │ Zabbix ネイティブ UI │ │ Rust / API / React │
│ - サマリーカード    │ │ - 最新データ確認     │ │ - コード保守        │
│ - リアルタイムグラフ │ │ - グラフで生値確認   │ │ - TOML / JSON 設定  │
│ - フロアマップ      │ │ - トリガー閾値調整   │ │ - インフラ運用       │
│ - 月次レポート      │ │ - アラート対応       │ │                    │
│                    │ │ - アイテム追加       │ │                    │
│ 認証:不要/簡易     │ │ 認証:Zabbix 管理者  │ │ 認証:SSH / 各種     │
└────────┬───────────┘ └────────┬───────────┘ └────────┬───────────┘
         │ HTTPS + WebSocket    │ Zabbix UI (直接)      │
         │                     │                       │
    ┌────▼─────────────────────▼───────────────────────▼────┐
    │          Zabbix サーバー + 中間 API サーバー             │
    └──────────────────────┬───────────────────────────────┘
                           │
                   ┌───────▼───────┐
                   │ Rust Collector │ ← 10秒周期で全センサー取得
                   │ (現場 Linux)   │
                   └───────┬───────┘
                           │ MC Protocol 3E
                      ┌────▼──────────┐
                      │ MELSEC Q シリーズ│
                      └───────────────┘

利用者にとってのメリット:

  • Zabbix のアカウント管理が不要
  • 「見るだけ」のシンプルな画面で認知負荷が低い
  • 10 秒更新のリアルタイム性でストレスのない監視体験

現地管理者にとってのメリット:

  • SQL やダッシュボード構築の知識なしに、Zabbix の GUI だけでセンサーの生値・グラフを即座に確認 できる
  • 閾値の変更やアラート対応も同じ画面で完結
  • 担当者が異動で交代しても、数時間の引き継ぎで基本操作を習得可能
  • 時系列 DB + Grafana のみの構成と比べて、エンジニア不在でも自走できる
  • 「あの時刻に何が起きていたか」を SQL なしでグラフ操作だけで調べられる安心感

開発者にとってのメリット:

  • 現地管理者が Zabbix で自走してくれるため、日常の問い合わせ対応が激減
  • 利用者の操作ミスによる設定破壊のリスクがゼロ
  • スキーマ設計・マイグレーション管理が不要で、Rust と React の開発に集中できる
  • 監視項目の追加は Zabbix 設定 + Rust TOML + 拠点 JSON のみ

応用展開 ── 電力だけじゃない

本シリーズで解説したアーキテクチャ(PLC → Rust → Zabbix + WebSocket → React)は、電力監視に限らず あらゆる設備データの可視化 に応用可能です。
当社では同じ基盤を以下の用途にも展開しています。

監視対象 センサー 活用例
温湿度・CO2 環境センサー 設備内の環境モニタリング
空調 温度・運転状態 拠点の空調遠隔監視
風向・風速 風向風速計 屋外設備メンテナンス安全管理
高圧電力 CT + 高圧計器 電力基本料金のデマンド最適化

共通するのは 「Rust がデータを集め、Zabbix が蓄積し、React が人に見せる」 というシンプルな構造です。Rust モジュールの TOML 設定と拠点 JSON を更新するだけで、新しい監視対象がダッシュボードに自動反映されます。


シリーズまとめ ── OT × Rust × Web の融合を一社で

全3回を通じて、電力監視システムのアーキテクチャを下層から上層まで解説してきました。

各層の技術選択と設計思想

技術 設計思想
データ収集 三菱 MELSEC Q + CT/センサー モジュール構成で拡張自在。一度動けば止まらない産業用の信頼性
データ中継 Rust(Tokio + MC Protocol 3E 自前実装) GC なし・非同期で 10 秒周期を確実に維持。全センサーの現在値を 1 つの JSON に
データ蓄積 Zabbix(トラッパー) 蓄積・アラートに加え、現地管理者の「計器盤」を提供
データ配信 中間 API(WebSocket + REST) リアルタイムは DB を経由しない JSON 直送、長期のみ Zabbix API
データ表示 React + Tailwind CSS 直感的 UI で「Zabbix にログインさせない」

本システムが目指したもの

私たちが目指したのは、「分野の枠組みにとらわれずに、コンピューターがお客様の思い通りに動いてくれること」に加えて、「わかりやすい」「使いやすい」「後戻りしない」仕組み の実現です。

  • わかりやすい:開いた瞬間に状況がわかるサマリーカードとフロアマップ(10 秒更新、DB 経由なし)
  • 使いやすい:レスポンシブ対応でデバイスを選ばない
  • 速い・落ちない:リアルタイム画面は DB を経由しない構造のため、閲覧者が増えても DB がボトルネックにならない
  • 後戻りしない:Rust の信頼性 × Zabbix の実績ある基盤 × JSON による宣言的設定で、拡張はコード修正不要

MELSEC Q × Rust × 全データ JSON という構成が、DB を介さないリアルタイム配信と高いスケーラビリティを構造的に保証しています。まだまだ語りきれないノウハウはありますが、この設計思想が本システムの根幹です。

OT(MELSEC Q の選定・モジュール構成・盤内配線・CT 取付)、システムプログラミング(Rust)、IT(Zabbix・React・クラウド)──この 3 領域を一社で一気通貫に対応できることが、システム全体の品質と整合性を支えています。


関連記事


参考リンク


本シリーズは、筆者が所属するクイックイタレート株式会社での電力監視システム開発の知見をもとに執筆しています。関連する公開事例はこちら

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?