Menghubungkan Grafana dan Rapid SCADA

Menghubungkan Grafana dan Rapid SCADA
Photo by Stephen Dawson / Unsplash

Artikel ini menjelaskan implementasi proxy server yang menghubungkan antara Grafana dan Rapid SCADA versi 6. Proxy ini bertugas untuk menangani autentikasi berbasis cookie, mengakses data historis dari endpoint SCADA, dan mengubah format respons agar sesuai dengan format data time series Grafana.

Arsitektur Singkat

Berikut arsitektur umum alur komunikasi antara Grafana dan Rapid SCADA:

Grafana --> [Basic Auth] --> ScadaGrafanaProxy --> [Cookie Session] --> Rapid SCADA 6 API
  • Grafana: Mengirim permintaan data historis.
  • ScadaGrafanaProxy: Middleware berbasis Node.js yang mengautentikasi pengguna dan meneruskan permintaan ke Rapid SCADA.
  • Rapid SCADA 6: Menyediakan endpoint /Api/Main/GetHistData dengan autentikasi berbasis sesi.

Struktur File Utama

.
├── server.js          # Entrypoint dan autentikasi dasar
├── grafanaRouter.js   # Routing permintaan Grafana
├── scadaClient.js     # Login SCADA & manajemen cookie
├── config.js          # Konfigurasi aplikasi
├── .env               # Variabel lingkungan untuk konfigurasi

Penjelasan Tiap Komponen

  • server.js
    Titik masuk utama aplikasi. Menginisialisasi Express dan menggunakan middleware Basic Auth untuk membatasi akses dari klien seperti Grafana. Semua permintaan kemudian diteruskan ke grafanaRouter.js.

  • grafanaRouter.js
    Menangani rute /Api/Main/GetHistData. Modul ini bertanggung jawab untuk:

    • Memastikan sesi login SCADA masih valid.
    • Melakukan permintaan ke SCADA.
    • Mengubah format respons SCADA menjadi array of objects { timestamp, channel, value } agar cocok dengan format Grafana.
  • scadaClient.js
    Berisi fungsi untuk login ke Rapid SCADA dan menyimpan cookie sesi yang valid. Cookie hanya diperbarui jika dibutuhkan.

  • config.js dan .env
    Semua informasi konfigurasi diambil dari .env, seperti kredensial dan URL. File config.js hanya membaca dari environment variable menggunakan dotenv.

Contoh Isi .env

PROXY_PORT=3000
PROXY_USER=admin
PROXY_PASS=secret
SCADA_BASE_URL=http://localhost:10008
SCADA_USERNAME=admin
SCADA_PASSWORD=secret

Kode server.js

const express = require('express');
const bodyParser = require('body-parser');
const basicAuth = require('basic-auth');
const grafanaRouter = require('./grafanaRouter');
const config = require('./config');

const app = express();

// Basic Auth middleware
app.use((req, res, next) => {
  const user = basicAuth(req);
  if (!user || user.name !== config.proxy.username || user.pass !== config.proxy.password) {
    res.set('WWW-Authenticate', 'Basic realm="GrafanaDataProxy"');
    return res.status(401).send('Authentication required.');
  }
  next();
});

app.use(bodyParser.json());
app.use('/', grafanaRouter);

// Error handling middleware
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).send('Internal Server Error');
});

app.listen(config.proxy.port, () => {
  console.log(`SCADA Grafana Proxy listening on port ${config.proxy.port}`);
});

Kode scadaClient.js

const axios = require('axios');
const config = require('./config');

let cookieJar = [];

async function login() {
  const url = `${config.scada.baseUrl}/Api/Auth/Login`;
  const payload = {
    Username: config.scada.username,
    Password: config.scada.password,
  };

  try {
    const response = await axios.post(url, payload, {
      headers: {
        Accept: 'application/json;charset=utf-8',
        'Content-Type': 'application/json',
      },
      withCredentials: true,
    });

    console.log(`[CLIENT] Login status: ${response.status}`);
    const setCookieHeaders = response.headers['set-cookie'];
    if (!setCookieHeaders) {
      throw new Error('[CLIENT] No set-cookie header received from SCADA.');
    }

    if (response.data?.success === false) {
      throw new Error('[CLIENT] SCADA login failed: ' + (response.data?.message || 'Unknown error'));
    }

    cookieJar = setCookieHeaders.map(cookie => cookie.split(';')[0]);
  } catch (err) {
    console.error('[CLIENT] Login to SCADA failed:', err.message);
    throw err;
  }
}

