Files
MH-DashBoard-organization/frontend/apps/db-status/index.html

721 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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>