T-CREATOR

Convex でリアルタイムダッシュボード:KPI/閾値アラート/役割別ビューの実装例

Convex でリアルタイムダッシュボード:KPI/閾値アラート/役割別ビューの実装例

ビジネスアプリケーションにおいて、リアルタイムで KPI を監視し、異常値を即座に検知するダッシュボードは欠かせません。Convex を活用すれば、WebSocket ベースのリアルタイム通信と強力なデータベース機能を組み合わせて、役割別に最適化されたダッシュボードを簡単に構築できます。本記事では、KPI 追跡、閾値アラート、役割別ビューの実装方法を、実践的なコード例とともに解説していきます。

背景

リアルタイムダッシュボードの必要性

現代のビジネス環境では、データドリブンな意思決定が求められています。売上、トラフィック、システムパフォーマンスなどの KPI(重要業績評価指標)をリアルタイムで可視化することで、問題の早期発見や迅速な対応が可能になります。

従来のダッシュボードでは、定期的なポーリングやページリロードが必要でしたが、これではデータの鮮度が保たれず、サーバー負荷も高くなってしまいます。

Convex の特徴とメリット

Convex は、フルスタックアプリケーション向けのバックエンドプラットフォームで、以下の特徴を持っています。

  • リアクティブクエリ: データベースの変更が自動的にクライアントに反映される
  • TypeScript 完全対応: 型安全な API 開発が可能
  • リアルタイム同期: WebSocket による双方向通信を標準搭載
  • 認証・認可: 役割ベースのアクセス制御が簡単に実装できる

以下の図は、Convex を使ったリアルタイムダッシュボードの基本アーキテクチャを示しています。

mermaidflowchart TB
    user["ユーザー<br/>(各役割)"] -->|アクセス| dashboard["Next.js<br/>ダッシュボード"]
    dashboard -->|useQuery| convex["Convex<br/>バックエンド"]
    convex -->|WebSocket| dashboard
    convex -->|読み書き| db[("Convex DB<br/>KPIデータ")]

    subgraph monitoring["監視システム"]
        checker["閾値チェッカー"] -->|定期実行| convex
        alert["アラート通知"] -->|検知時| convex
    end

    convex -.->|リアクティブ更新| dashboard

    style dashboard fill:#e1f5ff
    style convex fill:#fff4e1
    style db fill:#f0f0f0
    style monitoring fill:#ffe1e1

図の要点:

  • ユーザーは Next.js ダッシュボードを通じて KPI データにアクセスします
  • Convex が WebSocket でリアルタイムにデータを配信します
  • 閾値チェッカーが定期的にデータを監視し、異常を検知します

課題

従来のダッシュボード実装の問題点

リアルタイムダッシュボードを構築する際、以下のような課題に直面します。

#課題具体的な問題
1データの鮮度ポーリング間隔が長いと最新データが反映されない
2サーバー負荷頻繁なポーリングで API サーバーに過負荷がかかる
3実装の複雑さWebSocket の接続管理、再接続処理が煩雑
4型安全性の欠如フロントエンドとバックエンドで型の不一致が発生
5権限管理役割ごとに異なるデータを表示する実装が困難

リアルタイム性と権限管理の両立

特に難しいのが、リアルタイム性と細かい権限管理の両立です。例えば、管理者には全 KPI を表示し、営業担当者には売上関連のみ、サポート担当者には顧客満足度のみを表示するといった要件があります。

従来のアプローチでは、クライアント側で権限チェックを行うとセキュリティリスクが高まり、サーバー側で行うと実装が複雑になってしまいます。

以下の図は、役割別のデータアクセス要件を示しています。

mermaidflowchart LR
    admin["管理者"] -->|全データ| allkpi["全KPI<br/>売上・顧客・システム"]
    sales["営業担当"] -->|売上のみ| saleskpi["売上KPI<br/>受注・商談"]
    support["サポート担当"] -->|顧客のみ| supportkpi["顧客KPI<br/>満足度・問い合わせ"]
    engineer["エンジニア"] -->|システムのみ| systemkpi["システムKPI<br/>パフォーマンス・エラー"]

    style admin fill:#ffcccc
    style sales fill:#ccffcc
    style support fill:#ccccff
    style engineer fill:#ffffcc

解決策

Convex によるリアルタイムダッシュボード設計

