0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Hybrid License System Day 20: バニラJS管理ダッシュボード

Last updated at Posted at 2025-12-19

🎄 科学と神々株式会社 アドベントカレンダー 2025

Hybrid License System Day 20: バニラJS管理ダッシュボード

Admin Service編 (5/5)


📖 はじめに

Day 20では、バニラJavaScript管理ダッシュボードを学びます。シンプルなHTML/CSS/JavaScript構成で、API連携、リアルタイム更新、統計表示を実装しましょう。


🏗️ プロジェクト構成

ディレクトリ構造

admin-service/
├── src/
│   ├── dashboard/
│   │   └── index.html        # 管理ダッシュボード(バニラJS)
│   ├── routes/
│   │   ├── users.js          # ユーザー管理API
│   │   ├── stats.js          # 統計情報API
│   │   ├── audit.js          # 監査ログAPI
│   │   └── health.js         # ヘルスチェック
│   ├── server.js             # Expressサーバー
│   └── config.js             # 設定管理
├── package.json
└── Dockerfile

🎨 ダッシュボードHTML構造

index.html(完全版)

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>License System - Admin Dashboard</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
      background: #f5f7fa;
      color: #333;
    }

    .container {
      max-width: 1400px;
      margin: 0 auto;
      padding: 20px;
    }

    header {
      background: #2c3e50;
      color: white;
      padding: 20px 0;
      margin-bottom: 30px;
    }

    header h1 {
      font-size: 24px;
      font-weight: 600;
    }

    .stats-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
      gap: 20px;
      margin-bottom: 30px;
    }

    .stat-card {
      background: white;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }

    .stat-card h3 {
      font-size: 14px;
      color: #7f8c8d;
      margin-bottom: 10px;
      text-transform: uppercase;
    }

    .stat-card .value {
      font-size: 32px;
      font-weight: 700;
      color: #2c3e50;
    }

    .card {
      background: white;
      padding: 20px;
      border-radius: 8px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
      margin-bottom: 20px;
    }

    .card h2 {
      font-size: 18px;
      margin-bottom: 15px;
      color: #2c3e50;
    }

    table {
      width: 100%;
      border-collapse: collapse;
    }

    table th,
    table td {
      padding: 12px;
      text-align: left;
      border-bottom: 1px solid #ecf0f1;
    }

    table th {
      background: #f8f9fa;
      font-weight: 600;
      color: #2c3e50;
    }

    table tr:hover {
      background: #f8f9fa;
    }

    .badge {
      display: inline-block;
      padding: 4px 8px;
      border-radius: 4px;
      font-size: 12px;
      font-weight: 600;
    }

    .badge-success {
      background: #d4edda;
      color: #155724;
    }

    .badge-warning {
      background: #fff3cd;
      color: #856404;
    }

    .badge-info {
      background: #d1ecf1;
      color: #0c5460;
    }

    .loading {
      text-align: center;
      padding: 40px;
      color: #7f8c8d;
    }

    .error {
      background: #f8d7da;
      color: #721c24;
      padding: 15px;
      border-radius: 4px;
      margin-bottom: 20px;
    }
  </style>
