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:
251
frontend/admin-portal/schedule-logs.html
Normal file
251
frontend/admin-portal/schedule-logs.html
Normal 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>
|
||||
Reference in New Issue
Block a user