feat: Gravity Web Phase 1 - CDP remote control dashboard
This commit is contained in:
252
public/js/app.js
Normal file
252
public/js/app.js
Normal file
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Gravity Web — App 초기화 및 WebSocket 관리
|
||||
*/
|
||||
|
||||
(function () {
|
||||
// ─── 컴포넌트 초기화 ──────────────────────────────────
|
||||
const sessionPanel = new SessionPanel();
|
||||
const chatPanel = new ChatPanel();
|
||||
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
const WS_URL = `ws://${location.host}/ws`;
|
||||
|
||||
// ─── DOM 레퍼런스 ─────────────────────────────────────
|
||||
const connectionStatus = document.getElementById('connectionStatus');
|
||||
const statusDot = connectionStatus.querySelector('.status-dot');
|
||||
const statusText = connectionStatus.querySelector('.status-text');
|
||||
|
||||
const addSessionBtn = document.getElementById('addSessionBtn');
|
||||
const addSessionModal = document.getElementById('addSessionModal');
|
||||
const closeModal = document.getElementById('closeModal');
|
||||
const cancelModal = document.getElementById('cancelModal');
|
||||
const confirmAddSession = document.getElementById('confirmAddSession');
|
||||
const sessionNameInput = document.getElementById('sessionName');
|
||||
const sessionHostInput = document.getElementById('sessionHost');
|
||||
const sessionPortInput = document.getElementById('sessionPort');
|
||||
|
||||
const screenshotBtn = document.getElementById('screenshotBtn');
|
||||
const reconnectBtn = document.getElementById('reconnectBtn');
|
||||
const screenshotOverlay = document.getElementById('screenshotOverlay');
|
||||
const screenshotImage = document.getElementById('screenshotImage');
|
||||
const closeScreenshot = document.getElementById('closeScreenshot');
|
||||
|
||||
// ─── WebSocket 연결 ───────────────────────────────────
|
||||
function connectWebSocket() {
|
||||
ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus('connected', '서버 연결됨');
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnectionStatus('error', '연결 끊김');
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus('error', '연결 오류');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleMessage(msg);
|
||||
} catch (e) {
|
||||
console.error('WS 메시지 파싱 오류:', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connectWebSocket();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function sendWs(msg) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 서버 메시지 핸들러 ───────────────────────────────
|
||||
function handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'sessions_list':
|
||||
sessionPanel.update(msg.sessions);
|
||||
break;
|
||||
|
||||
case 'session_switched':
|
||||
sessionPanel.setActive(msg.sessionId);
|
||||
chatPanel.showSession(msg.session);
|
||||
break;
|
||||
|
||||
case 'chat_update':
|
||||
if (msg.sessionId === sessionPanel.activeSessionId) {
|
||||
chatPanel.updateChat(msg.html);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_sent':
|
||||
if (!msg.success) {
|
||||
showToast(`전송 실패: ${msg.error}`, 'error');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'screenshot':
|
||||
screenshotImage.src = `data:image/jpeg;base64,${msg.data}`;
|
||||
screenshotOverlay.style.display = 'flex';
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
showToast(msg.message, 'error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 세션 패널 이벤트 ─────────────────────────────────
|
||||
sessionPanel.onSessionSelect = (sessionId) => {
|
||||
sendWs({ type: 'switch_session', sessionId });
|
||||
};
|
||||
|
||||
sessionPanel.onSessionRemove = async (sessionId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
if (sessionPanel.activeSessionId === sessionId) {
|
||||
chatPanel.showEmpty();
|
||||
sessionPanel.setActive(null);
|
||||
}
|
||||
showToast('세션이 제거되었습니다', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('세션 제거 실패', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 채팅 패널 이벤트 ─────────────────────────────────
|
||||
chatPanel.onSendMessage = (text) => {
|
||||
sendWs({
|
||||
type: 'send_message',
|
||||
sessionId: sessionPanel.activeSessionId,
|
||||
text,
|
||||
});
|
||||
};
|
||||
|
||||
// ─── 모달 ─────────────────────────────────────────────
|
||||
function showModal() {
|
||||
addSessionModal.style.display = 'flex';
|
||||
sessionNameInput.value = '';
|
||||
sessionHostInput.value = 'localhost';
|
||||
sessionPortInput.value = '9000';
|
||||
setTimeout(() => sessionNameInput.focus(), 100);
|
||||
}
|
||||
|
||||
function hideModal() {
|
||||
addSessionModal.style.display = 'none';
|
||||
}
|
||||
|
||||
async function addSession() {
|
||||
const name = sessionNameInput.value.trim();
|
||||
const host = sessionHostInput.value.trim();
|
||||
const port = parseInt(sessionPortInput.value, 10);
|
||||
|
||||
if (!name) {
|
||||
sessionNameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
hideModal();
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, host, cdpPort: port }),
|
||||
});
|
||||
|
||||
const session = await res.json();
|
||||
|
||||
if (session.status === 'connected') {
|
||||
showToast(`${name} 연결 성공`, 'success');
|
||||
} else {
|
||||
showToast(`${name} 연결 실패: ${session.error || '알 수 없는 오류'}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('세션 추가 실패', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
addSessionBtn.addEventListener('click', showModal);
|
||||
closeModal.addEventListener('click', hideModal);
|
||||
cancelModal.addEventListener('click', hideModal);
|
||||
confirmAddSession.addEventListener('click', addSession);
|
||||
|
||||
// Enter로 모달 확인
|
||||
addSessionModal.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') addSession();
|
||||
if (e.key === 'Escape') hideModal();
|
||||
});
|
||||
|
||||
// 배경 클릭으로 모달 닫기
|
||||
addSessionModal.addEventListener('click', (e) => {
|
||||
if (e.target === addSessionModal) hideModal();
|
||||
});
|
||||
|
||||
// ─── 스크린샷 ─────────────────────────────────────────
|
||||
screenshotBtn.addEventListener('click', () => {
|
||||
sendWs({ type: 'get_screenshot', sessionId: sessionPanel.activeSessionId });
|
||||
});
|
||||
|
||||
closeScreenshot.addEventListener('click', () => {
|
||||
screenshotOverlay.style.display = 'none';
|
||||
});
|
||||
|
||||
screenshotOverlay.addEventListener('click', (e) => {
|
||||
if (e.target === screenshotOverlay) {
|
||||
screenshotOverlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 재연결 ───────────────────────────────────────────
|
||||
reconnectBtn.addEventListener('click', async () => {
|
||||
const id = sessionPanel.activeSessionId;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${id}/reconnect`, { method: 'POST' });
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
showToast('재연결 성공', 'success');
|
||||
} else {
|
||||
showToast(`재연결 실패: ${result.error}`, 'error');
|
||||
}
|
||||
} catch {
|
||||
showToast('재연결 실패', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 유틸리티 ─────────────────────────────────────────
|
||||
function setConnectionStatus(status, text) {
|
||||
statusDot.className = `status-dot ${status}`;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
function showToast(message, type = '') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
// ─── 시작 ─────────────────────────────────────────────
|
||||
connectWebSocket();
|
||||
})();
|
||||
134
public/js/chat-panel.js
Normal file
134
public/js/chat-panel.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Chat Panel — 채팅 표시/입력 UI 관리
|
||||
*/
|
||||
|
||||
class ChatPanel {
|
||||
constructor() {
|
||||
this.containerEl = document.getElementById('chatContainer');
|
||||
this.emptyEl = document.getElementById('emptyState');
|
||||
this.messagesEl = document.getElementById('chatMessages');
|
||||
this.inputEl = document.getElementById('chatInput');
|
||||
this.sendBtn = document.getElementById('sendBtn');
|
||||
this.sessionNameEl = document.getElementById('chatSessionName');
|
||||
this.sessionStatusEl = document.getElementById('chatSessionStatus');
|
||||
|
||||
this.onSendMessage = null; // callback(text)
|
||||
this.activeSession = null;
|
||||
|
||||
this._setupInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 이벤트 바인딩
|
||||
*/
|
||||
_setupInput() {
|
||||
// 자동 높이 조절
|
||||
this.inputEl.addEventListener('input', () => {
|
||||
this.inputEl.style.height = 'auto';
|
||||
this.inputEl.style.height = Math.min(this.inputEl.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
// Enter로 전송 (Shift+Enter는 줄바꿈)
|
||||
this.inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this._sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
this.sendBtn.addEventListener('click', () => {
|
||||
this._sendMessage();
|
||||
});
|
||||
}
|
||||
|
||||
_sendMessage() {
|
||||
const text = this.inputEl.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
if (this.onSendMessage) {
|
||||
this.onSendMessage(text);
|
||||
}
|
||||
|
||||
// 입력창 초기화
|
||||
this.inputEl.value = '';
|
||||
this.inputEl.style.height = 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 선택 시 UI 표시
|
||||
*/
|
||||
showSession(session) {
|
||||
this.activeSession = session;
|
||||
this.emptyEl.style.display = 'none';
|
||||
this.containerEl.style.display = 'flex';
|
||||
|
||||
this.sessionNameEl.textContent = session.name;
|
||||
this.sessionStatusEl.textContent = session.status === 'connected' ? '● 연결됨' : session.status;
|
||||
|
||||
this.messagesEl.innerHTML = `
|
||||
<div class="chat-welcome">
|
||||
<p>채팅 데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.inputEl.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 빈 상태 표시
|
||||
*/
|
||||
showEmpty() {
|
||||
this.activeSession = null;
|
||||
this.emptyEl.style.display = 'flex';
|
||||
this.containerEl.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 채팅 내용 업데이트 (Antigravity DOM HTML)
|
||||
*/
|
||||
updateChat(html) {
|
||||
if (!html || html.includes('chat container not found')) {
|
||||
this.messagesEl.innerHTML = `
|
||||
<div class="chat-welcome">
|
||||
<p>⚠️ 채팅 컨테이너를 찾을 수 없습니다.<br>
|
||||
Antigravity에서 채팅을 시작해주세요.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML 삽입 (Antigravity에서 가져온 DOM)
|
||||
const wasAtBottom = this._isScrolledToBottom();
|
||||
|
||||
this.messagesEl.innerHTML = `<div class="ag-content">${html}</div>`;
|
||||
|
||||
// 스크롤 유지
|
||||
if (wasAtBottom) {
|
||||
this._scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 상태 업데이트
|
||||
*/
|
||||
updateSessionStatus(status) {
|
||||
if (!this.activeSession) return;
|
||||
|
||||
const statusText = {
|
||||
connected: '● 연결됨',
|
||||
disconnected: '○ 연결 끊김',
|
||||
error: '⚠ 오류',
|
||||
};
|
||||
|
||||
this.sessionStatusEl.textContent = statusText[status] || status;
|
||||
}
|
||||
|
||||
_isScrolledToBottom() {
|
||||
const el = this.messagesEl;
|
||||
return el.scrollTop + el.clientHeight >= el.scrollHeight - 50;
|
||||
}
|
||||
|
||||
_scrollToBottom() {
|
||||
this.messagesEl.scrollTop = this.messagesEl.scrollHeight;
|
||||
}
|
||||
}
|
||||
91
public/js/session-panel.js
Normal file
91
public/js/session-panel.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Session Panel — 세션 목록 UI 관리
|
||||
*/
|
||||
|
||||
class SessionPanel {
|
||||
constructor() {
|
||||
this.sessions = [];
|
||||
this.activeSessionId = null;
|
||||
this.onSessionSelect = null; // callback(sessionId)
|
||||
this.onSessionRemove = null; // callback(sessionId)
|
||||
|
||||
this.listEl = document.getElementById('sessionList');
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 목록 업데이트
|
||||
*/
|
||||
update(sessions) {
|
||||
this.sessions = sessions;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 세션 설정
|
||||
*/
|
||||
setActive(sessionId) {
|
||||
this.activeSessionId = sessionId;
|
||||
this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 목록 렌더링
|
||||
*/
|
||||
render() {
|
||||
if (!this.listEl) return;
|
||||
|
||||
if (this.sessions.length === 0) {
|
||||
this.listEl.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 12px;">
|
||||
세션이 없습니다<br>+ 버튼으로 추가하세요
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.listEl.innerHTML = this.sessions.map(s => `
|
||||
<div class="session-card ${s.id === this.activeSessionId ? 'active' : ''}"
|
||||
data-session-id="${s.id}">
|
||||
<div class="session-indicator ${s.status}"></div>
|
||||
<div class="session-info">
|
||||
<div class="session-name">${this._escapeHtml(s.name)}</div>
|
||||
<div class="session-detail">${s.host}:${s.cdpPort} · ${this._statusText(s.status)}</div>
|
||||
</div>
|
||||
<button class="session-remove" data-remove-id="${s.id}" title="세션 제거">✕</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// 이벤트 바인딩
|
||||
this.listEl.querySelectorAll('.session-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.session-remove')) return;
|
||||
const id = card.dataset.sessionId;
|
||||
if (this.onSessionSelect) this.onSessionSelect(id);
|
||||
});
|
||||
});
|
||||
|
||||
this.listEl.querySelectorAll('.session-remove').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.removeId;
|
||||
if (this.onSessionRemove) this.onSessionRemove(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_statusText(status) {
|
||||
const map = {
|
||||
connected: '연결됨',
|
||||
connecting: '연결 중...',
|
||||
disconnected: '연결 끊김',
|
||||
error: '오류',
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
_escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user