feat(vmis): 租戶自動開通完整流程 + Admin Portal SSO + NC 行事曆訂閱
Backend: - schedule_tenant: NC 新容器自動 pgsql 安裝 (_nc_db_check 全新容器處理) - schedule_tenant: NC 初始化加入 Redis + APCu memcache 設定 (修正 OIDC invalid_state) - schedule_tenant: 新租戶 KC realm 自動設定 accessCodeLifespan=600s (修正 authentication_expired) - schedule_account: NC Mail 帳號自動設定 (nc_mail_result/nc_mail_done_at) - schedule_account: NC 台灣國定假日行事曆自動訂閱 (CalDAV MKCALENDAR) - nextcloud_client: 新增 subscribe_calendar() CalDAV 訂閱方法 - settings: 新增系統設定 API (site_title/version/timezone/SSO/Keycloak) - models/result: 新增 nc_mail_result, nc_mail_done_at 欄位 - alembic: 遷移 002(system_settings) 003(keycloak_admin) 004(nc_mail_result) Frontend (Admin Portal): - 新增完整管理後台 (index/tenants/accounts/servers/schedules/logs/settings/system-status) - api.js: Keycloak JS Adapter SSO 整合 (PKCE/S256, fallback KC JS 來源, 自動 token 更新) - index.html: Promise.allSettled 取代 Promise.all,防止單一 API 失敗影響整頁 - 所有頁面加入 try/catch + toast 錯誤處理 - 新增品牌 LOGO 與 favicon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
198
frontend/admin-portal/index.html
Normal file
198
frontend/admin-portal/index.html
Normal file
@@ -0,0 +1,198 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VMIS Admin Portal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="icon" href="img/logo.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav id="sidebar">
|
||||
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
|
||||
<div class="pt-2">
|
||||
<div class="nav-section">租戶服務</div>
|
||||
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
|
||||
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
|
||||
<div class="nav-section">基礎設施</div>
|
||||
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
|
||||
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
|
||||
<div class="nav-section">排程</div>
|
||||
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
|
||||
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
|
||||
<div class="nav-section">系統</div>
|
||||
<a class="nav-link" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<div id="main">
|
||||
<div id="topbar">
|
||||
<span class="fw-semibold text-secondary"><i class="bi bi-speedometer2 me-1"></i>儀表板</span>
|
||||
<div class="ms-auto text-muted small" id="last-refresh"></div>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<!-- Summary Cards -->
|
||||
<div class="row g-3 mb-4" id="summary-cards">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-2 bg-primary-subtle"><i class="bi bi-building fs-4 text-primary"></i></div>
|
||||
<div>
|
||||
<div class="text-muted small">租戶總數</div>
|
||||
<div class="fs-4 fw-bold" id="cnt-tenants">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-2 bg-success-subtle"><i class="bi bi-people fs-4 text-success"></i></div>
|
||||
<div>
|
||||
<div class="text-muted small">帳號總數</div>
|
||||
<div class="fs-4 fw-bold" id="cnt-accounts">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-2 bg-warning-subtle"><i class="bi bi-hdd-network fs-4 text-warning"></i></div>
|
||||
<div>
|
||||
<div class="text-muted small">伺服器</div>
|
||||
<div class="fs-4 fw-bold" id="cnt-servers">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-2 bg-info-subtle"><i class="bi bi-activity fs-4 text-info"></i></div>
|
||||
<div>
|
||||
<div class="text-muted small">系統狀態</div>
|
||||
<div id="sys-status-summary" class="fw-bold">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Schedules status -->
|
||||
<div class="col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-header bg-white d-flex align-items-center">
|
||||
<span class="fw-semibold">排程狀態</span>
|
||||
<a href="schedules.html" class="ms-auto btn btn-outline-secondary btn-sm">管理</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody id="sched-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Servers quick status -->
|
||||
<div class="col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-header bg-white d-flex align-items-center">
|
||||
<span class="fw-semibold">伺服器</span>
|
||||
<a href="servers.html" class="ms-auto btn btn-outline-secondary btn-sm">詳細</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody id="server-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script>
|
||||
const STATUS_BADGE = {
|
||||
Waiting: '<span class="badge bg-secondary">Waiting</span>',
|
||||
Going: '<span class="badge bg-warning text-dark">Going</span>',
|
||||
};
|
||||
const RESULT_BADGE = {
|
||||
ok: '<span class="badge bg-success">ok</span>',
|
||||
error: '<span class="badge bg-danger">error</span>',
|
||||
};
|
||||
|
||||
async function loadDashboard() {
|
||||
const [r_tenants, r_accounts, r_servers, r_schedules, r_sysStatus] = await Promise.allSettled([
|
||||
apiFetch('/tenants'),
|
||||
apiFetch('/accounts'),
|
||||
apiFetch('/servers'),
|
||||
apiFetch('/schedules'),
|
||||
apiFetch('/system-status'),
|
||||
]);
|
||||
|
||||
const tenants = r_tenants.status === 'fulfilled' ? r_tenants.value : null;
|
||||
const accounts = r_accounts.status === 'fulfilled' ? r_accounts.value : null;
|
||||
const servers = r_servers.status === 'fulfilled' ? r_servers.value : null;
|
||||
const schedules = r_schedules.status === 'fulfilled' ? r_schedules.value : null;
|
||||
const sysStatus = r_sysStatus.status === 'fulfilled' ? r_sysStatus.value : null;
|
||||
|
||||
document.getElementById('cnt-tenants').textContent = tenants ? tenants.length : '—';
|
||||
document.getElementById('cnt-accounts').textContent = accounts ? accounts.length : '—';
|
||||
document.getElementById('cnt-servers').textContent = servers ? servers.length : '—';
|
||||
|
||||
// System status summary
|
||||
if (!sysStatus) {
|
||||
document.getElementById('sys-status-summary').innerHTML = '<span class="text-muted">無法取得</span>';
|
||||
} else if (sysStatus.length === 0) {
|
||||
document.getElementById('sys-status-summary').innerHTML = '<span class="text-muted">無資料</span>';
|
||||
} else {
|
||||
const bad = sysStatus.filter(r => !r.result).length;
|
||||
document.getElementById('sys-status-summary').innerHTML = bad === 0
|
||||
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>全部正常</span>'
|
||||
: `<span class="text-danger"><i class="bi bi-exclamation-circle-fill me-1"></i>${bad} 項異常</span>`;
|
||||
}
|
||||
|
||||
// Schedules
|
||||
if (schedules) {
|
||||
const schedRows = schedules.map(s => `
|
||||
<tr>
|
||||
<td class="ps-3">${s.name}</td>
|
||||
<td><code class="small">${s.cron_timer}</code></td>
|
||||
<td>${STATUS_BADGE[s.status] || s.status}</td>
|
||||
<td>${s.last_status ? (RESULT_BADGE[s.last_status] || s.last_status) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="text-muted small pe-3">${fmtDt(s.next_run_at)}</td>
|
||||
</tr>`).join('');
|
||||
document.getElementById('sched-rows').innerHTML = schedRows;
|
||||
}
|
||||
|
||||
// Servers
|
||||
if (servers) {
|
||||
const svrRows = servers.map(s => `
|
||||
<tr>
|
||||
<td class="ps-3">${lightHtml(s.last_result)}</td>
|
||||
<td><strong>${s.name}</strong></td>
|
||||
<td><code class="small">${s.ip_address}</code></td>
|
||||
<td class="text-muted small pe-3">${s.last_response_time != null ? s.last_response_time.toFixed(1) + ' ms' : '—'}</td>
|
||||
</tr>`).join('');
|
||||
document.getElementById('server-rows').innerHTML = svrRows;
|
||||
}
|
||||
|
||||
document.getElementById('last-refresh').textContent = '更新:' + new Date().toLocaleTimeString('zh-TW', { hour12: false });
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
setInterval(loadDashboard, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user