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:
8
frontend/admin-portal/.env.production
Normal file
8
frontend/admin-portal/.env.production
Normal 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
39
frontend/admin-portal/.gitignore
vendored
Normal 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
|
||||
4
frontend/admin-portal/START_FRONTEND.bat
Normal file
4
frontend/admin-portal/START_FRONTEND.bat
Normal 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
|
||||
284
frontend/admin-portal/accounts.html
Normal file
284
frontend/admin-portal/accounts.html
Normal 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>
|
||||
161
frontend/admin-portal/css/style.css
Normal file
161
frontend/admin-portal/css/style.css
Normal 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; }
|
||||
BIN
frontend/admin-portal/img/logo.png
Normal file
BIN
frontend/admin-portal/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
198
frontend/admin-portal/index.html
Normal file
198
frontend/admin-portal/index.html
Normal file
@@ -0,0 +1,198 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VMIS Admin Portal</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="icon" href="img/logo.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav id="sidebar">
|
||||
<a class="brand" href="index.html"><img src="img/logo.png" alt="logo" style="height:28px;object-fit:contain;vertical-align:middle;" class="me-2">VMIS Admin</a>
|
||||
<div class="pt-2">
|
||||
<div class="nav-section">租戶服務</div>
|
||||
<a class="nav-link" href="tenants.html"><i class="bi bi-building"></i> 租戶管理</a>
|
||||
<a class="nav-link" href="accounts.html"><i class="bi bi-people"></i> 帳號管理</a>
|
||||
<div class="nav-section">基礎設施</div>
|
||||
<a class="nav-link" href="servers.html"><i class="bi bi-hdd-network"></i> 伺服器狀態</a>
|
||||
<a class="nav-link" href="system-status.html"><i class="bi bi-activity"></i> 系統狀態</a>
|
||||
<div class="nav-section">排程</div>
|
||||
<a class="nav-link" href="schedules.html"><i class="bi bi-clock"></i> 排程管理</a>
|
||||
<a class="nav-link" href="schedule-logs.html"><i class="bi bi-list-ul"></i> 執行紀錄</a>
|
||||
<div class="nav-section">系統</div>
|
||||
<a class="nav-link" href="settings.html"><i class="bi bi-gear"></i> 系統設定</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main -->
|
||||
<div id="main">
|
||||
<div id="topbar">
|
||||
<span class="fw-semibold text-secondary"><i class="bi bi-speedometer2 me-1"></i>儀表板</span>
|
||||
<div class="ms-auto text-muted small" id="last-refresh"></div>
|
||||
</div>
|
||||
|
||||
<div id="content">
|
||||
<!-- Summary Cards -->
|
||||
<div class="row g-3 mb-4" id="summary-cards">
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-2 bg-primary-subtle"><i class="bi bi-building fs-4 text-primary"></i></div>
|
||||
<div>
|
||||
<div class="text-muted small">租戶總數</div>
|
||||
<div class="fs-4 fw-bold" id="cnt-tenants">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-2 bg-success-subtle"><i class="bi bi-people fs-4 text-success"></i></div>
|
||||
<div>
|
||||
<div class="text-muted small">帳號總數</div>
|
||||
<div class="fs-4 fw-bold" id="cnt-accounts">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-2 bg-warning-subtle"><i class="bi bi-hdd-network fs-4 text-warning"></i></div>
|
||||
<div>
|
||||
<div class="text-muted small">伺服器</div>
|
||||
<div class="fs-4 fw-bold" id="cnt-servers">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-3 p-2 bg-info-subtle"><i class="bi bi-activity fs-4 text-info"></i></div>
|
||||
<div>
|
||||
<div class="text-muted small">系統狀態</div>
|
||||
<div id="sys-status-summary" class="fw-bold">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Schedules status -->
|
||||
<div class="col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-header bg-white d-flex align-items-center">
|
||||
<span class="fw-semibold">排程狀態</span>
|
||||
<a href="schedules.html" class="ms-auto btn btn-outline-secondary btn-sm">管理</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody id="sched-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Servers quick status -->
|
||||
<div class="col-md-6">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-header bg-white d-flex align-items-center">
|
||||
<span class="fw-semibold">伺服器</span>
|
||||
<a href="servers.html" class="ms-auto btn btn-outline-secondary btn-sm">詳細</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody id="server-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script>
|
||||
const STATUS_BADGE = {
|
||||
Waiting: '<span class="badge bg-secondary">Waiting</span>',
|
||||
Going: '<span class="badge bg-warning text-dark">Going</span>',
|
||||
};
|
||||
const RESULT_BADGE = {
|
||||
ok: '<span class="badge bg-success">ok</span>',
|
||||
error: '<span class="badge bg-danger">error</span>',
|
||||
};
|
||||
|
||||
async function loadDashboard() {
|
||||
const [r_tenants, r_accounts, r_servers, r_schedules, r_sysStatus] = await Promise.allSettled([
|
||||
apiFetch('/tenants'),
|
||||
apiFetch('/accounts'),
|
||||
apiFetch('/servers'),
|
||||
apiFetch('/schedules'),
|
||||
apiFetch('/system-status'),
|
||||
]);
|
||||
|
||||
const tenants = r_tenants.status === 'fulfilled' ? r_tenants.value : null;
|
||||
const accounts = r_accounts.status === 'fulfilled' ? r_accounts.value : null;
|
||||
const servers = r_servers.status === 'fulfilled' ? r_servers.value : null;
|
||||
const schedules = r_schedules.status === 'fulfilled' ? r_schedules.value : null;
|
||||
const sysStatus = r_sysStatus.status === 'fulfilled' ? r_sysStatus.value : null;
|
||||
|
||||
document.getElementById('cnt-tenants').textContent = tenants ? tenants.length : '—';
|
||||
document.getElementById('cnt-accounts').textContent = accounts ? accounts.length : '—';
|
||||
document.getElementById('cnt-servers').textContent = servers ? servers.length : '—';
|
||||
|
||||
// System status summary
|
||||
if (!sysStatus) {
|
||||
document.getElementById('sys-status-summary').innerHTML = '<span class="text-muted">無法取得</span>';
|
||||
} else if (sysStatus.length === 0) {
|
||||
document.getElementById('sys-status-summary').innerHTML = '<span class="text-muted">無資料</span>';
|
||||
} else {
|
||||
const bad = sysStatus.filter(r => !r.result).length;
|
||||
document.getElementById('sys-status-summary').innerHTML = bad === 0
|
||||
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>全部正常</span>'
|
||||
: `<span class="text-danger"><i class="bi bi-exclamation-circle-fill me-1"></i>${bad} 項異常</span>`;
|
||||
}
|
||||
|
||||
// Schedules
|
||||
if (schedules) {
|
||||
const schedRows = schedules.map(s => `
|
||||
<tr>
|
||||
<td class="ps-3">${s.name}</td>
|
||||
<td><code class="small">${s.cron_timer}</code></td>
|
||||
<td>${STATUS_BADGE[s.status] || s.status}</td>
|
||||
<td>${s.last_status ? (RESULT_BADGE[s.last_status] || s.last_status) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="text-muted small pe-3">${fmtDt(s.next_run_at)}</td>
|
||||
</tr>`).join('');
|
||||
document.getElementById('sched-rows').innerHTML = schedRows;
|
||||
}
|
||||
|
||||
// Servers
|
||||
if (servers) {
|
||||
const svrRows = servers.map(s => `
|
||||
<tr>
|
||||
<td class="ps-3">${lightHtml(s.last_result)}</td>
|
||||
<td><strong>${s.name}</strong></td>
|
||||
<td><code class="small">${s.ip_address}</code></td>
|
||||
<td class="text-muted small pe-3">${s.last_response_time != null ? s.last_response_time.toFixed(1) + ' ms' : '—'}</td>
|
||||
</tr>`).join('');
|
||||
document.getElementById('server-rows').innerHTML = svrRows;
|
||||
}
|
||||
|
||||
document.getElementById('last-refresh').textContent = '更新:' + new Date().toLocaleTimeString('zh-TW', { hour12: false });
|
||||
}
|
||||
|
||||
loadDashboard();
|
||||
setInterval(loadDashboard, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
184
frontend/admin-portal/js/api.js
Normal file
184
frontend/admin-portal/js/api.js
Normal 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);
|
||||
}
|
||||
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>
|
||||
177
frontend/admin-portal/schedules.html
Normal file
177
frontend/admin-portal/schedules.html
Normal 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>
|
||||
253
frontend/admin-portal/servers.html
Normal file
253
frontend/admin-portal/servers.html
Normal 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>
|
||||
324
frontend/admin-portal/settings.html
Normal file
324
frontend/admin-portal/settings.html
Normal 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>
|
||||
163
frontend/admin-portal/system-status.html
Normal file
163
frontend/admin-portal/system-status.html
Normal 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>
|
||||
321
frontend/admin-portal/tenants.html
Normal file
321
frontend/admin-portal/tenants.html
Normal 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>
|
||||
Reference in New Issue
Block a user