</head>
<body>
  <header>
    <div class="container">
      <h1>🔐 License System - Admin Dashboard</h1>
    </div>
  </header>

  <div class="container">
    <div id="error-message" style="display: none;" class="error"></div>

    <!-- Statistics -->
    <div class="stats-grid" id="stats-grid">
      <div class="stat-card">
        <h3>Total Users</h3>
        <div class="value" id="total-users">-</div>
      </div>
      <div class="stat-card">
        <h3>Active Users</h3>
        <div class="value" id="active-users">-</div>
      </div>
      <div class="stat-card">
        <h3>Total Licenses</h3>
        <div class="value" id="total-licenses">-</div>
      </div>
      <div class="stat-card">
        <h3>Active Licenses</h3>
        <div class="value" id="active-licenses">-</div>
      </div>
    </div>

    <!-- Users Table -->
    <div class="card">
      <h2>Recent Users</h2>
      <div id="users-loading" class="loading">Loading users...</div>
      <table id="users-table" style="display: none;">
        <thead>
          <tr>
            <th>Email</th>
            <th>Plan</th>
            <th>Licenses</th>
            <th>Last Login</th>
            <th>Status</th>
          </tr>
        </thead>
        <tbody id="users-tbody"></tbody>
      </table>
    </div>

    <!-- Audit Logs -->
    <div class="card">
      <h2>Recent Activity</h2>
      <div id="logs-loading" class="loading">Loading activity...</div>
      <table id="logs-table" style="display: none;">
        <thead>
          <tr>
            <th>Timestamp</th>
            <th>Action</th>
            <th>Service</th>
            <th>Status</th>
          </tr>
        </thead>
        <tbody id="logs-tbody"></tbody>
      </table>
    </div>
  </div>

  <script>
    const API_BASE = window.location.origin;

    // Fetch statistics
    async function fetchStats() {
      try {
        const response = await fetch(`${API_BASE}/api/v1/admin/stats`);
        const data = await response.json();

        if (data.success) {
          document.getElementById('total-users').textContent = data.data.totalUsers;
          document.getElementById('active-users').textContent = data.data.activeUsers;
          document.getElementById('total-licenses').textContent = data.data.totalLicenses;
          document.getElementById('active-licenses').textContent = data.data.activeLicenses;
        } else {
          showError('Failed to load statistics');
        }
      } catch (error) {
        console.error('Error fetching stats:', error);
        showError('Failed to load statistics');
      }
    }

    // Fetch users
    async function fetchUsers() {
      try {
        const response = await fetch(`${API_BASE}/api/v1/admin/users?limit=10`);
        const data = await response.json();

        if (data.success) {
          const tbody = document.getElementById('users-tbody');
          tbody.innerHTML = '';

          data.data.users.forEach(user => {
            const row = document.createElement('tr');
            row.innerHTML = `
              <td>${user.email}</td>
              <td><span class="badge badge-info">${user.plan_type || 'No Plan'}</span></td>
              <td>${user.license_count}</td>
              <td>${user.last_login ? new Date(user.last_login).toLocaleString() : 'Never'}</td>
              <td><span class="badge ${user.is_active ? 'badge-success' : 'badge-warning'}">${user.is_active ? 'Active' : 'Inactive'}</span></td>
            `;
            tbody.appendChild(row);
          });

          document.getElementById('users-loading').style.display = 'none';
          document.getElementById('users-table').style.display = 'table';
        } else {
          showError('Failed to load users');
        }
      } catch (error) {
        console.error('Error fetching users:', error);
        showError('Failed to load users');
      }
    }

    // Fetch audit logs
    async function fetchLogs() {
      try {
        const response = await fetch(`${API_BASE}/api/v1/admin/audit-logs?limit=10`);
        const data = await response.json();

        if (data.success) {
          const tbody = document.getElementById('logs-tbody');
          tbody.innerHTML = '';

          data.data.logs.forEach(log => {
            const row = document.createElement('tr');
            row.innerHTML = `
              <td>${new Date(log.timestamp).toLocaleString()}</td>
              <td>${log.action}</td>
              <td>${log.service_name || 'N/A'}</td>
              <td><span class="badge ${log.status === 'success' ? 'badge-success' : 'badge-warning'}">${log.status || 'N/A'}</span></td>
            `;
            tbody.appendChild(row);
          });

          document.getElementById('logs-loading').style.display = 'none';
          document.getElementById('logs-table').style.display = 'table';
        } else {
          showError('Failed to load activity logs');
        }
      } catch (error) {
        console.error('Error fetching logs:', error);
        showError('Failed to load activity logs');
      }
    }

    function showError(message) {
      const errorDiv = document.getElementById('error-message');
      errorDiv.textContent = message;
      errorDiv.style.display = 'block';
    }

    // Initialize dashboard
    fetchStats();
    fetchUsers();
    fetchLogs();

    // Auto-refresh every 30 seconds
    setInterval(() => {
      fetchStats();
      fetchUsers();
      fetchLogs();
    }, 30000);
  </script>
</body>
</html>

📊 統計情報カードの実装

HTML構造