Convex を使うことで、上記の課題を以下のように解決できます。

#解決策Convex の機能
1自動リアルタイム更新リアクティブクエリによる自動再取得
2効率的な通信WebSocket による双方向通信
3シンプルな実装useQuery フックで接続管理が不要
4完全な型安全性TypeScript によるエンドツーエンドの型推論
5サーバーサイド認可クエリ内での役割チェック

アーキテクチャ設計

リアルタイムダッシュボードは、以下の 3 つの主要コンポーネントで構成されます。

1. KPI データモデル KPI の種類、値、タイムスタンプ、メタデータを保存します。

2. 閾値アラート機能 定期的に KPI をチェックし、閾値を超えた場合にアラートを生成します。

3. 役割別ビュー ユーザーの役割に応じて、表示する KPI をフィルタリングします。

以下の図は、これらのコンポーネントの連携を示しています。

mermaidsequenceDiagram
    participant U as ユーザー
    participant D as ダッシュボード
    participant C as Convex
    participant DB as Database
    participant A as アラート

    U->>D: ログイン(役割情報付き)
    D->>C: useQuery(getKPIs)
    C->>DB: 役割に応じたKPI取得
    DB-->>C: KPIデータ
    C-->>D: リアルタイムデータ配信
    D-->>U: 役割別KPI表示

    loop 定期チェック
        A->>C: 閾値チェック実行
        C->>DB: 全KPI取得
        DB-->>C: 最新データ
        C->>C: 閾値比較
        alt 閾値超過
            C->>DB: アラート保存
            DB-->>D: リアクティブ更新
            D-->>U: アラート通知表示
        end
    end

図で理解できる要点:

  • ユーザーの役割情報に基づいて、表示する KPI が動的に決定されます
  • 閾値チェックは定期的に自動実行され、異常時のみアラートが生成されます
  • データベースの変更は即座にダッシュボードに反映されます

具体例

プロジェクトのセットアップ

まず、Next.js プロジェクトに Convex をセットアップします。

パッケージのインストール

bashyarn create next-app realtime-dashboard --typescript
cd realtime-dashboard
yarn add convex react-chartjs-2 chart.js date-fns

Convex の初期化

bashnpx convex dev

これにより、convexディレクトリが作成され、開発サーバーが起動します。

スキーマ定義

Convex のスキーマは、データベースの構造を型安全に定義します。

KPI テーブルのスキーマ

typescript// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  kpis: defineTable({
    // KPIの種類(売上、トラフィック、エラー率など)
    type: v.string(),
    // KPIの実測値
    value: v.number(),
    // 単位(円、件、%など)
    unit: v.string(),
    // 測定日時
    timestamp: v.number(),

メタデータとカテゴリ

typescript    // カテゴリ(sales, customer, systemなど)
    category: v.string(),
    // アクセス可能な役割のリスト
    allowedRoles: v.array(v.string()),
    // 追加のメタデータ
    metadata: v.optional(v.object({
      source: v.string(),
      region: v.optional(v.string()),
      department: v.optional(v.string()),
    })),
  }),

アラートテーブルのスキーマ

typescript  alerts: defineTable({
    // 関連するKPI ID
    kpiId: v.id("kpis"),
    // アラートの重要度
    severity: v.union(
      v.literal("info"),
      v.literal("warning"),
      v.literal("critical")
    ),
    // アラートメッセージ
    message: v.string(),
    // 閾値情報
    threshold: v.object({
      operator: v.string(), // ">", "<", ">=", "<="
      value: v.number(),
    }),
    // 実際の値
    actualValue: v.number(),
    // 発生日時
    createdAt: v.number(),
    // 確認済みフラグ
    acknowledged: v.boolean(),
  }),

閾値設定テーブル

typescript  thresholds: defineTable({
    // 対象KPIタイプ
    kpiType: v.string(),
    // 警告レベルの閾値
    warningThreshold: v.number(),
    // 危機レベルの閾値
    criticalThreshold: v.number(),
    // 比較演算子(greater_than, less_thanなど)
    operator: v.string(),
    // 有効/無効フラグ
    enabled: v.boolean(),
  }),
});

このスキーマ定義により、TypeScript の型推論が効き、フロントエンドからバックエンドまで一貫した型安全性が保証されます。

KPI 取得クエリの実装