function getSessionCookies() {
  return cookieJar;
}

async function ensureLogin() {
  if (!cookieJar.length) {
    await login();
  }
}

async function forceLogin() {
  cookieJar = [];
  await login();
}

module.exports = {
  login,
  getSessionCookies,
  ensureLogin,
  forceLogin,
};

Kode grafanaRouter.js

const express = require('express');
const axios = require('axios');
const router = express.Router();
const config = require('./config');
const { getSessionCookies, ensureLogin, forceLogin } = require('./scadaClient');

router.get('/Api/Main/GetHistData', async (req, res) => {
  if (!req.query || !req.query.cnlNums || !req.query.startTime || !req.query.endTime) {
    return res.status(400).json({ message: '[ROUTER] Missing required query parameters' });
  }

  const url = `${config.scada.baseUrl}/Api/Main/GetHistData`;

  try {
    await ensureLogin();
    const cookies = getSessionCookies();

    const response = await axios.get(url, {
      headers: {
        Accept: 'application/json;charset=utf-8',
        Cookie: cookies.join('; '),
        'User-Agent': 'Mozilla/5.0',
      },
      params: req.query,
    });

    console.log('[ROUTER] Fetching data from SCADA with params:', req.query);
    try {
      const transformed = transformSCADAResponse(response);
      return res.json(transformed);
    } catch (transformError) {
      return res.status(500).json({
        message: '[ROUTER] Error transforming SCADA response',
        details: transformError.message,
      });
    }
  } catch (error) {
    if (error.response?.status === 401) {
      console.warn(`[ROUTER] Session expired. Re-authenticating...`);
      await forceLogin();
      const cookies = getSessionCookies();

      try {
        const retryResponse = await axios.get(url, {
          headers: {
            Accept: 'application/json;charset=utf-8',
            Cookie: cookies.join('; '),
            'User-Agent': 'Mozilla/5.0',
          },
          params: req.query,
        });

        return res.json(transformSCADAResponse(retryResponse));
      } catch (retryError) {
        return res.status(retryError.response?.status || 500).json({
          message: '[ROUTER] Error after retrying login',
          details: retryError.response?.data || retryError.message,
        });
      }
    }

    res.status(error.response?.status || 500).json({
      message: '[ROUTER] Error forwarding request to SCADA',
      details: error.response?.data || error.message,
    });
  }
});

router.get('/health', (req, res) => res.send('SCADA Grafana Proxy is running'));

function transformSCADAResponse(response) {
  const { data } = response;
  if (!data?.data?.timestamps || !data?.data?.trends || !data?.data?.cnlNums) {
    console.error('[ROUTER] SCADA response format invalid:', data);
    throw new Error('[ROUTER] Invalid SCADA response format');
  }

  const timestamps = data.data.timestamps.map(ts => ts.ms);

  return (data.data.trends || []).flatMap((trendValues, index) => {
    const channel = data.data.cnlNums?.[index] ?? null;
    return (trendValues || []).map((item, i) => ({
      timestamp: timestamps?.[i] ?? null,
      channel,
      value: item?.d?.val ?? null,
    }));
});

}

module.exports = router;

Kesimpulan

Proxy ini memungkinkan Grafana untuk mengambil data historis dari Rapid SCADA dengan cara yang lebih mudah, aman, dan terstruktur. Pendekatan cookie session Rapid SCADA 6 lebih andal dibanding basic auth, serta format JSON hasilnya dapat digunakan langsung oleh plugin data source seperti Infinity atau JSON API.

Dengan desain modular, kita dapat memperluas proxy ini untuk endpoint Rapid SCADA lainnya atau menambahkan fitur autentikasi tambahan jika diperlukan.

👉 Kode sumber lengkap tersedia di: https://github.com/kumajaya/scada-grafana-proxy

Konfigurasi Infinity Datasource di Grafana

Untuk menghubungkan Grafana dengan ScadaGrafanaProxy, kita bisa menggunakan plugin Infinity Datasource, yang mendukung permintaan HTTP ke endpoint JSON/REST.