<!-- 統計情報グリッドレイアウト -->
<div class="stats-grid" id="stats-grid">
  <div class="stat-card">
    <h3>Total Users</h3>
    <div class="value" id="total-users">-</div>
  </div>
  <div class="stat-card">
    <h3>Active Users</h3>
    <div class="value" id="active-users">-</div>
  </div>
  <div class="stat-card">
    <h3>Total Licenses</h3>
    <div class="value" id="total-licenses">-</div>
  </div>
  <div class="stat-card">
    <h3>Active Licenses</h3>
    <div class="value" id="active-licenses">-</div>
  </div>
</div>

CSSグリッドレイアウト

/* レスポンシブグリッド(CSS Grid) */
.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px;
  margin-bottom: 30px;
}

/* カードスタイル */
.stat-card {
  background: white;
  padding: 20px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

/* 数値の強調 */
.stat-card .value {
  font-size: 32px;
  font-weight: 700;
  color: #2c3e50;
}

🌐 Fetch APIによるデータ取得

統計情報の取得

// Fetch statistics
async function fetchStats() {
  try {
    const response = await fetch(`${API_BASE}/api/v1/admin/stats`);
    const data = await response.json();

    if (data.success) {
      document.getElementById('total-users').textContent = data.data.totalUsers;
      document.getElementById('active-users').textContent = data.data.activeUsers;
      document.getElementById('total-licenses').textContent = data.data.totalLicenses;
      document.getElementById('active-licenses').textContent = data.data.activeLicenses;
    } else {
      showError('Failed to load statistics');
    }
  } catch (error) {
    console.error('Error fetching stats:', error);
    showError('Failed to load statistics');
  }
}

APIレスポンス形式

{
  "success": true,
  "data": {
    "totalUsers": 1234,
    "activeUsers": 987,
    "totalLicenses": 2345,
    "activeLicenses": 1876
  }
}

👥 ユーザーテーブルの動的生成

ユーザーリストの取得と表示

// Fetch users
async function fetchUsers() {
  try {
    const response = await fetch(`${API_BASE}/api/v1/admin/users?limit=10`);
    const data = await response.json();

    if (data.success) {
      const tbody = document.getElementById('users-tbody');
      tbody.innerHTML = '';  // 既存の行をクリア

      // 各ユーザーの行を動的生成
      data.data.users.forEach(user => {
        const row = document.createElement('tr');
        row.innerHTML = `
          <td>${user.email}</td>
          <td><span class="badge badge-info">${user.plan_type || 'No Plan'}</span></td>
          <td>${user.license_count}</td>
          <td>${user.last_login ? new Date(user.last_login).toLocaleString() : 'Never'}</td>
          <td><span class="badge ${user.is_active ? 'badge-success' : 'badge-warning'}">${user.is_active ? 'Active' : 'Inactive'}</span></td>
        `;
        tbody.appendChild(row);
      });

      // ローディング表示を隠し、テーブルを表示
      document.getElementById('users-loading').style.display = 'none';
      document.getElementById('users-table').style.display = 'table';
    } else {
      showError('Failed to load users');
    }
  } catch (error) {
    console.error('Error fetching users:', error);
    showError('Failed to load users');
  }
}

HTMLテーブル構造

<!-- Users Table -->
<div class="card">
  <h2>Recent Users</h2>
  <div id="users-loading" class="loading">Loading users...</div>
  <table id="users-table" style="display: none;">
    <thead>
      <tr>
        <th>Email</th>
        <th>Plan</th>
        <th>Licenses</th>
        <th>Last Login</th>
        <th>Status</th>
      </tr>
    </thead>
    <tbody id="users-tbody"></tbody>
  </table>
</div>

📝 監査ログテーブルの実装

監査ログの取得と表示

// Fetch audit logs
async function fetchLogs() {
  try {
    const response = await fetch(`${API_BASE}/api/v1/admin/audit-logs?limit=10`);
    const data = await response.json();

    if (data.success) {
      const tbody = document.getElementById('logs-tbody');
      tbody.innerHTML = '';

      // 各ログエントリを動的生成
      data.data.logs.forEach(log => {
        const row = document.createElement('tr');
        row.innerHTML = `
          <td>${new Date(log.timestamp).toLocaleString()}</td>
          <td>${log.action}</td>
          <td>${log.service_name || 'N/A'}</td>
          <td><span class="badge ${log.status === 'success' ? 'badge-success' : 'badge-warning'}">${log.status || 'N/A'}</span></td>
        `;
        tbody.appendChild(row);
      });

      document.getElementById('logs-loading').style.display = 'none';
      document.getElementById('logs-table').style.display = 'table';
    } else {
      showError('Failed to load activity logs');
    }
  } catch (error) {
    console.error('Error fetching logs:', error);
    showError('Failed to load activity logs');
  }
}

監査ログAPIレスポンス形式

{
  "success": true,
  "data": {
    "logs": [
      {
        "timestamp": "2025-11-24T10:30:00Z",
        "action": "LICENSE_ACTIVATED",
        "service_name": "auth-service",
        "status": "success"
      },
      {
        "timestamp": "2025-11-24T10:25:00Z",
        "action": "USER_LOGIN",
        "service_name": "api-gateway",
        "status": "success"
      }
    ]
  }
}

🔄 リアルタイム更新

自動更新の実装

// Initialize dashboard on page load
fetchStats();
fetchUsers();
fetchLogs();

// Auto-refresh every 30 seconds
setInterval(() => {
  fetchStats();
  fetchUsers();
  fetchLogs();
}, 30000);  // 30秒ごとに更新

リフレッシュボタン(オプション)

<!-- ヘッダーに更新ボタンを追加 -->
<header>
  <div class="container">
    <h1>🔐 License System - Admin Dashboard</h1>
    <button id="refresh-btn" onclick="refreshAll()">🔄 Refresh</button>
  </div>
</header>

<script>
function refreshAll() {
  fetchStats();
  fetchUsers();
  fetchLogs();
}
</script>

⚠️ エラーハンドリング

エラー表示機能

function showError(message) {
  const errorDiv = document.getElementById('error-message');
  errorDiv.textContent = message;
  errorDiv.style.display = 'block';

  // 5秒後に自動的に非表示
  setTimeout(() => {
    errorDiv.style.display = 'none';
  }, 5000);
}

エラーメッセージのCSS

.error {
  background: #f8d7da;
  color: #721c24;
  padding: 15px;
  border-radius: 4px;
  margin-bottom: 20px;
  border: 1px solid #f5c6cb;
}

🎨 バッジコンポーネント

プラン別バッジ

.badge {
  display: inline-block;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: 600;
}

.badge-success {
  background: #d4edda;
  color: #155724;
}

.badge-warning {
  background: #fff3cd;
  color: #856404;
}

.badge-info {
  background: #d1ecf1;
  color: #0c5460;
}

動的バッジ生成

// プランに応じたバッジクラス
function getPlanBadgeClass(plan) {
  switch(plan) {
    case 'enterprise':
      return 'badge-success';
    case 'professional':
      return 'badge-info';
    case 'free':
      return 'badge-warning';
    default:
      return 'badge-info';
  }
}

// 使用例
const badge = `<span class="badge ${getPlanBadgeClass(user.plan)}">${user.plan}</span>`;

🔐 認証統合(拡張版)

ログイン機能の追加

// ローカルストレージにトークンを保存
function saveAuthToken(token) {
  localStorage.setItem('admin_token', token);
}

function getAuthToken() {
  return localStorage.getItem('admin_token');
}

// 認証ヘッダーを含むFetch
async function fetchWithAuth(url) {
  const token = getAuthToken();

  const response = await fetch(url, {
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    }
  });

  if (response.status === 401) {
    // トークン期限切れ → ログインページへリダイレクト
    window.location.href = '/login';
  }

  return response;
}

