feat: add db status viewer and db cleanup baseline
This commit is contained in:
@@ -281,6 +281,19 @@ CREATE TABLE IF NOT EXISTS integration_vouchers (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS integration_binary_sources (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
source_key TEXT NOT NULL UNIQUE,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL DEFAULT '',
|
||||||
|
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||||||
|
content BYTEA NOT NULL,
|
||||||
|
content_sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS history_revisions (
|
CREATE TABLE IF NOT EXISTS history_revisions (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
scope TEXT NOT NULL DEFAULT 'organization',
|
scope TEXT NOT NULL DEFAULT 'organization',
|
||||||
@@ -329,18 +342,6 @@ CREATE TABLE IF NOT EXISTS seat_assignment_versions (
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entity_change_events (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
entity_type TEXT NOT NULL,
|
|
||||||
entity_id BIGINT NOT NULL,
|
|
||||||
action_type TEXT NOT NULL,
|
|
||||||
revision_no BIGINT NOT NULL,
|
|
||||||
changed_by_user_id BIGINT,
|
|
||||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
change_reason TEXT NOT NULL DEFAULT '',
|
|
||||||
patch_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE SCHEMA IF NOT EXISTS auth;
|
CREATE SCHEMA IF NOT EXISTS auth;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS auth.users (
|
CREATE TABLE IF NOT EXISTS auth.users (
|
||||||
@@ -534,6 +535,9 @@ ON integration_vouchers (project_code, project_name);
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
|
||||||
ON integration_project_category_mappings (source_key, normalized_project_key);
|
ON integration_project_category_mappings (source_key, normalized_project_key);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_binary_sources_source_key_idx
|
||||||
|
ON integration_binary_sources (source_key);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
|
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
|
||||||
ON member_versions (member_id, valid_from, valid_to);
|
ON member_versions (member_id, valid_from, valid_to);
|
||||||
|
|
||||||
@@ -543,9 +547,6 @@ ON seat_assignment_versions (member_id, valid_from, valid_to);
|
|||||||
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
|
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
|
||||||
ON history_revisions (scope, created_at DESC);
|
ON history_revisions (scope, created_at DESC);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS entity_change_events_entity_idx
|
|
||||||
ON entity_change_events (entity_type, entity_id, changed_at DESC);
|
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -611,6 +612,9 @@ ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_id BIGINT NULL R
|
|||||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT;
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT;
|
||||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET;
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET;
|
||||||
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS entity_change_events_entity_idx;
|
||||||
|
DROP TABLE IF EXISTS entity_change_events;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ LEGACY_STATIC_DIR = LEGACY_DIR / "static"
|
|||||||
INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
|
INCOMING_FILES_DIR = BASE_DIR / "incoming-files"
|
||||||
INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served"
|
INCOMING_SERVED_DIR = INCOMING_FILES_DIR / "served"
|
||||||
INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference"
|
INCOMING_REFERENCE_DIR = INCOMING_FILES_DIR / "reference"
|
||||||
|
DB_STATUS_SERVED_DIR = INCOMING_SERVED_DIR / "db-status"
|
||||||
BUSINESS_DASHBOARD_DIR = INCOMING_FILES_DIR / "사업관리대장"
|
BUSINESS_DASHBOARD_DIR = INCOMING_FILES_DIR / "사업관리대장"
|
||||||
BUSINESS_LEDGER_SERVED_DIR = INCOMING_SERVED_DIR / "ledger"
|
BUSINESS_LEDGER_SERVED_DIR = INCOMING_SERVED_DIR / "ledger"
|
||||||
BUSINESS_LEDGER_INDEX_PATH = BUSINESS_LEDGER_SERVED_DIR / "index.html"
|
BUSINESS_LEDGER_INDEX_PATH = BUSINESS_LEDGER_SERVED_DIR / "index.html"
|
||||||
@@ -88,7 +89,157 @@ MH_HEADER_ORDER = [
|
|||||||
"사업 종류", "연장근무 프로젝트 코드", "연장근무 프로젝트명", "연장근무 서브코드", "연장근무 시간(실제)", "연장근무 시간(가공)"
|
"사업 종류", "연장근무 프로젝트 코드", "연장근무 프로젝트명", "연장근무 서브코드", "연장근무 시간(실제)", "연장근무 시간(가공)"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DB_STATUS_TABLES = [
|
||||||
|
{
|
||||||
|
"table_ref": "public.members",
|
||||||
|
"label": "구성원 마스터",
|
||||||
|
"domain": "organization",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["조직 현황", "자리배치도"],
|
||||||
|
"description": "조직/구성원 화면의 기준이 되는 현재 인원 마스터",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.member_versions",
|
||||||
|
"label": "구성원 이력",
|
||||||
|
"domain": "history",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["조직 현황", "이력 비교"],
|
||||||
|
"description": "as-of 조회와 변경 이력을 위한 시점 버전",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.seat_maps",
|
||||||
|
"label": "자리배치도 도면",
|
||||||
|
"domain": "seatmap",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["자리배치도"],
|
||||||
|
"description": "오피스별 도면 메타데이터와 활성 상태",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.seat_positions",
|
||||||
|
"label": "현재 좌석 배치",
|
||||||
|
"domain": "seatmap",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["자리배치도"],
|
||||||
|
"description": "현재 인원의 실제 배치 좌표/슬롯 연결",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.seat_assignment_versions",
|
||||||
|
"label": "좌석 배치 이력",
|
||||||
|
"domain": "history",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["자리배치도", "이력 비교"],
|
||||||
|
"description": "자리 이동 이력과 시점 조회용 배치 버전",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_import_batches",
|
||||||
|
"label": "원본 업로드 배치",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "imported_at",
|
||||||
|
"related_views": ["프로젝트별 분석", "팀/개인별 분석", "조직 현황"],
|
||||||
|
"description": "원본 파일 적재 단위와 최근 import 기록",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_projects",
|
||||||
|
"label": "통합 프로젝트 표준화",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["프로젝트별 분석", "팀/개인별 분석"],
|
||||||
|
"description": "프로젝트 코드/이름/카테고리 정규화 결과",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_work_logs",
|
||||||
|
"label": "근무 로그 표준화",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["팀/개인별 분석"],
|
||||||
|
"description": "MH workbook 기준 일자별 근무 로그 본체",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_work_log_segments",
|
||||||
|
"label": "근무 로그 세그먼트",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["팀/개인별 분석"],
|
||||||
|
"description": "근무 로그를 프로젝트/활동 기준으로 분해한 상세 세그먼트",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_vouchers",
|
||||||
|
"label": "전표 표준화",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["프로젝트별 분석"],
|
||||||
|
"description": "payment CSV 기준 프로젝트별 수입/지출 전표",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "public.integration_binary_sources",
|
||||||
|
"label": "바이너리 원본 보관",
|
||||||
|
"domain": "integration",
|
||||||
|
"timestamp_column": "imported_at",
|
||||||
|
"related_views": ["사업관리대장"],
|
||||||
|
"description": "엑셀/바이너리 원본을 DB에 보관하는 저장소",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "auth.users",
|
||||||
|
"label": "인증 사용자",
|
||||||
|
"domain": "auth",
|
||||||
|
"timestamp_column": "updated_at",
|
||||||
|
"related_views": ["로그인", "권한"],
|
||||||
|
"description": "로그인 계정, role, 활성 상태",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "auth.sessions",
|
||||||
|
"label": "인증 세션",
|
||||||
|
"domain": "auth",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["로그인", "권한"],
|
||||||
|
"description": "현재/과거 로그인 세션과 만료 상태",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table_ref": "auth.login_audit_logs",
|
||||||
|
"label": "로그인 감사 로그",
|
||||||
|
"domain": "auth",
|
||||||
|
"timestamp_column": "created_at",
|
||||||
|
"related_views": ["로그인", "권한"],
|
||||||
|
"description": "로그인 성공/실패 기록",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DB_STATUS_TABLE_META = {str(item["table_ref"]): item for item in DB_STATUS_TABLES}
|
||||||
|
DB_STATUS_TABLE_GROUPS = {
|
||||||
|
"public.members": "유지",
|
||||||
|
"public.member_versions": "유지",
|
||||||
|
"public.seat_maps": "유지",
|
||||||
|
"public.seat_positions": "유지",
|
||||||
|
"public.seat_slots": "유지",
|
||||||
|
"public.seat_assignment_versions": "유지",
|
||||||
|
"public.history_revisions": "유지",
|
||||||
|
"public.integration_import_batches": "유지",
|
||||||
|
"public.integration_projects": "유지",
|
||||||
|
"public.integration_work_logs": "유지",
|
||||||
|
"public.integration_work_log_segments": "유지",
|
||||||
|
"public.integration_vouchers": "유지",
|
||||||
|
"public.integration_binary_sources": "유지",
|
||||||
|
"auth.users": "유지",
|
||||||
|
"auth.sessions": "유지",
|
||||||
|
"auth.login_audit_logs": "유지",
|
||||||
|
"public.member_overrides": "주의",
|
||||||
|
"public.member_retirements": "주의",
|
||||||
|
"public.member_aliases": "주의",
|
||||||
|
"public.integration_project_aliases": "주의",
|
||||||
|
"public.integration_project_category_mappings": "주의",
|
||||||
|
"public.integration_project_pm_assignments": "주의",
|
||||||
|
"public.integration_raw_organization_rows": "원본·추적",
|
||||||
|
"public.integration_raw_mh_rows": "원본·추적",
|
||||||
|
"public.integration_raw_mh_pm_rows": "원본·추적",
|
||||||
|
"public.integration_raw_payment_rows": "원본·추적",
|
||||||
|
}
|
||||||
|
|
||||||
def sync_default_business_ledger_source(cur) -> None:
|
def sync_default_business_ledger_source(cur) -> None:
|
||||||
|
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
|
||||||
|
row = cur.fetchone()
|
||||||
|
table_exists = bool(row["table_exists"]) if row is not None else False
|
||||||
|
if not table_exists:
|
||||||
|
return
|
||||||
candidates = [
|
candidates = [
|
||||||
BUSINESS_LEDGER_SERVED_DIR / "사업관리대장-1.xlsx",
|
BUSINESS_LEDGER_SERVED_DIR / "사업관리대장-1.xlsx",
|
||||||
BUSINESS_DASHBOARD_DIR / "사업관리대장-1.xlsx",
|
BUSINESS_DASHBOARD_DIR / "사업관리대장-1.xlsx",
|
||||||
@@ -137,6 +288,250 @@ def sync_default_business_ledger_source(cur) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_json_safe(value: object) -> object:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.isoformat()
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return f"<{len(value)} bytes>"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {str(key): make_json_safe(val) for key, val in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [make_json_safe(item) for item in value]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_db_status_snapshot() -> dict[str, object]:
|
||||||
|
table_items: list[dict[str, object]] = []
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT schemaname, tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname IN ('public', 'auth')
|
||||||
|
ORDER BY schemaname, tablename
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
all_tables = cur.fetchall()
|
||||||
|
for row in all_tables:
|
||||||
|
schema_name = str(row["schemaname"])
|
||||||
|
table_name = str(row["tablename"])
|
||||||
|
table_ref = f"{schema_name}.{table_name}"
|
||||||
|
spec = DB_STATUS_TABLE_META.get(table_ref, {})
|
||||||
|
cur.execute("SELECT to_regclass(%s) IS NOT NULL AS table_exists", (table_ref,))
|
||||||
|
exists_row = cur.fetchone()
|
||||||
|
exists = bool(exists_row["table_exists"]) if exists_row is not None else False
|
||||||
|
row_count = 0
|
||||||
|
last_event_at = None
|
||||||
|
if exists:
|
||||||
|
timestamp_column = str(spec.get("timestamp_column") or "")
|
||||||
|
query = f"SELECT COUNT(*)::bigint AS row_count"
|
||||||
|
if timestamp_column:
|
||||||
|
query += f", MAX({timestamp_column}) AS last_event_at"
|
||||||
|
else:
|
||||||
|
query += ", NULL::timestamptz AS last_event_at"
|
||||||
|
query += f" FROM {schema_name}.{table_name}"
|
||||||
|
cur.execute(query)
|
||||||
|
metric_row = cur.fetchone() or {}
|
||||||
|
row_count = int(metric_row.get("row_count") or 0)
|
||||||
|
last_event_at = metric_row.get("last_event_at")
|
||||||
|
table_items.append(
|
||||||
|
{
|
||||||
|
"table_ref": table_ref,
|
||||||
|
"schema": schema_name,
|
||||||
|
"table_name": table_name,
|
||||||
|
"label": str(spec.get("label") or table_name),
|
||||||
|
"domain": str(spec.get("domain") or "other"),
|
||||||
|
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
|
||||||
|
"related_views": spec.get("related_views") or [],
|
||||||
|
"group": DB_STATUS_TABLE_GROUPS.get(table_ref, "주의"),
|
||||||
|
"exists": exists,
|
||||||
|
"row_count": row_count,
|
||||||
|
"last_event_at": last_event_at.isoformat() if last_event_at else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_key, source_name, row_count, source_path, imported_at
|
||||||
|
FROM integration_import_batches
|
||||||
|
ORDER BY imported_at DESC, id DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
import_batches = [
|
||||||
|
{
|
||||||
|
"source_key": str(row["source_key"] or ""),
|
||||||
|
"source_name": str(row["source_name"] or ""),
|
||||||
|
"row_count": int(row["row_count"] or 0),
|
||||||
|
"source_path": str(row["source_path"] or ""),
|
||||||
|
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
|
||||||
|
}
|
||||||
|
for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
binary_sources: list[dict[str, object]] = []
|
||||||
|
cur.execute("SELECT to_regclass('public.integration_binary_sources') IS NOT NULL AS table_exists")
|
||||||
|
binary_exists_row = cur.fetchone()
|
||||||
|
binary_exists = bool(binary_exists_row["table_exists"]) if binary_exists_row is not None else False
|
||||||
|
if binary_exists:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_key, source_name, filename, mime_type, OCTET_LENGTH(content) AS byte_size,
|
||||||
|
content_sha256, imported_at
|
||||||
|
FROM integration_binary_sources
|
||||||
|
ORDER BY imported_at DESC, id DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
binary_sources = [
|
||||||
|
{
|
||||||
|
"source_key": str(row["source_key"] or ""),
|
||||||
|
"source_name": str(row["source_name"] or ""),
|
||||||
|
"filename": str(row["filename"] or ""),
|
||||||
|
"mime_type": str(row["mime_type"] or ""),
|
||||||
|
"byte_size": int(row["byte_size"] or 0),
|
||||||
|
"content_sha256": str(row["content_sha256"] or ""),
|
||||||
|
"imported_at": row["imported_at"].isoformat() if row.get("imported_at") else None,
|
||||||
|
}
|
||||||
|
for row in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::bigint AS total_members,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE COALESCE(BTRIM(work_status), '') NOT IN ('퇴직', '휴직')
|
||||||
|
)::bigint AS active_members
|
||||||
|
FROM members
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
member_row = cur.fetchone() or {}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::bigint AS active_seat_maps
|
||||||
|
FROM seat_maps
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
seat_map_row = cur.fetchone() or {}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)::bigint AS fixed_office_maps
|
||||||
|
FROM seat_maps
|
||||||
|
WHERE source_type = 'fixed_html'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
fixed_office_row = cur.fetchone() or {}
|
||||||
|
|
||||||
|
overview = {
|
||||||
|
"visible_tables": len(DB_STATUS_TABLES),
|
||||||
|
"total_tables": len(table_items),
|
||||||
|
"existing_tables": sum(1 for item in table_items if item["exists"]),
|
||||||
|
"registered_members": int(member_row.get("total_members") or 0),
|
||||||
|
"active_members": int(member_row.get("active_members") or 0),
|
||||||
|
"active_seat_maps": int(seat_map_row.get("active_seat_maps") or 0),
|
||||||
|
"fixed_office_maps": int(fixed_office_row.get("fixed_office_maps") or 0),
|
||||||
|
"import_batches": len(import_batches),
|
||||||
|
"binary_sources": len(binary_sources),
|
||||||
|
}
|
||||||
|
group_summary = {
|
||||||
|
"유지": [item["table_ref"] for item in table_items if item["group"] == "유지"],
|
||||||
|
"주의": [item["table_ref"] for item in table_items if item["group"] == "주의"],
|
||||||
|
"원본·추적": [item["table_ref"] for item in table_items if item["group"] == "원본·추적"],
|
||||||
|
"정리 후보": [item["table_ref"] for item in table_items if item["group"] == "정리 후보"],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"overview": overview,
|
||||||
|
"tables": table_items,
|
||||||
|
"import_batches": import_batches,
|
||||||
|
"binary_sources": binary_sources,
|
||||||
|
"group_summary": group_summary,
|
||||||
|
"notes": [
|
||||||
|
"members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.",
|
||||||
|
"member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.",
|
||||||
|
"integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.",
|
||||||
|
"integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.",
|
||||||
|
"재직 인원은 work_status 값이 '퇴직' 또는 '휴직'이 아닌 구성원 기준입니다.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_db_table_preview(schema_name: str, table_name: str, limit: int = 50) -> dict[str, object]:
|
||||||
|
if schema_name not in {"public", "auth"}:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown schema.")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = %s
|
||||||
|
AND tablename = %s
|
||||||
|
""",
|
||||||
|
(schema_name, table_name),
|
||||||
|
)
|
||||||
|
exists_row = cur.fetchone()
|
||||||
|
if exists_row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown table.")
|
||||||
|
|
||||||
|
table_ref = f"{schema_name}.{table_name}"
|
||||||
|
spec = DB_STATUS_TABLE_META.get(table_ref, {})
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = %s
|
||||||
|
AND table_name = %s
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
""",
|
||||||
|
(schema_name, table_name),
|
||||||
|
)
|
||||||
|
columns = [{"name": str(row["column_name"]), "type": str(row["data_type"])} for row in cur.fetchall()]
|
||||||
|
|
||||||
|
cur.execute(f"SELECT COUNT(*)::bigint AS row_count FROM {schema_name}.{table_name}")
|
||||||
|
row_count = int((cur.fetchone() or {}).get("row_count") or 0)
|
||||||
|
|
||||||
|
safe_limit = max(1, min(int(limit), 50))
|
||||||
|
cur.execute(f"SELECT * FROM {schema_name}.{table_name} LIMIT {safe_limit}")
|
||||||
|
rows = [make_json_safe(dict(row)) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"table_ref": table_ref,
|
||||||
|
"schema": schema_name,
|
||||||
|
"table_name": table_name,
|
||||||
|
"label": str(spec.get("label") or table_name),
|
||||||
|
"domain": str(spec.get("domain") or "other"),
|
||||||
|
"description": str(spec.get("description") or "세부 보조/원본/운영 테이블"),
|
||||||
|
"related_views": spec.get("related_views") or [],
|
||||||
|
"row_count": row_count,
|
||||||
|
"limit": safe_limit,
|
||||||
|
"columns": columns,
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
"overview": overview,
|
||||||
|
"tables": table_items,
|
||||||
|
"import_batches": import_batches,
|
||||||
|
"binary_sources": binary_sources,
|
||||||
|
"notes": [
|
||||||
|
"members / seat_positions / seat_maps 는 현재 운영 상태를 나타냅니다.",
|
||||||
|
"member_versions / seat_assignment_versions / history_revisions 는 시점 조회와 변경 이력을 위한 테이블입니다.",
|
||||||
|
"integration_raw_* / integration_* 는 원본 적재와 표준화 결과를 분리해서 보관합니다.",
|
||||||
|
"integration_binary_sources 는 사업관리대장 같은 바이너리 원본 보관용입니다.",
|
||||||
|
"재직 인원은 work_status 값이 '퇴직' 또는 '휴직'이 아닌 구성원 기준입니다.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
app.mount(
|
app.mount(
|
||||||
"/integrations/ledger-assets",
|
"/integrations/ledger-assets",
|
||||||
StaticFiles(directory=str(BUSINESS_LEDGER_SERVED_DIR), check_dir=False),
|
StaticFiles(directory=str(BUSINESS_LEDGER_SERVED_DIR), check_dir=False),
|
||||||
@@ -4001,6 +4396,27 @@ def health() -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/db-status")
|
||||||
|
def admin_db_status() -> dict[str, object]:
|
||||||
|
return fetch_db_status_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/db-status/table")
|
||||||
|
def admin_db_status_table(schema: str, table: str, limit: int = 50) -> dict[str, object]:
|
||||||
|
return fetch_db_table_preview(schema, table, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/db-status")
|
||||||
|
def admin_db_status_view() -> FileResponse:
|
||||||
|
target = DB_STATUS_SERVED_DIR / "index.html"
|
||||||
|
if not target.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="DB status dashboard file not found.")
|
||||||
|
response = FileResponse(target)
|
||||||
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/integration/business-ledger-default")
|
@app.get("/api/integration/business-ledger-default")
|
||||||
def integration_business_ledger_default() -> Response:
|
def integration_business_ledger_default() -> Response:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
@@ -53,12 +53,15 @@
|
|||||||
- [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
|
- [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css)
|
||||||
- 디자인 기준 문서:
|
- 디자인 기준 문서:
|
||||||
- [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md)
|
- [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md)
|
||||||
|
- DB 테이블 분류 기준 문서:
|
||||||
|
- [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md)
|
||||||
- 로그인 기본 스타일은 [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css) 기준으로 유지
|
- 로그인 기본 스타일은 [frontend/public/styles.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles.css) 기준으로 유지
|
||||||
- `8081` 허브 전용 디자인은 [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)에서만 덮어씀
|
- `8081` 허브 전용 디자인은 [frontend/public/styles-8081-design.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/styles-8081-design.css)에서만 덮어씀
|
||||||
- 조직현황은 [legacy/static/common.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/common.css), [legacy/static/organization.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.css), [legacy/static/organization.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.js)를 사용
|
- 조직현황은 [legacy/static/common.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/common.css), [legacy/static/organization.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.css), [legacy/static/organization.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/legacy/static/organization.js)를 사용
|
||||||
- 프로젝트별 분석 디자인은 [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html) 내부에서 `design-tokens.css` + `design-patterns.css`를 참조
|
- 프로젝트별 분석 디자인은 [incoming-files/served/payment.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/payment.html) 내부에서 `design-tokens.css` + `design-patterns.css`를 참조
|
||||||
- 프로젝트별 분석 수정 원본은 [frontend/apps/payment/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/payment/index.html) 이고, 반영은 [scripts/publish_payment_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_payment_app.sh)로 한다.
|
- 프로젝트별 분석 수정 원본은 [frontend/apps/payment/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/payment/index.html) 이고, 반영은 [scripts/publish_payment_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_payment_app.sh)로 한다.
|
||||||
- 팀/개인별 분석 수정 원본은 [frontend/apps/team/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/team/index.html) 이고, 반영은 [scripts/publish_team_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_team_app.sh)로 한다.
|
- 팀/개인별 분석 수정 원본은 [frontend/apps/team/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/team/index.html) 이고, 반영은 [scripts/publish_team_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_team_app.sh)로 한다.
|
||||||
|
- DB 상태 화면 수정 원본은 [frontend/apps/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/db-status/index.html) 이고, 반영은 [scripts/publish_db_status_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_db_status_app.sh)로 한다.
|
||||||
- 사업관리대장 실제 서비스 코드는 [incoming-files/served/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger) 기준으로 본다.
|
- 사업관리대장 실제 서비스 코드는 [incoming-files/served/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger) 기준으로 본다.
|
||||||
- 사업관리대장 앱 소스 기준은 [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger) 이고, 반영은 [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)로 한다.
|
- 사업관리대장 앱 소스 기준은 [frontend/apps/ledger](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger) 이고, 반영은 [scripts/publish_ledger_app.sh](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/scripts/publish_ledger_app.sh)로 한다.
|
||||||
- 사업관리대장 상세 팝업 디자인 수정 원본은 [frontend/apps/ledger/assets/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger/assets/ledger-override.js) 기준으로 본다.
|
- 사업관리대장 상세 팝업 디자인 수정 원본은 [frontend/apps/ledger/assets/ledger-override.js](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/apps/ledger/assets/ledger-override.js) 기준으로 본다.
|
||||||
@@ -106,6 +109,8 @@
|
|||||||
- [incoming-files/served/ledger/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger/index.html)
|
- [incoming-files/served/ledger/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/ledger/index.html)
|
||||||
- `/integrations/mh`:
|
- `/integrations/mh`:
|
||||||
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
|
- [incoming-files/served/mh.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/mh.html)
|
||||||
|
- `/admin/db-status`:
|
||||||
|
- [incoming-files/served/db-status/index.html](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/incoming-files/served/db-status/index.html)
|
||||||
|
|
||||||
## Cross Checks Last Confirmed
|
## Cross Checks Last Confirmed
|
||||||
|
|
||||||
@@ -126,20 +131,18 @@
|
|||||||
|
|
||||||
## Open Issues Relevant Now
|
## Open Issues Relevant Now
|
||||||
|
|
||||||
|
- `#2` 백엔드 영속 저장 구조 운영 마무리
|
||||||
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
|
- `#14` 누적된 임시 로직 정리 및 중복 코드 제거
|
||||||
- `#16` 사업관리대장 메인 연동 및 기본 원본 DB화
|
- `#16` 사업관리대장 메인 후속 정리 및 기준 분석
|
||||||
- `#17` 8081 분리 worktree 기동 절차와 로컬 디자인 자산 복제 고정
|
- `#19` 8081 백엔드 라우터/서빙 deeper 모듈 분리
|
||||||
- `#18` 8081 파일 책임 맵 정리 및 프런트 서빙 경로 정돈
|
- `#21` organization 레거시 구조 승격 및 장기 고도화
|
||||||
- `#19` 8081 백엔드 라우터/서빙 책임 분리
|
|
||||||
- `#20` 8081 worktree 준비 스크립트·문서·운영 규칙 정리
|
|
||||||
- `#21` reference 의존 제거 및 8081 실제 서비스 코드 독립화
|
|
||||||
|
|
||||||
## Recommended Next Work Order
|
## Recommended Next Work Order
|
||||||
|
|
||||||
1. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
|
1. `#2` 기준으로 DB 상태 화면과 저장 구조 검증 흐름 고도화
|
||||||
2. 사업관리대장 세부 데이터 정합성 보정
|
2. `#21` 이후 기준으로 실제 서비스 파일과 reference 파일 경계를 유지
|
||||||
3. 그 다음 화면별 앱 구조 승격 검토
|
3. 사업관리대장 세부 데이터 정합성 보정은 원본 규칙 분석 후 진행
|
||||||
4. 필요 시 `#19`, `#20` 잔여 정리 항목 재평가
|
4. 필요 시 `#19` 잔여 정리 항목 재평가
|
||||||
|
|
||||||
## Quick Resume Prompt
|
## Quick Resume Prompt
|
||||||
|
|
||||||
@@ -150,4 +153,6 @@
|
|||||||
- 먼저 [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md), [NEXT_SESSION_CHECKPOINT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/NEXT_SESSION_CHECKPOINT.md), [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md) 확인
|
- 먼저 [WORK_RULEBOOK.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/WORK_RULEBOOK.md), [NEXT_SESSION_CHECKPOINT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/NEXT_SESSION_CHECKPOINT.md), [architecture/8081_SERVING_MAP.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/8081_SERVING_MAP.md) 확인
|
||||||
- 디자인 수정이면 [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css), [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css), [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md) 먼저 확인
|
- 디자인 수정이면 [frontend/public/design-tokens.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-tokens.css), [frontend/public/design-patterns.css](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/frontend/public/design-patterns.css), [architecture/DESIGN_SSOT.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DESIGN_SSOT.md) 먼저 확인
|
||||||
- 현재 구조 독립화 기준 이슈는 `#21`
|
- 현재 구조 독립화 기준 이슈는 `#21`
|
||||||
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`를 먼저 확인
|
- `#2` 기준 DB 상태 확인은 `/api/admin/db-status` 또는 허브의 `DB 상태` 탭을 먼저 본다.
|
||||||
|
- DB 테이블 유지/주의/원본·추적/정리 후보 분류는 [architecture/DB_TABLE_CATALOG.md](/home/hyunho/projects/mh-dashboard-organization/.dev-worktree-8081/docs/architecture/DB_TABLE_CATALOG.md) 기준으로 본다.
|
||||||
|
- 작업 전 `git status`, dev 컨테이너 상태, `/api/health`, `/legacy/organization`, `/integrations/payment`, `/integrations/ledger`, `/integrations/mh`, `/api/admin/db-status`를 먼저 확인
|
||||||
|
|||||||
@@ -47,6 +47,10 @@
|
|||||||
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
|
- 현재 실제 서빙 파일: `incoming-files/served/mh.html`
|
||||||
- 앱 소스 기준: `frontend/apps/team/index.html`
|
- 앱 소스 기준: `frontend/apps/team/index.html`
|
||||||
- publish 규칙: `scripts/publish_team_app.sh`
|
- publish 규칙: `scripts/publish_team_app.sh`
|
||||||
|
- URL: `/admin/db-status`
|
||||||
|
- 현재 실제 서빙 파일: `incoming-files/served/db-status/index.html`
|
||||||
|
- 앱 소스 기준: `frontend/apps/db-status/index.html`
|
||||||
|
- publish 규칙: `scripts/publish_db_status_app.sh`
|
||||||
|
|
||||||
정리 원칙:
|
정리 원칙:
|
||||||
|
|
||||||
@@ -109,4 +113,5 @@
|
|||||||
- 로그인은 `styles.css`만 본다.
|
- 로그인은 `styles.css`만 본다.
|
||||||
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
|
- 허브 8081 디자인은 `styles-8081-design.css`만 본다.
|
||||||
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
- `/integrations/payment`, `/integrations/mh`의 실제 서빙 파일 위치가 문서와 코드에서 일치한다.
|
||||||
|
- `/admin/db-status`가 현재 DB 저장 구조와 import 상태를 화면에서 바로 보여준다.
|
||||||
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
- 기존 참고 자산을 지우지 않고도 실제 서빙 경로와 참고 경로를 구분할 수 있다.
|
||||||
|
|||||||
160
docs/architecture/DB_TABLE_CATALOG.md
Normal file
160
docs/architecture/DB_TABLE_CATALOG.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# DB Table Catalog
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
이 문서는 `8081 / work-8081` 기준 현재 PostgreSQL 테이블 27개를 역할별로 분류한 운영 기준 문서다.
|
||||||
|
|
||||||
|
핵심 원칙:
|
||||||
|
|
||||||
|
- 테이블 수가 많다고 바로 줄이지 않는다.
|
||||||
|
- 먼저 `유지 / 주의 / 원본·추적 / 정리 후보`로 나눈다.
|
||||||
|
- 실제 운영 화면과 저장 흐름에 필요한 것은 유지한다.
|
||||||
|
- 의미가 불분명하거나 중복 역할만 하는 것은 후보로 남겨두고, 실제 삭제는 별도 검증 후 진행한다.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- 전체 테이블: `26`
|
||||||
|
- 유지: `16`
|
||||||
|
- 주의: `6`
|
||||||
|
- 원본·추적: `4`
|
||||||
|
- 정리 후보: `0`
|
||||||
|
|
||||||
|
## 1. 유지
|
||||||
|
|
||||||
|
현재 운영 화면, 인증, 이력, 적재 흐름에서 계속 필요하다.
|
||||||
|
|
||||||
|
- `auth.users`
|
||||||
|
- `auth.sessions`
|
||||||
|
- `auth.login_audit_logs`
|
||||||
|
- `public.members`
|
||||||
|
- `public.member_versions`
|
||||||
|
- `public.history_revisions`
|
||||||
|
- `public.seat_maps`
|
||||||
|
- `public.seat_slots`
|
||||||
|
- `public.seat_positions`
|
||||||
|
- `public.seat_assignment_versions`
|
||||||
|
- `public.integration_import_batches`
|
||||||
|
- `public.integration_projects`
|
||||||
|
- `public.integration_work_logs`
|
||||||
|
- `public.integration_work_log_segments`
|
||||||
|
- `public.integration_vouchers`
|
||||||
|
- `public.integration_binary_sources`
|
||||||
|
|
||||||
|
설명:
|
||||||
|
|
||||||
|
- `members`, `seat_*`는 조직현황/자리배치도 핵심
|
||||||
|
- `member_versions`, `seat_assignment_versions`, `history_revisions`는 as-of 조회와 이력 비교 핵심
|
||||||
|
- `integration_*` 표준화 결과는 프로젝트별 분석 / 팀·개인별 분석 핵심
|
||||||
|
- `integration_binary_sources`는 사업관리대장 같은 바이너리 원본 보관용
|
||||||
|
- `auth.*`는 로그인과 권한 운영 핵심
|
||||||
|
|
||||||
|
## 2. 주의
|
||||||
|
|
||||||
|
현재도 역할은 있지만, 실제 운영에서 얼마나 계속 필요한지 주기적으로 점검해야 한다.
|
||||||
|
|
||||||
|
- `public.member_overrides`
|
||||||
|
- `public.member_retirements`
|
||||||
|
- `public.member_aliases`
|
||||||
|
- `public.integration_project_aliases`
|
||||||
|
- `public.integration_project_category_mappings`
|
||||||
|
- `public.integration_project_pm_assignments`
|
||||||
|
|
||||||
|
설명:
|
||||||
|
|
||||||
|
- 이 테이블들은 핵심 마스터라기보다 “보정/매핑/예외 처리” 성격이 강하다.
|
||||||
|
- 운영상 필요할 수 있지만, 남용되면 기준 데이터가 흐려진다.
|
||||||
|
- 사용 규칙과 관리 책임을 분명히 해야 한다.
|
||||||
|
|
||||||
|
## 3. 원본·추적
|
||||||
|
|
||||||
|
원본 적재와 검증을 위해 필요하다. 직접 서비스 화면의 주 출력원이 아니라, 적재 근거와 추적용이다.
|
||||||
|
|
||||||
|
- `public.integration_raw_organization_rows`
|
||||||
|
- `public.integration_raw_mh_rows`
|
||||||
|
- `public.integration_raw_mh_pm_rows`
|
||||||
|
- `public.integration_raw_payment_rows`
|
||||||
|
|
||||||
|
설명:
|
||||||
|
|
||||||
|
- 원본 파일을 바로 표준화 테이블에만 넣으면, 나중에 적재 오류를 추적하기 어렵다.
|
||||||
|
- raw row 보관은 import 검증과 재현성 측면에서 의미가 있다.
|
||||||
|
- 단, 장기 보관 정책과 용량 관리는 별도 필요하다.
|
||||||
|
|
||||||
|
## 4. 정리 후보
|
||||||
|
|
||||||
|
현재 기준 정리 후보 테이블은 없다.
|
||||||
|
|
||||||
|
## Domain Map
|
||||||
|
|
||||||
|
### 인증
|
||||||
|
|
||||||
|
- `auth.users`
|
||||||
|
- `auth.sessions`
|
||||||
|
- `auth.login_audit_logs`
|
||||||
|
|
||||||
|
### 조직 / 구성원
|
||||||
|
|
||||||
|
- `public.members`
|
||||||
|
- `public.member_overrides`
|
||||||
|
- `public.member_retirements`
|
||||||
|
- `public.member_aliases`
|
||||||
|
|
||||||
|
### 자리배치도
|
||||||
|
|
||||||
|
- `public.seat_maps`
|
||||||
|
- `public.seat_slots`
|
||||||
|
- `public.seat_positions`
|
||||||
|
|
||||||
|
### 이력
|
||||||
|
|
||||||
|
- `public.history_revisions`
|
||||||
|
- `public.member_versions`
|
||||||
|
- `public.seat_assignment_versions`
|
||||||
|
|
||||||
|
### integration 표준화
|
||||||
|
|
||||||
|
- `public.integration_import_batches`
|
||||||
|
- `public.integration_projects`
|
||||||
|
- `public.integration_project_aliases`
|
||||||
|
- `public.integration_project_category_mappings`
|
||||||
|
- `public.integration_project_pm_assignments`
|
||||||
|
- `public.integration_work_logs`
|
||||||
|
- `public.integration_work_log_segments`
|
||||||
|
- `public.integration_vouchers`
|
||||||
|
- `public.integration_binary_sources`
|
||||||
|
|
||||||
|
### integration raw
|
||||||
|
|
||||||
|
- `public.integration_raw_organization_rows`
|
||||||
|
- `public.integration_raw_mh_rows`
|
||||||
|
- `public.integration_raw_mh_pm_rows`
|
||||||
|
- `public.integration_raw_payment_rows`
|
||||||
|
|
||||||
|
## Operational Guidance
|
||||||
|
|
||||||
|
### 바로 줄이지 말아야 하는 것
|
||||||
|
|
||||||
|
- `integration_raw_*`
|
||||||
|
- `member_versions`
|
||||||
|
- `seat_assignment_versions`
|
||||||
|
- `auth.*`
|
||||||
|
|
||||||
|
이건 지금 구조상 “많아 보여도 필요한 층”이다.
|
||||||
|
|
||||||
|
### 먼저 점검할 것
|
||||||
|
|
||||||
|
- `member_overrides`, `member_aliases`, `project_aliases`의 실제 운영 빈도
|
||||||
|
- `seat_maps`의 과거 실험 도면 정리 기준
|
||||||
|
|
||||||
|
### 정리 원칙
|
||||||
|
|
||||||
|
1. 테이블을 없애기 전에 실제 읽는 API/화면/스크립트를 확인한다.
|
||||||
|
2. 원본 추적용 테이블은 운영 출력용 테이블과 구분해서 판단한다.
|
||||||
|
3. 테이블 삭제보다 먼저 “사용 안 함” 상태를 문서화한다.
|
||||||
|
4. 삭제는 백업과 검증 절차가 준비된 뒤에만 한다.
|
||||||
|
|
||||||
|
## Recommended Next Checks
|
||||||
|
|
||||||
|
1. `seat_maps` 과거 DXF 시도본 정리 기준 수립
|
||||||
|
2. `주의` 그룹 테이블의 입력/수정 주체 명확화
|
||||||
|
3. `DB 상태` 화면에서 이 분류를 기준으로 계속 설명 유지
|
||||||
7
frontend/apps/db-status/README.md
Normal file
7
frontend/apps/db-status/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
## DB Status App
|
||||||
|
|
||||||
|
- 수정 원본: `frontend/apps/db-status/index.html`
|
||||||
|
- 실제 서빙: `incoming-files/served/db-status/index.html`
|
||||||
|
- publish: `./scripts/publish_db_status_app.sh`
|
||||||
|
|
||||||
|
`#2` 이슈용 관리자 화면으로, 현재 DB 저장 구조와 적재 상태를 사람이 읽을 수 있게 보여준다.
|
||||||
720
frontend/apps/db-status/index.html
Normal file
720
frontend/apps/db-status/index.html
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
<!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>
|
||||||
@@ -23,6 +23,8 @@ const projectFrame = document.getElementById("project-frame");
|
|||||||
const projectStage = document.getElementById("project-stage");
|
const projectStage = document.getElementById("project-stage");
|
||||||
const teamFrame = document.getElementById("team-frame");
|
const teamFrame = document.getElementById("team-frame");
|
||||||
const teamStage = document.getElementById("team-stage");
|
const teamStage = document.getElementById("team-stage");
|
||||||
|
const dbStatusFrame = document.getElementById("db-status-frame");
|
||||||
|
const dbStatusStage = document.getElementById("db-status-stage");
|
||||||
const seatMapAdminStage = document.getElementById("seatmap-admin-stage");
|
const seatMapAdminStage = document.getElementById("seatmap-admin-stage");
|
||||||
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
|
const seatMapReadonlyStage = document.getElementById("seatmap-readonly-stage");
|
||||||
const emptyStage = document.getElementById("empty-stage");
|
const emptyStage = document.getElementById("empty-stage");
|
||||||
@@ -115,6 +117,7 @@ const viewLabels = {
|
|||||||
project: "프로젝트별 분석",
|
project: "프로젝트별 분석",
|
||||||
team: "팀/개인별 분석",
|
team: "팀/개인별 분석",
|
||||||
organization: "조직 현황",
|
organization: "조직 현황",
|
||||||
|
"db-status": "DB 상태",
|
||||||
"seatmap-admin": "자리배치도",
|
"seatmap-admin": "자리배치도",
|
||||||
"seatmap-readonly": "자리배치도",
|
"seatmap-readonly": "자리배치도",
|
||||||
};
|
};
|
||||||
@@ -1623,6 +1626,7 @@ function setActiveView(view) {
|
|||||||
const isLedger = currentView === "ledger";
|
const isLedger = currentView === "ledger";
|
||||||
const isProject = currentView === "project";
|
const isProject = currentView === "project";
|
||||||
const isTeam = currentView === "team";
|
const isTeam = currentView === "team";
|
||||||
|
const isDbStatus = currentView === "db-status";
|
||||||
const isSeatMapAdmin = currentView === "seatmap-admin";
|
const isSeatMapAdmin = currentView === "seatmap-admin";
|
||||||
const isSeatMapReadonly = currentView === "seatmap-readonly";
|
const isSeatMapReadonly = currentView === "seatmap-readonly";
|
||||||
if (ledgerStage) {
|
if (ledgerStage) {
|
||||||
@@ -1641,6 +1645,10 @@ function setActiveView(view) {
|
|||||||
teamStage.hidden = !isTeam;
|
teamStage.hidden = !isTeam;
|
||||||
teamStage.style.display = isTeam ? "flex" : "none";
|
teamStage.style.display = isTeam ? "flex" : "none";
|
||||||
}
|
}
|
||||||
|
if (dbStatusStage) {
|
||||||
|
dbStatusStage.hidden = !isDbStatus;
|
||||||
|
dbStatusStage.style.display = isDbStatus ? "flex" : "none";
|
||||||
|
}
|
||||||
if (seatMapAdminStage) {
|
if (seatMapAdminStage) {
|
||||||
seatMapAdminStage.hidden = !isSeatMapAdmin;
|
seatMapAdminStage.hidden = !isSeatMapAdmin;
|
||||||
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
|
seatMapAdminStage.style.display = isSeatMapAdmin ? "flex" : "none";
|
||||||
@@ -1650,7 +1658,7 @@ function setActiveView(view) {
|
|||||||
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
|
seatMapReadonlyStage.style.display = isSeatMapReadonly ? "flex" : "none";
|
||||||
}
|
}
|
||||||
if (emptyStage) {
|
if (emptyStage) {
|
||||||
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isSeatMapAdmin && !isSeatMapReadonly;
|
const showEmpty = !isLedger && !isOrganization && !isProject && !isTeam && !isDbStatus && !isSeatMapAdmin && !isSeatMapReadonly;
|
||||||
emptyStage.hidden = !showEmpty;
|
emptyStage.hidden = !showEmpty;
|
||||||
emptyStage.style.display = showEmpty ? "flex" : "none";
|
emptyStage.style.display = showEmpty ? "flex" : "none";
|
||||||
}
|
}
|
||||||
@@ -1677,6 +1685,10 @@ function setActiveView(view) {
|
|||||||
} else if (isTeam) {
|
} else if (isTeam) {
|
||||||
postGlobalDateRangeToFrame(teamFrame);
|
postGlobalDateRangeToFrame(teamFrame);
|
||||||
}
|
}
|
||||||
|
if (isDbStatus && previousView !== "db-status" && dbStatusFrame) {
|
||||||
|
const frameSrc = dbStatusFrame.dataset.src || dbStatusFrame.src;
|
||||||
|
dbStatusFrame.src = resolveAppUrl(frameSrc);
|
||||||
|
}
|
||||||
if (isSeatMapAdmin || isSeatMapReadonly) {
|
if (isSeatMapAdmin || isSeatMapReadonly) {
|
||||||
loadSeatMapData();
|
loadSeatMapData();
|
||||||
}
|
}
|
||||||
|
|||||||
720
frontend/public/db-status.html
Normal file
720
frontend/public/db-status.html
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
<!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>
|
||||||
@@ -79,6 +79,7 @@
|
|||||||
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
|
<button class="nav-pill" type="button" data-view="project">프로젝트별 분석</button>
|
||||||
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
|
<button class="nav-pill" type="button" data-view="team">팀/개인별 분석</button>
|
||||||
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
|
<button class="nav-pill active" type="button" data-view="organization">조직 현황</button>
|
||||||
|
<button class="nav-pill" type="button" data-view="db-status">DB 상태</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
@@ -119,6 +120,11 @@
|
|||||||
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
|
<iframe id="team-frame" src="/integrations/mh" data-src="/integrations/mh" title="팀/개인별 분석 화면"></iframe>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="db-status-stage" class="main-stage" hidden>
|
||||||
|
<div class="stage-frame">
|
||||||
|
<iframe id="db-status-frame" src="/db-status.html?v=20260401-02" data-src="/db-status.html?v=20260401-02" title="DB 상태 화면"></iframe>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section id="seatmap-admin-stage" class="main-stage" hidden>
|
<section id="seatmap-admin-stage" class="main-stage" hidden>
|
||||||
<div class="seatmap-layout">
|
<div class="seatmap-layout">
|
||||||
<div class="seatmap-topbar">
|
<div class="seatmap-topbar">
|
||||||
|
|||||||
5
incoming-files/served/db-status/README.md
Normal file
5
incoming-files/served/db-status/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
## DB Status Served Output
|
||||||
|
|
||||||
|
- 이 디렉터리는 `frontend/apps/db-status` publish 결과물만 둔다.
|
||||||
|
- backend `/admin/db-status`는 여기의 `index.html`만 서빙한다.
|
||||||
|
- 수정은 직접 여기서 하지 말고 `./scripts/publish_db_status_app.sh`를 사용한다.
|
||||||
720
incoming-files/served/db-status/index.html
Normal file
720
incoming-files/served/db-status/index.html
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
<!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>
|
||||||
@@ -32,6 +32,13 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /admin/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend:80;
|
proxy_pass http://frontend:80;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
14
scripts/publish_db_status_app.sh
Executable file
14
scripts/publish_db_status_app.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
APP_SRC_DIR="${REPO_ROOT}/frontend/apps/db-status"
|
||||||
|
SERVED_DIR="${REPO_ROOT}/incoming-files/served/db-status"
|
||||||
|
FRONTEND_PUBLIC_DIR="${REPO_ROOT}/frontend/public"
|
||||||
|
|
||||||
|
mkdir -p "${SERVED_DIR}"
|
||||||
|
cp "${APP_SRC_DIR}/index.html" "${SERVED_DIR}/index.html"
|
||||||
|
cp "${APP_SRC_DIR}/index.html" "${FRONTEND_PUBLIC_DIR}/db-status.html"
|
||||||
|
|
||||||
|
echo "Published db-status app to ${SERVED_DIR} and ${FRONTEND_PUBLIC_DIR}/db-status.html"
|
||||||
Reference in New Issue
Block a user