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:
VMIS Developer
2026-03-15 15:31:37 +08:00
parent 42d1420f9c
commit 62baadb06f
53 changed files with 5638 additions and 195 deletions

View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>系統狀態 — VMIS Admin</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">
<style>
.matrix-table th, .matrix-table td { vertical-align: middle; text-align: center; }
.matrix-table th:first-child, .matrix-table td:first-child { text-align: left; }
.env-badge-test { background: #cfe2ff; color: #084298; }
.env-badge-prod { background: #d1e7dd; color: #0a3622; }
.service-icon { font-size: 1.1rem; margin-right: 0.35rem; }
.light-lg { width: 18px; height: 18px; }
.log-time { font-size: 0.78rem; color: #6c757d; }
.history-dot {
display: inline-block;
width: 10px; height: 10px;
border-radius: 50%;
margin: 1px;
}
</style>
<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 active" 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-activity me-1"></i>系統狀態</span>
<div class="ms-auto text-muted small" id="last-update">載入中...</div>
</div>
<div id="content">
<!-- Matrix -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-white d-flex align-items-center">
<span class="fw-semibold">基礎設施狀態矩陣</span>
<span class="ms-2 text-muted small">(最新一次執行結果)</span>
</div>
<div class="card-body">
<div id="matrix-area">
<p class="text-muted text-center py-4">尚無資料,請等待系統狀態排程執行(每日 08:00</p>
</div>
</div>
</div>
<!-- Legend -->
<div class="d-flex gap-3 mb-4 text-sm">
<span><span class="light light-green me-1"></span>正常</span>
<span><span class="light light-red me-1"></span>異常</span>
<span><span class="light light-grey me-1"></span>無資料</span>
</div>
</div>
</div>
<div id="toast-container" class="position-fixed bottom-0 end-0 p-3" style="z-index:9999"></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 SERVICE_ICON = {
traefik: 'bi-diagram-3',
keycloak: 'bi-shield-lock',
mail: 'bi-envelope',
db: 'bi-database',
};
const SERVICE_LABEL = {
traefik: 'Traefik',
keycloak: 'Keycloak',
mail: 'Mail Server',
db: 'PostgreSQL',
};
async function load() {
const rows = await apiFetch('/system-status');
if (!rows || rows.length === 0) {
document.getElementById('last-update').textContent = '尚無執行紀錄';
return;
}
// Group by service_name, environment
// rows: [{ environment, service_name, service_desc, result, fail_reason, recorded_at }, ...]
const latest = {};
rows.forEach(r => {
const key = `${r.environment}__${r.service_name}`;
if (!latest[key]) latest[key] = r;
});
// Update time from first row
const anyRow = rows[0];
document.getElementById('last-update').textContent = '最後更新:' + fmtDt(anyRow.recorded_at);
// Build matrix: services × environments
const services = ['traefik', 'keycloak', 'mail', 'db'];
const envs = ['test', 'prod'];
const envLabel = { test: '測試環境', prod: '正式環境' };
let html = `
<table class="table matrix-table table-bordered mb-0">
<thead>
<tr>
<th style="width:200px">服務</th>
${envs.map(e => `<th><span class="badge ${e === 'test' ? 'env-badge-test' : 'env-badge-prod'}">${envLabel[e]}</span></th>`).join('')}
</tr>
</thead>
<tbody>`;
services.forEach(svc => {
html += `<tr>
<td class="text-start">
<i class="bi ${SERVICE_ICON[svc] || 'bi-circle'} service-icon text-primary"></i>
${SERVICE_LABEL[svc] || svc}
</td>`;
envs.forEach(env => {
const r = latest[`${env}__${svc}`];
if (!r) {
html += `<td><span class="light light-grey light-lg"></span></td>`;
} else {
const cls = r.result ? 'light-green' : 'light-red';
const title = r.fail_reason ? ` title="${r.fail_reason}"` : '';
html += `<td>
<span class="light ${cls} light-lg"${title}></span>
${!r.result && r.fail_reason
? `<br><small class="text-danger">${r.fail_reason.substring(0, 60)}${r.fail_reason.length > 60 ? '...' : ''}</small>`
: ''}
<div class="log-time">${fmtDt(r.recorded_at)}</div>
</td>`;
}
});
html += '</tr>';
});
html += `</tbody></table>`;
document.getElementById('matrix-area').innerHTML = html;
}
load();
</script>
</body>
</html>