/** * DB Console JavaScript */ var DbConsole = (function() { 'use strict'; // Private 변수 var editor = null; var grid = null; var gridApi = null; var config = { contextPath: '', sessionId: '', datasources: [] }; var currentConnection = { connected: false, datasourceType: '', jndiName: '', jdbcUrl: '', username: '', schema: '' }; var sqlLogs = []; var heartbeatInterval = null; // 상수 var DOWNLOAD_THRESHOLD = 500; var HEARTBEAT_INTERVAL = 60000; // 1분 // ======================================== // 초기화 // ======================================== function init(options) { config = $.extend(config, options); initEditor(); initGrid(); initEventHandlers(); initDatasourceSelect(); initContextMenu(); restoreSettings(); // 페이지 종료 시 세션 정리 $(window).on('beforeunload', function() { if (currentConnection.connected) { disconnect(); } }); } // Monaco Editor 초기화 function initEditor() { require.config({ paths: { 'vs': config.contextPath + '/addon/monaco' }}); require(['vs/editor/editor.main'], function() { // 저장된 테마 불러오기 var savedTheme = loadSetting('editorTheme', 'vs-dark'); // 초기 SQL (샘플) var initialSql = '-- DB Console\n' + '-- 테이블 더블클릭: SELECT 문 자동 생성\n' + '-- Ctrl+Enter: 커서 위치 SQL 실행\n' + '-- Ctrl+Shift+Enter: 전체 SQL 실행\n' + '-- 셀 우클릭: 복호화/복사 메뉴\n' + '\n'; editor = monaco.editor.create(document.getElementById('sql-editor'), { value: initialSql, language: 'sql', theme: savedTheme, minimap: { enabled: false }, automaticLayout: true, fontSize: 13, lineNumbers: 'on', scrollBeyondLastLine: false, wordWrap: 'on', tabSize: 4 }); // 테마 셀렉트 동기화 $('#editor-theme').val(savedTheme); // Ctrl+Enter로 실행 (커서 위치) editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, function() { execute(); }); // Ctrl+Shift+Enter로 전체 실행 (Run All) editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.Enter, function() { executeAll(); }); // F5로 실행 editor.addCommand(monaco.KeyCode.F5, function() { execute(); }); }); } // AG Grid 초기화 function initGrid() { var gridOptions = { columnDefs: [], rowData: [], defaultColDef: { sortable: true, resizable: true, filter: true, minWidth: 120, editable: true // 셀 편집 가능 (복사 편의) }, rowSelection: { mode: 'multiRow', enableClickSelection: true }, animateRows: true, suppressCellFocus: false, enableCellTextSelection: false, // 텍스트 선택 비활성화 (셀 선택과 충돌 방지) ensureDomOrder: true, onGridReady: function(params) { gridApi = params.api; }, // 셀 편집 시작 시 전체 선택 onCellEditingStarted: function(params) { setTimeout(function() { var input = document.querySelector('.ag-cell-edit-input'); if (input) { input.select(); } }, 10); }, // 편집 완료 시 원래 값 유지 (읽기 전용 동작) onCellEditingStopped: function(params) { // 원래 값으로 복원 (수정 방지) if (params.oldValue !== params.newValue) { params.node.setDataValue(params.column.colId, params.oldValue); } } }; var gridDiv = document.getElementById('result-grid'); grid = agGrid.createGrid(gridDiv, gridOptions); } // 이벤트 핸들러 초기화 function initEventHandlers() { // 실행 버튼 (커서 위치) $('#btn-execute').on('click', execute); // Run All 버튼 (전체 실행) $('#btn-execute-all').on('click', executeAll); // COMMIT 버튼 $('#btn-commit').on('click', function() { showCommitConfirm(); }); // ROLLBACK 버튼 $('#btn-rollback').on('click', function() { if (confirm('변경사항을 롤백하시겠습니까?')) { rollback(); } }); // 연결/해제 버튼 $('#btn-connect').on('click', connect); $('#btn-disconnect').on('click', function() { if (confirm('연결을 해제하시겠습니까? 미커밋 트랜잭션은 롤백됩니다.')) { disconnect(); } }); // 데이터소스 변경 - localStorage 저장 $('#datasource-select').on('change', function() { var idx = $(this).val(); if (idx !== '') { var ds = config.datasources[idx]; if (ds.type === 'DIRECT' && !ds.jdbcUrl) { // 직접 입력인 경우 $('#direct-input-area').show(); } else { $('#direct-input-area').hide(); } // 데이터소스 선택값 저장 saveSetting('datasource', idx); } }); // 결과 탭 $('.dbconsole-result-tab').on('click', function() { var target = $(this).data('target'); $('.dbconsole-result-tab').removeClass('active'); $(this).addClass('active'); $('.dbconsole-result-content').removeClass('active'); $('#' + target).addClass('active'); }); // 내보내기 버튼 $('#btn-export-csv').on('click', function() { exportResult('csv'); }); $('#btn-export-md').on('click', function() { exportResult('md'); }); $('#btn-export-sql').on('click', function() { exportResult('sql'); }); $('#btn-export-json').on('click', function() { exportResult('json'); }); // Fetch Count 변경 - localStorage 저장 $('#fetch-count').on('change', function() { saveSetting('fetchCount', $(this).val()); }); // 에디터 테마 변경 - localStorage 저장 $('#editor-theme').on('change', function() { var theme = $(this).val(); if (editor) { monaco.editor.setTheme(theme); } saveSetting('editorTheme', theme); }); // 복호화 모드 체크박스 - localStorage 저장 $('#chk-decrypt-mode').on('change', function() { var enabled = $(this).is(':checked'); toggleDecryptMode(enabled); saveSetting('decryptMode', enabled); }); // 리사이저 initResizer(); // 키보드 단축키 (그리드 영역) $('#result-grid').on('keydown', function(e) { // Ctrl+Shift+C: 선택 영역 CSV 복사 if (e.ctrlKey && e.shiftKey && e.key === 'C') { e.preventDefault(); copySelectedCells('csv'); } }); } // 데이터소스 셀렉트 초기화 function initDatasourceSelect() { var $select = $('#datasource-select'); $select.empty(); $select.append(''); $.each(config.datasources, function(idx, ds) { $select.append(''); }); } // 리사이저 초기화 function initResizer() { var $resizer = $('.dbconsole-resizer'); var $sidebar = $('.dbconsole-sidebar'); var isResizing = false; $resizer.on('mousedown', function(e) { isResizing = true; $('body').css('cursor', 'col-resize'); }); $(document).on('mousemove', function(e) { if (!isResizing) return; var newWidth = e.clientX; if (newWidth >= 200 && newWidth <= 400) { $sidebar.css('width', newWidth + 'px'); } }); $(document).on('mouseup', function() { if (isResizing) { isResizing = false; $('body').css('cursor', ''); } }); } // ======================================== // 연결 관리 // ======================================== function connect() { var dsIdx = $('#datasource-select').val(); if (dsIdx === '') { alert('데이터소스를 선택하세요.'); return; } var ds = config.datasources[dsIdx]; var request = { sessionId: config.sessionId, datasourceType: ds.type, jndiName: ds.jndiName || '', jdbcUrl: ds.jdbcUrl || $('#direct-jdbc-url').val(), username: ds.username || $('#direct-username').val(), password: $('#direct-password').val(), driverClassName: ds.driverClassName || 'oracle.jdbc.OracleDriver' }; // 직접 입력 검증 if (ds.type === 'DIRECT' && !ds.jdbcUrl) { if (!request.jdbcUrl) { alert('JDBC URL을 입력하세요.'); return; } if (!request.username) { alert('사용자명을 입력하세요.'); return; } } showLoading(true); // 간단한 테스트 쿼리로 연결 확인 request.sql = 'SELECT 1 FROM DUAL'; request.maxRows = 1; $.ajax({ url: config.contextPath + '/admin/dbconsole/execute.do', type: 'POST', contentType: 'application/json', data: JSON.stringify(request), success: function(result) { showLoading(false); if (result.success) { currentConnection = { connected: true, datasourceType: ds.type, jndiName: ds.jndiName, jdbcUrl: request.jdbcUrl, username: request.username, schema: ds.schema || request.username }; updateConnectionStatus(); loadSchemas(); startHeartbeat(); addSqlLog('연결 성공', 'CONNECT', true, '데이터소스: ' + ds.displayName); } else { alert('연결 실패: ' + result.message); addSqlLog('연결 실패', 'CONNECT', false, result.message); } }, error: function(xhr) { showLoading(false); alert('연결 오류: ' + (xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText)); } }); } function disconnect() { if (!currentConnection.connected) return; var request = { sessionId: config.sessionId }; $.ajax({ url: config.contextPath + '/admin/dbconsole/disconnect.do', type: 'POST', contentType: 'application/json', data: JSON.stringify(request), success: function(result) { currentConnection.connected = false; updateConnectionStatus(); clearSchemaTree(); stopHeartbeat(); addSqlLog('연결 해제', 'DISCONNECT', true, ''); }, error: function() { currentConnection.connected = false; updateConnectionStatus(); stopHeartbeat(); } }); } function updateConnectionStatus() { var $status = $('#connection-status'); var $dot = $status.find('.status-dot'); var $text = $status.find('.status-text'); if (currentConnection.connected) { $dot.removeClass('disconnected pending').addClass('connected'); $text.text('연결됨: ' + (currentConnection.schema || currentConnection.username)); $('#btn-connect').hide(); $('#btn-disconnect').show(); $('#btn-execute, #btn-execute-all, #btn-commit, #btn-rollback').prop('disabled', false); } else { $dot.removeClass('connected pending').addClass('disconnected'); $text.text('연결 안됨'); $('#btn-connect').show(); $('#btn-disconnect').hide(); $('#btn-execute, #btn-execute-all, #btn-commit, #btn-rollback').prop('disabled', true); } } // ======================================== // SQL 실행 // ======================================== // 커서 위치의 SQL 구문 찾기 function getSqlAtCursor() { if (!editor) return ''; var model = editor.getModel(); var position = editor.getPosition(); var fullText = model.getValue(); // 세미콜론으로 구문 분리 (문자열 내 세미콜론 제외) var statements = parseSqlStatements(fullText); if (statements.length === 0) return fullText.trim(); // 커서 위치(offset) 계산 var cursorOffset = model.getOffsetAt(position); // 커서가 속한 구문 찾기 var currentOffset = 0; for (var i = 0; i < statements.length; i++) { var stmt = statements[i]; var stmtStart = fullText.indexOf(stmt.text, currentOffset); var stmtEnd = stmtStart + stmt.text.length; if (cursorOffset >= stmtStart && cursorOffset <= stmtEnd + 1) { return stmt.text.trim(); } currentOffset = stmtEnd + 1; // +1 for semicolon } // 찾지 못하면 마지막 구문 반환 return statements[statements.length - 1].text.trim(); } // SQL 구문 파싱 (세미콜론 기준, 문자열 내 세미콜론 무시) function parseSqlStatements(text) { var statements = []; var current = ''; var inSingleQuote = false; var inDoubleQuote = false; var inLineComment = false; var inBlockComment = false; for (var i = 0; i < text.length; i++) { var char = text[i]; var nextChar = text[i + 1] || ''; // 라인 주석 시작 if (!inSingleQuote && !inDoubleQuote && !inBlockComment && char === '-' && nextChar === '-') { inLineComment = true; } // 라인 주석 끝 if (inLineComment && (char === '\n' || char === '\r')) { inLineComment = false; } // 블록 주석 시작 if (!inSingleQuote && !inDoubleQuote && !inLineComment && char === '/' && nextChar === '*') { inBlockComment = true; } // 블록 주석 끝 if (inBlockComment && char === '*' && nextChar === '/') { current += char; current += nextChar; i++; inBlockComment = false; continue; } // 문자열 처리 if (!inLineComment && !inBlockComment) { if (char === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; } else if (char === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; } } // 세미콜론 (구문 구분자) if (char === ';' && !inSingleQuote && !inDoubleQuote && !inLineComment && !inBlockComment) { var trimmed = current.trim(); if (trimmed) { statements.push({ text: trimmed }); } current = ''; continue; } current += char; } // 마지막 구문 (세미콜론 없이 끝난 경우) var lastTrimmed = current.trim(); if (lastTrimmed) { statements.push({ text: lastTrimmed }); } return statements; } // SQL에서 주석 제거 function removeComments(sql) { var result = ''; var inSingleQuote = false; var inDoubleQuote = false; var inLineComment = false; var inBlockComment = false; for (var i = 0; i < sql.length; i++) { var char = sql[i]; var nextChar = sql[i + 1] || ''; // 라인 주석 시작 if (!inSingleQuote && !inDoubleQuote && !inBlockComment && char === '-' && nextChar === '-') { inLineComment = true; i++; // skip next '-' continue; } // 라인 주석 끝 if (inLineComment && (char === '\n' || char === '\r')) { inLineComment = false; result += char; // 줄바꿈은 유지 continue; } // 블록 주석 시작 if (!inSingleQuote && !inDoubleQuote && !inLineComment && char === '/' && nextChar === '*') { inBlockComment = true; i++; // skip '*' continue; } // 블록 주석 끝 if (inBlockComment && char === '*' && nextChar === '/') { inBlockComment = false; i++; // skip '/' continue; } // 주석 내부면 skip if (inLineComment || inBlockComment) { continue; } // 문자열 처리 if (char === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; } else if (char === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; } result += char; } return result.trim(); } // 현재 커서 위치 기준 실행 function execute() { if (!currentConnection.connected) { alert('먼저 데이터소스에 연결하세요.'); return; } var sql = ''; // 선택 영역이 있으면 선택 영역 실행 if (editor) { var selection = editor.getSelection(); if (selection && !selection.isEmpty()) { sql = editor.getModel().getValueInRange(selection); } else { // 커서 위치의 구문 실행 sql = getSqlAtCursor(); } } // 주석 제거 후 실행할 SQL이 있는지 확인 var sqlWithoutComments = removeComments(sql); if (!sqlWithoutComments) { alert('실행할 SQL을 입력하세요. (주석만 있습니다)'); return; } executeSql(sql); } // 전체 SQL 실행 (Run All) function executeAll() { if (!currentConnection.connected) { alert('먼저 데이터소스에 연결하세요.'); return; } if (!editor) return; var fullText = editor.getModel().getValue(); var statements = parseSqlStatements(fullText); // 주석만 있는 구문 필터링 var executableStatements = statements.filter(function(stmt) { return removeComments(stmt.text).length > 0; }); if (executableStatements.length === 0) { alert('실행할 SQL을 입력하세요. (주석만 있습니다)'); return; } // 여러 구문 순차 실행 executeStatementsSequentially(executableStatements, 0); } // 구문 순차 실행 function executeStatementsSequentially(statements, index) { if (index >= statements.length) { return; } var sql = statements[index].text; // 주석 제거 후 COMMIT/ROLLBACK 체크 var sqlWithoutComments = removeComments(sql); var upperSql = sqlWithoutComments.toUpperCase(); // COMMIT/ROLLBACK은 특별 처리 if (upperSql === 'COMMIT') { showCommitConfirm(); return; // COMMIT 후에는 중단 (사용자 확인 필요) } if (upperSql === 'ROLLBACK') { if (confirm('변경사항을 롤백하시겠습니까?')) { rollback(); } return; } executeSql(sql, function() { // 다음 구문 실행 executeStatementsSequentially(statements, index + 1); }); } // 단일 SQL 실행 function executeSql(sql, callback) { // COMMIT/ROLLBACK 인터셉트 var upperSql = sql.toUpperCase().trim(); if (upperSql === 'COMMIT') { showCommitConfirm(); return; } if (upperSql === 'ROLLBACK') { if (confirm('변경사항을 롤백하시겠습니까?')) { rollback(); } return; } var fetchCount = $('#fetch-count').val(); var maxRows = fetchCount === 'all' ? 0 : parseInt(fetchCount); var request = { sessionId: config.sessionId, sql: sql, maxRows: maxRows, datasourceType: currentConnection.datasourceType, jndiName: currentConnection.jndiName, jdbcUrl: currentConnection.jdbcUrl, username: currentConnection.username }; showLoading(true); var startTime = Date.now(); $.ajax({ url: config.contextPath + '/admin/dbconsole/execute.do', type: 'POST', contentType: 'application/json', data: JSON.stringify(request), success: function(result) { showLoading(false); var elapsed = Date.now() - startTime; if (result.success) { if (result.queryType === 'SELECT') { displaySelectResult(result); } else { displayUpdateResult(result); } addSqlLog(sql, result.queryType, true, result.message + ' (' + elapsed + 'ms)'); clearEditorErrors(); } else { displayError(result.message); addSqlLog(sql, 'ERROR', false, result.message); markEditorError(sql, result.message); } updateTransactionIndicator(result.transactionActive); if (callback) callback(); }, error: function(xhr) { showLoading(false); var msg = xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText; displayError('실행 오류: ' + msg); addSqlLog(sql, 'ERROR', false, msg); } }); } // ======================================== // COMMIT / ROLLBACK // ======================================== function showCommitConfirm() { $('#commit-modal').modal('show'); $('#commit-password').val('').focus(); } function commit() { var password = $('#commit-password').val(); if (!password) { alert('비밀번호를 입력하세요.'); return; } var request = { sessionId: config.sessionId, password: password }; showLoading(true); $.ajax({ url: config.contextPath + '/admin/dbconsole/commit.do', type: 'POST', contentType: 'application/json', data: JSON.stringify(request), success: function(result) { showLoading(false); $('#commit-modal').modal('hide'); if (result.success) { alert('COMMIT 완료'); addSqlLog('COMMIT', 'COMMIT', true, result.message); } else { alert('COMMIT 실패: ' + result.message); addSqlLog('COMMIT', 'COMMIT', false, result.message); } }, error: function(xhr) { showLoading(false); alert('COMMIT 오류: ' + (xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText)); } }); } function rollback() { var request = { sessionId: config.sessionId }; showLoading(true); $.ajax({ url: config.contextPath + '/admin/dbconsole/rollback.do', type: 'POST', contentType: 'application/json', data: JSON.stringify(request), success: function(result) { showLoading(false); if (result.success) { alert('ROLLBACK 완료'); addSqlLog('ROLLBACK', 'ROLLBACK', true, result.message); } else { alert('ROLLBACK 실패: ' + result.message); addSqlLog('ROLLBACK', 'ROLLBACK', false, result.message); } }, error: function(xhr) { showLoading(false); alert('ROLLBACK 오류: ' + (xhr.responseJSON ? xhr.responseJSON.message : xhr.statusText)); } }); } // ======================================== // 암호화 데이터 복호화 // ======================================== var decryptModeEnabled = false; var contextMenuTarget = null; // 복호화 모드 토글 function toggleDecryptMode(enabled) { decryptModeEnabled = enabled; // 그리드 행 다시 그리기 (cellRenderer가 다시 호출되도록) if (gridApi) { gridApi.redrawRows(); } } // 암호화된 값인지 확인 (Base64 패턴) // 예: "FnJ/5lM72/Q7gBn7KG+piiABZLALMVQQ4YK8akyFJ5Q=" function isEncryptedValue(value) { if (!value || typeof value !== 'string') { return false; } var trimmed = value.trim(); // 최소 길이 체크 (Base64로 인코딩된 최소 16바이트 = 24자 정도) if (trimmed.length < 20) { return false; } // Base64 패턴: A-Z, a-z, 0-9, +, /, = (패딩) // 끝에 =나 ==가 있거나, +나 /가 포함된 경우 Base64로 판단 var base64Pattern = /^[A-Za-z0-9+/]+=*$/; if (!base64Pattern.test(trimmed)) { return false; } // Base64 특수문자(+, /)가 포함되어 있거나 =로 끝나면 암호화 데이터로 판단 var hasBase64Chars = /[+/]/.test(trimmed) || /=$/.test(trimmed); // 길이가 4의 배수인지 확인 (유효한 Base64) var isValidLength = trimmed.length % 4 === 0; return hasBase64Chars && isValidLength; } // 셀 복호화 (버튼 클릭) function decryptCell(btn) { var $cell = $(btn).closest('.cell-encrypted'); var encryptedValue = $cell.data('value'); var rowIndex = $cell.data('row'); var colId = $cell.data('col'); doDecrypt(encryptedValue, rowIndex, colId); } // 실제 복호화 수행 function doDecrypt(encryptedValue, rowIndex, colId) { if (!encryptedValue) { alert('복호화할 값이 없습니다.'); return; } $.ajax({ url: config.contextPath + '/admin/dbconsole/decrypt.do', type: 'POST', contentType: 'application/json', data: JSON.stringify({ value: encryptedValue }), success: function(response) { if (response.success) { // 복호화 성공 - 값 저장소에 저장하고 그리드 새로고침 if (rowIndex !== null && rowIndex !== undefined && colId) { var key = rowIndex + '_' + colId; decryptedValues[key] = response.decryptedValue; // 그리드 셀 새로고침 if (gridApi) { gridApi.refreshCells({ force: true }); } } else { // row/col 정보 없으면 alert로 표시 alert('복호화 결과:\n' + response.decryptedValue); } } else { alert('복호화 실패: ' + (response.message || '알 수 없는 오류')); } }, error: function(xhr) { alert('복호화 요청 오류: ' + xhr.statusText); } }); } // 컨텍스트 메뉴 초기화 function initContextMenu() { // 컨텍스트 메뉴 HTML (셀 우클릭용) var menuHtml = '
' + '
이 셀 복호화
' + '
이 셀 복사
' + '
' + '
선택 행 복사 (CSV)
' + '
선택 행 복사 (MD)
' + '
선택 행 복사 (SQL)
' + '
선택 행 복사 (JSON)
' + '
'; $('body').append(menuHtml); var $menu = $('#dbconsole-context-menu'); // 그리드 셀 우클릭 $('#result-grid').on('contextmenu', '.ag-cell', function(e) { e.preventDefault(); var cellValue = $(this).text().trim(); // (NULL) 표시 제거 if (cellValue === '(NULL)') cellValue = ''; // row/col 정보 추출 var rowIndex = null; var colId = null; // data-value 속성이 있으면 (암호화된 값) 그걸 사용 var $encrypted = $(this).find('.cell-encrypted'); if ($encrypted.length) { cellValue = $encrypted.data('value') || cellValue; rowIndex = $encrypted.data('row'); colId = $encrypted.data('col'); } // row/col 정보가 없으면 AG Grid API에서 추출 시도 if (rowIndex === null || rowIndex === undefined) { var $row = $(this).closest('.ag-row'); if ($row.length) { var rowIdAttr = $row.attr('row-index'); if (rowIdAttr !== undefined) { rowIndex = parseInt(rowIdAttr, 10); } } // colId 추출 var colIdAttr = $(this).attr('col-id'); if (colIdAttr) { colId = colIdAttr; } } contextMenuTarget = { element: $(this), value: cellValue, rowIndex: rowIndex, colId: colId }; $menu.css({ top: e.pageY + 'px', left: e.pageX + 'px' }).show(); }); // 메뉴 아이템 클릭 $menu.on('click', '.context-menu-item', function() { var action = $(this).data('action'); if (action === 'decrypt' && contextMenuTarget) { // 셀에 바로 복호화 결과 표시 doDecrypt(contextMenuTarget.value, contextMenuTarget.rowIndex, contextMenuTarget.colId); } else if (action === 'copy' && contextMenuTarget) { copyToClipboard(contextMenuTarget.value || ''); } else if (action.startsWith('copy-selection-')) { // 선택 영역 복사 var format = action.replace('copy-selection-', ''); copySelectedCells(format); } $menu.hide(); contextMenuTarget = null; }); // 다른 곳 클릭시 메뉴 숨김 $(document).on('click', function() { $menu.hide(); }); } // 클립보드 복사 function copyToClipboard(text) { if (navigator.clipboard) { navigator.clipboard.writeText(text); } else { var textarea = document.createElement('textarea'); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } } // 선택된 행 복사 (다양한 형식 지원) function copySelectedCells(format) { if (!gridApi) { alert('그리드가 초기화되지 않았습니다.'); return; } // 선택된 행 가져오기 (AG Grid Community는 행 선택만 지원) var selectedRows = gridApi.getSelectedRows(); if (!selectedRows || selectedRows.length === 0) { alert('복사할 행을 선택하세요.\n(클릭: 단일 선택, Ctrl+클릭: 다중 선택, Shift+클릭: 범위 선택)'); return; } // 선택된 데이터 추출 var selectedData = extractSelectedData(selectedRows); if (selectedData.rows.length === 0) { alert('복사할 데이터가 없습니다.'); return; } var content = ''; switch (format) { case 'csv': content = formatSelectionToCsv(selectedData); break; case 'md': content = formatSelectionToMarkdown(selectedData); break; case 'sql': content = formatSelectionToSql(selectedData); break; case 'json': content = formatSelectionToJson(selectedData); break; default: content = formatSelectionToCsv(selectedData); } copyToClipboard(content); alert('선택된 ' + selectedData.rows.length + '개 행이 ' + format.toUpperCase() + ' 형식으로 클립보드에 복사되었습니다.'); } // 선택된 행에서 데이터 추출 function extractSelectedData(selectedRows) { if (!selectedRows || selectedRows.length === 0) { return { columns: [], rows: [] }; } // 컬럼 정보 가져오기 (행 번호 컬럼 제외) var columnDefs = gridApi.getColumnDefs() || []; var columns = []; columnDefs.forEach(function(colDef) { // 행 번호 컬럼(_rowNum) 제외 if (colDef.field && colDef.field !== '_rowNum') { columns.push({ colId: colDef.field, headerName: colDef.headerName || colDef.field }); } }); // 데이터 추출 var rows = selectedRows.map(function(rowData) { var row = {}; columns.forEach(function(col) { row[col.colId] = rowData[col.colId]; }); return row; }); return { columns: columns, rows: rows }; } // 선택 데이터 -> CSV 형식 function formatSelectionToCsv(data) { var lines = []; // 헤더 lines.push(data.columns.map(function(c) { return c.headerName; }).join(',')); // 데이터 data.rows.forEach(function(row) { var values = data.columns.map(function(col) { var val = row[col.colId]; if (val === null || val === undefined) return ''; val = String(val).replace(/"/g, '""'); if (val.indexOf(',') >= 0 || val.indexOf('"') >= 0 || val.indexOf('\n') >= 0) { val = '"' + val + '"'; } return val; }); lines.push(values.join(',')); }); return lines.join('\n'); } // 선택 데이터 -> Markdown 형식 function formatSelectionToMarkdown(data) { var lines = []; // 헤더 lines.push('| ' + data.columns.map(function(c) { return c.headerName; }).join(' | ') + ' |'); lines.push('| ' + data.columns.map(function() { return '---'; }).join(' | ') + ' |'); // 데이터 data.rows.forEach(function(row) { var values = data.columns.map(function(col) { var val = row[col.colId]; if (val === null || val === undefined) return ''; return String(val).replace(/\|/g, '\\|').replace(/\n/g, ' '); }); lines.push('| ' + values.join(' | ') + ' |'); }); return lines.join('\n'); } // 선택 데이터 -> INSERT SQL 형식 function formatSelectionToSql(data) { var tableName = 'TABLE_NAME'; var columnNames = data.columns.map(function(c) { return c.colId; }).join(', '); var lines = []; data.rows.forEach(function(row) { var values = data.columns.map(function(col) { var val = row[col.colId]; if (val === null || val === undefined) return 'NULL'; val = String(val); // CLOB 처리 (4000바이트 초과 시 분할) if (val.length > 4000) { return splitClobValue(val); } return "'" + val.replace(/'/g, "''") + "'"; }); lines.push('INSERT INTO ' + tableName + ' (' + columnNames + ') VALUES (' + values.join(', ') + ');'); }); return lines.join('\n'); } // 선택 데이터 -> JSON 형식 function formatSelectionToJson(data) { var result = data.rows.map(function(row) { var obj = {}; data.columns.forEach(function(col) { obj[col.colId] = row[col.colId]; }); return obj; }); return JSON.stringify(result, null, 2); } // ======================================== // 결과 표시 // ======================================== // 복호화된 값 저장소 (rowIndex_colId -> decryptedValue) var decryptedValues = {}; function displaySelectResult(result) { if (!gridApi) return; // 복호화 값 저장소 초기화 decryptedValues = {}; // 행 번호 컬럼 (첫 번째) var rowNumCol = { headerName: '#', field: '_rowNum', width: 60, minWidth: 50, maxWidth: 80, pinned: 'left', editable: false, sortable: false, filter: false, resizable: false, suppressMovable: true, cellClass: 'row-number-cell', valueGetter: function(params) { return params.node.rowIndex + 1; } }; // 데이터 컬럼 정의 var dataColumnDefs = result.columns.map(function(col, idx) { var colMeta = result.columnMetas ? result.columnMetas[idx] : null; return { headerName: col, field: col, // valueGetter로 실제 값 반환 (복호화된 값 포함) valueGetter: function(params) { var key = params.node.rowIndex + '_' + col; // 복호화된 값이 있으면 그 값 반환 if (decryptedValues[key] !== undefined) { return decryptedValues[key]; } return params.data[col]; }, cellRenderer: function(params) { var key = params.node.rowIndex + '_' + col; var originalValue = params.data[col]; var displayValue = params.value; // NULL 체크 if (originalValue === null || originalValue === undefined) { return '(NULL)'; } var originalStr = String(originalValue); var displayStr = String(displayValue); // LOB 타입 if (colMeta && colMeta.lobType) { return '' + escapeHtml(displayStr) + ''; } // 복호화된 값인 경우 (저장소에 있음) if (decryptedValues[key] !== undefined) { return '' + ' ' + escapeHtml(displayStr) + ''; } // 복호화 모드 활성화 + 암호화된 값 감지 시에만 버튼 표시 (원본값 기준) if (decryptModeEnabled && isEncryptedValue(originalStr)) { var shortValue = originalStr.length > 20 ? originalStr.substring(0, 20) + '...' : originalStr; return '' + escapeHtml(shortValue) + ' '; } return escapeHtml(displayStr); } }; }); var columnDefs = [rowNumCol].concat(dataColumnDefs); gridApi.updateGridOptions({ columnDefs: columnDefs, rowData: result.rows || [] }); gridApi.sizeColumnsToFit(); // 결과 탭으로 전환 $('.dbconsole-result-tab[data-target="result-grid-container"]').click(); // 메시지 표시 $('#result-message').text(result.message || ''); } function displayUpdateResult(result) { // 그리드 초기화 if (gridApi) { gridApi.updateGridOptions({ columnDefs: [], rowData: [] }); } // 메시지 표시 $('#result-message').text(result.message || ''); alert(result.message); } function displayError(message) { $('#result-message').text('오류: ' + message); } function updateTransactionIndicator(active) { var $indicator = $('#transaction-indicator'); if (active) { $indicator.addClass('active').text('트랜잭션 활성'); } else { $indicator.removeClass('active').text(''); } } // ======================================== // 스키마 탐색기 // ======================================== function loadSchemas() { var request = { sessionId: config.sessionId, datasourceType: currentConnection.datasourceType, jndiName: currentConnection.jndiName, jdbcUrl: currentConnection.jdbcUrl, username: currentConnection.username }; $.ajax({ url: config.contextPath + '/admin/dbconsole/schemas.do', type: 'POST', contentType: 'application/json', data: JSON.stringify(request), success: function(schemas) { renderSchemaTree(schemas); } }); } function renderSchemaTree(schemas) { var $tree = $('#schema-tree'); $tree.empty(); // 현재 스키마를 우선 표시 var currentSchema = (currentConnection.schema || currentConnection.username || '').toUpperCase(); var sortedSchemas = schemas.sort(function(a, b) { if (a === currentSchema) return -1; if (b === currentSchema) return 1; return a.localeCompare(b); }); $.each(sortedSchemas, function(idx, schema) { var $item = $('
' + '' + '' + '' + schema + '' + '
'); var $children = $('
'); $item.on('click', function(e) { e.stopPropagation(); var $toggle = $(this).find('.tree-toggle i'); if ($children.hasClass('expanded')) { $children.removeClass('expanded'); $toggle.removeClass('bi-chevron-down').addClass('bi-chevron-right'); } else { $children.addClass('expanded'); $toggle.removeClass('bi-chevron-right').addClass('bi-chevron-down'); if ($children.children().length === 0) { loadTables(schema, $children); } } }); $tree.append($item).append($children); // 현재 스키마 자동 확장 if (schema === currentSchema) { $item.click(); } }); } function loadTables(schema, $container) { var request = { sessionId: config.sessionId, schemaName: schema, datasourceType: currentConnection.datasourceType, jndiName: currentConnection.jndiName, jdbcUrl: currentConnection.jdbcUrl, username: currentConnection.username }; $container.html('
로딩 중...
'); $.ajax({ url: config.contextPath + '/admin/dbconsole/tables.do', type: 'POST', contentType: 'application/json', data: JSON.stringify(request), success: function(tables) { renderTables(schema, tables, $container); }, error: function() { $container.html('
로드 실패
'); } }); } function renderTables(schema, tables, $container) { $container.empty(); if (tables.length === 0) { $container.html('
테이블 없음
'); return; } $.each(tables, function(idx, table) { var typeClass = table.tableType === 'VIEW' ? 'view' : ''; var $item = $('
' + '' + '' + '' + table.tableName + '' + '' + table.tableType + '' + '
'); var $columns = $('
'); // 테이블 클릭 - 확장/축소 $item.find('.tree-toggle').on('click', function(e) { e.stopPropagation(); var $toggle = $(this).find('i'); if ($columns.hasClass('expanded')) { $columns.removeClass('expanded'); $toggle.removeClass('bi-chevron-down').addClass('bi-chevron-right'); } else { $columns.addClass('expanded'); $toggle.removeClass('bi-chevron-right').addClass('bi-chevron-down'); if ($columns.children().length === 0) { loadColumns(schema, table.tableName, $columns); } } }); // 테이블 더블클릭 - SELECT 생성 (새 라인에 세미콜론 포함) $item.on('dblclick', function(e) { e.stopPropagation(); var sql = 'SELECT * FROM ' + schema + '.' + table.tableName + ';'; if (editor) { var model = editor.getModel(); var lastLine = model.getLineCount(); var lastLineContent = model.getLineContent(lastLine); // 마지막 라인이 비어있지 않으면 새 라인 추가 var prefix = lastLineContent.trim() ? '\n' : ''; editor.executeEdits('', [{ range: new monaco.Range(lastLine, model.getLineMaxColumn(lastLine), lastLine, model.getLineMaxColumn(lastLine)), text: prefix + sql }]); // 커서를 새로 추가된 SQL 끝으로 이동 var newLastLine = model.getLineCount(); editor.setPosition({ lineNumber: newLastLine, column: model.getLineMaxColumn(newLastLine) }); editor.focus(); } }); // 우클릭 - DDL 조회 $item.on('contextmenu', function(e) { e.preventDefault(); showTableDdl(schema, table.tableName); }); $container.append($item).append($columns); }); } function loadColumns(schema, tableName, $container) { var request = { sessionId: config.sessionId, schemaName: schema, tableName: tableName, datasourceType: currentConnection.datasourceType, jndiName: currentConnection.jndiName, jdbcUrl: currentConnection.jdbcUrl, username: currentConnection.username }; $container.html('
로딩 중...
'); $.ajax({ url: config.contextPath + '/admin/dbconsole/columns.do', type: 'POST', contentType: 'application/json', data: JSON.stringify(request), success: function(columns) { renderColumns(columns, $container); }, error: function() { $container.html('
로드 실패
'); } }); } function renderColumns(columns, $container) { $container.empty(); $.each(columns, function(idx, col) { var badges = ''; if (col.primaryKey) badges += 'PK '; if (col.foreignKey) badges += 'FK '; if (col.indexed && !col.primaryKey) badges += 'IDX '; var $item = $('
' + '' + col.columnName + '' + badges + '' + col.dataType + (col.dataLength ? '(' + col.dataLength + ')' : '') + (col.nullable === 'N' ? ' NOT NULL' : '') + '' + '
'); $container.append($item); }); } function clearSchemaTree() { $('#schema-tree').empty(); } function showTableDdl(schema, tableName) { var request = { sessionId: config.sessionId, schemaName: schema, tableName: tableName, datasourceType: currentConnection.datasourceType, jndiName: currentConnection.jndiName, jdbcUrl: currentConnection.jdbcUrl, username: currentConnection.username }; $.ajax({ url: config.contextPath + '/admin/dbconsole/ddl.do', type: 'POST', contentType: 'application/json', data: JSON.stringify(request), success: function(result) { $('#ddl-modal .modal-title').text('DDL: ' + schema + '.' + tableName); $('#ddl-content').text(result.ddl || '-- DDL 조회 실패'); $('#ddl-modal').modal('show'); } }); } // ======================================== // 내보내기 // ======================================== function exportResult(format) { if (!gridApi) return; var rowData = []; gridApi.forEachNode(function(node) { rowData.push(node.data); }); if (rowData.length === 0) { alert('내보낼 데이터가 없습니다.'); return; } var columns = gridApi.getColumnDefs().map(function(col) { return col.field; }); var content = ''; var filename = ''; var mimeType = ''; switch (format) { case 'csv': content = exportToCsv(columns, rowData); filename = 'query_result.csv'; mimeType = 'text/csv;charset=utf-8;'; break; case 'md': content = exportToMarkdown(columns, rowData); filename = 'query_result.md'; mimeType = 'text/markdown;charset=utf-8;'; break; case 'sql': content = exportToInsertSql(columns, rowData); filename = 'insert_statements.sql'; mimeType = 'text/plain;charset=utf-8;'; break; case 'json': content = JSON.stringify(rowData, null, 2); filename = 'query_result.do'; mimeType = 'application/json;charset=utf-8;'; break; } if (rowData.length >= DOWNLOAD_THRESHOLD) { downloadFile(content, filename, mimeType); } else { copyToClipboard(content); alert('클립보드에 복사되었습니다. (' + rowData.length + '건)'); } } function exportToCsv(columns, rows) { var lines = []; lines.push(columns.join(',')); $.each(rows, function(idx, row) { var values = columns.map(function(col) { var val = row[col]; if (val === null || val === undefined) return ''; val = String(val).replace(/"/g, '""'); if (val.indexOf(',') >= 0 || val.indexOf('"') >= 0 || val.indexOf('\n') >= 0) { val = '"' + val + '"'; } return val; }); lines.push(values.join(',')); }); return lines.join('\n'); } function exportToMarkdown(columns, rows) { var lines = []; lines.push('| ' + columns.join(' | ') + ' |'); lines.push('| ' + columns.map(function() { return '---'; }).join(' | ') + ' |'); $.each(rows, function(idx, row) { var values = columns.map(function(col) { var val = row[col]; if (val === null || val === undefined) return ''; return String(val).replace(/\|/g, '\\|'); }); lines.push('| ' + values.join(' | ') + ' |'); }); return lines.join('\n'); } function exportToInsertSql(columns, rows) { var tableName = 'TABLE_NAME'; // 실제 테이블명은 알 수 없으므로 placeholder var lines = []; $.each(rows, function(idx, row) { var values = columns.map(function(col) { var val = row[col]; if (val === null || val === undefined) return 'NULL'; val = String(val); // CLOB 처리 (4000바이트 초과 시 분할) if (val.length > 4000) { return splitClobValue(val); } return "'" + val.replace(/'/g, "''") + "'"; }); lines.push('INSERT INTO ' + tableName + ' (' + columns.join(', ') + ') VALUES (' + values.join(', ') + ');'); }); return lines.join('\n'); } function splitClobValue(str) { var chunks = []; var maxBytes = 3900; // 안전 마진 var currentChunk = ''; var currentBytes = 0; for (var i = 0; i < str.length; i++) { var char = str.charAt(i); var charBytes = getByteLength(char); if (currentBytes + charBytes > maxBytes) { chunks.push(currentChunk); currentChunk = char; currentBytes = charBytes; } else { currentChunk += char; currentBytes += charBytes; } } if (currentChunk) { chunks.push(currentChunk); } if (chunks.length === 1) { return "TO_CLOB('" + chunks[0].replace(/'/g, "''") + "')"; } return chunks.map(function(chunk) { return "TO_CLOB('" + chunk.replace(/'/g, "''") + "')"; }).join(' || '); } function getByteLength(char) { var code = char.charCodeAt(0); if (code <= 0x7f) return 1; if (code <= 0x7ff) return 2; if (code <= 0xffff) return 3; return 4; } function downloadFile(content, filename, mimeType) { var blob = new Blob(['\ufeff' + content], { type: mimeType }); // BOM 포함 var link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); URL.revokeObjectURL(link.href); } function copyToClipboard(text) { if (navigator.clipboard) { navigator.clipboard.writeText(text); } else { var textarea = document.createElement('textarea'); textarea.value = text; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } } // ======================================== // SQL Log // ======================================== function addSqlLog(sql, type, success, result) { var entry = { time: new Date().toLocaleTimeString(), sql: sql, type: type, success: success, result: result }; sqlLogs.unshift(entry); if (sqlLogs.length > 100) { sqlLogs.pop(); } renderSqlLogs(); } function renderSqlLogs() { var $log = $('#sql-log'); $log.empty(); $.each(sqlLogs, function(idx, entry) { var statusClass = entry.success ? 'success' : 'error'; var $entry = $('
' + '
[' + entry.time + '] ' + entry.type + '
' + '
' + escapeHtml(entry.sql) + '
' + '
' + escapeHtml(entry.result) + '
' + '
'); $log.append($entry); }); } // ======================================== // 에디터 에러 마커 // ======================================== function markEditorError(sql, message) { if (!editor) return; var model = editor.getModel(); monaco.editor.setModelMarkers(model, 'sql-error', [{ startLineNumber: 1, startColumn: 1, endLineNumber: model.getLineCount(), endColumn: model.getLineMaxColumn(model.getLineCount()), message: message, severity: monaco.MarkerSeverity.Error }]); } function clearEditorErrors() { if (!editor) return; monaco.editor.setModelMarkers(editor.getModel(), 'sql-error', []); } // ======================================== // 하트비트 // ======================================== function startHeartbeat() { stopHeartbeat(); heartbeatInterval = setInterval(function() { if (!currentConnection.connected) { stopHeartbeat(); return; } $.ajax({ url: config.contextPath + '/admin/dbconsole/heartbeat.do', type: 'POST', contentType: 'application/json', data: JSON.stringify({ sessionId: config.sessionId }), success: function(result) { if (!result.success) { currentConnection.connected = false; updateConnectionStatus(); stopHeartbeat(); alert('세션이 만료되었습니다. 다시 연결하세요.'); } } }); }, HEARTBEAT_INTERVAL); } function stopHeartbeat() { if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; } } // ======================================== // 유틸리티 // ======================================== function showLoading(show) { if (show) { $('.dbconsole-loading').addClass('show'); } else { $('.dbconsole-loading').removeClass('show'); } } function escapeHtml(text) { if (!text) return ''; return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // ======================================== // localStorage 설정 관리 // ======================================== var STORAGE_PREFIX = 'dbconsole_'; function saveSetting(key, value) { try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch (e) { // localStorage 사용 불가 시 무시 } } function loadSetting(key, defaultValue) { try { var value = localStorage.getItem(STORAGE_PREFIX + key); return value !== null ? JSON.parse(value) : defaultValue; } catch (e) { return defaultValue; } } // 저장된 설정값들을 UI에 적용 function restoreSettings() { // Fetch Count var savedFetchCount = loadSetting('fetchCount', '1000'); $('#fetch-count').val(savedFetchCount); // 복호화 모드 var savedDecryptMode = loadSetting('decryptMode', false); $('#chk-decrypt-mode').prop('checked', savedDecryptMode); decryptModeEnabled = savedDecryptMode; // 데이터소스 var savedDatasource = loadSetting('datasource', ''); if (savedDatasource !== '' && config.datasources[savedDatasource]) { $('#datasource-select').val(savedDatasource).trigger('change'); } } // ======================================== // Public API // ======================================== return { init: init, execute: execute, executeAll: executeAll, commit: commit, rollback: rollback, connect: connect, disconnect: disconnect, exportResult: exportResult, decryptCell: decryptCell, toggleDecryptMode: toggleDecryptMode, copySelectedCells: copySelectedCells }; })();