From 516a38777c07de15ff21243b5294133dcdad71f1 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sat, 14 Mar 2026 16:35:43 -0300 Subject: [PATCH] feat: add campaigns, lists, and templates UI --- src/ui_server/mod.rs | 274 +++++++++++++++++----------- ui/suite/campaigns/campaigns.html | 292 ++++++++++++++++++++++++++++++ ui/suite/crm/crm.css | 119 ++++++++++-- ui/suite/crm/crm.html | 47 ++--- ui/suite/desktop.html | 121 +++++++++++++ ui/suite/js/terminal.js | 121 +++++++++++-- ui/suite/lists/lists.html | 181 ++++++++++++++++++ ui/suite/sources/index.html | 21 +++ ui/suite/templates/templates.html | 200 ++++++++++++++++++++ 9 files changed, 1199 insertions(+), 177 deletions(-) create mode 100644 ui/suite/campaigns/campaigns.html create mode 100644 ui/suite/lists/lists.html create mode 100644 ui/suite/templates/templates.html diff --git a/src/ui_server/mod.rs b/src/ui_server/mod.rs index 0393621..ccdb815 100644 --- a/src/ui_server/mod.rs +++ b/src/ui_server/mod.rs @@ -357,8 +357,8 @@ pub async fn serve_suite(bot_name: Option) -> impl IntoResponse { match raw_html_res { Ok(raw_html) => { - #[allow(unused_mut)] // Mutable required for feature-gated blocks let mut html = raw_html; + let _ = &mut html; // Suppress unused_mut if no features are disabled // Inject base tag and bot_name into the page if let Some(head_end) = html.find("") { @@ -563,7 +563,6 @@ pub async fn serve_suite(bot_name: Option) -> impl IntoResponse { } } -#[allow(dead_code)] pub fn remove_section(html: &str, section: &str) -> String { let start_marker = format!("", section); let end_marker = format!("", section); @@ -840,6 +839,116 @@ async fn ws_proxy( ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params_with_bot)) } +async fn handle_ws_proxy( + client_socket: WebSocket, + state: AppState, + params: WsQuery, +) { + let bot_name = params.bot_name.unwrap_or_else(|| "default".to_string()); + let backend_url = format!( + "{}/ws/{}", + state + .client + .base_url() + .replace("https://", "wss://") + .replace("http://", "ws://"), + bot_name + ); + + info!("Proxying WebSocket to: {backend_url}"); + + let Ok(tls_connector) = native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build() + else { + error!("Failed to build TLS connector for WebSocket proxy"); + return; + }; + + let connector = tokio_tungstenite::Connector::NativeTls(tls_connector); + + let backend_result = + connect_async_tls_with_config(&backend_url, None, false, Some(connector)).await; + + let backend_socket: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + > = match backend_result { + Ok((socket, _)) => socket, + Err(e) => { + error!("Failed to connect to backend WebSocket: {e}"); + return; + } + }; + + info!("Connected to backend WebSocket"); + + let (mut client_tx, mut client_rx) = client_socket.split(); + let (mut backend_tx, mut backend_rx) = backend_socket.split(); + + let client_to_backend = async { + while let Some(msg) = client_rx.next().await { + match msg { + Ok(AxumMessage::Text(text)) => { + if backend_tx.send(TungsteniteMessage::Text(text)).await.is_err() { + break; + } + } + Ok(AxumMessage::Binary(data)) => { + if backend_tx.send(TungsteniteMessage::Binary(data)).await.is_err() { + break; + } + } + Ok(AxumMessage::Ping(data)) => { + if backend_tx.send(TungsteniteMessage::Ping(data)).await.is_err() { + break; + } + } + Ok(AxumMessage::Pong(data)) => { + if backend_tx.send(TungsteniteMessage::Pong(data)).await.is_err() { + break; + } + } + Ok(AxumMessage::Close(_)) | Err(_) => break, + } + } + }; + + let backend_to_client = async { + while let Some(msg) = backend_rx.next().await { + match msg { + Ok(TungsteniteMessage::Text(text)) => { + if client_tx.send(AxumMessage::Text(text)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Binary(data)) => { + if client_tx.send(AxumMessage::Binary(data)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Ping(data)) => { + if client_tx.send(AxumMessage::Ping(data)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Pong(data)) => { + if client_tx.send(AxumMessage::Pong(data)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + }; + + tokio::select! { + () = client_to_backend => info!("Client connection closed"), + () = backend_to_client => info!("Backend connection closed"), + } +} + async fn ws_task_progress_proxy( ws: WebSocketUpgrade, State(state): State, @@ -983,119 +1092,66 @@ async fn handle_task_progress_ws_proxy( } } -#[allow(clippy::too_many_lines)] -async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) { - let backend_url = format!( - "{}/ws?session_id={}&user_id={}&bot_name={}", - state - .client - .base_url() - .replace("https://", "wss://") - .replace("http://", "ws://"), - params.session_id, - params.user_id, - params.bot_name.unwrap_or_else(|| "default".to_string()) - ); - - info!("Proxying WebSocket to: {backend_url}"); - - let Ok(tls_connector) = native_tls::TlsConnector::builder() - .danger_accept_invalid_certs(true) - .danger_accept_invalid_hostnames(true) - .build() - else { - error!("Failed to build TLS connector"); - return; - }; - - let connector = tokio_tungstenite::Connector::NativeTls(tls_connector); - - let backend_result = - connect_async_tls_with_config(&backend_url, None, false, Some(connector)).await; - - let backend_socket: tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - > = match backend_result { - Ok((socket, _)) => socket, - Err(e) => { - error!("Failed to connect to backend WebSocket: {e}"); - return; - } - }; - - info!("Connected to backend WebSocket"); - - let (mut client_tx, mut client_rx) = client_socket.split(); - let (mut backend_tx, mut backend_rx) = backend_socket.split(); - - let client_to_backend = async { - while let Some(msg) = client_rx.next().await { - match msg { - Ok(AxumMessage::Text(text)) => { - let res: Result<(), tungstenite::Error> = - backend_tx.send(TungsteniteMessage::Text(text)).await; - if res.is_err() { - break; - } +async fn forward_client_to_backend( + client_rx: &mut futures_util::stream::SplitStream, + backend_tx: &mut futures_util::stream::SplitSink>, TungsteniteMessage>, +) { + while let Some(msg) = client_rx.next().await { + match msg { + Ok(AxumMessage::Text(text)) => { + if backend_tx.send(TungsteniteMessage::Text(text)).await.is_err() { + break; } - Ok(AxumMessage::Binary(data)) => { - let res: Result<(), tungstenite::Error> = - backend_tx.send(TungsteniteMessage::Binary(data)).await; - if res.is_err() { - break; - } - } - Ok(AxumMessage::Ping(data)) => { - let res: Result<(), tungstenite::Error> = - backend_tx.send(TungsteniteMessage::Ping(data)).await; - if res.is_err() { - break; - } - } - Ok(AxumMessage::Pong(data)) => { - let res: Result<(), tungstenite::Error> = - backend_tx.send(TungsteniteMessage::Pong(data)).await; - if res.is_err() { - break; - } - } - Ok(AxumMessage::Close(_)) | Err(_) => break, } - } - }; - - let backend_to_client = async { - while let Some(msg) = backend_rx.next().await { - match msg { - Ok(TungsteniteMessage::Text(text)) => { - if client_tx.send(AxumMessage::Text(text)).await.is_err() { - break; - } + Ok(AxumMessage::Binary(data)) => { + if backend_tx.send(TungsteniteMessage::Binary(data)).await.is_err() { + break; } - Ok(TungsteniteMessage::Binary(data)) => { - if client_tx.send(AxumMessage::Binary(data)).await.is_err() { - break; - } - } - Ok(TungsteniteMessage::Ping(data)) => { - if client_tx.send(AxumMessage::Ping(data)).await.is_err() { - break; - } - } - Ok(TungsteniteMessage::Pong(data)) => { - if client_tx.send(AxumMessage::Pong(data)).await.is_err() { - break; - } - } - Ok(TungsteniteMessage::Close(_)) | Err(_) => break, - Ok(_) => {} } + Ok(AxumMessage::Ping(data)) => { + if backend_tx.send(TungsteniteMessage::Ping(data)).await.is_err() { + break; + } + } + Ok(AxumMessage::Pong(data)) => { + if backend_tx.send(TungsteniteMessage::Pong(data)).await.is_err() { + break; + } + } + Ok(AxumMessage::Close(_)) | Err(_) => break, } - }; + } +} - tokio::select! { - () = client_to_backend => info!("Client connection closed"), - () = backend_to_client => info!("Backend connection closed"), +async fn forward_backend_to_client( + backend_rx: &mut futures_util::stream::SplitStream>>, + client_tx: &mut futures_util::stream::SplitSink, +) { + while let Some(msg) = backend_rx.next().await { + match msg { + Ok(TungsteniteMessage::Text(text)) => { + if client_tx.send(AxumMessage::Text(text)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Binary(data)) => { + if client_tx.send(AxumMessage::Binary(data)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Ping(data)) => { + if client_tx.send(AxumMessage::Ping(data)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Pong(data)) => { + if client_tx.send(AxumMessage::Pong(data)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Close(_)) | Err(_) => break, + Ok(_) => {} + } } } diff --git a/ui/suite/campaigns/campaigns.html b/ui/suite/campaigns/campaigns.html new file mode 100644 index 0000000..4c9ce2e --- /dev/null +++ b/ui/suite/campaigns/campaigns.html @@ -0,0 +1,292 @@ + + + + + + +
+ +
+
+

Campaigns

+ +
+
+ +
+
+ + +
+
+ +
+
+ Loading campaigns... +
+
+
+
+
+ + + + + + + diff --git a/ui/suite/crm/crm.css b/ui/suite/crm/crm.css index 853b043..37889dd 100644 --- a/ui/suite/crm/crm.css +++ b/ui/suite/crm/crm.css @@ -5,8 +5,8 @@ display: flex; flex-direction: column; height: 100%; - background: var(--bg-primary, var(--surface, #0a0a0a)); - color: var(--text, #f8fafc); + background: var(--bg); + color: var(--text); } /* Header */ @@ -55,7 +55,7 @@ .crm-tab.active { background: var(--accent, var(--color1, #d4f505)); - color: #000; + color: var(--bg); } .crm-header-right { @@ -120,7 +120,7 @@ gap: 6px; padding: 8px 16px; background: var(--accent, #d4f505); - color: #000; + color: var(--bg); border: none; border-radius: 6px; font-size: 13px; @@ -168,15 +168,15 @@ /* Accent Button */ .btn-accent { - background: var(--accent, #d4f505); - color: #000; - border-color: var(--accent, #d4f505); + background: var(--accent); + color: var(--bg, var(--bg)); + border-color: var(--accent); font-weight: 600; } .btn-accent:hover { filter: brightness(1.1); - box-shadow: 0 0 12px var(--accent, rgba(212, 245, 5, 0.4)); + box-shadow: 0 0 12px var(--accent); } /* Views */ @@ -259,7 +259,7 @@ /* Pipeline Card */ .pipeline-card { padding: 12px; - background: var(--bg-primary, #0a0a0a); + background: var(--bg, #0a0a0a); border: 1px solid var(--border, #2a2a2a); border-radius: 8px; cursor: grab; @@ -321,7 +321,7 @@ height: 18px; border-radius: 50%; background: var(--accent, #d4f505); - color: #000; + color: var(--bg); font-size: 10px; font-weight: 600; display: flex; @@ -376,7 +376,7 @@ flex-direction: column; gap: 4px; padding: 12px 16px; - background: var(--bg-primary, #0a0a0a); + background: var(--bg, #0a0a0a); border: 1px solid var(--border, #2a2a2a); border-radius: 8px; min-width: 150px; @@ -510,7 +510,7 @@ .action-btn.primary { background: var(--accent, #d4f505); border-color: var(--accent, #d4f505); - color: #000; + color: var(--bg); } .action-btn.primary:hover { @@ -547,7 +547,7 @@ width: 100%; max-width: 560px; max-height: 90vh; - background: var(--bg-primary, #0a0a0a); + background: var(--bg, #0a0a0a); border: 1px solid var(--border, #2a2a2a); border-radius: 12px; box-shadow: var(--shadow-xl); @@ -671,7 +671,7 @@ .crm-form-btn.primary { background: var(--accent, #d4f505); border: 1px solid var(--accent, #d4f505); - color: #000; + color: var(--bg); } .crm-form-btn.primary:hover { @@ -801,3 +801,94 @@ padding: 20px; text-align: center; } + +/* Campaigns Grid */ +.campaigns-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; + padding: 24px; +} + +.campaign-card { + background: var(--surface, #1a1a1a); + border: 1px solid var(--border, #333); + border-radius: 8px; + padding: 16px; + transition: all 0.15s ease; +} + +.campaign-card:hover { + border-color: var(--accent, #d4f505); + transform: translateY(-2px); +} + +.campaign-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.campaign-card-title { + font-size: 15px; + font-weight: 600; + color: var(--text, #f8fafc); + margin: 0; +} + +.campaign-status { + display: inline-flex; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.campaign-status.draft { background: rgba(251, 191, 36, 0.15); color: #fbbf24; } +.campaign-status.scheduled { background: rgba(96, 165, 250, 0.15); color: #60a5fa; } +.campaign-status.running { background: rgba(52, 211, 153, 0.15); color: #34d399; } +.campaign-status.completed { background: rgba(156, 163, 175, 0.15); color: #9ca3af; } + +.campaign-channels { + display: flex; + gap: 6px; + margin-bottom: 12px; +} + +.campaign-channel-tag { + display: inline-flex; + padding: 3px 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + font-size: 12px; + color: var(--text-secondary, #888); +} + +.campaign-metrics { + display: flex; + gap: 16px; + padding-top: 12px; + border-top: 1px solid var(--border, #333); +} + +.campaign-metric { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} + +.campaign-metric-value { + font-size: 18px; + font-weight: 700; + color: var(--accent, #d4f505); +} + +.campaign-metric-label { + font-size: 11px; + color: var(--text-secondary, #888); + text-transform: uppercase; + margin-top: 2px; +} diff --git a/ui/suite/crm/crm.html b/ui/suite/crm/crm.html index a09f137..f687127 100644 --- a/ui/suite/crm/crm.html +++ b/ui/suite/crm/crm.html @@ -1,40 +1,6 @@ - @@ -51,6 +17,7 @@ +
@@ -274,6 +241,18 @@
+ + +
+
+
+ Loading campaigns... +
+
+
diff --git a/ui/suite/desktop.html b/ui/suite/desktop.html index d590031..f0ef8e3 100644 --- a/ui/suite/desktop.html +++ b/ui/suite/desktop.html @@ -462,6 +462,79 @@ CRM +
+
+ + + +
+ Campaigns +
+ +
+
+ + + + + + + + +
+ Lists +
+ +
+
+ + + + + +
+ Templates +
+
Editor
+
+
+ + + + + + +
+ Designer +
+ +
+
+ + + + +
+ BASIC +
+
{ - if (e === '\r') { - this.term.write('\r\n$ '); - } else if (e === '\u007f') { // Backspace - this.term.write('\b \b'); - } else { - this.term.write(e); + + this.term.onData(data => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(data); + } + }); + + this.term.onResize(({ cols, rows }) => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(`resize ${cols} ${rows}`); } }); - // Initialize WebSocket (mock endpoint) this.connect(); }, + generateSessionId: function() { + return 'term-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); + }, + connect: function() { - // ws = new WebSocket('ws://localhost:8080/ws/terminal/session-123'); - // ws.onmessage = (msg) => this.term.write(msg.data); + this.sessionId = this.generateSessionId(); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/terminal/ws?session_id=${this.sessionId}`; + + this.term.write('\x1b[36mConnecting to isolated terminal...\x1b[0m\r\n'); + + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + this.reconnectAttempts = 0; + this.term.write('\x1b[32m✓ Connected to isolated container terminal\x1b[0m\r\n\r\n'); + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'connected') { + this.term.write(`\x1b[33mContainer: ${data.container}\x1b[0m\r\n`); + this.term.write(`\x1b[90mSession: ${data.session_id}\x1b[0m\r\n\r\n`); + } else if (data.type === 'system') { + this.term.write(`\x1b[90m${data.message}\x1b[0m`); + } else if (data.type === 'error') { + this.term.write(`\x1b[31mError: ${data.message}\x1b[0m\r\n`); + } + } catch (e) { + this.term.write(event.data); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + this.term.write('\x1b[31mConnection error. Attempting to reconnect...\x1b[0m\r\n'); + }; + + this.ws.onclose = () => { + this.term.write('\x1b[33m\x1b[1mDisconnected from terminal.\x1b[0m\r\n'); + this.term.write('\x1b[90mType "reconnect" to start a new session\x1b[0m\r\n'); + + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + setTimeout(() => this.connect(), 2000 * this.reconnectAttempts); + } + }; }, newTerminal: function() { - alert("New terminal tab created!"); + if (this.ws) { + this.ws.close(); + } + this.connect(); }, closeTerminal: function() { - alert("Terminal tab closed!"); + if (this.ws) { + this.ws.send('\\exit'); + this.ws.close(); + } }, clearTerminal: function() { if (this.term) { this.term.clear(); - this.term.write('$ '); } + }, + + reconnect: function() { + this.reconnectAttempts = 0; + if (this.ws) { + this.ws.close(); + } + this.connect(); } }; @@ -64,3 +143,5 @@ document.addEventListener('DOMContentLoaded', () => botCoderTerminal.init()); if (document.readyState === 'complete' || document.readyState === 'interactive') { botCoderTerminal.init(); } + +window.botCoderTerminal = botCoderTerminal; diff --git a/ui/suite/lists/lists.html b/ui/suite/lists/lists.html new file mode 100644 index 0000000..92d163a --- /dev/null +++ b/ui/suite/lists/lists.html @@ -0,0 +1,181 @@ + + + + + + +
+
+
+

Marketing Lists

+ +
+
+ +
+
+ +
+
+ Loading lists... +
+
+
+ + + + + + + diff --git a/ui/suite/sources/index.html b/ui/suite/sources/index.html index e97ea82..c5421d3 100644 --- a/ui/suite/sources/index.html +++ b/ui/suite/sources/index.html @@ -259,6 +259,27 @@ Accounts + diff --git a/ui/suite/templates/templates.html b/ui/suite/templates/templates.html new file mode 100644 index 0000000..d742760 --- /dev/null +++ b/ui/suite/templates/templates.html @@ -0,0 +1,200 @@ + + + + + + +
+
+
+

Content Templates

+ +
+
+ +
+
+ +
+
+ Loading templates... +
+
+
+ + + + + + +