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,251 @@
<!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="https://cdn.datatables.net/2.0.3/css/dataTables.bootstrap5.min.css">
<link rel="stylesheet" href="css/style.css">
<style>
.result-dot { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:3px; }
.dot-ok { background:#198754; }
.dot-fail { background:#dc3545; }
.dot-na { background:#adb5bd; }
tr.log-row { cursor:pointer; }
tr.log-row:hover td { background:#f8f9fa; }
.detail-row td { padding:0!important; }
.detail-panel { padding:12px 16px; background:#f8f9fa; border-top:1px solid #dee2e6; }
</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" 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 active" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
</div>
</nav>
<!-- Main -->
<div id="main">
<div id="topbar">
<span class="fw-semibold text-secondary"><i class="bi bi-list-ul me-1"></i>排程執行紀錄</span>
<div class="ms-auto d-flex gap-2 align-items-center">
<select class="form-select form-select-sm" id="schedule-filter" style="width:180px" onchange="loadTable()">
<option value="">全部排程</option>
</select>
</div>
</div>
<div id="content">
<div class="card shadow-sm">
<div class="card-body p-0">
<table id="tbl" class="table table-hover mb-0 w-100">
<thead>
<tr>
<th>ID</th>
<th>排程名稱</th>
<th>開始時間</th>
<th>結束時間</th>
<th>耗時</th>
<th class="text-center">結果</th>
<th class="text-center">詳細</th>
</tr>
</thead>
<tbody id="tbl-body"></tbody>
</table>
</div>
</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>
let allRows = [];
let expandedLogId = null;
function durationHtml(started, ended) {
if (!ended) return '<span class="text-muted">—</span>';
const ms = new Date(ended) - new Date(started);
const sec = Math.round(ms / 1000);
if (sec < 60) return `${sec}s`;
return `${Math.floor(sec/60)}m ${sec%60}s`;
}
function dot(val) {
if (val === true) return '<span class="result-dot dot-ok"></span>';
if (val === false) return '<span class="result-dot dot-fail"></span>';
return '<span class="result-dot dot-na"></span>';
}
function statusBadge(s) {
if (s === 'ok') return '<span class="badge bg-success">ok</span>';
if (s === 'error') return '<span class="badge bg-danger">error</span>';
if (s === 'running') return '<span class="badge bg-warning text-dark">running</span>';
return `<span class="badge bg-secondary">${s}</span>`;
}
function renderTenantResults(results) {
if (!results.length) return '<p class="text-muted mb-0 small">無租戶資料</p>';
let html = `<table class="table table-sm table-bordered mb-0 small">
<thead class="table-light"><tr>
<th>租戶</th><th class="text-center">Traefik</th><th class="text-center">SSO</th>
<th class="text-center">Mailbox</th><th class="text-center">NC</th>
<th class="text-center">OO</th><th class="text-center">Quota(GB)</th>
<th>失敗原因</th>
</tr></thead><tbody>`;
for (const r of results) {
html += `<tr>
<td>${r.tenant_name || r.tenant_id}</td>
<td class="text-center">${dot(r.traefik_status)}</td>
<td class="text-center">${dot(r.sso_result)}</td>
<td class="text-center">${dot(r.mailbox_result)}</td>
<td class="text-center">${dot(r.nc_result)}</td>
<td class="text-center">${dot(r.office_result)}</td>
<td class="text-center">${r.quota_usage != null ? r.quota_usage.toFixed(2) : '—'}</td>
<td class="text-danger small">${r.fail_reason || ''}</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
function renderAccountResults(results) {
if (!results.length) return '<p class="text-muted mb-0 small">無帳號資料</p>';
let html = `<table class="table table-sm table-bordered mb-0 small">
<thead class="table-light"><tr>
<th>帳號</th><th class="text-center">SSO</th>
<th class="text-center">Mailbox</th><th class="text-center">NC</th>
<th class="text-center">Mail</th>
<th class="text-center">Quota(GB)</th><th>失敗原因</th>
</tr></thead><tbody>`;
for (const r of results) {
html += `<tr>
<td>${r.sso_account || r.account_id}</td>
<td class="text-center">${dot(r.sso_result)}</td>
<td class="text-center">${dot(r.mailbox_result)}</td>
<td class="text-center">${dot(r.nc_result)}</td>
<td class="text-center">${dot(r.nc_mail_result)}</td>
<td class="text-center">${r.quota_usage != null ? r.quota_usage.toFixed(2) : '—'}</td>
<td class="text-danger small">${r.fail_reason || ''}</td>
</tr>`;
}
html += '</tbody></table>';
return html;
}
async function toggleDetail(logId, scheduleId, btn) {
const detailRowId = `detail-${logId}`;
const existing = document.getElementById(detailRowId);
if (existing) {
existing.remove();
btn.innerHTML = '<i class="bi bi-chevron-down"></i>';
expandedLogId = null;
return;
}
// Close previous
if (expandedLogId) {
const prev = document.getElementById(`detail-${expandedLogId}`);
if (prev) prev.remove();
const prevBtn = document.querySelector(`[data-log="${expandedLogId}"]`);
if (prevBtn) prevBtn.innerHTML = '<i class="bi bi-chevron-down"></i>';
}
expandedLogId = logId;
btn.innerHTML = '<i class="bi bi-chevron-up"></i>';
const logRow = document.getElementById(`row-${logId}`);
if (!logRow) return;
const detailRow = document.createElement('tr');
detailRow.id = detailRowId;
detailRow.className = 'detail-row';
detailRow.innerHTML = `<td colspan="7"><div class="detail-panel"><span class="text-muted small">載入中...</span></div></td>`;
logRow.after(detailRow);
try {
const data = await apiFetch(`/schedules/${scheduleId}/logs/${logId}/results`);
let inner = '';
if (data.tenant_results && data.tenant_results.length > 0) {
inner = renderTenantResults(data.tenant_results);
} else if (data.account_results && data.account_results.length > 0) {
inner = renderAccountResults(data.account_results);
} else {
inner = '<span class="text-muted small">無詳細結果</span>';
}
detailRow.querySelector('.detail-panel').innerHTML = inner;
} catch (e) {
detailRow.querySelector('.detail-panel').innerHTML = `<span class="text-danger small">${e.message}</span>`;
}
}
async function init() {
let schedules;
try { schedules = await apiFetch('/schedules'); } catch (e) { toast('無法載入排程列表:' + e.message, 'error'); return; }
const sel = document.getElementById('schedule-filter');
schedules.forEach(s => {
sel.insertAdjacentHTML('beforeend', `<option value="${s.id}">${s.name}</option>`);
});
const params = new URLSearchParams(window.location.search);
if (params.get('schedule_id')) sel.value = params.get('schedule_id');
loadTable();
}
async function loadTable() {
const sid = document.getElementById('schedule-filter').value;
let rows = [];
try {
if (sid) {
rows = await apiFetch(`/schedules/${sid}/logs`);
} else {
const schedules = await apiFetch('/schedules');
const all = await Promise.allSettled(schedules.map(s => apiFetch(`/schedules/${s.id}/logs`)));
rows = all.filter(r => r.status === 'fulfilled').flatMap(r => r.value)
.sort((a, b) => new Date(b.started_at) - new Date(a.started_at));
}
} catch (e) { toast('無法載入紀錄:' + e.message, 'error'); return; }
const tbody = document.getElementById('tbl-body');
tbody.innerHTML = '';
expandedLogId = null;
for (const r of rows) {
const scheduleId = r.schedule_id;
const tr = document.createElement('tr');
tr.id = `row-${r.id}`;
tr.className = 'log-row';
tr.innerHTML = `
<td>${r.id}</td>
<td>${r.schedule_name}</td>
<td>${fmtDt(r.started_at)}</td>
<td>${fmtDt(r.ended_at)}</td>
<td>${durationHtml(r.started_at, r.ended_at)}</td>
<td class="text-center">${statusBadge(r.status)}</td>
<td class="text-center">
${r.status !== 'running' ? `<button class="btn btn-sm btn-outline-secondary py-0 px-1" data-log="${r.id}" onclick="toggleDetail(${r.id}, ${scheduleId}, this)"><i class="bi bi-chevron-down"></i></button>` : '—'}
</td>`;
tbody.appendChild(tr);
}
}
init();
</script>
</body>
</html>