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,8 @@
# Production Environment Variables
NEXT_PUBLIC_API_URL=https://vmis.lab.taipei/api
# Keycloak Configuration
NEXT_PUBLIC_KEYCLOAK_URL=https://auth.lab.taipei
NEXT_PUBLIC_KEYCLOAK_REALM=vmis-admin
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=vmis-admin-portal
NEXT_PUBLIC_REDIRECT_URI=https://vmis.lab.taipei/callback

39
frontend/admin-portal/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,4 @@
@echo off
echo Starting VMIS Admin Portal at http://localhost:10280
echo Press Ctrl+C to stop
python -m http.server 10280

View File

@@ -0,0 +1,284 @@
<!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">
<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 active" 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-people me-1"></i>帳號管理</span>
<div class="ms-auto d-flex gap-2 align-items-center">
<select class="form-select form-select-sm" id="tenant-filter" style="width:180px" onchange="loadTable()">
<option value="">全部租戶</option>
</select>
<button class="btn btn-primary btn-sm" onclick="openCreate()">
<i class="bi bi-plus-lg me-1"></i>新增帳號
</button>
</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>帳號編碼</th>
<th>租戶</th>
<th>SSO 帳號</th>
<th>系統郵件</th>
<th>法定姓名</th>
<th class="text-center">啟用</th>
<th class="text-center">SSO</th>
<th class="text-center">郵箱</th>
<th class="text-center">Drive</th>
<th class="text-center">Mail</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="formModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="formTitle">新增帳號</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="f-id">
<div class="row g-3">
<div class="col-12">
<label class="form-label">租戶 <span class="text-danger">*</span></label>
<select class="form-select" id="f-tenant"></select>
</div>
<div class="col-md-6">
<label class="form-label">SSO 帳號 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-sso" placeholder="alice">
</div>
<div class="col-md-6">
<label class="form-label">通知郵件 <span class="text-danger">*</span></label>
<input type="email" class="form-control" id="f-notify-email" placeholder="alice@gmail.com">
</div>
<div class="col-md-6">
<label class="form-label">法定姓名</label>
<input type="text" class="form-control" id="f-legal-name">
</div>
<div class="col-md-6">
<label class="form-label">英文姓名</label>
<input type="text" class="form-control" id="f-eng-name">
</div>
<div class="col-md-6">
<label class="form-label">配額 (GB)</label>
<input type="number" class="form-control" id="f-quota" value="20" min="1">
</div>
<div class="col-md-6">
<label class="form-label">預設密碼</label>
<input type="text" class="form-control" id="f-password" placeholder="留空自動產生">
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="f-active" checked>
<label class="form-check-label" for="f-active">啟用</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveForm()">儲存</button>
</div>
</div>
</div>
</div>
<!-- Confirm Modal -->
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header"><h6 class="modal-title">確認</h6></div>
<div class="modal-body" id="confirm-body"></div>
<div class="modal-footer">
<button id="confirm-cancel" class="btn btn-secondary btn-sm">取消</button>
<button id="confirm-ok" class="btn btn-danger btn-sm">確定</button>
</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="https://cdn.datatables.net/2.0.3/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.bootstrap5.min.js"></script>
<script src="js/api.js"></script>
<script>
let dt;
let tenants = [];
async function init() {
try { tenants = await apiFetch('/tenants'); } catch (e) { toast('無法載入租戶列表:' + e.message, 'error'); }
const sel = document.getElementById('tenant-filter');
const fSel = document.getElementById('f-tenant');
tenants.forEach(t => {
sel.insertAdjacentHTML('beforeend', `<option value="${t.id}">${t.name} (${t.code})</option>`);
fSel.insertAdjacentHTML('beforeend', `<option value="${t.id}">${t.name} (${t.code})</option>`);
});
loadTable();
}
async function loadTable() {
const tid = document.getElementById('tenant-filter').value;
const qs = tid ? `?tenant_id=${tid}` : '';
let rows;
try { rows = await apiFetch(`/accounts${qs}`); } catch (e) { toast('無法載入帳號資料:' + e.message, 'error'); return; }
const data = rows.map(a => {
const lights = a.lights || {};
return [
`<strong>${a.account_code}</strong>`,
`<small>${a.tenant_name || ''}</small>`,
a.sso_account,
`<small class="text-muted">${a.email || '—'}</small>`,
a.legal_name || '—',
a.is_active
? '<span class="badge bg-success-subtle text-success">啟用</span>'
: '<span class="badge bg-secondary-subtle text-secondary">停用</span>',
lightHtml(lights.sso_result),
lightHtml(lights.mailbox_result),
lightHtml(lights.nc_result),
lightHtml(lights.nc_mail_result),
`<button class="btn btn-outline-primary btn-sm me-1" onclick='editRow(${JSON.stringify(a)})'><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteRow(${a.id},'${a.sso_account}')"><i class="bi bi-trash"></i></button>`,
];
});
if (dt) {
dt.clear().rows.add(data).draw();
} else {
dt = $('#tbl').DataTable({
data,
columns: [
{}, {}, {}, {}, {},
{ className: 'text-center' },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
],
language: { url: 'https://cdn.datatables.net/plug-ins/2.0.3/i18n/zh-HANT.json' },
pageLength: 25,
order: [[0, 'asc']],
});
}
}
function clearForm() {
['f-id','f-sso','f-notify-email','f-legal-name','f-eng-name','f-password'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('f-quota').value = 20;
document.getElementById('f-active').checked = true;
if (tenants.length) document.getElementById('f-tenant').value = tenants[0].id;
}
function openCreate() {
clearForm();
document.getElementById('formTitle').textContent = '新增帳號';
document.getElementById('f-tenant').disabled = false;
new bootstrap.Modal(document.getElementById('formModal')).show();
}
function editRow(a) {
clearForm();
document.getElementById('formTitle').textContent = '編輯帳號';
document.getElementById('f-id').value = a.id;
document.getElementById('f-tenant').value = a.tenant_id;
document.getElementById('f-tenant').disabled = true;
document.getElementById('f-sso').value = a.sso_account;
document.getElementById('f-notify-email').value = a.notification_email || '';
document.getElementById('f-legal-name').value = a.legal_name || '';
document.getElementById('f-eng-name').value = a.english_name || '';
document.getElementById('f-quota').value = a.quota_limit;
document.getElementById('f-active').checked = a.is_active;
new bootstrap.Modal(document.getElementById('formModal')).show();
}
async function saveForm() {
const id = document.getElementById('f-id').value;
const payload = {
tenant_id: parseInt(document.getElementById('f-tenant').value),
sso_account: document.getElementById('f-sso').value.trim(),
notification_email: document.getElementById('f-notify-email').value.trim(),
legal_name: document.getElementById('f-legal-name').value.trim() || null,
english_name: document.getElementById('f-eng-name').value.trim() || null,
quota_limit: parseInt(document.getElementById('f-quota').value),
default_password: document.getElementById('f-password').value.trim() || null,
is_active: document.getElementById('f-active').checked,
};
try {
if (id) {
const { tenant_id, sso_account, ...updatePayload } = payload;
await apiFetch(`/accounts/${id}`, { method: 'PUT', body: JSON.stringify(updatePayload) });
toast('帳號已更新');
} else {
await apiFetch('/accounts', { method: 'POST', body: JSON.stringify(payload) });
toast('帳號已新增');
}
bootstrap.Modal.getInstance(document.getElementById('formModal')).hide();
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
async function deleteRow(id, name) {
const ok = await confirm(`確定要刪除帳號「${name}」?`);
if (!ok) return;
try {
await apiFetch(`/accounts/${id}`, { method: 'DELETE' });
toast('帳號已刪除');
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
init();
</script>
</body>
</html>

View File

@@ -0,0 +1,161 @@
/* VMIS Admin Portal - Custom Styles */
:root {
--sidebar-width: 220px;
--sidebar-bg: #1a1d23;
--sidebar-active: #0d6efd;
--header-height: 56px;
}
body {
font-size: 0.875rem;
background: #f5f6fa;
}
/* ── Sidebar ── */
#sidebar {
width: var(--sidebar-width);
min-height: 100vh;
background: var(--sidebar-bg);
position: fixed;
top: 0;
left: 0;
z-index: 100;
display: flex;
flex-direction: column;
}
#sidebar .brand {
height: var(--header-height);
display: flex;
align-items: center;
padding: 0 1.2rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
color: #fff;
font-weight: 700;
font-size: 1rem;
letter-spacing: 0.03em;
text-decoration: none;
}
#sidebar .nav-link {
color: rgba(255,255,255,0.65);
padding: 0.6rem 1.2rem;
border-radius: 0;
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
transition: background 0.15s, color 0.15s;
}
#sidebar .nav-link:hover {
color: #fff;
background: rgba(255,255,255,0.07);
}
#sidebar .nav-link.active {
color: #fff;
background: var(--sidebar-active);
}
#sidebar .nav-section {
color: rgba(255,255,255,0.3);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 1rem 1.2rem 0.3rem;
}
/* ── Main Content ── */
#main {
margin-left: var(--sidebar-width);
min-height: 100vh;
display: flex;
flex-direction: column;
}
#topbar {
height: var(--header-height);
background: #fff;
border-bottom: 1px solid #e9ecef;
display: flex;
align-items: center;
padding: 0 1.5rem;
position: sticky;
top: 0;
z-index: 99;
}
#content {
padding: 1.5rem;
flex: 1;
}
/* ── Status Lights ── */
.light {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
vertical-align: middle;
}
.light-grey { background: #aaaaaa; }
.light-green { background: #28a745; }
.light-red { background: #dc3545; }
/* ── DataTable tweaks ── */
.dataTables_wrapper .dataTables_filter input {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
}
.dataTables_wrapper .dataTables_length select {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
}
table.dataTable thead th {
border-bottom: 2px solid #dee2e6;
background: #f8f9fa;
font-weight: 600;
white-space: nowrap;
}
table.dataTable tbody tr:hover {
background: #f0f4ff;
}
/* ── Cards ── */
.stat-card {
border: none;
border-radius: 0.75rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
/* ── Availability bar ── */
.avail-bar {
height: 6px;
border-radius: 3px;
background: #e9ecef;
overflow: hidden;
}
.avail-fill {
height: 100%;
border-radius: 3px;
background: #28a745;
transition: width 0.4s;
}
.avail-fill.warn { background: #ffc107; }
.avail-fill.danger { background: #dc3545; }
/* ── System status matrix ── */
.status-matrix .env-col {
min-width: 120px;
text-align: center;
}
/* ── Modal ── */
.modal-header { background: #f8f9fa; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View 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>

View File

@@ -0,0 +1,184 @@
/* VMIS Admin Portal - API utilities */
const API = 'http://localhost:10281/api/v1';
/* ── Auth state ── */
let _sysSettings = null;
let _kcInstance = null;
let _accessToken = null;
/* ── Keycloak SSO guard ── */
async function _loadKcScript(src) {
return new Promise((resolve) => {
const s = document.createElement('script');
s.src = src;
s.onload = () => resolve(true);
s.onerror = () => resolve(false);
document.head.appendChild(s);
});
}
async function _initKcAuth(settings) {
// 嘗試路徑順序:新版(Quarkus) → 舊版(WildFly /auth) → CDN
const candidates = [
`${settings.keycloak_url}/js/keycloak.js`,
`${settings.keycloak_url}/auth/js/keycloak.js`,
'https://cdn.jsdelivr.net/npm/keycloak-js/dist/keycloak.min.js',
];
let loaded = false;
for (const src of candidates) {
loaded = await _loadKcScript(src);
if (loaded) { console.info('KC JS 載入成功:', src); break; }
}
if (!loaded) { console.warn('KC JS 全部來源載入失敗,跳過 SSO 守衛'); return; }
try {
const kc = new Keycloak({
url: settings.keycloak_url,
realm: settings.keycloak_realm,
clientId: settings.keycloak_client || 'vmis-portal',
});
const authenticated = await kc.init({
onLoad: 'login-required',
pkceMethod: 'S256',
checkLoginIframe: false,
});
if (authenticated) {
_kcInstance = kc;
_accessToken = kc.token;
setInterval(() => {
kc.updateToken(60)
.then(refreshed => { if (refreshed) _accessToken = kc.token; })
.catch(() => kc.login());
}, 30000);
// 在 topbar 插入使用者資訊與登出按鈕
const topbar = document.getElementById('topbar');
if (topbar) {
const username = kc.tokenParsed?.preferred_username || kc.tokenParsed?.name || '';
const btn = document.createElement('div');
btn.className = 'ms-3 d-flex align-items-center gap-2';
btn.innerHTML = `
<span class="text-muted small"><i class="bi bi-person-circle me-1"></i>${username}</span>
<button class="btn btn-outline-secondary btn-sm" onclick="_kcInstance.logout()">
<i class="bi bi-box-arrow-right me-1"></i>登出
</button>`;
topbar.appendChild(btn);
}
}
} catch (e) {
console.error('KC 初始化失敗:', e);
}
}
/* ── Global settings (loaded on every page) ── */
async function loadSysSettings() {
try {
_sysSettings = await apiFetch('/settings');
// Apply site title: prepend page name if title contains " — "
if (_sysSettings.site_title) {
const cur = document.title;
const sep = cur.indexOf(' — ');
const pageName = sep >= 0 ? cur.substring(0, sep) : cur;
document.title = `${pageName}${_sysSettings.site_title}`;
}
// Inject version badge into topbar if element exists
const topbar = document.getElementById('topbar');
if (topbar && _sysSettings.version) {
const badge = document.createElement('span');
badge.className = 'badge bg-secondary-subtle text-secondary ms-2';
badge.textContent = `v${_sysSettings.version}`;
topbar.querySelector('.fw-semibold')?.after(badge);
}
// SSO guard若已啟用觸發 Keycloak 認證
if (_sysSettings.sso_enabled && _sysSettings.keycloak_url && _sysSettings.keycloak_realm) {
await _initKcAuth(_sysSettings);
}
} catch (e) {
// Silently ignore if settings not yet available
}
}
// Auto-load on every page
document.addEventListener('DOMContentLoaded', loadSysSettings);
async function apiFetch(path, options = {}) {
const headers = { 'Content-Type': 'application/json', ...options.headers };
if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`;
const resp = await fetch(API + path, { headers, ...options });
if (!resp.ok) {
// Token 過期 → 重新登入
if (resp.status === 401 && _kcInstance) { _kcInstance.login(); return; }
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw Object.assign(new Error(err.detail || resp.statusText), { status: resp.status });
}
if (resp.status === 204) return null;
return resp.json();
}
/* ── light helper ── */
function lightHtml(val) {
if (val === null || val === undefined) return '<span class="light light-grey" title="無紀錄"></span>';
return val
? '<span class="light light-green" title="正常"></span>'
: '<span class="light light-red" title="異常"></span>';
}
/* ── availability color ── */
function availClass(pct) {
if (pct === null || pct === undefined) return '';
if (pct >= 99) return '';
if (pct >= 95) return 'warn';
return 'danger';
}
function availBar(pct) {
if (pct === null || pct === undefined) return '<span class="text-muted">—</span>';
const cls = availClass(pct);
return `
<div class="d-flex align-items-center gap-2">
<div class="avail-bar flex-grow-1">
<div class="avail-fill ${cls}" style="width:${pct}%"></div>
</div>
<small class="${cls === 'danger' ? 'text-danger' : cls === 'warn' ? 'text-warning' : 'text-success'}">${pct}%</small>
</div>`;
}
/* ── toast ── */
function toast(msg, type = 'success') {
const container = document.getElementById('toast-container');
if (!container) return;
const id = 'toast-' + Date.now();
const icon = type === 'success' ? '✓' : '✗';
const bg = type === 'success' ? 'text-bg-success' : 'text-bg-danger';
container.insertAdjacentHTML('beforeend', `
<div id="${id}" class="toast align-items-center ${bg} border-0 show" role="alert">
<div class="d-flex">
<div class="toast-body">${icon} ${msg}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>`);
setTimeout(() => document.getElementById(id)?.remove(), 3500);
}
/* ── confirm modal ── */
function confirm(msg) {
return new Promise(resolve => {
document.getElementById('confirm-body').textContent = msg;
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('confirmModal'));
document.getElementById('confirm-ok').onclick = () => { modal.hide(); resolve(true); };
document.getElementById('confirm-cancel').onclick = () => { modal.hide(); resolve(false); };
modal.show();
});
}
/* ── format datetime ── */
// 後端已依設定時區儲存,直接顯示資料庫原始時間,不做轉換
function fmtDt(iso) {
if (!iso) return '—';
return iso.replace('T', ' ').substring(0, 19);
}
function fmtDate(iso) {
if (!iso) return '—';
return iso.substring(0, 10);
}

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>

View File

@@ -0,0 +1,177 @@
<!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">
<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 active" 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-clock me-1"></i>排程管理</span>
</div>
<div id="content">
<div id="cards" class="row g-3"></div>
</div>
</div>
<!-- Edit Cron Modal -->
<div class="modal fade" id="cronModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">修改 Cron</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="c-id">
<label class="form-label">Cron 表達式 <small class="text-muted">(六碼:秒 分 時 日 月 週)</small></label>
<input type="text" class="form-control font-monospace" id="c-cron" placeholder="0 */3 * * * *">
<div class="form-text">範例:<code>0 */3 * * * *</code> = 每3分鐘<code>0 0 8 * * *</code> = 每日08:00</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveCron()">儲存</button>
</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>
const STATUS_BADGE = {
Waiting: '<span class="badge bg-secondary">Waiting</span>',
Going: '<span class="badge bg-warning text-dark"><i class="bi bi-arrow-repeat spin me-1"></i>Going</span>',
};
const RESULT_BADGE = {
ok: '<span class="badge bg-success">ok</span>',
error: '<span class="badge bg-danger">error</span>',
};
function cardHtml(s) {
const statusBadge = STATUS_BADGE[s.status] || s.status;
const resultBadge = s.last_status ? (RESULT_BADGE[s.last_status] || s.last_status) : '<span class="text-muted">—</span>';
return `
<div class="col-md-4">
<div class="card stat-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h6 class="mb-0">${s.name}</h6>
${statusBadge}
</div>
<div class="mb-2">
<small class="text-muted">Cron</small><br>
<code class="fs-6">${s.cron_timer}</code>
</div>
<div class="row g-2 text-sm mb-3">
<div class="col-6">
<small class="text-muted d-block">上次執行</small>
<span class="small">${fmtDt(s.last_run_at)}</span>
</div>
<div class="col-6">
<small class="text-muted d-block">下次執行</small>
<span class="small">${fmtDt(s.next_run_at)}</span>
</div>
<div class="col-6">
<small class="text-muted d-block">上次結果</small>
${resultBadge}
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm flex-fill"
onclick="openCron(${s.id},'${s.cron_timer}')">
<i class="bi bi-pencil me-1"></i>Cron
</button>
<button class="btn btn-outline-primary btn-sm flex-fill"
onclick="runNow(${s.id},'${s.name}')"
${s.status === 'Going' ? 'disabled' : ''}>
<i class="bi bi-play-fill me-1"></i>立即執行
</button>
<a class="btn btn-outline-secondary btn-sm" href="schedule-logs.html?schedule_id=${s.id}">
<i class="bi bi-list-ul"></i>
</a>
</div>
</div>
</div>
</div>`;
}
async function loadCards() {
let schedules;
try { schedules = await apiFetch('/schedules'); } catch (e) { toast('無法載入排程資料:' + e.message, 'error'); return; }
document.getElementById('cards').innerHTML = schedules.map(cardHtml).join('');
}
function openCron(id, cron) {
document.getElementById('c-id').value = id;
document.getElementById('c-cron').value = cron;
new bootstrap.Modal(document.getElementById('cronModal')).show();
}
async function saveCron() {
const id = document.getElementById('c-id').value;
const cron_timer = document.getElementById('c-cron').value.trim();
try {
await apiFetch(`/schedules/${id}`, {
method: 'PUT',
body: JSON.stringify({ cron_timer }),
});
toast('Cron 已更新');
bootstrap.Modal.getInstance(document.getElementById('cronModal')).hide();
loadCards();
} catch (e) {
toast(e.message, 'error');
}
}
async function runNow(id, name) {
try {
await apiFetch(`/schedules/${id}/run`, { method: 'POST' });
toast(`${name}」已觸發執行`);
setTimeout(loadCards, 1000);
} catch (e) {
toast(e.message, 'error');
}
}
// Add spin animation
const style = document.createElement('style');
style.textContent = `.spin { animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }`;
document.head.appendChild(style);
loadCards();
// Auto refresh every 10s to catch status changes
setInterval(loadCards, 10000);
</script>
</body>
</html>

View File

@@ -0,0 +1,253 @@
<!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">
<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 active" 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-hdd-network me-1"></i>伺服器狀態</span>
<div class="ms-auto">
<button class="btn btn-primary btn-sm" onclick="openCreate()">
<i class="bi bi-plus-lg me-1"></i>新增伺服器
</button>
</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>排序</th>
<th>主機名稱</th>
<th>IP 位址</th>
<th>說明</th>
<th class="text-center">狀態</th>
<th>回應時間</th>
<th style="min-width:160px">可用率 30天</th>
<th style="min-width:160px">可用率 90天</th>
<th style="min-width:160px">可用率 365天</th>
<th class="text-center">啟用</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="formModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="formTitle">新增伺服器</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="f-id">
<div class="row g-3">
<div class="col-md-8">
<label class="form-label">主機名稱 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-name" placeholder="home">
</div>
<div class="col-md-4">
<label class="form-label">排序</label>
<input type="number" class="form-control" id="f-sort" value="0" min="0">
</div>
<div class="col-12">
<label class="form-label">IP 位址 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-ip" placeholder="10.1.0.254">
</div>
<div class="col-12">
<label class="form-label">說明</label>
<input type="text" class="form-control" id="f-desc" placeholder="核心服務主機">
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="f-active" checked>
<label class="form-check-label" for="f-active">啟用(納入排程檢查)</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveForm()">儲存</button>
</div>
</div>
</div>
</div>
<!-- Confirm Modal -->
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header"><h6 class="modal-title">確認</h6></div>
<div class="modal-body" id="confirm-body"></div>
<div class="modal-footer">
<button id="confirm-cancel" class="btn btn-secondary btn-sm">取消</button>
<button id="confirm-ok" class="btn btn-danger btn-sm">確定</button>
</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="https://cdn.datatables.net/2.0.3/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.bootstrap5.min.js"></script>
<script src="js/api.js"></script>
<script>
let dt;
function rtHtml(ms) {
if (ms === null || ms === undefined) return '<span class="text-muted">—</span>';
return `<span class="text-success">${ms.toFixed(1)} ms</span>`;
}
async function loadTable() {
let rows;
try { rows = await apiFetch('/servers'); } catch (e) { toast('無法載入伺服器資料:' + e.message, 'error'); return; }
const data = rows.map(s => {
const av = s.availability || {};
return [
s.sort_order,
`<strong>${s.name}</strong>`,
`<code>${s.ip_address}</code>`,
`<small class="text-muted">${s.description || '—'}</small>`,
lightHtml(s.last_result),
rtHtml(s.last_response_time),
availBar(av.availability_30d),
availBar(av.availability_90d),
availBar(av.availability_365d),
s.is_active
? '<span class="badge bg-success-subtle text-success">啟用</span>'
: '<span class="badge bg-secondary-subtle text-secondary">停用</span>',
`<button class="btn btn-outline-primary btn-sm me-1" onclick='editRow(${JSON.stringify(s)})'><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteRow(${s.id},'${s.name}')"><i class="bi bi-trash"></i></button>`,
];
});
if (dt) {
dt.clear().rows.add(data).draw();
} else {
dt = $('#tbl').DataTable({
data,
columns: [
{ type: 'num' }, {}, { className: 'font-monospace' }, {},
{ className: 'text-center', orderable: false },
{},
{ orderable: false },
{ orderable: false },
{ orderable: false },
{ className: 'text-center' },
{ className: 'text-center', orderable: false },
],
language: { url: 'https://cdn.datatables.net/plug-ins/2.0.3/i18n/zh-HANT.json' },
pageLength: 25,
order: [[0, 'asc']],
});
}
}
function clearForm() {
document.getElementById('f-id').value = '';
document.getElementById('f-name').value = '';
document.getElementById('f-ip').value = '';
document.getElementById('f-desc').value = '';
document.getElementById('f-sort').value = 0;
document.getElementById('f-active').checked = true;
}
function openCreate() {
clearForm();
document.getElementById('formTitle').textContent = '新增伺服器';
new bootstrap.Modal(document.getElementById('formModal')).show();
}
function editRow(s) {
clearForm();
document.getElementById('formTitle').textContent = '編輯伺服器';
document.getElementById('f-id').value = s.id;
document.getElementById('f-name').value = s.name;
document.getElementById('f-ip').value = s.ip_address;
document.getElementById('f-desc').value = s.description || '';
document.getElementById('f-sort').value = s.sort_order;
document.getElementById('f-active').checked = s.is_active;
new bootstrap.Modal(document.getElementById('formModal')).show();
}
async function saveForm() {
const id = document.getElementById('f-id').value;
const payload = {
name: document.getElementById('f-name').value.trim(),
ip_address: document.getElementById('f-ip').value.trim(),
description: document.getElementById('f-desc').value.trim() || null,
sort_order: parseInt(document.getElementById('f-sort').value),
is_active: document.getElementById('f-active').checked,
};
try {
if (id) {
await apiFetch(`/servers/${id}`, { method: 'PUT', body: JSON.stringify(payload) });
toast('伺服器已更新');
} else {
await apiFetch('/servers', { method: 'POST', body: JSON.stringify(payload) });
toast('伺服器已新增');
}
bootstrap.Modal.getInstance(document.getElementById('formModal')).hide();
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
async function deleteRow(id, name) {
const ok = await confirm(`確定要刪除伺服器「${name}」?`);
if (!ok) return;
try {
await apiFetch(`/servers/${id}`, { method: 'DELETE' });
toast('伺服器已刪除');
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
loadTable();
</script>
</body>
</html>

View File

@@ -0,0 +1,324 @@
<!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">
<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 active" 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-gear me-1"></i>系統設定</span>
<div class="ms-auto text-muted small" id="last-update"></div>
</div>
<div id="content">
<!-- 初始化提示 -->
<div id="init-alert" class="alert alert-warning d-flex align-items-center gap-2 mb-4" style="display:none!important">
<i class="bi bi-exclamation-triangle-fill fs-5"></i>
<div>
<strong>系統初始化提示:</strong>
請依序完成以下步驟以啟動 SSO 認證:<br>
<span class="badge bg-secondary me-1">1</span>設定 Keycloak 連線資訊並測試連線
<span class="badge bg-secondary mx-1">2</span>初始化 SSO Realm
<span class="badge bg-secondary mx-1">3</span>建立管理租戶 (is_manager=true) 及帳號
<span class="badge bg-secondary mx-1">4</span>啟用 SSO
</div>
</div>
<div class="row g-4">
<!-- 基本設定 -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold">
<i class="bi bi-sliders me-1 text-primary"></i>基本設定
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-semibold">系統標題</label>
<input type="text" class="form-control" id="s-title" placeholder="VMIS Admin Portal">
<div class="form-text">瀏覽器 tab 顯示的標題文字</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">版本宣告</label>
<input type="text" class="form-control" id="s-version" placeholder="2.0.0">
<div class="form-text">顯示於頁面底部的系統版本號</div>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">資料時區</label>
<select class="form-select" id="s-timezone">
<option value="Asia/Taipei">Asia/Taipei (UTC+8)</option>
<option value="Asia/Tokyo">Asia/Tokyo (UTC+9)</option>
<option value="Asia/Singapore">Asia/Singapore (UTC+8)</option>
<option value="Asia/Shanghai">Asia/Shanghai (UTC+8)</option>
<option value="UTC">UTC (UTC+0)</option>
<option value="America/New_York">America/New_York (UTC-5)</option>
<option value="Europe/London">Europe/London (UTC+0)</option>
</select>
<div class="form-text">資料庫紀錄儲存使用的時區,變更後需重啟後端才完全生效</div>
</div>
</div>
</div>
</div>
<!-- SSO 設定 -->
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold">
<i class="bi bi-shield-lock me-1 text-warning"></i>SSO 驗證設定
</div>
<div class="card-body">
<!-- SSO 啟用 -->
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="s-sso" role="switch">
<label class="form-check-label fw-semibold" for="s-sso">啟用 SSO 登入</label>
</div>
<div class="form-text mt-1">
啟用後,進入系統必須通過 Keycloak 登入驗證。<br>
<strong class="text-danger">前提:需有管理租戶 (is_manager=true) 及其帳號。</strong>
</div>
</div>
<hr>
<!-- Master Realm 管理帳號 -->
<p class="text-muted small mb-2"><i class="bi bi-database me-1"></i>Master Realm 管理帳號(後端操作租戶 realm 用)</p>
<div class="mb-3">
<label class="form-label fw-semibold">Keycloak URL</label>
<input type="text" class="form-control" id="s-kc-url" placeholder="https://auth.lab.taipei">
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label fw-semibold">管理帳號</label>
<input type="text" class="form-control" id="s-kc-admin-user" placeholder="admin">
</div>
<div class="col-6">
<label class="form-label fw-semibold">管理密碼</label>
<div class="input-group">
<input type="password" class="form-control" id="s-kc-admin-pass" placeholder="••••••••">
<button class="btn btn-outline-secondary" type="button" onclick="togglePass('s-kc-admin-pass',this)">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- 測試連線 -->
<div class="d-flex align-items-center gap-2 mb-3">
<button class="btn btn-outline-primary btn-sm" onclick="testKeycloak()">
<i class="bi bi-plug me-1"></i>測試連線
</button>
<span id="kc-test-result" class="small"></span>
</div>
<hr>
<!-- Admin Portal SSO -->
<p class="text-muted small mb-2"><i class="bi bi-browser-chrome me-1"></i>Admin Portal 前端登入設定</p>
<div class="mb-3">
<label class="form-label fw-semibold">前端 Client ID</label>
<input type="text" class="form-control" id="s-kc-client" placeholder="vmis-portal">
<div class="form-text">前端登入用的 Keycloak Public Client ID。SSO Realm 由<strong>管理租戶</strong>的 Keycloak Realm 決定,請先至<a href="tenants.html">租戶管理</a>設定 is_manager=true 的租戶。</div>
</div>
<!-- 初始化 SSO Realm -->
<div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-warning btn-sm" onclick="initSsoRealm()">
<i class="bi bi-shield-plus me-1"></i>初始化 SSO Realm
</button>
<span id="kc-init-result" class="small"></span>
</div>
<div class="form-text mt-1">
在管理租戶的 Realm 中建立前端 Client ID依序儲存設定 → 測試連線 → 建立管理租戶 → 再執行)
</div>
</div>
</div>
</div>
<!-- 目前版本資訊(唯讀) -->
<div class="col-12">
<div class="card shadow-sm border-0 bg-light">
<div class="card-body d-flex align-items-center gap-4 py-3">
<div>
<small class="text-muted d-block">系統名稱</small>
<span class="fw-semibold" id="info-title"></span>
</div>
<div>
<small class="text-muted d-block">版本</small>
<span class="badge bg-primary" id="info-version"></span>
</div>
<div>
<small class="text-muted d-block">時區</small>
<span id="info-tz"></span>
</div>
<div>
<small class="text-muted d-block">SSO</small>
<span id="info-sso"></span>
</div>
<div>
<small class="text-muted d-block">Keycloak Realm</small>
<span id="info-realm" class="font-monospace small"></span>
</div>
<div>
<small class="text-muted d-block">管理租戶</small>
<span id="info-manager"></span>
</div>
<div>
<small class="text-muted d-block">最後更新</small>
<span id="info-updated"></span>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4 d-flex gap-2">
<button class="btn btn-primary px-4" onclick="saveSettings()">
<i class="bi bi-check-lg me-1"></i>儲存設定
</button>
</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>
function togglePass(id, btn) {
const el = document.getElementById(id);
const isPass = el.type === 'password';
el.type = isPass ? 'text' : 'password';
btn.innerHTML = isPass ? '<i class="bi bi-eye-slash"></i>' : '<i class="bi bi-eye"></i>';
}
async function loadSettings() {
const s = await apiFetch('/settings');
document.getElementById('s-title').value = s.site_title;
document.getElementById('s-version').value = s.version;
document.getElementById('s-timezone').value = s.timezone;
document.getElementById('s-sso').checked = s.sso_enabled;
document.getElementById('s-kc-url').value = s.keycloak_url;
document.getElementById('s-kc-admin-user').value = s.keycloak_admin_user;
document.getElementById('s-kc-admin-pass').value = s.keycloak_admin_pass;
document.getElementById('s-kc-client').value = s.keycloak_client;
// Info bar
document.getElementById('info-title').textContent = s.site_title;
document.getElementById('info-version').textContent = s.version;
document.getElementById('info-tz').textContent = s.timezone;
document.getElementById('info-sso').innerHTML = s.sso_enabled
? '<span class="badge bg-success">啟用</span>'
: '<span class="badge bg-secondary">停用</span>';
document.getElementById('info-realm').textContent = s.keycloak_realm || '—';
document.getElementById('info-updated').textContent = fmtDt(s.updated_at);
document.getElementById('last-update').textContent = '最後更新:' + fmtDt(s.updated_at);
if (s.site_title) document.title = `系統設定 — ${s.site_title}`;
// 載入管理租戶狀態
await loadManagerStatus();
}
async function loadManagerStatus() {
try {
const tenants = await apiFetch('/tenants');
const manager = tenants.find(t => t.is_manager && t.is_active);
const el = document.getElementById('info-manager');
if (manager) {
el.innerHTML = `<span class="badge bg-success">${manager.name}</span>`;
// 若沒有帳號也要提示
const accounts = await apiFetch(`/accounts?tenant_id=${manager.id}&is_active=true`);
if (accounts.length === 0) {
el.innerHTML += ' <span class="badge bg-warning text-dark">尚無帳號</span>';
} else {
el.innerHTML += ` <small class="text-muted">(${accounts.length} 帳號)</small>`;
}
} else {
el.innerHTML = '<span class="badge bg-danger">尚未建立</span>';
document.getElementById('init-alert').style.removeProperty('display');
}
} catch (e) {
document.getElementById('info-manager').textContent = '—';
}
}
async function saveSettings() {
const payload = {
site_title: document.getElementById('s-title').value.trim(),
version: document.getElementById('s-version').value.trim(),
timezone: document.getElementById('s-timezone').value,
sso_enabled: document.getElementById('s-sso').checked,
keycloak_url: document.getElementById('s-kc-url').value.trim(),
keycloak_admin_user: document.getElementById('s-kc-admin-user').value.trim(),
keycloak_admin_pass: document.getElementById('s-kc-admin-pass').value,
keycloak_client: document.getElementById('s-kc-client').value.trim(),
};
try {
await apiFetch('/settings', { method: 'PUT', body: JSON.stringify(payload) });
toast('設定已儲存');
loadSettings();
} catch (e) {
toast(e.message, 'error');
}
}
async function testKeycloak() {
const el = document.getElementById('kc-test-result');
el.innerHTML = '<span class="text-muted">測試中...</span>';
try {
const r = await apiFetch('/settings/test-keycloak', { method: 'POST' });
el.innerHTML = `<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>${r.message}</span>`;
} catch (e) {
el.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${e.message}</span>`;
}
}
async function initSsoRealm() {
const el = document.getElementById('kc-init-result');
el.innerHTML = '<span class="text-muted">初始化中...</span>';
try {
const r = await apiFetch('/settings/init-sso-realm', { method: 'POST' });
el.innerHTML = `<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>完成Realm: ${r.realm}</span>`;
toast('SSO Realm 初始化完成:' + r.details.join('、'));
loadSettings();
} catch (e) {
el.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle-fill me-1"></i>${e.message}</span>`;
toast(e.message, 'error');
}
}
loadSettings();
</script>
</body>
</html>

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>

View File

@@ -0,0 +1,321 @@
<!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">
<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 active" 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-building me-1"></i>租戶管理</span>
<div class="ms-auto">
<button class="btn btn-primary btn-sm" onclick="openCreate()">
<i class="bi bi-plus-lg me-1"></i>新增租戶
</button>
</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>租戶代碼</th>
<th>中文名稱</th>
<th>網域</th>
<th>狀態</th>
<th class="text-center">啟用</th>
<th class="text-center">SSO</th>
<th class="text-center">郵箱</th>
<th class="text-center">Drive</th>
<th class="text-center">Office</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="formModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="formTitle">新增租戶</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="f-id">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">租戶代碼 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-code" placeholder="porsche">
</div>
<div class="col-md-4">
<label class="form-label">前置碼 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-prefix" placeholder="PC">
</div>
<div class="col-md-4">
<label class="form-label">狀態</label>
<select class="form-select" id="f-status">
<option value="trial">試用 (trial)</option>
<option value="active">正式 (active)</option>
<option value="inactive">停用 (inactive)</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">中文名稱 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-name" placeholder="匠耘科技">
</div>
<div class="col-md-6">
<label class="form-label">英文名稱</label>
<input type="text" class="form-control" id="f-name-eng" placeholder="Jiang Yun Tech">
</div>
<div class="col-md-6">
<label class="form-label">網域 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="f-domain" placeholder="porsche.lab.taipei">
</div>
<div class="col-md-6">
<label class="form-label">統一編號</label>
<input type="text" class="form-control" id="f-tax-id">
</div>
<div class="col-md-4">
<label class="form-label">每帳號配額 (GB)</label>
<input type="number" class="form-control" id="f-quota-user" value="20" min="1">
</div>
<div class="col-md-4">
<label class="form-label">租戶總配額 (GB)</label>
<input type="number" class="form-control" id="f-quota-total" value="200" min="1">
</div>
<div class="col-md-4">
<label class="form-label">員工數上限</label>
<input type="number" class="form-control" id="f-emp-limit" value="50" min="1">
</div>
<div class="col-md-6">
<label class="form-label">聯絡人</label>
<input type="text" class="form-control" id="f-contact">
</div>
<div class="col-md-6">
<label class="form-label">聯絡人郵件</label>
<input type="email" class="form-control" id="f-contact-email">
</div>
<div class="col-12">
<label class="form-label">備註</label>
<textarea class="form-control" id="f-note" rows="2"></textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="f-active" checked>
<label class="form-check-label" for="f-active">啟用</label>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" id="f-manager">
<label class="form-check-label" for="f-manager">
管理租戶 <small class="text-muted">(系統維護用,全系統只能有一個)</small>
</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="saveForm()">儲存</button>
</div>
</div>
</div>
</div>
<!-- Confirm Modal -->
<div class="modal fade" id="confirmModal" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header"><h6 class="modal-title">確認</h6></div>
<div class="modal-body" id="confirm-body"></div>
<div class="modal-footer">
<button id="confirm-cancel" class="btn btn-secondary btn-sm">取消</button>
<button id="confirm-ok" class="btn btn-danger btn-sm">確定</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<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="https://cdn.datatables.net/2.0.3/js/dataTables.min.js"></script>
<script src="https://cdn.datatables.net/2.0.3/js/dataTables.bootstrap5.min.js"></script>
<script src="js/api.js"></script>
<script>
let dt;
const statusBadge = s => ({
trial: '<span class="badge bg-warning text-dark">試用</span>',
active: '<span class="badge bg-success">正式</span>',
inactive: '<span class="badge bg-secondary">停用</span>',
}[s] || s);
async function loadTable() {
let rows;
try { rows = await apiFetch('/tenants'); } catch (e) { toast('無法載入租戶資料:' + e.message, 'error'); return; }
const data = rows.map(t => {
const lights = t.lights || {};
return [
`<strong>${t.code}</strong>`,
t.name,
`<small class="text-muted">${t.domain}</small>`,
statusBadge(t.status),
t.is_active
? '<span class="badge bg-success-subtle text-success">啟用</span>'
: '<span class="badge bg-secondary-subtle text-secondary">停用</span>',
lightHtml(lights.sso_result),
lightHtml(lights.mailbox_result),
lightHtml(lights.nc_result),
lightHtml(lights.office_result),
`<button class="btn btn-outline-primary btn-sm me-1" onclick='editRow(${JSON.stringify(t)})'><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-sm" onclick="deleteRow(${t.id},'${t.name}')"><i class="bi bi-trash"></i></button>`,
];
});
if (dt) {
dt.clear().rows.add(data).draw();
} else {
dt = $('#tbl').DataTable({
data,
columns: [
{}, {}, {}, {},
{ className: 'text-center' },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
{ className: 'text-center', orderable: false },
],
language: { url: 'https://cdn.datatables.net/plug-ins/2.0.3/i18n/zh-HANT.json' },
pageLength: 25,
order: [[0, 'asc']],
});
}
}
function clearForm() {
['f-id','f-code','f-prefix','f-name','f-name-eng','f-domain','f-tax-id','f-contact','f-contact-email','f-note'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('f-quota-user').value = 20;
document.getElementById('f-quota-total').value = 200;
document.getElementById('f-emp-limit').value = 50;
document.getElementById('f-status').value = 'trial';
document.getElementById('f-active').checked = true;
document.getElementById('f-manager').checked = false;
}
function openCreate() {
clearForm();
document.getElementById('formTitle').textContent = '新增租戶';
new bootstrap.Modal(document.getElementById('formModal')).show();
}
function editRow(t) {
clearForm();
document.getElementById('formTitle').textContent = '編輯租戶';
document.getElementById('f-id').value = t.id;
document.getElementById('f-code').value = t.code;
document.getElementById('f-prefix').value = t.prefix;
document.getElementById('f-name').value = t.name;
document.getElementById('f-name-eng').value = t.name_eng || '';
document.getElementById('f-domain').value = t.domain;
document.getElementById('f-tax-id').value = t.tax_id || '';
document.getElementById('f-quota-user').value = t.quota_per_user;
document.getElementById('f-quota-total').value = t.total_quota;
document.getElementById('f-emp-limit').value = t.employee_limit || 50;
document.getElementById('f-contact').value = t.contact || '';
document.getElementById('f-contact-email').value = t.contact_email || '';
document.getElementById('f-note').value = t.note || '';
document.getElementById('f-status').value = t.status;
document.getElementById('f-active').checked = t.is_active;
document.getElementById('f-manager').checked = t.is_manager || false;
new bootstrap.Modal(document.getElementById('formModal')).show();
}
async function saveForm() {
const id = document.getElementById('f-id').value;
const payload = {
code: document.getElementById('f-code').value.trim(),
prefix: document.getElementById('f-prefix').value.trim(),
name: document.getElementById('f-name').value.trim(),
name_eng: document.getElementById('f-name-eng').value.trim() || null,
domain: document.getElementById('f-domain').value.trim(),
tax_id: document.getElementById('f-tax-id').value.trim() || null,
quota_per_user: parseInt(document.getElementById('f-quota-user').value),
total_quota: parseInt(document.getElementById('f-quota-total').value),
employee_limit: parseInt(document.getElementById('f-emp-limit').value),
contact: document.getElementById('f-contact').value.trim() || null,
contact_email: document.getElementById('f-contact-email').value.trim() || null,
note: document.getElementById('f-note').value.trim() || null,
status: document.getElementById('f-status').value,
is_active: document.getElementById('f-active').checked,
is_manager: document.getElementById('f-manager').checked,
};
try {
if (id) {
await apiFetch(`/tenants/${id}`, { method: 'PUT', body: JSON.stringify(payload) });
toast('租戶已更新');
} else {
await apiFetch('/tenants', { method: 'POST', body: JSON.stringify(payload) });
toast('租戶已新增');
}
bootstrap.Modal.getInstance(document.getElementById('formModal')).hide();
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
async function deleteRow(id, name) {
const ok = await confirm(`確定要刪除租戶「${name}」?\n(關聯帳號將一併刪除)`);
if (!ok) return;
try {
await apiFetch(`/tenants/${id}`, { method: 'DELETE' });
toast('租戶已刪除');
loadTable();
} catch (e) {
toast(e.message, 'error');
}
}
loadTable();
</script>
</body>
</html>