Langkah-langkah:

  1. Install Infinity Plugin
    Di Grafana, buka menu Plugins, cari Infinity, dan install.

  2. Tambahkan Datasource Baru

    • Masuk ke Configuration > Data Sources.

    • Pilih Infinity.

    • Atur sebagai berikut:

      • Name: Beri nama unik, misalnya bekasi-infinity-datasource
      • Authentication: Pilih Basic Auth, dan masukkan admin:secret (atau sesuai .env)
  3. Membuat Panel di Dashboard

    Konfigurasi Untuk Rapid SCADA 6:
    • Pilih bekasi-infinity-datasource sebagai datasource.

    • Pilih Type: JSON

    • Method: GET

    • URL: http://<ip_proxy>:3000/Api/Main/GetHistData

      • URL Query Params:

        • archiveBit: Tipe arsip Rapid SCADA (1: minutes data, 2: hours data), bisa juga diatur dengan variabel kustom misalnya ${_archiveBit}
        • startTime: Tanggal mulai dalam format ISO (${__from:date:iso})
        • endTime: Tanggal akhir dalam format ISO (${__to:date:iso})
        • endInclusive: Untuk menyertakan batas waktu di dalam range atau tidak (true atau false)
        • cnlNums: Daftar channel number (misalnya: 101,102)
    • Untuk Root selector dikosongkan.

    • Tambahkan Columns selector:

      • timestamp: Time field format sebagai Time (UNIX ms) agar Grafana dapat mengenali timestamp
      • value: Nilai pengukuran sebagai Number
      • channel: Channel pengukuran (untuk group by) sebagai String
    Konfigurasi Untuk Rapid SCADA 5 dengan GrafanaDataProvider plugin:
    • Pilih bekasi-infinity-datasource sebagai datasource.
    • Pilih Type: JSON
    • Method: POST
    • URL: http://<ip_proxy>:3000/api/trends/query
      • Body Content Type: JSON
{
    "range": {
        "from": "${__from:date:iso}",
        "to": "${__to:date:iso}"
    },
    "intervalMs": "${__interval_ms}",
    "targets": [
        {
            "target": "101"
        },
        {
            "target": "102"
        }
    ]
}
  • Untuk Root selector dikosongkan.

  • Tambahkan Columns selector:

    • timestamp: Time field format sebagai Time (UNIX ms) agar Grafana dapat mengenali timestamp
    • value: Nilai pengukuran sebagai Number
    • target: Target pengukuran (untuk group by) sebagai String
  1. Transformation
    Tambahkan Prepare time series dengan format Multi-frame time series.

Contoh URL dengan parameter (tanpa URL encode):

http://localhost:3000/Api/Main/GetHistData?archiveBit=2&startTime=2025-05-25T00:00:00.000Z&endTime=2025-05-25T23:59:59.000Z&endInclusive=true&cnlNums=101,102&

FAQ Singkat

Q: Apakah proxy ini aman digunakan di jaringan publik?
A: Proxy ini mengimplementasikan Basic Auth untuk membatasi akses, namun disarankan untuk menggunakannya di belakang VPN atau reverse proxy dengan HTTPS jika digunakan di jaringan terbuka.

Q: Apa yang terjadi jika session cookie kedaluwarsa?
A: Proxy akan secara otomatis melakukan login ulang ke SCADA dan mencoba kembali permintaan dari Grafana.

Q: Format data seperti apa yang dikembalikan oleh endpoint /Api/Main/GetHistData?
A: Format data asli berbasis timestamp array, channel number array, dan nilai tren. Proxy mengubahnya menjadi array objek { timestamp, channel, value } agar lebih mudah digunakan di Grafana.

Q: Apakah bisa digunakan untuk SCADA versi 5?
A: Tidak langsung. Rapid SCADA v5 memiliki autentikasi berbeda dan tidak menggunakan endpoint REST seperti versi 6.

Q: Bisakah ditambahkan endpoint untuk data realtime atau status channel?
A: Ya, kamu bisa menambahkan rute baru dan menggunakan pendekatan serupa di grafanaRouter.js, lalu sesuaikan transformasi data sesuai kebutuhan.