878 lines
44 KiB
HTML
878 lines
44 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>GUARDiA — PM 정기점검</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<style>
|
|
.page-wrap { display:flex; flex-direction:column; height:100vh; }
|
|
.topnav { display:flex; align-items:center; gap:16px; padding:0 24px; height:52px; background:var(--card-bg); border-bottom:1px solid var(--border); flex-shrink:0; }
|
|
.topnav-logo { font-weight:700; font-size:16px; color:var(--accent); text-decoration:none; }
|
|
.topnav-links { display:flex; gap:4px; }
|
|
.topnav-link { padding:6px 12px; font-size:13px; color:var(--text-muted); text-decoration:none; border-radius:6px; transition:background .15s,color .15s; }
|
|
.topnav-link:hover,.topnav-link.active { background:var(--sidebar-hover-bg); color:var(--text-primary); }
|
|
.topnav-right { margin-left:auto; display:flex; align-items:center; gap:12px; }
|
|
.page-content { flex:1; overflow-y:auto; padding:24px; }
|
|
.page-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
|
|
.page-title { font-size:22px; font-weight:700; color:var(--text-bright); }
|
|
.stats-row-pm { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; margin-bottom:20px; }
|
|
.stat-card-pm { background:var(--card-bg); border:1px solid var(--border); border-radius:10px; padding:16px 18px; }
|
|
.stat-val { font-size:28px; font-weight:700; color:var(--accent); }
|
|
.stat-lbl { font-size:12px; color:var(--text-muted); margin-top:2px; }
|
|
.toolbar { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:16px; align-items:center; }
|
|
.search-box-pm { background:var(--input-bg); border:1px solid var(--border); border-radius:6px; padding:7px 12px; color:var(--text-primary); font-size:13px; min-width:200px; outline:none; }
|
|
.search-box-pm:focus { border-color:var(--accent); }
|
|
.filter-select-pm { background:var(--input-bg); border:1px solid var(--border); border-radius:6px; padding:7px 10px; color:var(--text-primary); font-size:13px; outline:none; cursor:pointer; }
|
|
.sr-table { width:100%; border-collapse:collapse; }
|
|
.sr-table th { background:var(--card-bg); color:var(--text-muted); font-size:11px; font-weight:700; padding:10px 12px; text-align:left; border-bottom:1px solid var(--border); text-transform:uppercase; letter-spacing:.05em; }
|
|
.sr-table td { padding:10px 12px; border-bottom:1px solid var(--border); font-size:13px; color:var(--text-primary); vertical-align:middle; }
|
|
.sr-table tr:hover td { background:var(--sidebar-hover-bg); }
|
|
.badge-pm { display:inline-block; font-size:11px; font-weight:600; padding:2px 8px; border-radius:4px; }
|
|
.badge-PASS { background:rgba(74,222,128,.15); color:#4ade80; }
|
|
.badge-FAIL { background:rgba(248,113,113,.15); color:#f87171; }
|
|
.badge-WARNING { background:rgba(252,211,77,.15); color:#fcd34d; }
|
|
.badge-NA { background:rgba(107,114,128,.15); color:#6b7280; }
|
|
.badge-PENDING { background:rgba(129,140,248,.15); color:#818cf8; }
|
|
.badge-ACTIVE { background:rgba(52,211,153,.15); color:#34d399; }
|
|
.badge-INACTIVE{ background:rgba(107,114,128,.15); color:#6b7280; }
|
|
.badge-WEEKLY { background:rgba(34,211,238,.15); color:#22d3ee; }
|
|
.badge-MONTHLY { background:rgba(167,139,250,.15); color:#a78bfa; }
|
|
.badge-QUARTERLY{background:rgba(251,191,36,.15); color:#fbbf24; }
|
|
.badge-ANNUAL { background:rgba(251,146,60,.15); color:#fb923c; }
|
|
.modal-overlay { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:100; display:none; align-items:center; justify-content:center; backdrop-filter:blur(2px); }
|
|
.modal-overlay.open { display:flex; }
|
|
.modal-box { background:var(--card-bg); border:1px solid var(--border); border-radius:12px; padding:28px; width:560px; max-width:95vw; max-height:88vh; overflow-y:auto; position:relative; box-shadow:0 8px 40px rgba(0,0,0,.4); }
|
|
.modal-box h2 { font-size:18px; font-weight:700; margin-bottom:18px; color:var(--text-bright); }
|
|
.modal-close { position:absolute; top:14px; right:16px; background:rgba(255,255,255,.06); border:1px solid var(--border); font-size:18px; cursor:pointer; color:var(--text-muted); border-radius:6px; padding:3px 8px; line-height:1; transition:color .15s; }
|
|
.modal-close:hover { color:var(--text-bright); }
|
|
.form-label { display:flex; flex-direction:column; gap:4px; font-size:12px; font-weight:600; color:var(--text-muted); margin-bottom:12px; letter-spacing:.02em; }
|
|
.form-label input, .form-label select, .form-label textarea { background:var(--input-bg); border:1px solid var(--border); border-radius:6px; padding:8px 10px; color:var(--text-primary); font-size:13px; outline:none; font-family:inherit; transition:border-color .15s; }
|
|
.form-label input:focus, .form-label select:focus, .form-label textarea:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(99,102,241,.12); }
|
|
.form-row-2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
|
.tabs { display:flex; gap:4px; margin-bottom:20px; border-bottom:1px solid var(--border); }
|
|
.tab-btn { padding:8px 16px; font-size:13px; background:none; border:none; color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .15s; font-weight:500; }
|
|
.tab-btn.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
|
|
.tab-content { display:none; }
|
|
.tab-content.active { display:block; }
|
|
.btn-sm { padding:5px 12px; font-size:12px; border-radius:5px; cursor:pointer; border:1px solid transparent; font-weight:600; transition:all .15s; }
|
|
.btn-primary-sm { background:linear-gradient(135deg,var(--accent-dark),#8b5cf6); color:#fff; }
|
|
.btn-primary-sm:hover { opacity:.9; }
|
|
.btn-secondary-sm { background:rgba(255,255,255,.06); border-color:var(--border); color:var(--text-primary); }
|
|
.btn-secondary-sm:hover { background:rgba(255,255,255,.1); }
|
|
.btn-danger-sm { background:rgba(248,113,113,.15); color:#f87171; border-color:rgba(248,113,113,.3); }
|
|
.btn-danger-sm:hover { background:rgba(248,113,113,.25); }
|
|
.btn-green-sm { background:rgba(52,211,153,.15); color:#34d399; border-color:rgba(52,211,153,.3); }
|
|
.btn-green-sm:hover { background:rgba(52,211,153,.25); }
|
|
.checklist-section { background:var(--card-bg); border:1px solid var(--border); border-radius:10px; margin-top:16px; }
|
|
.checklist-header { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
|
|
.checklist-title { font-size:14px; font-weight:700; color:var(--text-bright); }
|
|
.checklist-table { width:100%; border-collapse:collapse; }
|
|
.checklist-table th { font-size:11px; font-weight:700; color:var(--text-muted); padding:10px 14px; text-align:left; border-bottom:1px solid var(--border); text-transform:uppercase; letter-spacing:.05em; }
|
|
.checklist-table td { padding:8px 14px; border-bottom:1px solid var(--border); font-size:13px; vertical-align:middle; }
|
|
.checklist-table tr:last-child td { border-bottom:none; }
|
|
.result-select { background:var(--input-bg); border:1px solid var(--border); border-radius:4px; padding:4px 8px; color:var(--text-primary); font-size:12px; outline:none; cursor:pointer; }
|
|
.actual-input { background:var(--input-bg); border:1px solid var(--border); border-radius:4px; padding:4px 8px; color:var(--text-primary); font-size:12px; width:120px; outline:none; }
|
|
.note-input { background:var(--input-bg); border:1px solid var(--border); border-radius:4px; padding:4px 8px; color:var(--text-primary); font-size:12px; width:160px; outline:none; }
|
|
.empty-state { text-align:center; padding:48px; color:var(--text-muted); }
|
|
.empty-icon { font-size:40px; margin-bottom:12px; }
|
|
.timetable-list { display:flex; flex-direction:column; gap:8px; margin-bottom:16px; }
|
|
.timetable-item { background:var(--card-bg); border:1px solid var(--border); border-radius:8px; padding:12px 16px; cursor:pointer; transition:border-color .15s; display:flex; align-items:center; justify-content:space-between; }
|
|
.timetable-item:hover { border-color:var(--accent); }
|
|
.timetable-item.selected { border-color:var(--accent); background:rgba(99,102,241,.08); }
|
|
.timetable-meta { font-size:11px; color:var(--text-muted); margin-top:2px; }
|
|
.template-card { background:var(--card-bg); border:1px solid var(--border); border-radius:10px; padding:16px; margin-bottom:12px; }
|
|
.template-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; }
|
|
.template-name { font-size:14px; font-weight:600; color:var(--text-bright); }
|
|
.template-items { font-size:12px; color:var(--text-muted); margin-top:4px; }
|
|
.item-list { list-style:none; margin-top:10px; display:flex; flex-direction:column; gap:4px; }
|
|
.item-row { font-size:12px; color:var(--text-primary); background:var(--input-bg); border:1px solid var(--border); border-radius:4px; padding:6px 10px; display:flex; justify-content:space-between; }
|
|
.item-cmd { font-family:monospace; color:var(--accent); font-size:11px; }
|
|
.loading-dots::after { content:'...'; animation:dots 1.2s steps(4,end) infinite; }
|
|
@keyframes dots { 0%,20%{content:''} 40%{content:'.'} 60%{content:'..'} 80%,100%{content:'...'} }
|
|
.toast { position:fixed; bottom:24px; right:24px; background:var(--card-bg); border:1px solid var(--border); border-radius:8px; padding:12px 18px; font-size:13px; color:var(--text-primary); box-shadow:0 4px 20px rgba(0,0,0,.3); z-index:9999; display:none; max-width:360px; }
|
|
.toast.show { display:block; animation:slideUp .2s ease; }
|
|
.toast.success { border-color:rgba(52,211,153,.4); color:#34d399; }
|
|
.toast.error { border-color:rgba(248,113,113,.4); color:#f87171; }
|
|
@keyframes slideUp { from{transform:translateY(12px);opacity:0} to{transform:none;opacity:1} }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<script>document.body.dataset.theme = localStorage.getItem("guardia_theme")||"dark";</script>
|
|
|
|
<div class="page-wrap">
|
|
<!-- Top Nav -->
|
|
<nav class="topnav">
|
|
<a href="/" class="topnav-logo">GUARDiA</a>
|
|
<div class="topnav-links">
|
|
<a href="/" class="topnav-link">대시보드</a>
|
|
<a href="/static/agents.html" class="topnav-link">에이전트</a>
|
|
<a href="/static/pm.html" class="topnav-link active">PM 점검</a>
|
|
<a href="/static/oncall.html" class="topnav-link">온콜/당직</a>
|
|
</div>
|
|
<div class="topnav-right">
|
|
<span id="nav-user" style="font-size:13px;color:var(--text-muted)"></span>
|
|
<button class="btn-sm btn-secondary-sm" onclick="logout()">로그아웃</button>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Page Content -->
|
|
<div class="page-content">
|
|
<div class="page-header">
|
|
<div class="page-title">PM 정기점검 관리</div>
|
|
<div style="display:flex;gap:8px;">
|
|
<button class="btn-sm btn-secondary-sm" onclick="refreshAll()">새로고침</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats -->
|
|
<div class="stats-row-pm" id="stats-row">
|
|
<div class="stat-card-pm">
|
|
<div class="stat-val" id="stat-schedule-cnt">-</div>
|
|
<div class="stat-lbl">등록된 스케줄</div>
|
|
</div>
|
|
<div class="stat-card-pm">
|
|
<div class="stat-val" style="color:#34d399" id="stat-pass-cnt">-</div>
|
|
<div class="stat-lbl">이번달 PASS</div>
|
|
</div>
|
|
<div class="stat-card-pm">
|
|
<div class="stat-val" style="color:#f87171" id="stat-fail-cnt">-</div>
|
|
<div class="stat-lbl">이번달 FAIL</div>
|
|
</div>
|
|
<div class="stat-card-pm">
|
|
<div class="stat-val" style="color:#fcd34d" id="stat-pending-cnt">-</div>
|
|
<div class="stat-lbl">대기중 점검</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="tabs">
|
|
<button class="tab-btn active" onclick="switchTab('schedules', this)">점검 스케줄</button>
|
|
<button class="tab-btn" onclick="switchTab('results', this)">점검 결과</button>
|
|
<button class="tab-btn" onclick="switchTab('templates', this)">체크리스트 템플릿</button>
|
|
</div>
|
|
|
|
<!-- Tab 1: 점검 스케줄 -->
|
|
<div class="tab-content active" id="tab-schedules">
|
|
<div class="toolbar">
|
|
<input class="search-box-pm" id="sched-search" type="text" placeholder="서버명 / 템플릿명 검색..." oninput="filterSchedules()">
|
|
<select class="filter-select-pm" id="sched-period" onchange="filterSchedules()">
|
|
<option value="">모든 주기</option>
|
|
<option value="WEEKLY">WEEKLY</option>
|
|
<option value="BIWEEKLY">BIWEEKLY</option>
|
|
<option value="MONTHLY">MONTHLY</option>
|
|
<option value="QUARTERLY">QUARTERLY</option>
|
|
<option value="SEMIANNUAL">SEMIANNUAL</option>
|
|
<option value="ANNUAL">ANNUAL</option>
|
|
<option value="CUSTOM">CUSTOM</option>
|
|
</select>
|
|
<div style="margin-left:auto">
|
|
<button class="btn-sm btn-primary-sm" onclick="openScheduleModal()">+ 스케줄 등록</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="overflow-x:auto">
|
|
<table class="sr-table">
|
|
<thead>
|
|
<tr>
|
|
<th>서버명</th>
|
|
<th>템플릿명</th>
|
|
<th>주기</th>
|
|
<th>다음 예정일</th>
|
|
<th>담당자</th>
|
|
<th>상태</th>
|
|
<th>작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="sched-tbody">
|
|
<tr><td colspan="7" style="text-align:center;padding:40px;color:var(--text-muted)"><span class="loading-dots">로딩중</span></td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: 점검 결과 -->
|
|
<div class="tab-content" id="tab-results">
|
|
<div class="toolbar">
|
|
<select class="filter-select-pm" id="result-tt-select" onchange="loadChecklist(this.value)" style="min-width:300px;">
|
|
<option value="">타임테이블 선택...</option>
|
|
</select>
|
|
<div id="result-actions" style="display:none;margin-left:auto;display:flex;gap:8px;align-items:center;">
|
|
<button class="btn-sm btn-green-sm" onclick="saveAllResults()" id="btn-save-results" style="display:none">결과 저장</button>
|
|
<button class="btn-sm btn-secondary-sm" onclick="downloadReport()" id="btn-dl-report" style="display:none">Excel 다운로드</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="checklist-area">
|
|
<div class="empty-state">
|
|
<div class="empty-icon">📋</div>
|
|
<div>타임테이블을 선택하면 체크리스트가 표시됩니다.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 3: 체크리스트 템플릿 -->
|
|
<div class="tab-content" id="tab-templates">
|
|
<div class="toolbar">
|
|
<input class="search-box-pm" id="tmpl-search" type="text" placeholder="템플릿명 검색..." oninput="filterTemplates()">
|
|
<select class="filter-select-pm" id="tmpl-role" onchange="filterTemplates()">
|
|
<option value="">모든 서버역할</option>
|
|
<option value="WEB">WEB</option>
|
|
<option value="WAS">WAS</option>
|
|
<option value="DB">DB</option>
|
|
<option value="ALL">ALL</option>
|
|
</select>
|
|
<div style="margin-left:auto">
|
|
<button class="btn-sm btn-primary-sm" onclick="openTemplateModal()">+ 템플릿 등록</button>
|
|
</div>
|
|
</div>
|
|
<div id="templates-list">
|
|
<div style="text-align:center;padding:40px;color:var(--text-muted)"><span class="loading-dots">로딩중</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── 스케줄 등록/수정 모달 ── -->
|
|
<div class="modal-overlay" id="modal-schedule">
|
|
<div class="modal-box">
|
|
<button class="modal-close" onclick="closeModal('modal-schedule')">✕</button>
|
|
<h2 id="sched-modal-title">스케줄 등록</h2>
|
|
<input type="hidden" id="sched-edit-id">
|
|
<div class="form-row-2">
|
|
<div class="form-label">서버
|
|
<select id="sched-server-id">
|
|
<option value="">서버 선택...</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-label">체크리스트 템플릿
|
|
<select id="sched-template-id">
|
|
<option value="">템플릿 선택...</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-row-2">
|
|
<div class="form-label">주기
|
|
<select id="sched-period-type">
|
|
<option value="WEEKLY">WEEKLY — 매주</option>
|
|
<option value="BIWEEKLY">BIWEEKLY — 격주</option>
|
|
<option value="MONTHLY" selected>MONTHLY — 매월</option>
|
|
<option value="QUARTERLY">QUARTERLY — 분기</option>
|
|
<option value="SEMIANNUAL">SEMIANNUAL — 반기</option>
|
|
<option value="ANNUAL">ANNUAL — 연간</option>
|
|
<option value="CUSTOM">CUSTOM — 직접 설정</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-label">월 기준일 (MONTHLY/QUARTERLY 등)
|
|
<input type="number" id="sched-day-of-month" min="1" max="31" placeholder="1~31">
|
|
</div>
|
|
</div>
|
|
<div class="form-row-2">
|
|
<div class="form-label">점검 시간
|
|
<input type="time" id="sched-time" value="09:00">
|
|
</div>
|
|
<div class="form-label">사전 알림 (일 전)
|
|
<input type="number" id="sched-advance-days" min="0" max="30" value="1">
|
|
</div>
|
|
</div>
|
|
<div class="form-label" id="cron-row" style="display:none">Cron 표현식 (CUSTOM 전용)
|
|
<input type="text" id="sched-cron-expr" placeholder="0 9 * * 1">
|
|
</div>
|
|
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px;">
|
|
<button class="btn-sm btn-secondary-sm" onclick="closeModal('modal-schedule')">취소</button>
|
|
<button class="btn-sm btn-primary-sm" onclick="saveSchedule()">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── 템플릿 등록/수정 모달 ── -->
|
|
<div class="modal-overlay" id="modal-template">
|
|
<div class="modal-box" style="width:640px">
|
|
<button class="modal-close" onclick="closeModal('modal-template')">✕</button>
|
|
<h2 id="tmpl-modal-title">템플릿 등록</h2>
|
|
<input type="hidden" id="tmpl-edit-id">
|
|
<div class="form-row-2">
|
|
<div class="form-label">템플릿명
|
|
<input type="text" id="tmpl-name" placeholder="예) 리눅스 WEB 서버 점검">
|
|
</div>
|
|
<div class="form-label">서버 역할
|
|
<select id="tmpl-server-role">
|
|
<option value="ALL">ALL</option>
|
|
<option value="WEB">WEB</option>
|
|
<option value="WAS">WAS</option>
|
|
<option value="DB">DB</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="form-label">설명
|
|
<input type="text" id="tmpl-desc" placeholder="템플릿 설명">
|
|
</div>
|
|
|
|
<div style="margin-bottom:12px">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
|
|
<span style="font-size:12px;font-weight:700;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em">점검 항목</span>
|
|
<button class="btn-sm btn-secondary-sm" onclick="addCheckItem()" type="button">+ 항목 추가</button>
|
|
</div>
|
|
<div id="check-items-container" style="display:flex;flex-direction:column;gap:6px;">
|
|
<!-- dynamic rows -->
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px;">
|
|
<button class="btn-sm btn-secondary-sm" onclick="closeModal('modal-template')">취소</button>
|
|
<button class="btn-sm btn-primary-sm" onclick="saveTemplate()">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast -->
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
/* ════════════════════════════════════════
|
|
AUTH
|
|
════════════════════════════════════════ */
|
|
const token = localStorage.getItem('guardia_token');
|
|
if (!token) location.href = '/login';
|
|
|
|
function authHeaders() {
|
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
|
}
|
|
|
|
async function apiFetch(url, opts = {}) {
|
|
const res = await fetch(url, { headers: authHeaders(), ...opts });
|
|
if (res.status === 401) { location.href = '/login'; throw new Error('Unauthorized'); }
|
|
return res;
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
STATE
|
|
════════════════════════════════════════ */
|
|
let schedules = [], schedFiltered = [];
|
|
let templates = [], tmplFiltered = [];
|
|
let servers = [];
|
|
let timetables = [];
|
|
let currentTimetableId = null;
|
|
let checklistResults = [];
|
|
|
|
/* ════════════════════════════════════════
|
|
INIT
|
|
════════════════════════════════════════ */
|
|
(async function init() {
|
|
try {
|
|
const me = await apiFetch('/api/auth/me').then(r => r.json()).catch(()=>null);
|
|
if (me) document.getElementById('nav-user').textContent = me.display_name || me.username || '';
|
|
} catch(e) {}
|
|
await Promise.all([loadServers(), loadTemplates(), loadSchedules(), loadTimetables()]);
|
|
updateStats();
|
|
})();
|
|
|
|
function logout() {
|
|
localStorage.removeItem('guardia_token');
|
|
location.href = '/login';
|
|
}
|
|
|
|
function refreshAll() {
|
|
Promise.all([loadSchedules(), loadTimetables(), loadTemplates()]);
|
|
showToast('새로고침 완료', 'success');
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
TABS
|
|
════════════════════════════════════════ */
|
|
function switchTab(name, btn) {
|
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById('tab-' + name).classList.add('active');
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
SERVERS
|
|
════════════════════════════════════════ */
|
|
async function loadServers() {
|
|
try {
|
|
const res = await apiFetch('/api/servers');
|
|
if (!res.ok) return;
|
|
servers = await res.json();
|
|
const sel = document.getElementById('sched-server-id');
|
|
sel.innerHTML = '<option value="">서버 선택...</option>';
|
|
(servers.servers || servers).forEach(s => {
|
|
sel.innerHTML += `<option value="${s.id}">${s.hostname || s.name} (${s.ip_address || ''})</option>`;
|
|
});
|
|
} catch(e) { console.warn('servers load err', e); }
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
SCHEDULES
|
|
════════════════════════════════════════ */
|
|
async function loadSchedules() {
|
|
try {
|
|
const res = await apiFetch('/api/pm/schedules');
|
|
if (!res.ok) return;
|
|
schedules = await res.json();
|
|
schedFiltered = [...schedules];
|
|
renderSchedules();
|
|
} catch(e) { console.warn('schedules load err', e); }
|
|
}
|
|
|
|
function renderSchedules() {
|
|
const tbody = document.getElementById('sched-tbody');
|
|
if (!schedFiltered.length) {
|
|
tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state"><div class="empty-icon">📅</div><div>등록된 스케줄이 없습니다.</div></div></td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = schedFiltered.map(s => `
|
|
<tr>
|
|
<td>${escHtml(s.server_name || s.server?.hostname || '-')}</td>
|
|
<td>${escHtml(s.template_name || s.template?.name || '-')}</td>
|
|
<td><span class="badge-pm badge-${s.period_type||'MONTHLY'}">${s.period_type||'-'}</span></td>
|
|
<td>${fmtDate(s.next_scheduled_at || s.next_run_at)}</td>
|
|
<td>${escHtml(s.assignee_name || s.assignee?.display_name || '-')}</td>
|
|
<td><span class="badge-pm badge-${s.is_active===false?'INACTIVE':'ACTIVE'}">${s.is_active===false?'비활성':'활성'}</span></td>
|
|
<td style="display:flex;gap:6px;flex-wrap:wrap">
|
|
<button class="btn-sm btn-green-sm" onclick="triggerSchedule(${s.id})">즉시 실행</button>
|
|
<button class="btn-sm btn-secondary-sm" onclick="editSchedule(${s.id})">수정</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function filterSchedules() {
|
|
const q = document.getElementById('sched-search').value.toLowerCase();
|
|
const period = document.getElementById('sched-period').value;
|
|
schedFiltered = schedules.filter(s => {
|
|
const nameMatch = (s.server_name||s.server?.hostname||'').toLowerCase().includes(q) ||
|
|
(s.template_name||s.template?.name||'').toLowerCase().includes(q);
|
|
const periodMatch = !period || s.period_type === period;
|
|
return nameMatch && periodMatch;
|
|
});
|
|
renderSchedules();
|
|
}
|
|
|
|
async function triggerSchedule(id) {
|
|
if (!confirm('지금 즉시 점검을 실행하시겠습니까?')) return;
|
|
try {
|
|
const res = await apiFetch(`/api/pm/schedules/${id}/trigger`, { method: 'POST' });
|
|
if (res.ok) {
|
|
showToast('점검이 즉시 실행되었습니다.', 'success');
|
|
await loadTimetables();
|
|
} else {
|
|
const d = await res.json().catch(()=>({}));
|
|
showToast('실행 실패: ' + (d.detail || res.status), 'error');
|
|
}
|
|
} catch(e) { showToast('오류: ' + e.message, 'error'); }
|
|
}
|
|
|
|
function editSchedule(id) {
|
|
const s = schedules.find(x => x.id === id);
|
|
if (!s) return;
|
|
document.getElementById('sched-modal-title').textContent = '스케줄 수정';
|
|
document.getElementById('sched-edit-id').value = s.id;
|
|
document.getElementById('sched-server-id').value = s.server_id || '';
|
|
document.getElementById('sched-template-id').value = s.template_id || '';
|
|
document.getElementById('sched-period-type').value = s.period_type || 'MONTHLY';
|
|
document.getElementById('sched-day-of-month').value = s.day_of_month || '';
|
|
document.getElementById('sched-time').value = s.scheduled_time || '09:00';
|
|
document.getElementById('sched-advance-days').value = s.advance_days ?? 1;
|
|
document.getElementById('sched-cron-expr').value = s.cron_expr || '';
|
|
toggleCronRow(s.period_type);
|
|
openModal('modal-schedule');
|
|
}
|
|
|
|
function openScheduleModal() {
|
|
document.getElementById('sched-modal-title').textContent = '스케줄 등록';
|
|
document.getElementById('sched-edit-id').value = '';
|
|
document.getElementById('sched-server-id').value = '';
|
|
document.getElementById('sched-template-id').value = '';
|
|
document.getElementById('sched-period-type').value = 'MONTHLY';
|
|
document.getElementById('sched-day-of-month').value = '';
|
|
document.getElementById('sched-time').value = '09:00';
|
|
document.getElementById('sched-advance-days').value = 1;
|
|
document.getElementById('sched-cron-expr').value = '';
|
|
toggleCronRow('MONTHLY');
|
|
openModal('modal-schedule');
|
|
}
|
|
|
|
document.getElementById('sched-period-type').addEventListener('change', function() {
|
|
toggleCronRow(this.value);
|
|
});
|
|
|
|
function toggleCronRow(period) {
|
|
document.getElementById('cron-row').style.display = period === 'CUSTOM' ? 'flex' : 'none';
|
|
}
|
|
|
|
async function saveSchedule() {
|
|
const editId = document.getElementById('sched-edit-id').value;
|
|
const body = {
|
|
server_id: parseInt(document.getElementById('sched-server-id').value) || null,
|
|
template_id: parseInt(document.getElementById('sched-template-id').value) || null,
|
|
period_type: document.getElementById('sched-period-type').value,
|
|
day_of_month: parseInt(document.getElementById('sched-day-of-month').value) || null,
|
|
scheduled_time: document.getElementById('sched-time').value,
|
|
advance_days: parseInt(document.getElementById('sched-advance-days').value) || 1,
|
|
cron_expr: document.getElementById('sched-cron-expr').value || null,
|
|
};
|
|
try {
|
|
let res;
|
|
if (editId) {
|
|
res = await apiFetch(`/api/pm/schedules/${editId}`, { method: 'PATCH', body: JSON.stringify(body) });
|
|
} else {
|
|
res = await apiFetch('/api/pm/schedules', { method: 'POST', body: JSON.stringify(body) });
|
|
}
|
|
if (res.ok) {
|
|
closeModal('modal-schedule');
|
|
showToast(editId ? '스케줄이 수정되었습니다.' : '스케줄이 등록되었습니다.', 'success');
|
|
await loadSchedules();
|
|
} else {
|
|
const d = await res.json().catch(()=>({}));
|
|
showToast('저장 실패: ' + (d.detail || res.status), 'error');
|
|
}
|
|
} catch(e) { showToast('오류: ' + e.message, 'error'); }
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
TIMETABLES (PM 결과)
|
|
════════════════════════════════════════ */
|
|
async function loadTimetables() {
|
|
try {
|
|
const res = await apiFetch('/api/timetable?work_type=PM&limit=50');
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
timetables = data.timetables || data.items || data || [];
|
|
const sel = document.getElementById('result-tt-select');
|
|
sel.innerHTML = '<option value="">타임테이블 선택...</option>';
|
|
timetables.forEach(t => {
|
|
const label = `[${t.status||''}] ${t.server_name||t.server?.hostname||'-'} — ${fmtDate(t.scheduled_at||t.created_at)}`;
|
|
sel.innerHTML += `<option value="${t.id}">${label}</option>`;
|
|
});
|
|
updateStats();
|
|
} catch(e) { console.warn('timetables load err', e); }
|
|
}
|
|
|
|
async function loadChecklist(ttId) {
|
|
currentTimetableId = ttId;
|
|
const area = document.getElementById('checklist-area');
|
|
if (!ttId) {
|
|
area.innerHTML = '<div class="empty-state"><div class="empty-icon">📋</div><div>타임테이블을 선택하면 체크리스트가 표시됩니다.</div></div>';
|
|
document.getElementById('btn-save-results').style.display = 'none';
|
|
document.getElementById('btn-dl-report').style.display = 'none';
|
|
return;
|
|
}
|
|
area.innerHTML = '<div style="padding:24px;color:var(--text-muted);text-align:center"><span class="loading-dots">체크리스트 로딩중</span></div>';
|
|
try {
|
|
// 초기화 시도 (이미 있으면 에러 무시)
|
|
await apiFetch(`/api/pm/results/${ttId}/init`, { method: 'POST' }).catch(()=>{});
|
|
const res = await apiFetch(`/api/pm/results/${ttId}`);
|
|
if (!res.ok) {
|
|
area.innerHTML = '<div class="empty-state"><div class="empty-icon">⚠️</div><div>체크리스트를 불러올 수 없습니다.</div></div>';
|
|
return;
|
|
}
|
|
checklistResults = await res.json();
|
|
renderChecklist(checklistResults);
|
|
document.getElementById('btn-save-results').style.display = '';
|
|
document.getElementById('btn-dl-report').style.display = '';
|
|
} catch(e) {
|
|
area.innerHTML = `<div class="empty-state"><div class="empty-icon">❌</div><div>오류: ${e.message}</div></div>`;
|
|
}
|
|
}
|
|
|
|
function renderChecklist(results) {
|
|
const area = document.getElementById('checklist-area');
|
|
if (!results || !results.length) {
|
|
area.innerHTML = '<div class="empty-state"><div class="empty-icon">📋</div><div>점검 항목이 없습니다.</div></div>';
|
|
return;
|
|
}
|
|
area.innerHTML = `
|
|
<div class="checklist-section">
|
|
<div class="checklist-header">
|
|
<span class="checklist-title">체크리스트 (${results.length}개 항목)</span>
|
|
<div style="display:flex;gap:6px">
|
|
${countBadge(results,'PASS','#4ade80')} ${countBadge(results,'FAIL','#f87171')} ${countBadge(results,'WARNING','#fcd34d')} ${countBadge(results,'NA','#6b7280')}
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x:auto">
|
|
<table class="checklist-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>점검 항목</th>
|
|
<th>명령어</th>
|
|
<th>기준값</th>
|
|
<th>결과</th>
|
|
<th>실제값</th>
|
|
<th>메모</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="checklist-tbody">
|
|
${results.map((r, i) => renderCheckRow(r, i+1)).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function countBadge(results, status, color) {
|
|
const cnt = results.filter(r => r.result_status === status).length;
|
|
return `<span style="font-size:11px;font-weight:600;color:${color}">${status}: ${cnt}</span>`;
|
|
}
|
|
|
|
function renderCheckRow(r, idx) {
|
|
const statusOpts = ['', 'PASS', 'FAIL', 'WARNING', 'NA'].map(s =>
|
|
`<option value="${s}" ${r.result_status===s?'selected':''}>${s||'미입력'}</option>`
|
|
).join('');
|
|
return `
|
|
<tr id="crow-${r.id}">
|
|
<td style="color:var(--text-muted)">${idx}</td>
|
|
<td style="font-weight:500">${escHtml(r.check_name || r.item_name || '-')}</td>
|
|
<td><code style="font-size:11px;color:var(--accent)">${escHtml(r.command || '-')}</code></td>
|
|
<td style="font-size:12px;color:var(--text-muted)">${escHtml(r.expected_value || '-')}</td>
|
|
<td>
|
|
<select class="result-select" id="rs-${r.id}" onchange="onResultChange(${r.id})">
|
|
${statusOpts}
|
|
</select>
|
|
</td>
|
|
<td><input class="actual-input" id="rv-${r.id}" type="text" value="${escAttr(r.actual_value||'')}" placeholder="실제값"></td>
|
|
<td><input class="note-input" id="rn-${r.id}" type="text" value="${escAttr(r.note||'')}" placeholder="메모"></td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
function onResultChange(id) {
|
|
const sel = document.getElementById(`rs-${id}`);
|
|
const row = document.getElementById(`crow-${id}`);
|
|
const colorMap = { PASS:'rgba(74,222,128,.08)', FAIL:'rgba(248,113,113,.08)', WARNING:'rgba(252,211,77,.08)', NA:'rgba(107,114,128,.05)', '':'' };
|
|
row.style.background = colorMap[sel.value] || '';
|
|
}
|
|
|
|
async function saveAllResults() {
|
|
if (!currentTimetableId || !checklistResults.length) return;
|
|
let saved = 0, failed = 0;
|
|
for (const r of checklistResults) {
|
|
const result_status = document.getElementById(`rs-${r.id}`)?.value || '';
|
|
const actual_value = document.getElementById(`rv-${r.id}`)?.value || '';
|
|
const note = document.getElementById(`rn-${r.id}`)?.value || '';
|
|
try {
|
|
const res = await apiFetch(`/api/pm/results/${r.id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ result_status, actual_value, note })
|
|
});
|
|
if (res.ok) saved++; else failed++;
|
|
} catch(e) { failed++; }
|
|
}
|
|
showToast(`저장 완료: ${saved}건 성공${failed ? `, ${failed}건 실패` : ''}`, failed ? 'error' : 'success');
|
|
if (saved > 0) loadChecklist(currentTimetableId);
|
|
}
|
|
|
|
function downloadReport() {
|
|
if (!currentTimetableId) return;
|
|
const a = document.createElement('a');
|
|
a.href = `/api/pm/results/${currentTimetableId}/report`;
|
|
a.download = `pm_report_${currentTimetableId}.xlsx`;
|
|
a.click();
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
TEMPLATES
|
|
════════════════════════════════════════ */
|
|
async function loadTemplates() {
|
|
try {
|
|
const res = await apiFetch('/api/pm/templates');
|
|
if (!res.ok) return;
|
|
templates = await res.json();
|
|
tmplFiltered = [...templates];
|
|
renderTemplates();
|
|
// 스케줄 모달 템플릿 select 업데이트
|
|
const sel = document.getElementById('sched-template-id');
|
|
sel.innerHTML = '<option value="">템플릿 선택...</option>';
|
|
templates.forEach(t => {
|
|
sel.innerHTML += `<option value="${t.id}">${t.name}</option>`;
|
|
});
|
|
} catch(e) { console.warn('templates load err', e); }
|
|
}
|
|
|
|
function renderTemplates() {
|
|
const container = document.getElementById('templates-list');
|
|
if (!tmplFiltered.length) {
|
|
container.innerHTML = '<div class="empty-state"><div class="empty-icon">📝</div><div>등록된 템플릿이 없습니다.</div></div>';
|
|
return;
|
|
}
|
|
container.innerHTML = tmplFiltered.map(t => `
|
|
<div class="template-card">
|
|
<div class="template-header">
|
|
<div>
|
|
<div class="template-name">${escHtml(t.name)}</div>
|
|
<div class="template-items">${escHtml(t.description || '')} | 역할: ${t.server_role || 'ALL'}</div>
|
|
</div>
|
|
<div style="display:flex;gap:6px">
|
|
<button class="btn-sm btn-secondary-sm" onclick="editTemplate(${t.id})">수정</button>
|
|
</div>
|
|
</div>
|
|
${(t.items || t.check_items || []).length ? `
|
|
<ul class="item-list">
|
|
${(t.items || t.check_items).map(item => `
|
|
<li class="item-row">
|
|
<span>${escHtml(item.name || item.check_name || '-')}</span>
|
|
<span class="item-cmd">${escHtml(item.command || '')}</span>
|
|
</li>
|
|
`).join('')}
|
|
</ul>
|
|
` : '<div style="font-size:12px;color:var(--text-muted);margin-top:6px">점검 항목이 없습니다.</div>'}
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function filterTemplates() {
|
|
const q = document.getElementById('tmpl-search').value.toLowerCase();
|
|
const role = document.getElementById('tmpl-role').value;
|
|
tmplFiltered = templates.filter(t => {
|
|
const nameMatch = (t.name||'').toLowerCase().includes(q);
|
|
const roleMatch = !role || t.server_role === role;
|
|
return nameMatch && roleMatch;
|
|
});
|
|
renderTemplates();
|
|
}
|
|
|
|
function openTemplateModal() {
|
|
document.getElementById('tmpl-modal-title').textContent = '템플릿 등록';
|
|
document.getElementById('tmpl-edit-id').value = '';
|
|
document.getElementById('tmpl-name').value = '';
|
|
document.getElementById('tmpl-desc').value = '';
|
|
document.getElementById('tmpl-server-role').value = 'ALL';
|
|
document.getElementById('check-items-container').innerHTML = '';
|
|
addCheckItem(); // 기본 1개
|
|
openModal('modal-template');
|
|
}
|
|
|
|
function editTemplate(id) {
|
|
const t = templates.find(x => x.id === id);
|
|
if (!t) return;
|
|
document.getElementById('tmpl-modal-title').textContent = '템플릿 수정';
|
|
document.getElementById('tmpl-edit-id').value = t.id;
|
|
document.getElementById('tmpl-name').value = t.name || '';
|
|
document.getElementById('tmpl-desc').value = t.description || '';
|
|
document.getElementById('tmpl-server-role').value = t.server_role || 'ALL';
|
|
const container = document.getElementById('check-items-container');
|
|
container.innerHTML = '';
|
|
const items = t.items || t.check_items || [];
|
|
if (items.length) {
|
|
items.forEach(item => addCheckItem(item));
|
|
} else {
|
|
addCheckItem();
|
|
}
|
|
openModal('modal-template');
|
|
}
|
|
|
|
let checkItemIdx = 0;
|
|
function addCheckItem(item = null) {
|
|
const idx = checkItemIdx++;
|
|
const container = document.getElementById('check-items-container');
|
|
const div = document.createElement('div');
|
|
div.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:6px;align-items:center';
|
|
div.innerHTML = `
|
|
<input type="text" class="ci-name" placeholder="점검 항목명" value="${escAttr(item?.name||item?.check_name||'')}" style="background:var(--input-bg);border:1px solid var(--border);border-radius:4px;padding:5px 8px;color:var(--text-primary);font-size:12px;outline:none">
|
|
<input type="text" class="ci-cmd" placeholder="명령어" value="${escAttr(item?.command||'')}" style="background:var(--input-bg);border:1px solid var(--border);border-radius:4px;padding:5px 8px;color:var(--text-primary);font-size:12px;font-family:monospace;outline:none">
|
|
<input type="text" class="ci-expected" placeholder="기준값" value="${escAttr(item?.expected_value||'')}" style="background:var(--input-bg);border:1px solid var(--border);border-radius:4px;padding:5px 8px;color:var(--text-primary);font-size:12px;outline:none">
|
|
<button type="button" onclick="this.closest('div').remove()" style="background:none;border:none;color:#f87171;cursor:pointer;font-size:16px;padding:0 4px">✕</button>
|
|
`;
|
|
container.appendChild(div);
|
|
}
|
|
|
|
async function saveTemplate() {
|
|
const editId = document.getElementById('tmpl-edit-id').value;
|
|
const name = document.getElementById('tmpl-name').value.trim();
|
|
if (!name) { showToast('템플릿명을 입력하세요.', 'error'); return; }
|
|
|
|
const itemRows = document.getElementById('check-items-container').querySelectorAll('div');
|
|
const items = [];
|
|
itemRows.forEach(row => {
|
|
const n = row.querySelector('.ci-name')?.value.trim();
|
|
const cmd = row.querySelector('.ci-cmd')?.value.trim();
|
|
const exp = row.querySelector('.ci-expected')?.value.trim();
|
|
if (n) items.push({ name: n, command: cmd, expected_value: exp });
|
|
});
|
|
|
|
const body = {
|
|
name,
|
|
description: document.getElementById('tmpl-desc').value,
|
|
server_role: document.getElementById('tmpl-server-role').value,
|
|
items,
|
|
};
|
|
try {
|
|
let res;
|
|
if (editId) {
|
|
res = await apiFetch(`/api/pm/templates/${editId}`, { method: 'PATCH', body: JSON.stringify(body) });
|
|
} else {
|
|
res = await apiFetch('/api/pm/templates', { method: 'POST', body: JSON.stringify(body) });
|
|
}
|
|
if (res.ok) {
|
|
closeModal('modal-template');
|
|
showToast(editId ? '템플릿이 수정되었습니다.' : '템플릿이 등록되었습니다.', 'success');
|
|
await loadTemplates();
|
|
} else {
|
|
const d = await res.json().catch(()=>({}));
|
|
showToast('저장 실패: ' + (d.detail || res.status), 'error');
|
|
}
|
|
} catch(e) { showToast('오류: ' + e.message, 'error'); }
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
STATS
|
|
════════════════════════════════════════ */
|
|
function updateStats() {
|
|
document.getElementById('stat-schedule-cnt').textContent = schedules.length;
|
|
const now = new Date();
|
|
const monthTTs = timetables.filter(t => {
|
|
const d = new Date(t.scheduled_at || t.created_at || '');
|
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
|
|
});
|
|
// 실제 pass/fail 집계는 결과 API에서 해야 하지만 타임테이블 status로 근사
|
|
const pass = monthTTs.filter(t => t.status === 'COMPLETED').length;
|
|
const fail = monthTTs.filter(t => t.status === 'FAILED' || t.status === 'FAIL').length;
|
|
const pending = monthTTs.filter(t => t.status === 'PENDING' || t.status === 'SCHEDULED').length;
|
|
document.getElementById('stat-pass-cnt').textContent = pass;
|
|
document.getElementById('stat-fail-cnt').textContent = fail;
|
|
document.getElementById('stat-pending-cnt').textContent = pending;
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
MODAL HELPERS
|
|
════════════════════════════════════════ */
|
|
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
|
document.querySelectorAll('.modal-overlay').forEach(el => {
|
|
el.addEventListener('click', e => { if (e.target === el) el.classList.remove('open'); });
|
|
});
|
|
|
|
/* ════════════════════════════════════════
|
|
TOAST
|
|
════════════════════════════════════════ */
|
|
let toastTimer;
|
|
function showToast(msg, type = '') {
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg;
|
|
el.className = 'toast show' + (type ? ' ' + type : '');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => el.classList.remove('show'), 3500);
|
|
}
|
|
|
|
/* ════════════════════════════════════════
|
|
UTILS
|
|
════════════════════════════════════════ */
|
|
function escHtml(s) {
|
|
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
function escAttr(s) {
|
|
return String(s ?? '').replace(/"/g, '"');
|
|
}
|
|
function fmtDate(d) {
|
|
if (!d) return '-';
|
|
const dt = new Date(d);
|
|
if (isNaN(dt)) return d;
|
|
return dt.toLocaleDateString('ko-KR', { year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' });
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|