<?php
// db_tree_db_toggle.php
// DB -> TABLE -> COLUMN
// DB 클릭 : 테이블 전체 토글
// TABLE 클릭 : 컬럼 토글
// phpmyadmin DB 제외
date_default_timezone_set('Asia/Seoul');
header('Content-Type: text/html; charset=UTF-8');
require_once '/home/www/DB/db_upbit.php';
if (!isset($db_upbit)) die('DB 핸들러 없음');
$pdo = $db_upbit;
$sql = "
SELECT
t.table_schema,
t.table_name,
t.table_comment,
c.column_name,
c.column_type,
c.column_comment,
c.ordinal_position
FROM information_schema.tables t
LEFT JOIN information_schema.columns c
ON t.table_schema = c.table_schema
AND t.table_name = c.table_name
WHERE t.table_schema NOT IN (
'information_schema',
'mysql',
'performance_schema',
'sys',
'phpmyadmin'
)
ORDER BY
t.table_schema,
t.table_name,
c.ordinal_position
";
$rows = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
// 트리 구성
$tree = [];
foreach ($rows as $r) {
$db = $r['table_schema'];
$tb = $r['table_name'];
if (!isset($tree[$db])) $tree[$db] = [];
if (!isset($tree[$db][$tb])) {
$tree[$db][$tb] = [
'comment' => $r['table_comment'],
'columns' => []
];
}
if ($r['column_name']) {
$tree[$db][$tb]['columns'][] = [
'name' => $r['column_name'],
'type' => $r['column_type'],
'comment' => $r['column_comment'],
];
}
}
// ========== [신규 기능 추가 시작] ==========
// PK/INDEX 정보 조회
$pkIndexSql = "
SELECT
k.table_schema,
k.table_name,
k.column_name,
k.constraint_name,
k.ordinal_position
FROM information_schema.key_column_usage k
WHERE k.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', 'phpmyadmin')
ORDER BY k.table_schema, k.table_name, k.ordinal_position
";
$pkIndexRows = $pdo->query($pkIndexSql)->fetchAll(PDO::FETCH_ASSOC);
$pkIndexMap = [];
foreach ($pkIndexRows as $r) {
$key = $r['table_schema'] . '.' . $r['table_name'] . '.' . $r['column_name'];
if (!isset($pkIndexMap[$key])) $pkIndexMap[$key] = [];
$pkIndexMap[$key][] = [
'constraint' => $r['constraint_name'],
'is_pk' => ($r['constraint_name'] === 'PRIMARY')
];
}
// 테이블 ROW 수/용량 조회
$tableStatsSql = "
SELECT
t.table_schema,
t.table_name,
t.table_rows,
ROUND((t.data_length + t.index_length) / 1024 / 1024, 2) AS size_mb,
t.update_time,
t.create_time
FROM information_schema.tables t
WHERE t.table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', 'phpmyadmin')
AND t.table_type = 'BASE TABLE'
";
$tableStatsRows = $pdo->query($tableStatsSql)->fetchAll(PDO::FETCH_ASSOC);
$tableStatsMap = [];
foreach ($tableStatsRows as $r) {
$key = $r['table_schema'] . '.' . $r['table_name'];
$tableStatsMap[$key] = [
'rows' => $r['table_rows'],
'size_mb' => $r['size_mb'],
'update_time' => $r['update_time'],
'create_time' => $r['create_time']
];
}
// CREATE TABLE SQL 조회용
$createTableMap = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
try {
$createSql = "SHOW CREATE TABLE `{$dbName}`.`{$tableName}`";
$createRow = $pdo->query($createSql)->fetch(PDO::FETCH_ASSOC);
if ($createRow && isset($createRow['Create Table'])) {
$createTableMap[$dbName . '.' . $tableName] = $createRow['Create Table'];
}
} catch (Exception $e) {
// 무시
}
}
}
// 데몬 사용 테이블 스캔
$daemonTables = [];
$daemonColumnMap = []; // 데몬별 사용 컬럼 매핑
$daemonPaths = [
'/home/www/DATA/UPBIT/daemon',
'/home/www/admin/_cron',
'/home/www/admin/_daemon'
];
foreach ($daemonPaths as $path) {
if (is_dir($path)) {
$files = glob($path . '/daemon_*.php');
foreach ($files as $file) {
$daemonName = basename($file);
$content = @file_get_contents($file);
if ($content) {
// 테이블명 패턴 찾기
preg_match_all('/(?:INSERT\s+INTO|FROM|UPDATE|INTO)\s+[`"]?(\w+)[`"]?/i', $content, $matches);
foreach ($matches[1] as $table) {
if (strpos($table, 'daemon') !== false || strlen($table) > 5) {
$daemonTables[$table] = true;
if (!isset($daemonColumnMap[$table])) $daemonColumnMap[$table] = [];
if (!isset($daemonColumnMap[$table][$daemonName])) $daemonColumnMap[$table][$daemonName] = [];
// 컬럼명 패턴 찾기
preg_match_all('/[`"]?(\w+)[`"]?\s*[,\)]/i', $content, $colMatches);
foreach ($colMatches[1] as $col) {
if (strlen($col) > 2 && !in_array(strtoupper($col), ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'FROM', 'INTO', 'SET', 'WHERE', 'VALUES'])) {
$daemonColumnMap[$table][$daemonName][] = $col;
}
}
}
}
}
}
}
}
// DB 접근 권한 조회
$dbPrivilegesSql = "
SELECT
table_schema,
COUNT(DISTINCT table_name) as table_count
FROM information_schema.tables
WHERE table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', 'phpmyadmin')
GROUP BY table_schema
";
$dbPrivileges = $pdo->query($dbPrivilegesSql)->fetchAll(PDO::FETCH_ASSOC);
$dbPrivilegesMap = [];
foreach ($dbPrivileges as $r) {
$dbPrivilegesMap[$r['table_schema']] = $r['table_count'];
}
// ========== [신규: 탐색·분석 기능 추가 시작] ==========
// 컬럼 사용 빈도 계산 (전체 DB에서 동일 컬럼명 출현 횟수)
$columnFrequencyMap = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
foreach ($t['columns'] as $col) {
$colName = strtolower($col['name']);
if (!isset($columnFrequencyMap[$colName])) {
$columnFrequencyMap[$colName] = 0;
}
$columnFrequencyMap[$colName]++;
}
}
}
// 컬럼명 유사도 그룹핑 (Levenshtein 기반)
$columnSimilarityGroups = [];
$allColumnNames = array_keys($columnFrequencyMap);
foreach ($allColumnNames as $i => $col1) {
$group = [$col1];
foreach ($allColumnNames as $j => $col2) {
if ($i < $j) {
$similarity = 1 - (levenshtein($col1, $col2) / max(strlen($col1), strlen($col2)));
if ($similarity > 0.7 && $similarity < 1.0) {
$group[] = $col2;
}
}
}
if (count($group) > 1) {
$columnSimilarityGroups[] = $group;
}
}
// 테이블 간 참조 추정 (이름 기반)
$tableReferences = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
$refs = [];
// 외래키 패턴: table_id, table_name 등
foreach ($t['columns'] as $col) {
$colName = strtolower($col['name']);
if (preg_match('/(\w+)_id$/', $colName, $m)) {
$refTable = $m[1];
// 다른 테이블에 존재하는지 확인
foreach ($tree as $db2 => $tables2) {
foreach ($tables2 as $tb2 => $t2) {
if (strtolower($tb2) === $refTable || strtolower($tb2) . 's' === $refTable) {
$refs[] = $db2 . '.' . $tb2;
}
}
}
}
}
if (!empty($refs)) {
$tableReferences[$dbName . '.' . $tableName] = $refs;
}
}
}
// ========== [신규: 구조 이해 기능 추가 시작] ==========
// 컬럼 상세 정보 조회 (NULL, 기본값 등)
$columnDetailSql = "
SELECT
table_schema,
table_name,
column_name,
is_nullable,
column_default,
extra,
column_key
FROM information_schema.columns
WHERE table_schema NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys', 'phpmyadmin')
";
$columnDetailRows = $pdo->query($columnDetailSql)->fetchAll(PDO::FETCH_ASSOC);
$columnDetailMap = [];
foreach ($columnDetailRows as $r) {
$key = $r['table_schema'] . '.' . $r['table_name'] . '.' . $r['column_name'];
$columnDetailMap[$key] = [
'is_nullable' => $r['is_nullable'],
'column_default' => $r['column_default'],
'extra' => $r['extra'],
'column_key' => $r['column_key']
];
}
// 테이블 역할 태그 자동 분류
$tableRoleTags = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
$tags = [];
$tbLower = strtolower($tableName);
$commentLower = strtolower($t['comment']);
// 로그 테이블
if (strpos($tbLower, 'log') !== false || strpos($tbLower, '_log') !== false ||
strpos($commentLower, '로그') !== false || strpos($commentLower, 'log') !== false) {
$tags[] = 'LOG';
}
// 데몬 테이블
if (strpos($tbLower, 'daemon') !== false || isset($daemonTables[$tableName])) {
$tags[] = 'DAEMON';
}
// 정산 테이블
if (strpos($tbLower, 'settle') !== false || strpos($tbLower, 'calc') !== false ||
strpos($commentLower, '정산') !== false) {
$tags[] = 'SETTLE';
}
// 임시 테이블
if (strpos($tbLower, 'temp') !== false || strpos($tbLower, 'tmp') !== false ||
strpos($commentLower, '임시') !== false || strpos($commentLower, 'temp') !== false) {
$tags[] = 'TEMP';
}
if (!empty($tags)) {
$tableRoleTags[$dbName . '.' . $tableName] = $tags;
}
}
}
// 네이밍 규칙 위반 감지
$namingViolations = [];
$namingPatterns = [
'snake_case' => '/^[a-z][a-z0-9_]*$/',
'camelCase' => '/^[a-z][a-zA-Z0-9]*$/',
'PascalCase' => '/^[A-Z][a-zA-Z0-9]*$/'
];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
$violations = [];
// 테이블명 체크
if (!preg_match($namingPatterns['snake_case'], strtolower($tableName))) {
$violations[] = 'TABLE_NAME';
}
// 컬럼명 체크
foreach ($t['columns'] as $col) {
if (!preg_match($namingPatterns['snake_case'], strtolower($col['name']))) {
$violations[] = 'COLUMN_' . $col['name'];
}
}
if (!empty($violations)) {
$namingViolations[$dbName . '.' . $tableName] = $violations;
}
}
}
// 타입 이상치 감지
$typeAnomalies = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
$anomalies = [];
foreach ($t['columns'] as $col) {
$typeLower = strtolower($col['type']);
// varchar 남용 (255 초과)
if (preg_match('/varchar\((\d+)\)/', $typeLower, $m)) {
$len = intval($m[1]);
if ($len > 255) {
$anomalies[] = ['type' => 'VARCHAR_OVERUSE', 'column' => $col['name'], 'value' => $len];
}
}
// text 타입이 아닌데 큰 길이
if (preg_match('/varchar\((\d+)\)/', $typeLower, $m)) {
$len = intval($m[1]);
if ($len > 500) {
$anomalies[] = ['type' => 'SHOULD_BE_TEXT', 'column' => $col['name'], 'value' => $len];
}
}
}
if (!empty($anomalies)) {
$typeAnomalies[$dbName . '.' . $tableName] = $anomalies;
}
}
}
// 실시간 증가 테이블 감지 (최근 업데이트 + 큰 ROW 수)
$growingTables = [];
foreach ($tableStatsMap as $key => $stats) {
if ($stats['rows'] > 10000 && $stats['update_time']) {
$updateTime = strtotime($stats['update_time']);
if ((time() - $updateTime) < 3600) { // 1시간 이내 업데이트
$growingTables[$key] = $stats;
}
}
}
// 삭제 예정 테이블 마킹 (deprecated, old, backup 등 포함)
$deprecatedTables = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
$tbLower = strtolower($tableName);
$commentLower = strtolower($t['comment']);
if (strpos($tbLower, 'deprecated') !== false || strpos($tbLower, '_old') !== false ||
strpos($tbLower, '_backup') !== false || strpos($commentLower, '삭제') !== false ||
strpos($commentLower, 'deprecated') !== false) {
$deprecatedTables[$dbName . '.' . $tableName] = true;
}
}
}
// 백업 제외 테이블 (임시, 로그 등)
$backupExcludedTables = [];
foreach ($tableRoleTags as $key => $tags) {
if (in_array('TEMP', $tags) || in_array('LOG', $tags)) {
$backupExcludedTables[$key] = true;
}
}
// ========== [신규: 안정성 체크 추가 시작] ==========
// 인덱스 누락 경고 (자주 검색될 만한 컬럼에 인덱스 없음)
$missingIndexWarnings = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
$warnings = [];
foreach ($t['columns'] as $col) {
$colName = strtolower($col['name']);
$colKey = $dbName . '.' . $tableName . '.' . $col['name'];
// _id, _name, _date 등 패턴에 인덱스 없으면 경고
if ((strpos($colName, '_id') !== false || strpos($colName, '_name') !== false ||
strpos($colName, '_date') !== false || strpos($colName, 'timestamp') !== false) &&
!isset($pkIndexMap[$colKey])) {
$warnings[] = $col['name'];
}
}
if (!empty($warnings)) {
$missingIndexWarnings[$dbName . '.' . $tableName] = $warnings;
}
}
}
// bigint/int 오버플로우 위험
$overflowRisks = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
$risks = [];
foreach ($t['columns'] as $col) {
$typeLower = strtolower($col['type']);
// int 타입인데 큰 값이 들어갈 가능성
if (strpos($typeLower, 'int') !== false && strpos($typeLower, 'bigint') === false) {
$colName = strtolower($col['name']);
if (strpos($colName, 'count') !== false || strpos($colName, 'total') !== false ||
strpos($colName, 'amount') !== false) {
$risks[] = $col['name'];
}
}
}
if (!empty($risks)) {
$overflowRisks[$dbName . '.' . $tableName] = $risks;
}
}
}
// timestamp 표준 위반 (datetime 대신 timestamp 미사용 등)
$timestampViolations = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
$violations = [];
foreach ($t['columns'] as $col) {
$typeLower = strtolower($col['type']);
$colName = strtolower($col['name']);
// created_at, updated_at 등은 timestamp 권장
if ((strpos($colName, 'created_at') !== false || strpos($colName, 'updated_at') !== false ||
strpos($colName, 'timestamp') !== false) && strpos($typeLower, 'timestamp') === false) {
$violations[] = $col['name'];
}
}
if (!empty($violations)) {
$timestampViolations[$dbName . '.' . $tableName] = $violations;
}
}
}
// 중복 의미 컬럼 경고 (유사한 이름의 컬럼)
$duplicateMeaningColumns = [];
foreach ($tree as $dbName => $tables) {
foreach ($tables as $tableName => $t) {
$duplicates = [];
$colNames = array_map(function($c) { return strtolower($c['name']); }, $t['columns']);
foreach ($colNames as $i => $name1) {
foreach ($colNames as $j => $name2) {
if ($i < $j) {
$similarity = 1 - (levenshtein($name1, $name2) / max(strlen($name1), strlen($name2)));
if ($similarity > 0.8 && $similarity < 1.0) {
$duplicates[] = [$t['columns'][$i]['name'], $t['columns'][$j]['name']];
}
}
}
}
if (!empty($duplicates)) {
$duplicateMeaningColumns[$dbName . '.' . $tableName] = $duplicates;
}
}
}
// ========== [신규 기능 추가 끝] ==========
require_once '/home/www/GNU/_PAGE/head.php';
?>
<title>DB TREE TOGGLE</title>
<style>
body{
margin:0;
background:#0b0f16;
color:#e6edf6;
font-family:system-ui,-apple-system,"Noto Sans KR",sans-serif;
font-size:14px;
}
.MySQL { padding:50px; }
.Db_title { font-size: 30px; padding: 20px; color: #fb7185; }
.Db_title i { color: #5fe266; }
.db{margin-bottom:22px;}
.db-head{
display:flex;
gap:8px;
font-size:16px;
font-weight:800;
color:#9fc5ff;
cursor:pointer;
user-select:none;
}
.db-toggle{width:14px;color:#89a0bb;}
.tables{
margin-top:8px;
padding-left:14px;
display:none; /* DB 기본 접힘 */
}
.table{margin-bottom:10px;}
.table-head{
display:flex;
gap:10px;
cursor:pointer;
user-select:none;
}
.table-toggle{width:14px;color:#89a0bb;}
.table-name{
min-width:260px;
font-weight:700;
color:#ffffff;
}
.table-comment{color:#9aa9bc;}
.no-comment{color:#ff9b9b;}
.columns{
display:none; /* 컬럼 기본 숨김 */
padding-left:18px;
margin-top:6px;
}
.col-row{
display:flex;
gap:10px;
line-height:1.6;
}
.col-name{min-width:220px;color:#d7e6ff;}
.col-type{min-width:180px;color:#89a0bb;}
.col-comment{color:#9aa9bc;}
/* ========== [신규 스타일 추가 시작] ========== */
/* 검색/필터 UI */
.search-panel {
background: #1a1f2e;
padding: 16px;
margin-bottom: 20px;
border-radius: 8px;
border: 1px solid #2a3441;
}
.search-row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 300px;
padding: 8px 12px;
background: #0b0f16;
border: 1px solid #2a3441;
color: #e6edf6;
border-radius: 4px;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #9fc5ff;
}
.search-options {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.search-option {
display: flex;
align-items: center;
gap: 6px;
}
.search-option input[type="checkbox"],
.search-option input[type="radio"] {
cursor: pointer;
}
.search-option label {
cursor: pointer;
user-select: none;
font-size: 13px;
}
.search-btn {
padding: 8px 16px;
background: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.search-btn:hover {
background: #1d4ed8;
}
.control-panel {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
position: sticky;
top: 0;
background: #0b0f16;
padding: 12px;
z-index: 100;
border-bottom: 2px solid #2a3441;
}
.control-btn {
padding: 6px 12px;
background: #1a1f2e;
color: #e6edf6;
border: 1px solid #2a3441;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.control-btn:hover {
background: #2a3441;
}
/* 하이라이트 */
.highlight {
background: #fbbf24;
color: #000;
padding: 2px 4px;
border-radius: 2px;
font-weight: bold;
}
/* 컬럼 타입별 색상 */
.col-type-int { color: #60a5fa; }
.col-type-varchar { color: #34d399; }
.col-type-text { color: #a78bfa; }
.col-type-datetime { color: #fbbf24; }
.col-type-decimal { color: #fb7185; }
.col-type-enum { color: #f472b6; }
.col-type-other { color: #89a0bb; }
/* PK/INDEX 강조 */
.col-pk {
font-weight: bold;
color: #fbbf24 !important;
position: relative;
}
.col-pk::before {
content: "🔑 ";
}
.col-index {
color: #60a5fa !important;
position: relative;
}
.col-index::before {
content: "📌 ";
}
/* 설명 없는 항목 흐림 */
.no-comment {
opacity: 0.5;
filter: blur(0.5px);
}
/* 테이블 정보 배지 */
.table-badge {
display: inline-block;
padding: 2px 8px;
background: #1a1f2e;
border: 1px solid #2a3441;
border-radius: 4px;
font-size: 11px;
margin-left: 8px;
color: #9aa9bc;
}
.table-badge.daemon {
background: #dc2626;
color: white;
border-color: #991b1b;
}
.table-badge.recent {
background: #059669;
color: white;
border-color: #047857;
}
.table-badge.col-count {
background: #2563eb;
color: white;
border-color: #1e40af;
}
.table-badge.growing {
background: #f59e0b;
color: white;
border-color: #d97706;
}
.table-badge.deprecated {
background: #6b7280;
color: white;
border-color: #4b5563;
}
.table-badge.role-tag {
background: #8b5cf6;
color: white;
border-color: #7c3aed;
}
.table-badge.warning {
background: #ef4444;
color: white;
border-color: #dc2626;
}
/* 즐겨찾기 */
.favorite {
color: #fbbf24 !important;
}
.favorite::before {
content: "⭐ ";
}
/* 다크/라이트 모드 */
body.light-mode {
background: #ffffff;
color: #1a1f2e;
}
body.light-mode .search-panel {
background: #f3f4f6;
border-color: #d1d5db;
}
body.light-mode .search-input {
background: #ffffff;
border-color: #d1d5db;
color: #1a1f2e;
}
body.light-mode .control-btn {
background: #f3f4f6;
border-color: #d1d5db;
color: #1a1f2e;
}
body.light-mode .db-head {
color: #2563eb;
}
body.light-mode .table-name {
color: #111827;
}
body.light-mode .col-name {
color: #1e40af;
}
/* 숨김 처리 (검색 필터) */
.hidden {
display: none !important;
}
/* 팝업 */
.popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #1a1f2e;
border: 2px solid #2a3441;
border-radius: 8px;
padding: 20px;
max-width: 90%;
max-height: 90vh;
overflow: auto;
z-index: 10000;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #2a3441;
}
.popup-title {
font-size: 18px;
font-weight: bold;
color: #9fc5ff;
}
.popup-close {
background: #dc2626;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
.popup-content {
font-family: 'Courier New', monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
background: #0b0f16;
padding: 12px;
border-radius: 4px;
max-height: 70vh;
overflow: auto;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
z-index: 9999;
}
/* 비교 모드 */
.compare-mode .table {
border-left: 3px solid transparent;
}
.compare-mode .table.selected {
border-left-color: #2563eb;
background: rgba(37, 99, 235, 0.1);
}
/* 컬럼 클릭 복사 피드백 */
.copied {
background: #059669 !important;
color: white !important;
transition: all 0.3s;
}
/* ========== [신규: 툴팁 스타일 추가] ========== */
.tooltip {
position: relative;
cursor: help;
}
.tooltip:hover::after {
content: attr(data-tooltip);
position: absolute;
left: 100%;
top: 0;
background: #1a1f2e;
color: #e6edf6;
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #2a3441;
white-space: nowrap;
z-index: 1000;
font-size: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
margin-left: 8px;
}
/* ========== [신규: 컬럼 접기/펼치기] ========== */
.col-row.collapsed {
opacity: 0.3;
max-height: 20px;
overflow: hidden;
}
.col-toggle-btn {
cursor: pointer;
color: #89a0bb;
margin-right: 4px;
user-select: none;
}
/* ========== [신규: 드래그 정렬] ========== */
.col-row.dragging {
opacity: 0.5;
}
.col-row[draggable="true"] {
cursor: move;
}
/* ========== [신규: 모바일 모드] ========== */
@media (max-width: 768px) {
.table-head {
flex-direction: column;
align-items: flex-start;
}
.table-badge {
font-size: 10px;
padding: 1px 4px;
}
.col-row {
flex-direction: column;
gap: 4px;
}
.search-panel {
padding: 12px;
}
.control-panel {
flex-direction: column;
}
}
/*---------| BODY SCROLLBAR |---------*/
body::-webkit-scrollbar { width:10px; }
body::-webkit-scrollbar-thumb { background-color:#333; border:1px solid #222; }
body::-webkit-scrollbar-thumb:hover { background-color:#555; cursor:default; }
body::-webkit-scrollbar-track { background-color:#0b0e11; }
/* ========== [신규 스타일 추가 끝] ========== */
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<div class="MySQL">
<h2 class="Db_title"><i class="fa-solid fa-database"></i> DB → TABLE → COLUMN (DB / TABLE 토글)</h2>
<!-- ========== [신규 UI 추가 시작] ========== -->
<div class="search-panel">
<div class="search-row">
<input type="text" id="searchInput" class="search-input" placeholder="검색어 입력 (다중 키워드는 공백으로 구분, 정규식 지원)">
<button class="search-btn" onclick="doSearch()">검색</button>
<button class="search-btn" onclick="clearSearch()">초기화</button>
</div>
<div class="search-row">
<div class="search-options">
<div class="search-option">
<input type="radio" name="searchTarget" id="searchAll" value="all" checked>
<label for="searchAll">전체</label>
</div>
<div class="search-option">
<input type="radio" name="searchTarget" id="searchColumn" value="column">
<label for="searchColumn">컬럼명만</label>
</div>
<div class="search-option">
<input type="radio" name="searchTarget" id="searchComment" value="comment">
<label for="searchComment">설명만</label>
</div>
<div class="search-option">
<input type="checkbox" id="includeType">
<label for="includeType">타입 포함</label>
</div>
<div class="search-option">
<input type="radio" name="searchMode" id="searchAnd" value="and" checked>
<label for="searchAnd">AND</label>
</div>
<div class="search-option">
<input type="radio" name="searchMode" id="searchOr" value="or">
<label for="searchOr">OR</label>
</div>
<div class="search-option">
<input type="checkbox" id="useRegex">
<label for="useRegex">정규식</label>
</div>
</div>
</div>
</div>
<div class="control-panel">
<button class="control-btn" onclick="expandAll()">전체 펼치기</button>
<button class="control-btn" onclick="collapseAll()">전체 접기</button>
<button class="control-btn" onclick="toggleTheme()">다크/라이트</button>
<button class="control-btn" onclick="exportJSON()">JSON 내보내기</button>
<button class="control-btn" onclick="exportCSV()">CSV 내보내기</button>
<button class="control-btn" onclick="location.reload()">리로드</button>
<button class="control-btn" onclick="showCompareMode()">테이블 비교</button>
<button class="control-btn" onclick="showColumnFrequency()">컬럼 빈도</button>
<button class="control-btn" onclick="showSimilarityGroups()">유사도 그룹</button>
<button class="control-btn" onclick="showSafetyChecks()">안정성 체크</button>
<button class="control-btn" onclick="alert('자동 생성: 테이블의 생성 버튼을 클릭하세요')">자동 생성</button>
</div>
<!-- ========== [신규 UI 추가 끝] ========== -->
<?php foreach ($tree as $dbName => $tables): ?>
<div class="db" data-db="<?=htmlspecialchars($dbName)?>">
<div class="db-head" onclick="toggleDb(this)">
<div class="db-toggle">▶</div>
<div><?=htmlspecialchars($dbName)?></div>
<!-- ========== [신규: DB 접근 권한 표시] ========== -->
<?php if (isset($dbPrivilegesMap[$dbName])): ?>
<span class="table-badge"><?=$dbPrivilegesMap[$dbName]?> tables</span>
<?php endif; ?>
<!-- ========== [신규 끝] ========== -->
<span class="table-badge" onclick="event.stopPropagation(); toggleFavorite('db', '<?=htmlspecialchars($dbName)?>')" style="cursor:pointer;">⭐</span>
</div>
<div class="tables">
<?php foreach ($tables as $tableName => $t): ?>
<?php
$tableKey = $dbName . '.' . $tableName;
$colCount = count($t['columns']);
$isDaemon = isset($daemonTables[$tableName]);
$stats = isset($tableStatsMap[$tableKey]) ? $tableStatsMap[$tableKey] : null;
$isRecent = false;
if ($stats && $stats['update_time']) {
$updateTime = strtotime($stats['update_time']);
$isRecent = (time() - $updateTime) < 86400 * 7; // 7일 이내
}
$isGrowing = isset($growingTables[$tableKey]);
$isDeprecated = isset($deprecatedTables[$tableKey]);
$roleTags = isset($tableRoleTags[$tableKey]) ? $tableRoleTags[$tableKey] : [];
$hasWarnings = isset($missingIndexWarnings[$tableKey]) || isset($overflowRisks[$tableKey]) ||
isset($timestampViolations[$tableKey]) || isset($typeAnomalies[$tableKey]);
?>
<div class="table" data-db="<?=htmlspecialchars($dbName)?>" data-table="<?=htmlspecialchars($tableName)?>">
<div class="table-head" onclick="toggleTable(this)">
<div class="table-toggle">▶</div>
<div class="table-name"><?=htmlspecialchars($tableName)?></div>
<div class="table-comment <?=($t['comment']===''?'no-comment':'')?>">
<?= $t['comment'] !== '' ? htmlspecialchars($t['comment']) : '(설명 없음)' ?>
</div>
<!-- ========== [신규: 테이블 정보 배지] ========== -->
<span class="table-badge col-count"><?=$colCount?> cols</span>
<?php if ($stats): ?>
<span class="table-badge"><?=number_format($stats['rows'])?> rows</span>
<span class="table-badge"><?=$stats['size_mb']?> MB</span>
<?php if ($stats['create_time']): ?>
<span class="table-badge" title="생성: <?=$stats['create_time']?>">생성: <?=date('Y-m-d', strtotime($stats['create_time']))?></span>
<?php endif; ?>
<?php if ($stats['update_time']): ?>
<span class="table-badge" title="수정: <?=$stats['update_time']?>">수정: <?=date('Y-m-d H:i', strtotime($stats['update_time']))?></span>
<?php endif; ?>
<?php endif; ?>
<?php if ($isDaemon): ?>
<span class="table-badge daemon">DAEMON</span>
<?php endif; ?>
<?php if ($isRecent): ?>
<span class="table-badge recent">RECENT</span>
<?php endif; ?>
<?php if ($isGrowing): ?>
<span class="table-badge growing">GROWING</span>
<?php endif; ?>
<?php if ($isDeprecated): ?>
<span class="table-badge deprecated">DEPRECATED</span>
<?php endif; ?>
<?php foreach ($roleTags as $tag): ?>
<span class="table-badge role-tag"><?=$tag?></span>
<?php endforeach; ?>
<?php if ($hasWarnings): ?>
<span class="table-badge warning" onclick="event.stopPropagation(); showTableWarnings('<?=htmlspecialchars($dbName)?>', '<?=htmlspecialchars($tableName)?>')" style="cursor:pointer;">⚠️</span>
<?php endif; ?>
<!-- ========== [신규 끝] ========== -->
<span class="table-badge" onclick="event.stopPropagation(); showCreateTable('<?=htmlspecialchars($dbName)?>', '<?=htmlspecialchars($tableName)?>')" style="cursor:pointer;background:#7c3aed;">SQL</span>
<span class="table-badge" onclick="event.stopPropagation(); toggleFavorite('table', '<?=htmlspecialchars($dbName)?>', '<?=htmlspecialchars($tableName)?>')" style="cursor:pointer;">⭐</span>
<span class="table-badge" onclick="event.stopPropagation(); selectForCompare('<?=htmlspecialchars($dbName)?>', '<?=htmlspecialchars($tableName)?>')" style="cursor:pointer;background:#059669;">비교</span>
<span class="table-badge" onclick="event.stopPropagation(); showTableRefs('<?=htmlspecialchars($dbName)?>', '<?=htmlspecialchars($tableName)?>')" style="cursor:pointer;background:#0891b2;">참조</span>
<span class="table-badge" onclick="event.stopPropagation(); showAutoGenMenu('<?=htmlspecialchars($dbName)?>', '<?=htmlspecialchars($tableName)?>')" style="cursor:pointer;background:#be185d;">생성</span>
</div>
<div class="columns">
<?php foreach ($t['columns'] as $idx => $c): ?>
<?php
$colKey = $tableKey . '.' . $c['name'];
$isPk = false;
$isIndex = false;
if (isset($pkIndexMap[$colKey])) {
foreach ($pkIndexMap[$colKey] as $idxInfo) {
if ($idxInfo['is_pk']) $isPk = true;
else $isIndex = true;
}
}
$typeClass = 'col-type-other';
$typeLower = strtolower($c['type']);
if (strpos($typeLower, 'int') !== false) $typeClass = 'col-type-int';
elseif (strpos($typeLower, 'varchar') !== false || strpos($typeLower, 'char') !== false) $typeClass = 'col-type-varchar';
elseif (strpos($typeLower, 'text') !== false) $typeClass = 'col-type-text';
elseif (strpos($typeLower, 'datetime') !== false || strpos($typeLower, 'timestamp') !== false || strpos($typeLower, 'date') !== false) $typeClass = 'col-type-datetime';
elseif (strpos($typeLower, 'decimal') !== false || strpos($typeLower, 'float') !== false || strpos($typeLower, 'double') !== false) $typeClass = 'col-type-decimal';
elseif (strpos($typeLower, 'enum') !== false) $typeClass = 'col-type-enum';
$colDetail = isset($columnDetailMap[$colKey]) ? $columnDetailMap[$colKey] : null;
$frequency = isset($columnFrequencyMap[strtolower($c['name'])]) ? $columnFrequencyMap[strtolower($c['name'])] : 0;
$tooltipText = "컬럼명: {$c['name']}\n타입: {$c['type']}\n설명: " . ($c['comment'] ?: '없음');
if ($colDetail) {
$tooltipText .= "\nNULL: " . ($colDetail['is_nullable'] === 'YES' ? '허용' : '불가');
if ($colDetail['column_default'] !== null) {
$tooltipText .= "\n기본값: " . $colDetail['column_default'];
}
}
$tooltipText .= "\n사용 빈도: {$frequency}회";
?>
<div class="col-row"
data-col-name="<?=htmlspecialchars($c['name'])?>"
data-col-type="<?=htmlspecialchars($c['type'])?>"
data-col-comment="<?=htmlspecialchars($c['comment'])?>"
draggable="true"
ondragstart="handleDragStart(event)"
ondragover="handleDragOver(event)"
ondrop="handleDrop(event)">
<span class="col-toggle-btn" onclick="event.stopPropagation(); toggleColumnRow(this)" title="접기/펼치기">▾</span>
<div class="col-name <?=$isPk ? 'col-pk' : ($isIndex ? 'col-index' : '')?> tooltip"
data-tooltip="<?=htmlspecialchars($tooltipText)?>"
onclick="copyColumnName(this, '<?=htmlspecialchars($c['name'])?>')"
style="cursor:pointer;"
title="클릭하여 복사">
<?=htmlspecialchars($c['name'])?>
<?php if ($frequency > 5): ?>
<span style="color:#fbbf24;font-size:10px;">(빈도:<?=$frequency?>)</span>
<?php endif; ?>
</div>
<div class="col-type <?=$typeClass?>"><?=htmlspecialchars($c['type'])?>
<?php if ($colDetail && $colDetail['column_default'] !== null): ?>
<span style="color:#34d399;font-size:11px;">[기본:<?=htmlspecialchars($colDetail['column_default'])?>]</span>
<?php endif; ?>
<?php if ($colDetail && $colDetail['is_nullable'] === 'YES'): ?>
<span style="color:#fbbf24;font-size:11px;">[NULL]</span>
<?php endif; ?>
</div>
<div class="col-comment <?=($c['comment']===''?'no-comment':'')?>">
<?= $c['comment'] !== '' ? htmlspecialchars($c['comment']) : '(설명 없음)' ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<script>
// ========== [기존 함수 유지] ==========
function toggleDb(head){
const tables = head.nextElementSibling;
const icon = head.querySelector('.db-toggle');
if (!tables) return;
const open = tables.style.display === 'block';
tables.style.display = open ? 'none' : 'block';
icon.textContent = open ? '▶' : '▼';
// ========== [신규: localStorage 저장] ==========
const dbName = head.closest('.db').dataset.db;
saveExpandState();
// ========== [신규 끝] ==========
}
function toggleTable(head){
const cols = head.nextElementSibling;
const icon = head.querySelector('.table-toggle');
if (!cols) return;
const open = cols.style.display === 'block';
cols.style.display = open ? 'none' : 'block';
icon.textContent = open ? '▶' : '▼';
// ========== [신규: localStorage 저장] ==========
saveExpandState();
// ========== [신규 끝] ==========
}
// ========== [신규 함수 추가 시작] ==========
// 검색 기능
let currentSearchTerms = [];
let currentSearchMode = 'and';
let currentSearchTarget = 'all';
let currentIncludeType = false;
let currentUseRegex = false;
function doSearch() {
const input = document.getElementById('searchInput').value.trim();
if (!input) {
clearSearch();
return;
}
currentSearchTerms = input.split(/\s+/).filter(t => t);
currentSearchMode = document.querySelector('input[name="searchMode"]:checked').value;
currentSearchTarget = document.querySelector('input[name="searchTarget"]:checked').value;
currentIncludeType = document.getElementById('includeType').checked;
currentUseRegex = document.getElementById('useRegex').checked;
applySearch();
}
function applySearch() {
const allRows = document.querySelectorAll('.col-row, .table, .db');
allRows.forEach(row => {
let match = false;
let text = '';
if (row.classList.contains('col-row')) {
const name = row.dataset.colName || '';
const type = row.dataset.colType || '';
const comment = row.dataset.colComment || '';
if (currentSearchTarget === 'column') {
text = name;
} else if (currentSearchTarget === 'comment') {
text = comment;
} else {
text = name + ' ' + comment;
if (currentIncludeType) text += ' ' + type;
}
} else if (row.classList.contains('table')) {
const tableName = row.querySelector('.table-name')?.textContent || '';
const comment = row.querySelector('.table-comment')?.textContent || '';
text = tableName + ' ' + comment;
} else if (row.classList.contains('db')) {
const dbName = row.querySelector('.db-head > div:nth-child(2)')?.textContent || '';
text = dbName;
}
if (currentSearchTerms.length > 0) {
if (currentSearchMode === 'and') {
match = currentSearchTerms.every(term => testMatch(text, term));
} else {
match = currentSearchTerms.some(term => testMatch(text, term));
}
}
if (match) {
row.classList.remove('hidden');
highlightText(row, currentSearchTerms);
// 부모 요소들도 표시
let parent = row.parentElement;
while (parent) {
if (parent.classList.contains('tables')) {
parent.style.display = 'block';
const dbHead = parent.previousElementSibling;
if (dbHead) {
dbHead.querySelector('.db-toggle').textContent = '▼';
}
} else if (parent.classList.contains('columns')) {
parent.style.display = 'block';
const tableHead = parent.previousElementSibling;
if (tableHead) {
tableHead.querySelector('.table-toggle').textContent = '▼';
}
}
parent = parent.parentElement;
}
} else {
if (row.classList.contains('col-row')) {
row.classList.add('hidden');
} else if (row.classList.contains('table')) {
const hasVisibleCols = Array.from(row.querySelectorAll('.col-row')).some(r => !r.classList.contains('hidden'));
if (!hasVisibleCols) {
row.classList.add('hidden');
} else {
row.classList.remove('hidden');
}
} else if (row.classList.contains('db')) {
const hasVisibleTables = Array.from(row.querySelectorAll('.table')).some(t => !t.classList.contains('hidden'));
if (!hasVisibleTables) {
row.classList.add('hidden');
} else {
row.classList.remove('hidden');
}
}
}
});
}
function testMatch(text, term) {
if (currentUseRegex) {
try {
const regex = new RegExp(term, 'i');
return regex.test(text);
} catch (e) {
return text.toLowerCase().includes(term.toLowerCase());
}
} else {
return text.toLowerCase().includes(term.toLowerCase());
}
}
function highlightText(element, terms) {
if (!element || element.children.length === 0) return;
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
null,
false
);
const textNodes = [];
let node;
while (node = walker.nextNode()) {
if (node.parentElement && !node.parentElement.classList.contains('highlight')) {
textNodes.push(node);
}
}
textNodes.forEach(textNode => {
let text = textNode.textContent;
let newHTML = text;
terms.forEach(term => {
if (currentUseRegex) {
try {
const regex = new RegExp('(' + term + ')', 'gi');
newHTML = newHTML.replace(regex, '<span class="highlight">$1</span>');
} catch (e) {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
newHTML = newHTML.replace(new RegExp('(' + escaped + ')', 'gi'), '<span class="highlight">$1</span>');
}
} else {
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
newHTML = newHTML.replace(new RegExp('(' + escaped + ')', 'gi'), '<span class="highlight">$1</span>');
}
});
if (newHTML !== text) {
const wrapper = document.createElement('span');
wrapper.innerHTML = newHTML;
textNode.parentNode.replaceChild(wrapper, textNode);
}
});
}
function clearSearch() {
document.getElementById('searchInput').value = '';
currentSearchTerms = [];
document.querySelectorAll('.hidden').forEach(el => el.classList.remove('hidden'));
document.querySelectorAll('.highlight').forEach(el => {
const parent = el.parentNode;
parent.replaceChild(document.createTextNode(el.textContent), el);
parent.normalize();
});
}
// 전체 펼치기/접기
function expandAll() {
document.querySelectorAll('.tables').forEach(el => {
el.style.display = 'block';
el.previousElementSibling.querySelector('.db-toggle').textContent = '▼';
});
document.querySelectorAll('.columns').forEach(el => {
el.style.display = 'block';
el.previousElementSibling.querySelector('.table-toggle').textContent = '▼';
});
saveExpandState();
}
function collapseAll() {
document.querySelectorAll('.tables').forEach(el => {
el.style.display = 'none';
el.previousElementSibling.querySelector('.db-toggle').textContent = '▶';
});
document.querySelectorAll('.columns').forEach(el => {
el.style.display = 'none';
el.previousElementSibling.querySelector('.table-toggle').textContent = '▶';
});
saveExpandState();
}
// localStorage 저장/복원
function saveExpandState() {
const state = {
dbs: {},
tables: {}
};
document.querySelectorAll('.db').forEach(db => {
const dbName = db.dataset.db;
const tablesEl = db.querySelector('.tables');
state.dbs[dbName] = tablesEl.style.display === 'block';
db.querySelectorAll('.table').forEach(table => {
const tableKey = dbName + '.' + table.dataset.table;
const colsEl = table.querySelector('.columns');
state.tables[tableKey] = colsEl.style.display === 'block';
});
});
localStorage.setItem('dbTreeExpandState', JSON.stringify(state));
}
function restoreExpandState() {
try {
const saved = localStorage.getItem('dbTreeExpandState');
if (!saved) return;
const state = JSON.parse(saved);
document.querySelectorAll('.db').forEach(db => {
const dbName = db.dataset.db;
if (state.dbs[dbName]) {
const tablesEl = db.querySelector('.tables');
tablesEl.style.display = 'block';
db.querySelector('.db-toggle').textContent = '▼';
}
db.querySelectorAll('.table').forEach(table => {
const tableKey = dbName + '.' + table.dataset.table;
if (state.tables[tableKey]) {
const colsEl = table.querySelector('.columns');
colsEl.style.display = 'block';
table.querySelector('.table-toggle').textContent = '▼';
}
});
});
} catch (e) {
console.error('복원 실패:', e);
}
}
// 즐겨찾기
function toggleFavorite(type, dbName, tableName) {
const key = type === 'db' ? 'fav_db_' + dbName : 'fav_table_' + dbName + '.' + tableName;
const current = localStorage.getItem(key);
if (current === '1') {
localStorage.removeItem(key);
updateFavoriteUI(type, dbName, tableName, false);
} else {
localStorage.setItem(key, '1');
updateFavoriteUI(type, dbName, tableName, true);
}
}
function updateFavoriteUI(type, dbName, tableName, isFav) {
if (type === 'db') {
const db = document.querySelector(`.db[data-db="${dbName}"]`);
if (db) {
const head = db.querySelector('.db-head');
if (isFav) {
head.classList.add('favorite');
} else {
head.classList.remove('favorite');
}
}
} else {
const table = document.querySelector(`.table[data-db="${dbName}"][data-table="${tableName}"]`);
if (table) {
const head = table.querySelector('.table-head');
if (isFav) {
head.classList.add('favorite');
} else {
head.classList.remove('favorite');
}
}
}
}
function loadFavorites() {
document.querySelectorAll('.db').forEach(db => {
const dbName = db.dataset.db;
if (localStorage.getItem('fav_db_' + dbName) === '1') {
updateFavoriteUI('db', dbName, null, true);
}
db.querySelectorAll('.table').forEach(table => {
const tableName = table.dataset.table;
const key = 'fav_table_' + dbName + '.' + tableName;
if (localStorage.getItem(key) === '1') {
updateFavoriteUI('table', dbName, tableName, true);
}
});
});
}
// 컬럼명 복사
function copyColumnName(el, name) {
if (navigator.clipboard) {
navigator.clipboard.writeText(name).then(() => {
el.classList.add('copied');
setTimeout(() => el.classList.remove('copied'), 1000);
});
} else {
const textarea = document.createElement('textarea');
textarea.value = name;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
el.classList.add('copied');
setTimeout(() => el.classList.remove('copied'), 1000);
}
}
// CREATE TABLE SQL 팝업
const createTableData = <?=json_encode($createTableMap, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)?>;
function showCreateTable(dbName, tableName) {
const key = dbName + '.' + tableName;
const sql = createTableData[key] || 'CREATE TABLE SQL을 가져올 수 없습니다.';
const overlay = document.createElement('div');
overlay.className = 'popup-overlay';
overlay.onclick = () => document.body.removeChild(overlay);
const popup = document.createElement('div');
popup.className = 'popup';
popup.onclick = (e) => e.stopPropagation();
popup.innerHTML = `
<div class="popup-header">
<div class="popup-title">CREATE TABLE: ${dbName}.${tableName}</div>
<button class="popup-close" onclick="document.body.removeChild(this.closest('.popup-overlay'))">닫기</button>
</div>
<div class="popup-content">${escapeHtml(sql)}</div>
`;
overlay.appendChild(popup);
document.body.appendChild(overlay);
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 테이블 비교
let compareSelected = [];
function selectForCompare(dbName, tableName) {
const key = dbName + '.' + tableName;
const index = compareSelected.indexOf(key);
if (index > -1) {
compareSelected.splice(index, 1);
updateCompareUI();
} else {
if (compareSelected.length >= 2) {
compareSelected.shift();
}
compareSelected.push(key);
updateCompareUI();
}
if (compareSelected.length === 2) {
showCompareResult();
}
}
function updateCompareUI() {
document.querySelectorAll('.table').forEach(table => {
const key = table.dataset.db + '.' + table.dataset.table;
if (compareSelected.includes(key)) {
table.classList.add('selected');
} else {
table.classList.remove('selected');
}
});
}
function showCompareResult() {
if (compareSelected.length !== 2) return;
const [key1, key2] = compareSelected;
const [db1, tb1] = key1.split('.');
const [db2, tb2] = key2.split('.');
const table1 = document.querySelector(`.table[data-db="${db1}"][data-table="${tb1}"]`);
const table2 = document.querySelector(`.table[data-db="${db2}"][data-table="${tb2}"]`);
if (!table1 || !table2) return;
const cols1 = Array.from(table1.querySelectorAll('.col-row')).map(r => ({
name: r.dataset.colName,
type: r.dataset.colType,
comment: r.dataset.colComment
}));
const cols2 = Array.from(table2.querySelectorAll('.col-row')).map(r => ({
name: r.dataset.colName,
type: r.dataset.colType,
comment: r.dataset.colComment
}));
const only1 = cols1.filter(c1 => !cols2.some(c2 => c2.name === c1.name));
const only2 = cols2.filter(c2 => !cols1.some(c1 => c1.name === c2.name));
const common = cols1.filter(c1 => cols2.some(c2 => c2.name === c1.name));
const diff = common.filter(c1 => {
const c2 = cols2.find(c => c.name === c1.name);
return c1.type !== c2.type || c1.comment !== c2.comment;
});
let html = `
<h3>테이블 비교: ${key1} vs ${key2}</h3>
<h4>공통 컬럼 (${common.length})</h4>
<ul>
`;
common.forEach(c => {
const c2 = cols2.find(col => col.name === c.name);
const typeDiff = c.type !== c2.type ? ` [타입 다름: ${c.type} vs ${c2.type}]` : '';
const commentDiff = c.comment !== c2.comment ? ` [설명 다름]` : '';
html += `<li>${c.name}${typeDiff}${commentDiff}</li>`;
});
html += `</ul>`;
if (only1.length > 0) {
html += `<h4>${key1}에만 있는 컬럼 (${only1.length})</h4><ul>`;
only1.forEach(c => html += `<li>${c.name} (${c.type})</li>`);
html += `</ul>`;
}
if (only2.length > 0) {
html += `<h4>${key2}에만 있는 컬럼 (${only2.length})</h4><ul>`;
only2.forEach(c => html += `<li>${c.name} (${c.type})</li>`);
html += `</ul>`;
}
if (diff.length > 0) {
html += `<h4>타입/설명이 다른 컬럼 (${diff.length})</h4><ul>`;
diff.forEach(c => {
const c2 = cols2.find(col => col.name === c.name);
html += `<li>${c.name}: 타입(${c.type} vs ${c2.type}), 설명(${c.comment || '없음'} vs ${c2.comment || '없음'})</li>`;
});
html += `</ul>`;
}
const overlay = document.createElement('div');
overlay.className = 'popup-overlay';
overlay.onclick = () => {
document.body.removeChild(overlay);
compareSelected = [];
updateCompareUI();
};
const popup = document.createElement('div');
popup.className = 'popup';
popup.onclick = (e) => e.stopPropagation();
popup.innerHTML = `
<div class="popup-header">
<div class="popup-title">테이블 비교 결과</div>
<button class="popup-close" onclick="document.body.removeChild(this.closest('.popup-overlay')); compareSelected = []; updateCompareUI();">닫기</button>
</div>
<div class="popup-content" style="white-space:normal;">${html}</div>
`;
overlay.appendChild(popup);
document.body.appendChild(overlay);
}
function showCompareMode() {
alert('비교 모드: 비교할 테이블의 "비교" 버튼을 클릭하세요. (최대 2개)');
document.body.classList.add('compare-mode');
}
// 다크/라이트 모드
function toggleTheme() {
document.body.classList.toggle('light-mode');
localStorage.setItem('dbTreeTheme', document.body.classList.contains('light-mode') ? 'light' : 'dark');
}
function loadTheme() {
const theme = localStorage.getItem('dbTreeTheme');
if (theme === 'light') {
document.body.classList.add('light-mode');
}
}
// 내보내기
const treeData = <?=json_encode($tree, JSON_UNESCAPED_UNICODE)?>;
const columnFrequencyData = <?=json_encode($columnFrequencyMap, JSON_UNESCAPED_UNICODE)?>;
const tableReferencesData = <?=json_encode($tableReferences, JSON_UNESCAPED_UNICODE)?>;
const tableRoleTagsData = <?=json_encode($tableRoleTags, JSON_UNESCAPED_UNICODE)?>;
const missingIndexWarningsData = <?=json_encode($missingIndexWarnings, JSON_UNESCAPED_UNICODE)?>;
const overflowRisksData = <?=json_encode($overflowRisks, JSON_UNESCAPED_UNICODE)?>;
const timestampViolationsData = <?=json_encode($timestampViolations, JSON_UNESCAPED_UNICODE)?>;
const typeAnomaliesData = <?=json_encode($typeAnomalies, JSON_UNESCAPED_UNICODE)?>;
const daemonColumnMapData = <?=json_encode($daemonColumnMap, JSON_UNESCAPED_UNICODE)?>;
function exportJSON() {
const dataStr = JSON.stringify(treeData, null, 2);
const blob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'db_tree_' + new Date().toISOString().split('T')[0] + '.json';
a.click();
URL.revokeObjectURL(url);
}
function exportCSV() {
let csv = 'DB,Table,Column,Type,Comment\n';
Object.keys(treeData).forEach(dbName => {
Object.keys(treeData[dbName]).forEach(tableName => {
const table = treeData[dbName][tableName];
table.columns.forEach(col => {
csv += `"${dbName}","${tableName}","${col.name}","${col.type}","${col.comment || ''}"\n`;
});
});
});
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'db_tree_' + new Date().toISOString().split('T')[0] + '.csv';
a.click();
URL.revokeObjectURL(url);
}
// ========== [신규: 탐색·분석 기능] ==========
function showColumnFrequency() {
const sorted = Object.entries(columnFrequencyData).sort((a, b) => b[1] - a[1]);
let html = '<h3>컬럼 사용 빈도 (상위 50개)</h3><ul>';
sorted.slice(0, 50).forEach(([name, freq]) => {
html += `<li>${name}: ${freq}회</li>`;
});
html += '</ul>';
showPopup('컬럼 사용 빈도', html);
}
function showSimilarityGroups() {
const groups = <?=json_encode($columnSimilarityGroups, JSON_UNESCAPED_UNICODE)?>;
let html = '<h3>유사 컬럼명 그룹</h3><ul>';
groups.forEach(group => {
html += `<li>${group.join(', ')}</li>`;
});
html += '</ul>';
showPopup('유사도 그룹', html);
}
function showTableRefs(dbName, tableName) {
const key = dbName + '.' + tableName;
const refs = tableReferencesData[key] || [];
let html = `<h3>${key} 참조 추정</h3>`;
if (refs.length > 0) {
html += '<ul>';
refs.forEach(ref => html += `<li>${ref}</li>`);
html += '</ul>';
} else {
html += '<p>참조 관계를 찾을 수 없습니다.</p>';
}
showPopup('테이블 참조', html);
}
// ========== [신규: 안정성 체크] ==========
function showSafetyChecks() {
let html = '<h3>안정성 체크 결과</h3>';
html += '<h4>인덱스 누락 경고</h4><ul>';
Object.entries(missingIndexWarningsData).forEach(([table, cols]) => {
html += `<li>${table}: ${cols.join(', ')}</li>`;
});
html += '</ul>';
html += '<h4>오버플로우 위험</h4><ul>';
Object.entries(overflowRisksData).forEach(([table, cols]) => {
html += `<li>${table}: ${cols.join(', ')}</li>`;
});
html += '</ul>';
html += '<h4>Timestamp 표준 위반</h4><ul>';
Object.entries(timestampViolationsData).forEach(([table, cols]) => {
html += `<li>${table}: ${cols.join(', ')}</li>`;
});
html += '</ul>';
html += '<h4>타입 이상치</h4><ul>';
Object.entries(typeAnomaliesData).forEach(([table, anomalies]) => {
anomalies.forEach(a => {
html += `<li>${table}.${a.column}: ${a.type} (값: ${a.value})</li>`;
});
});
html += '</ul>';
showPopup('안정성 체크', html);
}
function showTableWarnings(dbName, tableName) {
const key = dbName + '.' + tableName;
let html = `<h3>${key} 경고 사항</h3><ul>`;
if (missingIndexWarningsData[key]) {
html += `<li>인덱스 누락: ${missingIndexWarningsData[key].join(', ')}</li>`;
}
if (overflowRisksData[key]) {
html += `<li>오버플로우 위험: ${overflowRisksData[key].join(', ')}</li>`;
}
if (timestampViolationsData[key]) {
html += `<li>Timestamp 표준 위반: ${timestampViolationsData[key].join(', ')}</li>`;
}
if (typeAnomaliesData[key]) {
typeAnomaliesData[key].forEach(a => {
html += `<li>타입 이상치: ${a.column} - ${a.type}</li>`;
});
}
html += '</ul>';
showPopup('테이블 경고', html);
}
// ========== [신규: 자동 생성 기능] ==========
function showAutoGenMenu(dbName, tableName) {
const table = document.querySelector(`.table[data-db="${dbName}"][data-table="${tableName}"]`);
if (!table) return;
const cols = Array.from(table.querySelectorAll('.col-row')).map(r => ({
name: r.dataset.colName,
type: r.dataset.colType,
comment: r.dataset.colComment
}));
let html = `<h3>${dbName}.${tableName} 자동 생성</h3>`;
html += '<div style="display:flex;flex-direction:column;gap:8px;">';
html += '<button onclick="generateCRUD(\'' + dbName + '\', \'' + tableName + '\')" style="padding:8px;">CRUD 템플릿</button>';
html += '<button onclick="generatePHPArray(\'' + dbName + '\', \'' + tableName + '\')" style="padding:8px;">PHP 배열</button>';
html += '<button onclick="generateDTO(\'' + dbName + '\', \'' + tableName + '\')" style="padding:8px;">DTO 클래스</button>';
html += '<button onclick="generateInsertSample(\'' + dbName + '\', \'' + tableName + '\')" style="padding:8px;">INSERT 샘플</button>';
html += '<button onclick="generateAlterPreview(\'' + dbName + '\', \'' + tableName + '\')" style="padding:8px;">ALTER 미리보기</button>';
html += '</div>';
showPopup('자동 생성 메뉴', html);
}
function generateCRUD(dbName, tableName) {
const table = document.querySelector(`.table[data-db="${dbName}"][data-table="${tableName}"]`);
const cols = Array.from(table.querySelectorAll('.col-row')).map(r => ({
name: r.dataset.colName,
type: r.dataset.colType
}));
let php = ['<?php', '// ' + tableName + ' CRUD', '',
'// CREATE', 'function create_' + tableName + '(\\$data) {', ' global \\$pdo;',
' \\$sql = "INSERT INTO ' + tableName + ' (`' + cols.map(c => c.name).join('`, `') + '`) VALUES (' +
cols.map(() => '?').join(', ') + ')";', ' \\$stmt = \\$pdo->prepare(\\$sql);',
' return \\$stmt->execute([' + cols.map(c => '\\$data[\\\'' + c.name + '\\\']').join(', ') + ']);', '}', '',
'// READ', 'function get_' + tableName + '(\\$id) {', ' global \\$pdo;',
' \\$sql = "SELECT * FROM ' + tableName + ' WHERE id = ?";', ' \\$stmt = \\$pdo->prepare(\\$sql);',
' \\$stmt->execute([\\$id]);', ' return \\$stmt->fetch(PDO::FETCH_ASSOC);', '}', '',
'// UPDATE', 'function update_' + tableName + '(\\$id, \\$data) {', ' global \\$pdo;', ' \\$set = [];',
' foreach (\\$data as \\$k => \\$v) {', ' \\$set[] = "\\$k = ?";', ' }',
' \\$sql = "UPDATE ' + tableName + ' SET " . implode(\\\', \\\', \\$set) . " WHERE id = ?";',
' \\$stmt = \\$pdo->prepare(\\$sql);', ' return \\$stmt->execute(array_merge(array_values(\\$data), [\\$id]));', '}', '',
'// DELETE', 'function delete_' + tableName + '(\\$id) {', ' global \\$pdo;',
' \\$sql = "DELETE FROM ' + tableName + ' WHERE id = ?";', ' \\$stmt = \\$pdo->prepare(\\$sql);',
' return \\$stmt->execute([\\$id]);', '}'].join('\n');
showPopup('CRUD 템플릿', '<pre>' + escapeHtml(php) + '</pre>');
}
function generatePHPArray(dbName, tableName) {
const table = document.querySelector(`.table[data-db="${dbName}"][data-table="${tableName}"]`);
const cols = Array.from(table.querySelectorAll('.col-row')).map(r => r.dataset.colName);
let php = ['<?php', '\\$' + tableName + ' = ['];
cols.forEach(col => {
php.push(' \\\'' + col + '\\\' => \\\'\\\',');
});
php.push('];');
php = php.join('\n');
showPopup('PHP 배열', '<pre>' + escapeHtml(php) + '</pre>');
}
function generateDTO(dbName, tableName) {
const table = document.querySelector(`.table[data-db="${dbName}"][data-table="${tableName}"]`);
const cols = Array.from(table.querySelectorAll('.col-row')).map(r => ({
name: r.dataset.colName,
type: r.dataset.colType
}));
let php = ['<?php', 'class ' + tableName + 'DTO {'];
cols.forEach(col => {
php.push(' public \\$' + col.name + ';');
});
php.push('', ' public function __construct(\\$data = []) {');
cols.forEach(col => {
php.push(' \\$this->' + col.name + ' = \\$data[\\\'' + col.name + '\\\'] ?? null;');
});
php.push(' }', '}');
php = php.join('\n');
showPopup('DTO 클래스', '<pre>' + escapeHtml(php) + '</pre>');
}
function generateInsertSample(dbName, tableName) {
const table = document.querySelector(`.table[data-db="${dbName}"][data-table="${tableName}"]`);
const cols = Array.from(table.querySelectorAll('.col-row')).map(r => r.dataset.colName);
let sql = `INSERT INTO ${tableName} (`;
sql += cols.join(', ') + ') VALUES (';
sql += cols.map(() => '?').join(', ') + ');';
showPopup('INSERT 샘플', '<pre>' + escapeHtml(sql) + '</pre>');
}
function generateAlterPreview(dbName, tableName) {
const table = document.querySelector(`.table[data-db="${dbName}"][data-table="${tableName}"]`);
const cols = Array.from(table.querySelectorAll('.col-row')).map(r => ({
name: r.dataset.colName,
type: r.dataset.colType
}));
let sql = ['-- ALTER TABLE ' + tableName + ' 미리보기', '', '-- 컬럼 추가 예시:',
'ALTER TABLE ' + tableName + ' ADD COLUMN new_column VARCHAR(255) NULL;', ''];
if (cols.length > 0) {
sql.push('-- 컬럼 수정 예시:', 'ALTER TABLE ' + tableName + ' MODIFY COLUMN ' + cols[0].name + ' ' + cols[0].type + ';', '');
}
if (cols.length > 0) {
sql.push('-- 컬럼 삭제 예시:', 'ALTER TABLE ' + tableName + ' DROP COLUMN ' + cols[0].name + ';');
}
sql = sql.join('\n');
showPopup('ALTER 미리보기', '<pre>' + escapeHtml(sql) + '</pre>');
}
// ========== [신규: UX 디테일] ==========
function toggleColumnRow(btn) {
const row = btn.closest('.col-row');
row.classList.toggle('collapsed');
btn.textContent = row.classList.contains('collapsed') ? '▸' : '▾';
}
let draggedElement = null;
function handleDragStart(e) {
draggedElement = e.target.closest('.col-row');
e.target.closest('.col-row').classList.add('dragging');
}
function handleDragOver(e) {
e.preventDefault();
const afterElement = getDragAfterElement(e.target.closest('.columns'), e.clientY);
const dragging = document.querySelector('.dragging');
if (afterElement == null) {
e.target.closest('.columns').appendChild(dragging);
} else {
e.target.closest('.columns').insertBefore(dragging, afterElement);
}
}
function handleDrop(e) {
e.preventDefault();
if (draggedElement) {
draggedElement.classList.remove('dragging');
draggedElement = null;
}
}
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.col-row:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function showPopup(title, content) {
const overlay = document.createElement('div');
overlay.className = 'popup-overlay';
overlay.onclick = () => document.body.removeChild(overlay);
const popup = document.createElement('div');
popup.className = 'popup';
popup.onclick = (e) => e.stopPropagation();
popup.innerHTML = `
<div class="popup-header">
<div class="popup-title">${title}</div>
<button class="popup-close" onclick="document.body.removeChild(this.closest('.popup-overlay'))">닫기</button>
</div>
<div class="popup-content" style="white-space:normal;">${content}</div>
`;
overlay.appendChild(popup);
document.body.appendChild(overlay);
}
// 키보드 단축키
document.addEventListener('keydown', (e) => {
// Ctrl+F: 검색 포커스
if (e.ctrlKey && e.key === 'f') {
e.preventDefault();
document.getElementById('searchInput').focus();
}
// Ctrl+E: 전체 펼치기
if (e.ctrlKey && e.key === 'e') {
e.preventDefault();
expandAll();
}
// Ctrl+C: 전체 접기
if (e.ctrlKey && e.key === 'c' && !e.shiftKey) {
e.preventDefault();
collapseAll();
}
// Esc: 검색 초기화
if (e.key === 'Escape') {
clearSearch();
document.getElementById('searchInput').blur();
}
// Enter: 검색 실행
if (e.key === 'Enter' && document.activeElement === document.getElementById('searchInput')) {
doSearch();
}
});
// 초기화
window.addEventListener('DOMContentLoaded', () => {
restoreExpandState();
loadFavorites();
loadTheme();
});
// ========== [신규 함수 추가 끝] ==========
</script>
</div>
<?php require_once '/home/www/GNU/_PAGE/tail.php'; ?>