📱 レスポンシブデザイン

モバイル対応CSS

/* タブレット・スマートフォン対応 */
@media (max-width: 768px) {
  .stats-grid {
    grid-template-columns: repeat(2, 1fr);
  }

  table {
    font-size: 14px;
  }

  table th,
  table td {
    padding: 8px;
  }

  /* テーブルを横スクロール可能に */
  .card {
    overflow-x: auto;
  }
}

@media (max-width: 480px) {
  .stats-grid {
    grid-template-columns: 1fr;
  }

  .stat-card .value {
    font-size: 24px;
  }
}

🎯 バニラJSの利点

フレームワークなし構成のメリット

✅ 依存関係ゼロ → ビルドプロセス不要
✅ 高速ロード → バンドルサイズ最小(単一HTMLファイル)
✅ 簡単デプロイ → 静的ファイルサーバーで配信可能
✅ メンテナンス容易 → シンプルなコード構成
✅ パフォーマンス最適 → フレームワークオーバーヘッドなし
✅ セキュリティ → 最小限の攻撃面
✅ 学習コスト低 → 標準Web API使用

バニラJSとReactの比較

特徴 バニラJS React
ビルドツール 不要 必須(Webpack/Vite)
依存関係 なし 多数(React、DOM、ルーター等)
ファイルサイズ ~10KB ~150KB+
初期表示速度 超高速 中速
開発生産性 高(大規模アプリ)
適用範囲 小〜中規模 中〜大規模