次に、役割別に KPI を取得するクエリを実装します。

基本的な KPI 取得クエリ

typescript// convex/kpis.ts
import { query } from "./_generated/server";
import { v } from "convex/values";

// 役割別にKPIを取得するクエリ
export const getKPIs = query({
  args: {
    // ユーザーの役割
    userRole: v.string(),
    // 取得期間(オプション)
    startTime: v.optional(v.number()),
    endTime: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const { userRole, startTime, endTime } = args;

権限フィルタリングロジック

typescript// 基本的なクエリ条件
let kpisQuery = ctx.db.query('kpis');

// 期間でフィルタリング
if (startTime && endTime) {
  kpisQuery = kpisQuery
    .filter((q) => q.gte(q.field('timestamp'), startTime))
    .filter((q) => q.lte(q.field('timestamp'), endTime));
}

// 全KPIを取得
const allKpis = await kpisQuery.collect();

役割に基づくアクセス制御

typescript    // ユーザーの役割に応じてフィルタリング
    const filteredKpis = allKpis.filter((kpi) => {
      // 管理者は全データにアクセス可能
      if (userRole === "admin") {
        return true;
      }
      // allowedRolesに含まれているかチェック
      return kpi.allowedRoles.includes(userRole);
    });

    return filteredKpis;
  },
});

この実装により、サーバーサイドで確実に権限チェックが行われ、クライアント側に不要なデータが送信されることを防ぎます。

KPI 登録ミューテーション

KPI データを登録するためのミューテーションを実装します。

KPI 作成ミューテーション

typescript// convex/kpis.ts に追加
import { mutation } from "./_generated/server";

export const createKPI = mutation({
  args: {
    type: v.string(),
    value: v.number(),
    unit: v.string(),
    category: v.string(),
    allowedRoles: v.array(v.string()),
    metadata: v.optional(v.object({
      source: v.string(),
      region: v.optional(v.string()),
      department: v.optional(v.string()),
    })),
  },
  handler: async (ctx, args) => {

データ検証と保存

typescript    // 現在時刻をタイムスタンプとして使用
    const timestamp = Date.now();

    // KPIデータを保存
    const kpiId = await ctx.db.insert("kpis", {
      type: args.type,
      value: args.value,
      unit: args.unit,
      timestamp,
      category: args.category,
      allowedRoles: args.allowedRoles,
      metadata: args.metadata,
    });

    return kpiId;
  },
});

閾値アラート機能の実装

KPI の値が閾値を超えた場合にアラートを生成する機能を実装します。

閾値チェック用アクション

typescript// convex/alerts.ts
import { action } from "./_generated/server";
import { internal } from "./_generated/api";

// 定期的に実行される閾値チェック
export const checkThresholds = action({
  args: {},
  handler: async (ctx) => {
    // 有効な閾値設定を取得
    const thresholds = await ctx.runQuery(
      internal.alerts.getActiveThresholds
    );

    // 各閾値に対してチェック
    for (const threshold of thresholds) {

KPI と閾値の比較

typescript// 該当するKPIの最新値を取得
const latestKpi = await ctx.runQuery(
  internal.alerts.getLatestKpiByType,
  { kpiType: threshold.kpiType }
);

if (!latestKpi) continue;

// 閾値超過をチェック
const isExceeded = checkThresholdExceeded(
  latestKpi.value,
  threshold
);

アラート生成ロジック

typescript      if (isExceeded) {
        // 重要度を判定
        const severity = determineSeverity(
          latestKpi.value,
          threshold
        );

        // アラートを作成
        await ctx.runMutation(internal.alerts.createAlert, {
          kpiId: latestKpi._id,
          severity,
          message: `${latestKpi.type}が閾値を超えました`,
          threshold: {
            operator: threshold.operator,
            value: getSeverityThreshold(threshold, severity),
          },
          actualValue: latestKpi.value,
        });
      }
    }
  },
});

ヘルパー関数の実装

typescript// 閾値超過判定
function checkThresholdExceeded(
  value: number,
  threshold: any
): boolean {
  const { operator, warningThreshold } = threshold;

  if (operator === 'greater_than') {
    return value > warningThreshold;
  } else if (operator === 'less_than') {
    return value < warningThreshold;
  }

  return false;
}

重要度判定関数

