🎄 科学と神々株式会社 アドベントカレンダー 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.