721 lines
24 KiB
HTML
721 lines
24 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>DB 상태</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="/design-tokens.css?v=20260401-01">
|
||
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||
<style>
|
||
:root {
|
||
color-scheme: light;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0;
|
||
font-family: "Pretendard", sans-serif;
|
||
background:
|
||
radial-gradient(circle at top left, rgba(247, 217, 119, 0.28), transparent 30%),
|
||
linear-gradient(180deg, var(--ds-bg, #f5f1e8) 0%, #efe6d5 100%);
|
||
color: var(--ds-ink, #2f2419);
|
||
}
|
||
.page {
|
||
max-width: 2000px;
|
||
margin: 0 auto;
|
||
padding: 28px;
|
||
display: grid;
|
||
gap: 20px;
|
||
}
|
||
.hero {
|
||
display: grid;
|
||
gap: 12px;
|
||
padding: 28px 30px;
|
||
border: 1px solid rgba(134, 98, 47, 0.14);
|
||
border-radius: 28px;
|
||
background: linear-gradient(135deg, rgba(255, 250, 240, 0.96), rgba(242, 232, 214, 0.92));
|
||
box-shadow: 0 28px 68px rgba(88, 61, 23, 0.15);
|
||
}
|
||
.hero h1 {
|
||
margin: 0;
|
||
font-size: 30px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.hero p {
|
||
margin: 0;
|
||
color: rgba(76, 58, 35, 0.82);
|
||
line-height: 1.6;
|
||
}
|
||
.overview {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 14px;
|
||
}
|
||
.kpi {
|
||
padding: 18px 20px;
|
||
border-radius: 22px;
|
||
background: rgba(255, 252, 247, 0.92);
|
||
border: 1px solid rgba(140, 110, 59, 0.14);
|
||
box-shadow: 0 14px 34px rgba(81, 58, 23, 0.08);
|
||
}
|
||
.kpi-label {
|
||
display: block;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: rgba(112, 84, 41, 0.72);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.kpi-value {
|
||
display: block;
|
||
margin-top: 8px;
|
||
font-size: 28px;
|
||
font-weight: 800;
|
||
color: #3d2e1d;
|
||
}
|
||
.grid {
|
||
display: grid;
|
||
grid-template-columns: 1.4fr 1fr;
|
||
gap: 20px;
|
||
}
|
||
.panel {
|
||
border-radius: 24px;
|
||
background: rgba(255, 251, 245, 0.96);
|
||
border: 1px solid rgba(142, 110, 54, 0.14);
|
||
box-shadow: 0 18px 48px rgba(85, 60, 24, 0.08);
|
||
overflow: hidden;
|
||
}
|
||
.panel-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 18px 22px 14px;
|
||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||
}
|
||
.panel-head h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.panel-head p {
|
||
margin: 4px 0 0;
|
||
font-size: 13px;
|
||
color: rgba(102, 77, 41, 0.72);
|
||
}
|
||
.panel-body {
|
||
padding: 16px 18px 20px;
|
||
}
|
||
.panel-body.tight {
|
||
padding-top: 0;
|
||
}
|
||
.meta-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 11px;
|
||
border-radius: 999px;
|
||
background: rgba(251, 236, 196, 0.8);
|
||
color: #7a5923;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
th, td {
|
||
padding: 12px 10px;
|
||
vertical-align: top;
|
||
border-bottom: 1px solid rgba(130, 100, 53, 0.1);
|
||
text-align: left;
|
||
}
|
||
th {
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
color: rgba(104, 79, 40, 0.76);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
white-space: nowrap;
|
||
}
|
||
tbody tr:hover {
|
||
background: rgba(250, 240, 213, 0.34);
|
||
}
|
||
.domain-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 9px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
background: rgba(90, 122, 94, 0.14);
|
||
color: #456b4c;
|
||
}
|
||
.domain-tag.integration { background: rgba(196, 143, 58, 0.16); color: #8c5f18; }
|
||
.domain-tag.history { background: rgba(120, 92, 156, 0.14); color: #6a4b8b; }
|
||
.domain-tag.auth { background: rgba(103, 114, 154, 0.14); color: #48567c; }
|
||
.domain-tag.other { background: rgba(131, 112, 80, 0.12); color: #6a5637; }
|
||
.group-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 8px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.03em;
|
||
background: rgba(240, 231, 214, 0.95);
|
||
color: #674d27;
|
||
white-space: nowrap;
|
||
}
|
||
.group-tag.keep { background: rgba(112, 143, 87, 0.18); color: #49623c; }
|
||
.group-tag.caution { background: rgba(214, 167, 84, 0.18); color: #8f5d17; }
|
||
.group-tag.trace { background: rgba(113, 120, 168, 0.16); color: #56628c; }
|
||
.group-tag.cleanup { background: rgba(184, 111, 84, 0.16); color: #884d39; }
|
||
.table-title {
|
||
font-weight: 800;
|
||
color: #2f2419;
|
||
}
|
||
.table-trigger {
|
||
all: unset;
|
||
cursor: pointer;
|
||
color: #2f2419;
|
||
font-weight: 800;
|
||
}
|
||
.table-trigger:hover {
|
||
color: #80591f;
|
||
text-decoration: underline;
|
||
}
|
||
.table-desc {
|
||
margin-top: 5px;
|
||
color: rgba(98, 75, 42, 0.72);
|
||
line-height: 1.5;
|
||
}
|
||
.view-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.view-pill {
|
||
display: inline-flex;
|
||
padding: 4px 8px;
|
||
border-radius: 999px;
|
||
background: rgba(86, 119, 93, 0.12);
|
||
color: #456b4c;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
}
|
||
.notes {
|
||
margin: 0;
|
||
padding-left: 18px;
|
||
display: grid;
|
||
gap: 10px;
|
||
color: rgba(84, 65, 38, 0.84);
|
||
line-height: 1.55;
|
||
}
|
||
.preview-meta {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 16px 18px 0;
|
||
}
|
||
.preview-columns {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.column-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
border-radius: 999px;
|
||
background: rgba(240, 231, 214, 0.9);
|
||
color: #634a25;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.column-pill em {
|
||
font-style: normal;
|
||
color: rgba(99, 74, 37, 0.68);
|
||
font-weight: 600;
|
||
}
|
||
.preview-table-wrap {
|
||
overflow: auto;
|
||
max-height: 520px;
|
||
border-top: 1px solid rgba(128, 98, 48, 0.12);
|
||
}
|
||
.sticky-head th {
|
||
position: sticky;
|
||
top: 0;
|
||
background: rgba(255, 248, 236, 0.98);
|
||
z-index: 1;
|
||
}
|
||
.muted {
|
||
color: rgba(110, 86, 50, 0.72);
|
||
}
|
||
.empty {
|
||
padding: 22px;
|
||
text-align: center;
|
||
color: rgba(102, 77, 41, 0.72);
|
||
}
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
background: rgba(44, 31, 16, 0.42);
|
||
backdrop-filter: blur(6px);
|
||
z-index: 1000;
|
||
}
|
||
.modal-overlay.open {
|
||
display: flex;
|
||
}
|
||
.modal-card {
|
||
width: min(1600px, 100%);
|
||
max-height: min(88vh, 980px);
|
||
border-radius: 28px;
|
||
background: rgba(255, 250, 243, 0.98);
|
||
border: 1px solid rgba(142, 110, 54, 0.18);
|
||
box-shadow: 0 32px 80px rgba(59, 40, 16, 0.28);
|
||
overflow: hidden;
|
||
display: grid;
|
||
grid-template-rows: auto auto 1fr;
|
||
}
|
||
.modal-head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 22px 24px 16px;
|
||
border-bottom: 1px solid rgba(128, 98, 48, 0.12);
|
||
}
|
||
.modal-head h2 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
font-weight: 800;
|
||
letter-spacing: -0.03em;
|
||
}
|
||
.modal-close {
|
||
border: 0;
|
||
background: rgba(240, 229, 206, 0.9);
|
||
color: #6d5127;
|
||
width: 38px;
|
||
height: 38px;
|
||
border-radius: 999px;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
}
|
||
.modal-close:hover {
|
||
background: rgba(225, 208, 174, 0.96);
|
||
}
|
||
.modal-body {
|
||
overflow: hidden;
|
||
display: grid;
|
||
grid-template-rows: auto 1fr;
|
||
}
|
||
@media (max-width: 1200px) {
|
||
.grid { grid-template-columns: 1fr; }
|
||
}
|
||
@media (max-width: 720px) {
|
||
.page { padding: 16px; }
|
||
.hero { padding: 22px 20px; }
|
||
.kpi-value { font-size: 24px; }
|
||
th, td { padding: 10px 8px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<section class="hero">
|
||
<span class="meta-chip">#2 백엔드 영속 저장 구조 운영</span>
|
||
<h1>DB 상태와 저장 구조를 화면에서 바로 확인</h1>
|
||
<p>
|
||
이 화면은 현재 운영 DB의 핵심 테이블, 적재 상태, 최근 import 흐름을 SQL 없이 확인하기 위한 관리자용 뷰어입니다.
|
||
이후 저장 구조 검증과 데이터 정합성 작업은 이 화면을 기준으로 진행합니다.
|
||
</p>
|
||
<p>
|
||
`원본 import 배치`는 업로드한 원본 파일이 몇 행으로 적재됐는지 보여주고, `바이너리 원본 보관`은 엑셀 같은 파일 자체를 DB에 보관하는 상태를 보여줍니다.
|
||
</p>
|
||
</section>
|
||
|
||
<section id="overview" class="overview"></section>
|
||
|
||
<section class="grid">
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>전체 테이블 현황</h2>
|
||
<p>전체 27개 테이블을 보여주며, 테이블명을 누르면 샘플 row를 바로 확인할 수 있습니다.</p>
|
||
</div>
|
||
<span id="generated-at" class="meta-chip">로딩 중</span>
|
||
</div>
|
||
<div class="panel-body">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>도메인</th>
|
||
<th>테이블</th>
|
||
<th>Rows</th>
|
||
<th>최근 갱신</th>
|
||
<th>연결 화면</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="table-body">
|
||
<tr><td colspan="5" class="empty">DB 상태를 불러오는 중입니다.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
|
||
<div style="display:grid; gap:20px;">
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>원본 import 배치</h2>
|
||
<p>현재 적재된 원본 파일 배치와 row 수입니다.</p>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Source</th>
|
||
<th>Rows</th>
|
||
<th>Imported</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="batch-body">
|
||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>바이너리 원본 보관</h2>
|
||
<p>엑셀 같은 바이너리 원본의 DB 보관 상태입니다.</p>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Source</th>
|
||
<th>파일</th>
|
||
<th>크기</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="binary-body">
|
||
<tr><td colspan="3" class="empty">로딩 중</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>운영 메모</h2>
|
||
<p>#2에서 확인해야 할 저장 구조 핵심 포인트입니다.</p>
|
||
</div>
|
||
</div>
|
||
<div class="panel-body">
|
||
<ol id="notes" class="notes"></ol>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<div class="panel-head">
|
||
<div>
|
||
<h2>테이블 분류</h2>
|
||
<p>유지/주의/원본·추적/정리 후보로 나눈 현재 기준입니다.</p>
|
||
</div>
|
||
</div>
|
||
<div id="group-summary" class="panel-body"></div>
|
||
</article>
|
||
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="preview-modal" class="modal-overlay" aria-hidden="true">
|
||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="preview-title">
|
||
<div class="modal-head">
|
||
<div>
|
||
<h2 id="preview-title">테이블 내용 미리보기</h2>
|
||
<p id="preview-subtitle" class="muted">선택한 테이블의 컬럼과 최대 50개 row를 표시합니다.</p>
|
||
</div>
|
||
<button id="preview-close" class="modal-close" type="button" aria-label="닫기">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="preview-meta" class="preview-meta"></div>
|
||
<div class="panel-body tight">
|
||
<div class="preview-table-wrap">
|
||
<table>
|
||
<thead id="preview-head" class="sticky-head"></thead>
|
||
<tbody id="preview-body">
|
||
<tr><td class="empty">왼쪽 표에서 테이블을 선택하세요.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function formatNumber(value) {
|
||
return new Intl.NumberFormat("ko-KR").format(Number(value || 0));
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
if (!value) return '<span class="muted">-</span>';
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) return escapeHtml(value);
|
||
return parsed.toLocaleString("ko-KR", { hour12: false });
|
||
}
|
||
|
||
function formatBytes(value) {
|
||
const size = Number(value || 0);
|
||
if (size <= 0) return "0 B";
|
||
const units = ["B", "KB", "MB", "GB"];
|
||
let current = size;
|
||
let unit = 0;
|
||
while (current >= 1024 && unit < units.length - 1) {
|
||
current /= 1024;
|
||
unit += 1;
|
||
}
|
||
return `${current.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||
}
|
||
|
||
function renderOverview(overview) {
|
||
const target = document.getElementById("overview");
|
||
target.innerHTML = [
|
||
["핵심 테이블", overview.visible_tables],
|
||
["전체 테이블", overview.total_tables],
|
||
["등록 인원", overview.registered_members],
|
||
["재직 인원", overview.active_members],
|
||
["고정 오피스 도면", overview.fixed_office_maps],
|
||
["현재 active 도면", overview.active_seat_maps],
|
||
["Import 배치", overview.import_batches],
|
||
["바이너리 원본", overview.binary_sources],
|
||
].map(([label, value]) => `
|
||
<article class="kpi">
|
||
<span class="kpi-label">${escapeHtml(label)}</span>
|
||
<span class="kpi-value">${formatNumber(value)}</span>
|
||
</article>
|
||
`).join("");
|
||
}
|
||
|
||
function renderTables(items) {
|
||
const target = document.getElementById("table-body");
|
||
if (!items.length) {
|
||
target.innerHTML = '<tr><td colspan="5" class="empty">표시할 테이블이 없습니다.</td></tr>';
|
||
return;
|
||
}
|
||
target.innerHTML = items.map((item) => `
|
||
<tr>
|
||
<td><span class="domain-tag ${escapeHtml(item.domain)}">${escapeHtml(item.domain)}</span></td>
|
||
<td>
|
||
<div style="margin-bottom:8px;">
|
||
<span class="group-tag ${item.group === '유지' ? 'keep' : item.group === '원본·추적' ? 'trace' : item.group === '정리 후보' ? 'cleanup' : 'caution'}">${escapeHtml(item.group || '주의')}</span>
|
||
</div>
|
||
<button class="table-trigger" type="button" data-schema="${escapeHtml(item.schema)}" data-table="${escapeHtml(item.table_name)}">${escapeHtml(item.label)}</button>
|
||
<div class="muted">${escapeHtml(item.table_ref)}</div>
|
||
<div class="table-desc">${escapeHtml(item.description)}</div>
|
||
</td>
|
||
<td>${formatNumber(item.row_count)}</td>
|
||
<td>${formatDateTime(item.last_event_at)}</td>
|
||
<td>
|
||
<div class="view-list">
|
||
${(item.related_views || []).map((view) => `<span class="view-pill">${escapeHtml(view)}</span>`).join("")}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join("");
|
||
target.querySelectorAll(".table-trigger").forEach((button) => {
|
||
button.addEventListener("click", () => {
|
||
loadTablePreview(button.dataset.schema, button.dataset.table);
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderBatches(items) {
|
||
const target = document.getElementById("batch-body");
|
||
if (!items.length) {
|
||
target.innerHTML = '<tr><td colspan="3" class="empty">적재 배치가 없습니다.</td></tr>';
|
||
return;
|
||
}
|
||
target.innerHTML = items.map((item) => `
|
||
<tr>
|
||
<td>
|
||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||
</td>
|
||
<td>${formatNumber(item.row_count)}</td>
|
||
<td>${formatDateTime(item.imported_at)}</td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
|
||
function renderBinarySources(items) {
|
||
const target = document.getElementById("binary-body");
|
||
if (!items.length) {
|
||
target.innerHTML = '<tr><td colspan="3" class="empty">보관 중인 바이너리 원본이 없습니다.</td></tr>';
|
||
return;
|
||
}
|
||
target.innerHTML = items.map((item) => `
|
||
<tr>
|
||
<td>
|
||
<div class="table-title">${escapeHtml(item.source_name)}</div>
|
||
<div class="muted">${escapeHtml(item.source_key)}</div>
|
||
</td>
|
||
<td>${escapeHtml(item.filename)}</td>
|
||
<td>${formatBytes(item.byte_size)}</td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
|
||
function renderNotes(notes) {
|
||
const target = document.getElementById("notes");
|
||
target.innerHTML = (notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||
}
|
||
|
||
function renderGroupSummary(summary) {
|
||
const target = document.getElementById("group-summary");
|
||
const groups = [
|
||
["유지", "keep"],
|
||
["주의", "caution"],
|
||
["원본·추적", "trace"],
|
||
["정리 후보", "cleanup"],
|
||
];
|
||
target.innerHTML = groups.map(([label, klass]) => `
|
||
<div style="display:grid; gap:8px; margin-bottom:16px;">
|
||
<div><span class="group-tag ${klass}">${escapeHtml(label)}</span></div>
|
||
<div class="view-list">
|
||
${((summary && summary[label]) || []).map((item) => `<span class="view-pill">${escapeHtml(item)}</span>`).join("") || '<span class="muted">없음</span>'}
|
||
</div>
|
||
</div>
|
||
`).join("");
|
||
}
|
||
|
||
function renderTablePreview(payload) {
|
||
const previewModal = document.getElementById("preview-modal");
|
||
const previewMeta = document.getElementById("preview-meta");
|
||
const previewTitle = document.getElementById("preview-title");
|
||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||
const previewHead = document.getElementById("preview-head");
|
||
const previewBody = document.getElementById("preview-body");
|
||
|
||
previewTitle.textContent = `${payload.label} · ${payload.table_ref}`;
|
||
previewSubtitle.textContent = `${formatNumber(payload.row_count)} rows / 최대 ${formatNumber(payload.limit)}개 표시`;
|
||
previewMeta.innerHTML = `
|
||
<div>
|
||
<div class="table-title">${escapeHtml(payload.label)}</div>
|
||
<div class="muted">${escapeHtml(payload.description || "")}</div>
|
||
</div>
|
||
<div class="preview-columns">
|
||
${(payload.columns || []).map((column) => `
|
||
<span class="column-pill">${escapeHtml(column.name)} <em>${escapeHtml(column.type)}</em></span>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
|
||
const columns = payload.columns || [];
|
||
previewHead.innerHTML = `<tr>${columns.map((column) => `<th>${escapeHtml(column.name)}</th>`).join("")}</tr>`;
|
||
if (!payload.rows || !payload.rows.length) {
|
||
previewBody.innerHTML = `<tr><td colspan="${Math.max(columns.length, 1)}" class="empty">표시할 row가 없습니다.</td></tr>`;
|
||
previewModal.classList.add("open");
|
||
previewModal.setAttribute("aria-hidden", "false");
|
||
return;
|
||
}
|
||
previewBody.innerHTML = payload.rows.map((row) => `
|
||
<tr>
|
||
${columns.map((column) => `<td>${escapeHtml(row[column.name] ?? "")}</td>`).join("")}
|
||
</tr>
|
||
`).join("");
|
||
previewModal.classList.add("open");
|
||
previewModal.setAttribute("aria-hidden", "false");
|
||
}
|
||
|
||
async function loadTablePreview(schema, table) {
|
||
const previewModal = document.getElementById("preview-modal");
|
||
const previewMeta = document.getElementById("preview-meta");
|
||
const previewTitle = document.getElementById("preview-title");
|
||
const previewSubtitle = document.getElementById("preview-subtitle");
|
||
const previewHead = document.getElementById("preview-head");
|
||
const previewBody = document.getElementById("preview-body");
|
||
previewTitle.textContent = `${table}`;
|
||
previewSubtitle.textContent = "테이블 내용을 불러오는 중입니다.";
|
||
previewMeta.innerHTML = "";
|
||
previewHead.innerHTML = "";
|
||
previewBody.innerHTML = `<tr><td class="empty">테이블 내용을 불러오는 중입니다.</td></tr>`;
|
||
previewModal.classList.add("open");
|
||
previewModal.setAttribute("aria-hidden", "false");
|
||
const response = await fetch(`/api/admin/db-status/table?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(table)}`, { cache: "no-store" });
|
||
if (!response.ok) {
|
||
throw new Error(`테이블 내용을 불러오지 못했습니다. (${response.status})`);
|
||
}
|
||
const payload = await response.json();
|
||
renderTablePreview(payload);
|
||
}
|
||
|
||
async function bootstrap() {
|
||
const response = await fetch("/api/admin/db-status", { cache: "no-store" });
|
||
if (!response.ok) {
|
||
throw new Error(`DB 상태를 불러오지 못했습니다. (${response.status})`);
|
||
}
|
||
const payload = await response.json();
|
||
document.getElementById("generated-at").textContent = payload.generated_at
|
||
? `갱신 ${formatDateTime(payload.generated_at)}`
|
||
: "갱신 시각 없음";
|
||
renderOverview(payload.overview || {});
|
||
renderTables(payload.tables || []);
|
||
renderBatches(payload.import_batches || []);
|
||
renderBinarySources(payload.binary_sources || []);
|
||
renderNotes(payload.notes || []);
|
||
renderGroupSummary(payload.group_summary || {});
|
||
}
|
||
|
||
document.getElementById("preview-close").addEventListener("click", () => {
|
||
const modal = document.getElementById("preview-modal");
|
||
modal.classList.remove("open");
|
||
modal.setAttribute("aria-hidden", "true");
|
||
});
|
||
|
||
document.getElementById("preview-modal").addEventListener("click", (event) => {
|
||
if (event.target.id !== "preview-modal") return;
|
||
const modal = document.getElementById("preview-modal");
|
||
modal.classList.remove("open");
|
||
modal.setAttribute("aria-hidden", "true");
|
||
});
|
||
|
||
bootstrap().catch((error) => {
|
||
document.getElementById("table-body").innerHTML = `<tr><td colspan="5" class="empty">${escapeHtml(error.message || "DB 상태를 불러오지 못했습니다.")}</td></tr>`;
|
||
document.getElementById("batch-body").innerHTML = '<tr><td colspan="3" class="empty">배치 정보를 불러오지 못했습니다.</td></tr>';
|
||
document.getElementById("binary-body").innerHTML = '<tr><td colspan="3" class="empty">바이너리 원본 정보를 불러오지 못했습니다.</td></tr>';
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|