typescript// 重要度判定(warning or critical)
function determineSeverity(
  value: number,
  threshold: any
): 'warning' | 'critical' {
  const { operator, criticalThreshold } = threshold;

  if (operator === 'greater_than') {
    return value > criticalThreshold
      ? 'critical'
      : 'warning';
  } else if (operator === 'less_than') {
    return value < criticalThreshold
      ? 'critical'
      : 'warning';
  }

  return 'warning';
}

閾値取得関数

typescript// 重要度に応じた閾値を取得
function getSeverityThreshold(
  threshold: any,
  severity: 'warning' | 'critical'
): number {
  return severity === 'critical'
    ? threshold.criticalThreshold
    : threshold.warningThreshold;
}

内部クエリとミューテーション

アクションから呼び出される内部関数を実装します。

有効な閾値設定の取得

typescript// convex/alerts.ts に追加
import {
  internalQuery,
  internalMutation,
} from './_generated/server';

export const getActiveThresholds = internalQuery({
  args: {},
  handler: async (ctx) => {
    // 有効な閾値設定のみを取得
    return await ctx.db
      .query('thresholds')
      .filter((q) => q.eq(q.field('enabled'), true))
      .collect();
  },
});

最新 KPI の取得

typescriptexport const getLatestKpiByType = internalQuery({
  args: { kpiType: v.string() },
  handler: async (ctx, args) => {
    // 指定されたタイプの最新KPIを取得
    const kpis = await ctx.db
      .query('kpis')
      .filter((q) => q.eq(q.field('type'), args.kpiType))
      .order('desc') // 降順でソート
      .take(1);

    return kpis[0] ?? null;
  },
});

アラート作成ミューテーション

typescriptexport const createAlert = internalMutation({
  args: {
    kpiId: v.id('kpis'),
    severity: v.union(
      v.literal('info'),
      v.literal('warning'),
      v.literal('critical')
    ),
    message: v.string(),
    threshold: v.object({
      operator: v.string(),
      value: v.number(),
    }),
    actualValue: v.number(),
  },
  handler: async (ctx, args) => {
    // アラートを作成
    const alertId = await ctx.db.insert('alerts', {
      kpiId: args.kpiId,
      severity: args.severity,
      message: args.message,
      threshold: args.threshold,
      actualValue: args.actualValue,
      createdAt: Date.now(),
      acknowledged: false,
    });

    return alertId;
  },
});

アラート取得クエリ

ダッシュボードで表示するアラートを取得するクエリを実装します。

未確認アラートの取得

typescript// convex/alerts.ts に追加
export const getUnacknowledgedAlerts = query({
  args: {
    userRole: v.string(),
  },
  handler: async (ctx, args) => {
    // 未確認のアラートを取得
    const alerts = await ctx.db
      .query("alerts")
      .filter((q) => q.eq(q.field("acknowledged"), false))
      .order("desc")
      .collect();

アラートの KPI 情報取得

typescript// 各アラートに関連するKPI情報を取得
const alertsWithKpi = await Promise.all(
  alerts.map(async (alert) => {
    const kpi = await ctx.db.get(alert.kpiId);

    // ユーザーの役割でアクセス権チェック
    if (!kpi) return null;
    if (
      args.userRole !== 'admin' &&
      !kpi.allowedRoles.includes(args.userRole)
    ) {
      return null;
    }

    return {
      ...alert,
      kpi,
    };
  })
);

フィルタリング結果の返却

typescript    // nullを除外して返す
    return alertsWithKpi.filter((alert) => alert !== null);
  },
});

アラート確認ミューテーション

アラートを確認済みにするミューテーションです。

typescript// convex/alerts.ts に追加
export const acknowledgeAlert = mutation({
  args: {
    alertId: v.id('alerts'),
  },
  handler: async (ctx, args) => {
    // アラートを確認済みに更新
    await ctx.db.patch(args.alertId, {
      acknowledged: true,
    });

    return { success: true };
  },
});

Next.js でのダッシュボード実装

フロントエンドで Convex のリアルタイム機能を活用したダッシュボードを実装します。

Convex プロバイダーの設定

typescript// app/layout.tsx
'use client';

import {
  ConvexProvider,
  ConvexReactClient,
} from 'convex/react';