🧪 テスト戦略

E2Eテスト(Playwright)

// tests/dashboard.e2e.test.js
const { test, expect } = require('@playwright/test');

test.describe('Admin Dashboard', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3002/dashboard');
  });

  test('should display statistics cards', async ({ page }) => {
    // 統計カードの表示確認
    await expect(page.locator('#total-users')).toBeVisible();
    await expect(page.locator('#active-users')).toBeVisible();
    await expect(page.locator('#total-licenses')).toBeVisible();
    await expect(page.locator('#active-licenses')).toBeVisible();
  });

  test('should load users table', async ({ page }) => {
    // ユーザーテーブルの読み込み待機
    await page.waitForSelector('#users-table', { state: 'visible', timeout: 5000 });

    // テーブルヘッダー確認
    const headers = page.locator('#users-table th');
    await expect(headers).toContainText(['Email', 'Plan', 'Licenses', 'Last Login', 'Status']);
  });

  test('should load audit logs', async ({ page }) => {
    // 監査ログテーブルの読み込み待機
    await page.waitForSelector('#logs-table', { state: 'visible', timeout: 5000 });

    // ログが存在することを確認
    const rows = page.locator('#logs-tbody tr');
    await expect(rows.first()).toBeVisible();
  });

  test('should auto-refresh after 30 seconds', async ({ page }) => {
    // 初期値を取得
    const initialUsers = await page.locator('#total-users').textContent();

    // 30秒待機
    await page.waitForTimeout(31000);

    // 値が再取得されていることを確認(API呼び出しログで検証)
    const requests = [];
    page.on('request', req => requests.push(req.url()));

    // リフレッシュが実行されたか確認
    expect(requests.some(url => url.includes('/api/v1/admin/stats'))).toBe(true);
  });
});

🚀 デプロイ構成

Admin Serviceサーバー統合

// admin-service/src/server.js
const express = require('express');
const path = require('path');

const app = express();

// 静的ファイル配信
app.use('/dashboard', express.static(path.join(__dirname, 'dashboard')));

// ダッシュボードルート
app.get('/dashboard', (req, res) => {
  res.sendFile(path.join(__dirname, 'dashboard', 'index.html'));
});

// API routes
app.use('/api/v1/admin', require('./routes/users'));
app.use('/api/v1/admin', require('./routes/stats'));
app.use('/api/v1/admin', require('./routes/audit'));

const PORT = process.env.PORT || 3002;
app.listen(PORT, () => {
  console.log(`Admin Service running on http://localhost:${PORT}`);
  console.log(`Dashboard: http://localhost:${PORT}/dashboard`);
});

🎯 次のステップ

Day 21では、Docker Composeでの統合を学びます。3つのマイクロサービスをDocker Composeで統合し、ヘルスチェック、サービス間通信、永続化ボリューム管理について詳しく解説します。


🔗 関連リンク


次回予告: Day 21では、Docker Composeによるマイクロサービス統合と依存関係管理を詳しく解説します!


Copyright © 2025 Gods & Golem, Inc. All rights reserved.

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?