const convex = new ConvexReactClient(
  process.env.NEXT_PUBLIC_CONVEX_URL!
);

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja'>
      <body>
        <ConvexProvider client={convex}>
          {children}
        </ConvexProvider>
      </body>
    </html>
  );
}

ダッシュボードコンポーネント

typescript// app/dashboard/page.tsx
"use client";

import { useQuery, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import { useState } from "react";

export default function Dashboard() {
  // ユーザーの役割(実際は認証システムから取得)
  const [userRole, setUserRole] = useState<string>("sales");

リアルタイムデータの取得

typescript// KPIをリアルタイムで取得
const kpis = useQuery(api.kpis.getKPIs, {
  userRole,
  startTime: Date.now() - 24 * 60 * 60 * 1000, // 過去24時間
  endTime: Date.now(),
});

// 未確認アラートを取得
const alerts = useQuery(
  api.alerts.getUnacknowledgedAlerts,
  {
    userRole,
  }
);

ミューテーションの準備

typescript// アラート確認用ミューテーション
const acknowledgeAlert = useMutation(
  api.alerts.acknowledgeAlert
);

// KPI作成用ミューテーション
const createKPI = useMutation(api.kpis.createKPI);

ローディング状態の処理

typescriptif (kpis === undefined || alerts === undefined) {
  return (
    <div className='flex items-center justify-center h-screen'>
      <div className='text-xl'>データを読み込み中...</div>
    </div>
  );
}

ダッシュボード UI

typescript  return (
    <div className="min-h-screen bg-gray-100 p-8">
      <div className="max-w-7xl mx-auto">
        {/* ヘッダー */}
        <div className="mb-8">
          <h1 className="text-3xl font-bold text-gray-900">
            リアルタイムダッシュボード
          </h1>
          <p className="text-gray-600 mt-2">
            役割: {getRoleName(userRole)}
          </p>
        </div>

アラート表示セクション

typescript{
  /* アラートセクション */
}
{
  alerts && alerts.length > 0 && (
    <div className='mb-6'>
      <h2 className='text-xl font-semibold mb-4'>
        未確認アラート ({alerts.length})
      </h2>
      <div className='space-y-3'>
        {alerts.map((alert) => (
          <div
            key={alert._id}
            className={`p-4 rounded-lg border-l-4 ${getSeverityColor(
              alert.severity
            )}`}
          >
            <div className='flex justify-between items-start'>
              <div>
                <p className='font-semibold'>
                  {alert.message}
                </p>
                <p className='text-sm text-gray-600 mt-1'>
                  実測値: {alert.actualValue}{' '}
                  {alert.kpi?.unit}
                  (閾値: {alert.threshold.value})
                </p>
                <p className='text-xs text-gray-500 mt-1'>
                  {formatTimestamp(alert.createdAt)}
                </p>
              </div>
              <button
                onClick={() =>
                  acknowledgeAlert({
                    alertId: alert._id,
                  })
                }
                className='px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600'
              >
                確認
              </button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

KPI 表示セクション

typescript        {/* KPIセクション */}
        <div>
          <h2 className="text-xl font-semibold mb-4">
            KPI一覧 ({kpis?.length ?? 0})
          </h2>
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
            {kpis?.map((kpi) => (
              <div
                key={kpi._id}
                className="bg-white p-6 rounded-lg shadow"
              >
                <div className="flex justify-between items-start mb-2">
                  <h3 className="font-semibold text-gray-700">
                    {kpi.type}
                  </h3>
                  <span className="text-xs bg-gray-200 px-2 py-1 rounded">
                    {kpi.category}
                  </span>
                </div>
                <p className="text-3xl font-bold text-blue-600">
                  {kpi.value.toLocaleString()}
                </p>
                <p className="text-sm text-gray-600 mt-1">
                  {kpi.unit}
                </p>
                <p className="text-xs text-gray-500 mt-2">
                  {formatTimestamp(kpi.timestamp)}
                </p>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

ヘルパー関数の実装

UI 表示用のヘルパー関数を定義します。

役割名の日本語化

typescript// app/dashboard/page.tsx に追加
function getRoleName(role: string): string {
  const roleNames: Record<string, string> = {
    admin: '管理者',
    sales: '営業担当',
    support: 'サポート担当',
    engineer: 'エンジニア',
  };
  return roleNames[role] ?? role;
}

重要度に応じた色の取得

typescriptfunction getSeverityColor(severity: string): string {
  const colors: Record<string, string> = {
    info: 'border-blue-500 bg-blue-50',
    warning: 'border-yellow-500 bg-yellow-50',
    critical: 'border-red-500 bg-red-50',
  };
  return colors[severity] ?? colors.info;
}

タイムスタンプのフォーマット

typescriptimport { format } from 'date-fns';
import { ja } from 'date-fns/locale';

function formatTimestamp(timestamp: number): string {
  return format(
    new Date(timestamp),
    'yyyy/MM/dd HH:mm:ss',
    {
      locale: ja,
    }
  );
}

定期的な閾値チェックの設定

Convex の cron 機能を使って、定期的に閾値チェックを実行します。

cron 設定ファイル

typescript// convex/crons.ts
import { cronJobs } from 'convex/server';
import { internal } from './_generated/api';

const crons = cronJobs();

// 1分ごとに閾値チェックを実行
crons.interval(
  'check-thresholds',
  { minutes: 1 },
  internal.alerts.checkThresholds
);

export default crons;

これにより、1 分ごとに自動的に閾値チェックが実行され、異常があればアラートが生成されてダッシュボードにリアルタイムで反映されます。

テストデータの投入

動作確認のため、テストデータを投入するスクリプトを作成します。

サンプル KPI データ生成

typescript// convex/seedData.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const seedKPIs = mutation({
  args: {},
  handler: async (ctx) => {
    // 売上KPI(営業担当と管理者が閲覧可能)
    await ctx.db.insert("kpis", {
      type: "月間売上",
      value: 15000000,
      unit: "円",
      timestamp: Date.now(),
      category: "sales",
      allowedRoles: ["admin", "sales"],
    });

複数カテゴリの KPI 生成

typescript// 顧客満足度KPI(サポート担当と管理者が閲覧可能)
await ctx.db.insert('kpis', {
  type: '顧客満足度',
  value: 4.5,
  unit: '点',
  timestamp: Date.now(),
  category: 'customer',
  allowedRoles: ['admin', 'support'],
});

// システムエラー率(エンジニアと管理者が閲覧可能)
await ctx.db.insert('kpis', {
  type: 'エラー発生率',
  value: 0.02,
  unit: '%',
  timestamp: Date.now(),
  category: 'system',
  allowedRoles: ['admin', 'engineer'],
});

閾値設定データの生成

typescript    // 閾値設定を追加
    await ctx.db.insert("thresholds", {
      kpiType: "月間売上",
      warningThreshold: 10000000,
      criticalThreshold: 5000000,
      operator: "less_than",
      enabled: true,
    });

    await ctx.db.insert("thresholds", {
      kpiType: "エラー発生率",
      warningThreshold: 1.0,
      criticalThreshold: 5.0,
      operator: "greater_than",
      enabled: true,
    });

    return { success: true };
  },
});

グラフ表示の実装

Chart.js を使って KPI の時系列グラフを表示します。

グラフコンポーネント

typescript// components/KPIChart.tsx
'use client';

import { Line } from 'react-chartjs-2';
import {
  Chart as ChartJS,
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend,
} from 'chart.js';

// Chart.jsの登録
ChartJS.register(
  CategoryScale,
  LinearScale,
  PointElement,
  LineElement,
  Title,
  Tooltip,
  Legend
);

グラフデータの準備

typescriptinterface KPIChartProps {
  kpis: Array<{
    type: string;
    value: number;
    timestamp: number;
  }>;
  kpiType: string;
}

export function KPIChart({ kpis, kpiType }: KPIChartProps) {
  // 指定されたタイプのKPIのみをフィルタ
  const filteredKpis = kpis
    .filter((kpi) => kpi.type === kpiType)
    .sort((a, b) => a.timestamp - b.timestamp);

Chart.js 用データ構造

typescriptconst data = {
  labels: filteredKpis.map((kpi) =>
    new Date(kpi.timestamp).toLocaleTimeString('ja-JP', {
      hour: '2-digit',
      minute: '2-digit',
    })
  ),
  datasets: [
    {
      label: kpiType,
      data: filteredKpis.map((kpi) => kpi.value),
      borderColor: 'rgb(59, 130, 246)',
      backgroundColor: 'rgba(59, 130, 246, 0.1)',
      tension: 0.4,
    },
  ],
};

グラフオプション設定

typescript  const options = {
    responsive: true,
    plugins: {
      legend: {
        position: "top" as const,
      },
      title: {
        display: true,
        text: `${kpiType}の推移`,
      },
    },
    scales: {
      y: {
        beginAtZero: true,
      },
    },
  };

  return <Line data={data} options={options} />;
}

ダッシュボードへのグラフ統合

作成したグラフコンポーネントをダッシュボードに追加します。

グラフセクションの追加

typescript// app/dashboard/page.tsx の return 内に追加
{
  /* グラフセクション */
}
<div className='mt-8'>
  <h2 className='text-xl font-semibold mb-4'>KPI推移</h2>
  <div className='bg-white p-6 rounded-lg shadow'>
    {kpis && kpis.length > 0 && (
      <KPIChart kpis={kpis} kpiType={kpis[0].type} />
    )}
  </div>
</div>;

リアルタイム更新の動作確認

実装したダッシュボードのリアルタイム性を確認するための手順です。

開発サーバーの起動

bash# ターミナル1: Convex開発サーバー
npx convex dev

# ターミナル2: Next.js開発サーバー
yarn dev

テストデータの投入確認

ブラウザで http:​/​​/​localhost:3000​/​dashboard にアクセスし、以下を確認します。

確認ポイント:

  • 役割に応じた KPI のみが表示されること
  • データが自動的にリアルタイム更新されること
  • アラートが表示され、確認ボタンが機能すること

リアルタイム更新のテスト

Convex ダッシュボード(https://dashboard.convex.dev)から直接データを追加し、ブラウザのダッシュボードが即座に更新されることを確認できます。

パフォーマンス最適化

大量の KPI データを扱う場合の最適化手法を紹介します。

ページネーション対応クエリ

typescript// convex/kpis.ts に追加
export const getKPIsPaginated = query({
  args: {
    userRole: v.string(),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    // 役割に応じたKPIを取得(ページング対応)
    const results = await ctx.db
      .query("kpis")
      .order("desc")
      .paginate(args.paginationOpts);

権限フィルタリング付きページネーション

typescript    // 権限フィルタリング
    const filteredResults = {
      ...results,
      page: results.page.filter((kpi) => {
        if (args.userRole === "admin") return true;
        return kpi.allowedRoles.includes(args.userRole);
      }),
    };

    return filteredResults;
  },
});

インデックスの追加

クエリパフォーマンスを向上させるためのインデックス設定です。

スキーマへのインデックス追加

typescript// convex/schema.ts のkpisテーブルに追加
kpis: defineTable({
  // ... 既存のフィールド
})
  .index("by_type", ["type"])
  .index("by_category", ["category"])
  .index("by_timestamp", ["timestamp"]),

これにより、タイプ、カテゴリ、タイムスタンプでの検索が高速化されます。

まとめ

本記事では、Convex を活用したリアルタイムダッシュボードの実装方法について、KPI 追跡、閾値アラート、役割別ビューの 3 つの観点から詳しく解説しました。

Convex の強みは、WebSocket によるリアルタイム通信、TypeScript の型安全性、サーバーサイド認可が簡単に実装できる点にあります。従来のポーリングベースのダッシュボードと比べて、サーバー負荷を抑えながら常に最新のデータを表示できるため、ビジネスの意思決定を迅速化できます。

特に重要なポイントは以下の通りです。

リアクティブクエリによる自動更新 useQuery フックを使うだけで、データベースの変更が自動的に UI に反映されるため、複雑な状態管理が不要になります。

サーバーサイド認可の実装 クエリ内で役割チェックを行うことで、セキュアな権限管理が実現できます。クライアント側に不要なデータが送信されることがありません。

cron 機能による定期処理 閾値チェックなどのバックグラウンド処理を、簡単な設定だけで実現できます。外部のジョブスケジューラーが不要です。

今回実装したダッシュボードは、営業、サポート、エンジニアなど、さまざまな役割のユーザーがそれぞれに最適化された情報をリアルタイムで確認できる仕組みになっています。閾値アラート機能により、問題の早期発見と迅速な対応が可能です。

Convex を使えば、従来は複雑だったリアルタイム機能の実装が驚くほどシンプルになります。ぜひ、自社のダッシュボード構築に活用してみてください。

関連リンク