diff --git a/src/rust/lqosd/src/lts2_sys/control_channel.rs b/src/rust/lqosd/src/lts2_sys/control_channel.rs index 09ac3e96..f12829cc 100644 --- a/src/rust/lqosd/src/lts2_sys/control_channel.rs +++ b/src/rust/lqosd/src/lts2_sys/control_channel.rs @@ -15,7 +15,10 @@ use crate::lts2_sys::lts2_client::{LicenseStatus, set_license_status}; mod messages; use crate::throughput_tracker::flow_data::FlowbeeEffectiveDirection; -pub use messages::{RemoteInsightRequest, WsMessage}; +pub use messages::{ + RemoteInsightRequest, SupportTicket, SupportTicketComment, SupportTicketStatus, + SupportTicketSummary, WsMessage, +}; #[derive(Debug)] pub struct HistoryQueryResultPayload { @@ -46,6 +49,27 @@ pub enum ControlChannelCommand { ChatStop { request_id: u64, }, + SupportTicketList { + responder: oneshot::Sender, ()>>, + }, + SupportTicketGet { + ticket_id: i64, + responder: oneshot::Sender, ()>>, + }, + SupportTicketCreate { + subject: String, + priority: u8, + body: String, + commentor: String, + responder: oneshot::Sender>, + }, + SupportTicketAddComment { + ticket_id: i64, + commentor: String, + body: String, + date: i64, + responder: oneshot::Sender>, + }, } pub enum ConnectionCommand { @@ -70,6 +94,27 @@ pub enum ConnectionCommand { ChatStop { request_id: u64, }, + SupportTicketList { + responder: oneshot::Sender, ()>>, + }, + SupportTicketGet { + ticket_id: i64, + responder: oneshot::Sender, ()>>, + }, + SupportTicketCreate { + subject: String, + priority: u8, + body: String, + commentor: String, + responder: oneshot::Sender>, + }, + SupportTicketAddComment { + ticket_id: i64, + commentor: String, + body: String, + date: i64, + responder: oneshot::Sender>, + }, } pub struct ControlChannelBuilder { @@ -136,6 +181,87 @@ async fn control_channel_loop(mut builder: ControlChannelBuilder) -> Result<()> ControlChannelCommand::ChatStop { request_id } => { let _ = tx.try_send(ConnectionCommand::ChatStop { request_id }); } + ControlChannelCommand::SupportTicketList { responder } => { + if let Err(err) = tx.try_send(ConnectionCommand::SupportTicketList { responder }) { + if let tokio::sync::mpsc::error::TrySendError::Full( + ConnectionCommand::SupportTicketList { responder }, + ) + | tokio::sync::mpsc::error::TrySendError::Closed( + ConnectionCommand::SupportTicketList { responder }, + ) = err + { + let _ = responder.send(Err(())); + } + } + } + ControlChannelCommand::SupportTicketGet { + ticket_id, + responder, + } => { + if let Err(err) = + tx.try_send(ConnectionCommand::SupportTicketGet { ticket_id, responder }) + { + if let tokio::sync::mpsc::error::TrySendError::Full( + ConnectionCommand::SupportTicketGet { responder, .. }, + ) + | tokio::sync::mpsc::error::TrySendError::Closed( + ConnectionCommand::SupportTicketGet { responder, .. }, + ) = err + { + let _ = responder.send(Err(())); + } + } + } + ControlChannelCommand::SupportTicketCreate { + subject, + priority, + body, + commentor, + responder, + } => { + if let Err(err) = tx.try_send(ConnectionCommand::SupportTicketCreate { + subject, + priority, + body, + commentor, + responder, + }) { + if let tokio::sync::mpsc::error::TrySendError::Full( + ConnectionCommand::SupportTicketCreate { responder, .. }, + ) + | tokio::sync::mpsc::error::TrySendError::Closed( + ConnectionCommand::SupportTicketCreate { responder, .. }, + ) = err + { + let _ = responder.send(Err(())); + } + } + } + ControlChannelCommand::SupportTicketAddComment { + ticket_id, + commentor, + body, + date, + responder, + } => { + if let Err(err) = tx.try_send(ConnectionCommand::SupportTicketAddComment { + ticket_id, + commentor, + body, + date, + responder, + }) { + if let tokio::sync::mpsc::error::TrySendError::Full( + ConnectionCommand::SupportTicketAddComment { responder, .. }, + ) + | tokio::sync::mpsc::error::TrySendError::Closed( + ConnectionCommand::SupportTicketAddComment { responder, .. }, + ) = err + { + let _ = responder.send(Err(())); + } + } + } } } warn!("Control channel loop exiting"); @@ -175,11 +301,19 @@ async fn persistent_connection( u64, oneshot::Sender>, > = HashMap::new(); + enum PendingSupportTicket { + List(oneshot::Sender, ()>>), + Get(oneshot::Sender, ()>>), + Create(oneshot::Sender>), + AddComment(oneshot::Sender>), + } + let mut pending_support: HashMap = HashMap::new(); let mut chatbot_streams: HashMap>> = HashMap::new(); // Queue chatbot control messages until the connection is permitted (Welcome received) let mut pending_chatbot_messages: Vec> = Vec::new(); let mut next_history_request_id: u64 = 1; + let mut next_support_request_id: u64 = 1; let queue_license_grant_request = |socket_sender_tx: &tokio::sync::mpsc::Sender< Message, >| { @@ -326,6 +460,261 @@ async fn persistent_connection( } } } + Some(ConnectionCommand::SupportTicketList { responder }) => { + if !permitted { + warn!("Not permitted to request support tickets yet"); + let _ = responder.send(Err(())); + continue 'message_pump; + } + const MAX_PENDING_SUPPORT: usize = 256; + if pending_support.len() >= MAX_PENDING_SUPPORT { + warn!( + "Too many pending support ticket requests ({}); dropping newest", + MAX_PENDING_SUPPORT + ); + let _ = responder.send(Err(())); + continue 'message_pump; + } + let request_id = next_support_request_id; + next_support_request_id = next_support_request_id.wrapping_add(1); + if pending_support + .insert(request_id, PendingSupportTicket::List(responder)) + .is_some() + { + warn!("Duplicate pending support ticket request id {request_id}"); + } + let message = messages::WsMessage::SupportTicketList { request_id }; + let Ok((_, _, bytes)) = message.to_bytes() else { + error!("Failed to serialize SupportTicketList"); + if let Some(PendingSupportTicket::List(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + continue 'message_pump; + }; + if let Err(e) = + socket_sender_tx.try_send(Message::Binary(bytes.into())) + { + match e { + TrySendError::Full(_) => { + warn!("Send unavailable: support ticket list queue full; dropping request"); + if let Some(PendingSupportTicket::List(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + } + TrySendError::Closed(_) => { + error!("Failed to queue support ticket list: channel closed"); + if let Some(PendingSupportTicket::List(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + break 'message_pump; + } + } + } + } + Some(ConnectionCommand::SupportTicketGet { ticket_id, responder }) => { + if !permitted { + warn!("Not permitted to request support tickets yet"); + let _ = responder.send(Err(())); + continue 'message_pump; + } + const MAX_PENDING_SUPPORT: usize = 256; + if pending_support.len() >= MAX_PENDING_SUPPORT { + warn!( + "Too many pending support ticket requests ({}); dropping newest", + MAX_PENDING_SUPPORT + ); + let _ = responder.send(Err(())); + continue 'message_pump; + } + let request_id = next_support_request_id; + next_support_request_id = next_support_request_id.wrapping_add(1); + if pending_support + .insert(request_id, PendingSupportTicket::Get(responder)) + .is_some() + { + warn!("Duplicate pending support ticket request id {request_id}"); + } + let message = messages::WsMessage::SupportTicketGet { + request_id, + ticket_id, + }; + let Ok((_, _, bytes)) = message.to_bytes() else { + error!("Failed to serialize SupportTicketGet"); + if let Some(PendingSupportTicket::Get(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + continue 'message_pump; + }; + if let Err(e) = + socket_sender_tx.try_send(Message::Binary(bytes.into())) + { + match e { + TrySendError::Full(_) => { + warn!("Send unavailable: support ticket get queue full; dropping request"); + if let Some(PendingSupportTicket::Get(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + } + TrySendError::Closed(_) => { + error!("Failed to queue support ticket get: channel closed"); + if let Some(PendingSupportTicket::Get(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + break 'message_pump; + } + } + } + } + Some(ConnectionCommand::SupportTicketCreate { + subject, + priority, + body, + commentor, + responder, + }) => { + if !permitted { + warn!("Not permitted to request support tickets yet"); + let _ = responder.send(Err(())); + continue 'message_pump; + } + const MAX_PENDING_SUPPORT: usize = 256; + if pending_support.len() >= MAX_PENDING_SUPPORT { + warn!( + "Too many pending support ticket requests ({}); dropping newest", + MAX_PENDING_SUPPORT + ); + let _ = responder.send(Err(())); + continue 'message_pump; + } + let request_id = next_support_request_id; + next_support_request_id = next_support_request_id.wrapping_add(1); + if pending_support + .insert(request_id, PendingSupportTicket::Create(responder)) + .is_some() + { + warn!("Duplicate pending support ticket request id {request_id}"); + } + let message = messages::WsMessage::SupportTicketCreate { + request_id, + subject, + priority, + body, + commentor, + }; + let Ok((_, _, bytes)) = message.to_bytes() else { + error!("Failed to serialize SupportTicketCreate"); + if let Some(PendingSupportTicket::Create(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + continue 'message_pump; + }; + if let Err(e) = + socket_sender_tx.try_send(Message::Binary(bytes.into())) + { + match e { + TrySendError::Full(_) => { + warn!("Send unavailable: support ticket create queue full; dropping request"); + if let Some(PendingSupportTicket::Create(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + } + TrySendError::Closed(_) => { + error!("Failed to queue support ticket create: channel closed"); + if let Some(PendingSupportTicket::Create(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + break 'message_pump; + } + } + } + } + Some(ConnectionCommand::SupportTicketAddComment { + ticket_id, + commentor, + body, + date, + responder, + }) => { + if !permitted { + warn!("Not permitted to request support tickets yet"); + let _ = responder.send(Err(())); + continue 'message_pump; + } + const MAX_PENDING_SUPPORT: usize = 256; + if pending_support.len() >= MAX_PENDING_SUPPORT { + warn!( + "Too many pending support ticket requests ({}); dropping newest", + MAX_PENDING_SUPPORT + ); + let _ = responder.send(Err(())); + continue 'message_pump; + } + let request_id = next_support_request_id; + next_support_request_id = next_support_request_id.wrapping_add(1); + if pending_support + .insert(request_id, PendingSupportTicket::AddComment(responder)) + .is_some() + { + warn!("Duplicate pending support ticket request id {request_id}"); + } + let message = messages::WsMessage::SupportTicketAddComment { + request_id, + ticket_id, + commentor, + date, + body, + }; + let Ok((_, _, bytes)) = message.to_bytes() else { + error!("Failed to serialize SupportTicketAddComment"); + if let Some(PendingSupportTicket::AddComment(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + continue 'message_pump; + }; + if let Err(e) = + socket_sender_tx.try_send(Message::Binary(bytes.into())) + { + match e { + TrySendError::Full(_) => { + warn!("Send unavailable: support ticket add comment queue full; dropping request"); + if let Some(PendingSupportTicket::AddComment(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + } + TrySendError::Closed(_) => { + error!("Failed to queue support ticket add comment: channel closed"); + if let Some(PendingSupportTicket::AddComment(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Err(())); + } + break 'message_pump; + } + } + } + } Some(ConnectionCommand::StartChat { request_id, browser_ts_ms, browser_language, stream }) => { chatbot_streams.insert(request_id, stream); let message = messages::WsMessage::ChatbotStart { request_id, browser_ts_ms, browser_language }; @@ -500,6 +889,80 @@ async fn persistent_connection( warn!("History query result for unknown request id {request_id}"); } } + messages::WsMessage::SupportTicketListResult { + request_id, + tickets, + } => { + if let Some(PendingSupportTicket::List(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Ok(tickets)); + } else { + warn!( + "Support ticket list result for unknown request id {request_id}" + ); + } + } + messages::WsMessage::SupportTicketGetResult { + request_id, + ticket, + } => { + if let Some(PendingSupportTicket::Get(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Ok(ticket)); + } else { + warn!( + "Support ticket get result for unknown request id {request_id}" + ); + } + } + messages::WsMessage::SupportTicketCreateResult { + request_id, + ticket, + } => { + if let Some(PendingSupportTicket::Create(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Ok(ticket)); + } else { + warn!( + "Support ticket create result for unknown request id {request_id}" + ); + } + } + messages::WsMessage::SupportTicketAddCommentResult { + request_id, + } => { + if let Some(PendingSupportTicket::AddComment(responder)) = + pending_support.remove(&request_id) + { + let _ = responder.send(Ok(())); + } else { + warn!( + "Support ticket add comment result for unknown request id {request_id}" + ); + } + } + messages::WsMessage::SupportTicketError { request_id, message } => { + warn!("Support ticket request {request_id} failed: {message}"); + if let Some(pending) = pending_support.remove(&request_id) { + match pending { + PendingSupportTicket::List(responder) => { + let _ = responder.send(Err(())); + } + PendingSupportTicket::Get(responder) => { + let _ = responder.send(Err(())); + } + PendingSupportTicket::Create(responder) => { + let _ = responder.send(Err(())); + } + PendingSupportTicket::AddComment(responder) => { + let _ = responder.send(Err(())); + } + } + } + } messages::WsMessage::MakeApiRequest { request_id, method, url_suffix, body } => { let socket_sender_tx = socket_sender_tx.clone(); tokio::spawn(async move { @@ -632,6 +1095,22 @@ async fn persistent_connection( for (_, responder) in pending_history.drain() { let _ = responder.send(Err(())); } + for (_, pending) in pending_support.drain() { + match pending { + PendingSupportTicket::List(responder) => { + let _ = responder.send(Err(())); + } + PendingSupportTicket::Get(responder) => { + let _ = responder.send(Err(())); + } + PendingSupportTicket::Create(responder) => { + let _ = responder.send(Err(())); + } + PendingSupportTicket::AddComment(responder) => { + let _ = responder.send(Err(())); + } + } + } } debug!("Sleeping before reconnecting the persistent channel"); tokio::time::sleep(Duration::from_secs(sleep_seconds)).await; diff --git a/src/rust/lqosd/src/lts2_sys/control_channel/messages.rs b/src/rust/lqosd/src/lts2_sys/control_channel/messages.rs index 397639ae..bfe445a9 100644 --- a/src/rust/lqosd/src/lts2_sys/control_channel/messages.rs +++ b/src/rust/lqosd/src/lts2_sys/control_channel/messages.rs @@ -19,6 +19,49 @@ pub enum RemoteInsightRequest { CakeStatsTotals { seconds: i32 }, } +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum SupportTicketStatus { + #[serde(rename = "NEW")] + New, + #[serde(rename = "OPEN")] + Open, + #[serde(rename = "CLOSED")] + Closed, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SupportTicketComment { + pub commentor: String, + /// Unix timestamp (seconds). + pub date: i64, + pub body: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SupportTicketSummary { + pub id: i64, + pub subject: String, + pub priority: u8, + pub status: SupportTicketStatus, + /// Unix timestamp (seconds). + pub updated_at: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SupportTicket { + pub id: i64, + pub license: Uuid, + pub subject: String, + pub priority: u8, + pub body: String, + pub comments: Vec, + pub status: SupportTicketStatus, + /// Unix timestamp (seconds). + pub created_at: i64, + /// Unix timestamp (seconds). + pub updated_at: i64, +} + pub const MAX_DECOMPRESSED_WS_MSG_BYTES: usize = 16 * 1024 * 1024; // 16 MiB #[derive(Serialize, Deserialize)] @@ -154,6 +197,31 @@ pub enum WsMessage { ChatbotStop { request_id: u64, }, + + // Support tickets (customer support system) + // From Shaper -> Insight + SupportTicketList { + request_id: u64, + }, + SupportTicketGet { + request_id: u64, + ticket_id: i64, + }, + SupportTicketCreate { + request_id: u64, + subject: String, + priority: u8, + body: String, + commentor: String, + }, + SupportTicketAddComment { + request_id: u64, + ticket_id: i64, + commentor: String, + date: i64, + body: String, + }, + // From Insight -> Shaper (streaming chunks or errors) ChatbotChunk { request_id: u64, @@ -163,6 +231,27 @@ pub enum WsMessage { request_id: u64, message: String, }, + + // From Insight -> Shaper + SupportTicketListResult { + request_id: u64, + tickets: Vec, + }, + SupportTicketGetResult { + request_id: u64, + ticket: Option, + }, + SupportTicketCreateResult { + request_id: u64, + ticket: SupportTicket, + }, + SupportTicketAddCommentResult { + request_id: u64, + }, + SupportTicketError { + request_id: u64, + message: String, + }, } #[derive(Serialize, Deserialize)] diff --git a/src/rust/lqosd/src/node_manager/js_build/src/help.js b/src/rust/lqosd/src/node_manager/js_build/src/help.js index c9f4174b..482fd71a 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/help.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/help.js @@ -1,4 +1,309 @@ import {displayDaveMemorial} from "./dave"; +import {get_ws_client} from "./pubsub/ws"; + +function escapeHtml(str) { + if (str === null || str === undefined) { + return ""; + } + return String(str) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function getCommentor() { + const el = document.getElementById("username"); + const name = el ? (el.textContent || "").trim() : ""; + return name || "Anonymous"; +} + +function statusBadge(status) { + const text = (status || "").toUpperCase(); + if (text === "CLOSED") { + return { cls: "text-bg-secondary", label: "CLOSED" }; + } + if (text === "OPEN") { + return { cls: "text-bg-warning", label: "OPEN" }; + } + return { cls: "text-bg-primary", label: "NEW" }; +} + +function fmtUnix(ts) { + if (!ts) { + return ""; + } + try { + return new Date(ts * 1000).toLocaleString(); + } catch { + return ""; + } +} + +let wsClient = null; +let currentTicketId = null; + +function setAlert(id, message) { + const el = document.getElementById(id); + if (!el) return; + if (!message) { + el.classList.add("d-none"); + el.textContent = ""; + return; + } + el.classList.remove("d-none"); + el.textContent = message; +} + +function renderTicketList(tickets) { + const list = document.getElementById("ticketList"); + if (!list) return; + if (!tickets || tickets.length === 0) { + list.innerHTML = `
+ No tickets yet. +
`; + return; + } + + list.innerHTML = tickets + .map((t) => { + const badge = statusBadge(t.status); + const subject = escapeHtml(t.subject || "(no subject)"); + const updated = fmtUnix(t.updated_at); + return ``; + }) + .join(""); + + list.querySelectorAll("[data-ticket-id]").forEach((btn) => { + btn.addEventListener("click", () => { + const id = parseInt(btn.getAttribute("data-ticket-id"), 10); + if (!id || Number.isNaN(id)) return; + openTicket(id); + }); + }); +} + +function setTicketViewLoading() { + setAlert("ticketViewAlert", ""); + $("#ticketViewSubject").text("Loading…"); + $("#ticketViewMeta").text(""); + $("#ticketViewBody").text(""); + $("#ticketViewComments").html(`
Loading…
`); + $("#ticketViewStatus").removeClass().addClass("badge text-bg-secondary").text("…"); + $("#ticketViewPriority").text(""); +} + +function openTicket(ticketId) { + currentTicketId = ticketId; + setTicketViewLoading(); + const modalEl = document.getElementById("ticketViewModal"); + if (modalEl) { + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + } + wsClient.send({ SupportTicketGet: { ticket_id: ticketId } }); +} + +function renderTicketView(ticket) { + if (!ticket) { + setAlert("ticketViewAlert", "Ticket not found."); + return; + } + + $("#ticketViewSubject").text(ticket.subject || ""); + const meta = [`#${ticket.id}`]; + if (ticket.updated_at) meta.push(`Updated ${fmtUnix(ticket.updated_at)}`); + $("#ticketViewMeta").text(meta.join(" • ")); + + $("#ticketViewBody").text(ticket.body || ""); + + const badge = statusBadge(ticket.status); + $("#ticketViewStatus").removeClass().addClass(`badge ${badge.cls}`).text(badge.label); + $("#ticketViewPriority").text(`P${ticket.priority ?? 0}`); + + const comments = ticket.comments || []; + if (!comments.length) { + $("#ticketViewComments").html(`
No comments yet.
`); + return; + } + const html = comments + .map((c) => { + const who = escapeHtml(c.commentor || "Unknown"); + const when = escapeHtml(fmtUnix(c.date)); + const body = escapeHtml(c.body || ""); + return `
+
+
${who}
+
${when}
+
+
${body}
+
`; + }) + .join(""); + $("#ticketViewComments").html(html); +} + +function refreshTickets() { + $("#ticketList").html(`
+ Loading tickets… +
`); + wsClient.send({ SupportTicketList: {} }); +} + +function openNewTicketModal() { + setAlert("newTicketAlert", ""); + $("#newTicketSubject").val(""); + $("#newTicketPriority").val("3"); + $("#newTicketBody").val(""); + const modalEl = document.getElementById("newTicketModal"); + if (modalEl) { + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + } +} + +function submitNewTicket() { + setAlert("newTicketAlert", ""); + const subject = String($("#newTicketSubject").val() || "").trim(); + const body = String($("#newTicketBody").val() || "").trim(); + const priority = parseInt(String($("#newTicketPriority").val() || "3"), 10); + if (!subject) { + setAlert("newTicketAlert", "Subject is required."); + return; + } + if (!body) { + setAlert("newTicketAlert", "Details are required."); + return; + } + if (Number.isNaN(priority) || priority < 0 || priority > 5) { + setAlert("newTicketAlert", "Priority must be between 0 and 5."); + return; + } + wsClient.send({ + SupportTicketCreate: { + subject, + priority, + body, + commentor: getCommentor(), + }, + }); +} + +function submitComment() { + setAlert("ticketViewAlert", ""); + const body = String($("#ticketCommentBody").val() || "").trim(); + if (!currentTicketId) { + setAlert("ticketViewAlert", "No ticket selected."); + return; + } + if (!body) { + setAlert("ticketViewAlert", "Comment cannot be empty."); + return; + } + $("#ticketCommentBody").val(""); + wsClient.send({ + SupportTicketAddComment: { + ticket_id: currentTicketId, + commentor: getCommentor(), + body, + }, + }); +} + +function initSupportTickets() { + const hasInsight = typeof window.hasInsight !== "undefined" ? !!window.hasInsight : false; + const hasSupportTickets = + typeof window.hasSupportTickets !== "undefined" + ? !!window.hasSupportTickets + : hasInsight; + const noInsight = document.getElementById("ticketGateNoInsight"); + const yesInsight = document.getElementById("ticketGateInsight"); + const btnNew = document.getElementById("btnNewTicket"); + const btnRefresh = document.getElementById("btnRefreshTickets"); + + if (!hasSupportTickets) { + if (noInsight) noInsight.classList.remove("d-none"); + if (yesInsight) yesInsight.classList.add("d-none"); + if (btnNew) btnNew.disabled = true; + if (btnRefresh) btnRefresh.disabled = true; + renderTicketList([]); + return; + } + + if (noInsight) noInsight.classList.add("d-none"); + if (yesInsight) yesInsight.classList.remove("d-none"); + if (btnNew) btnNew.disabled = false; + if (btnRefresh) btnRefresh.disabled = false; + + wsClient = get_ws_client(); + wsClient.on("SupportTicketListResult", (msg) => { + renderTicketList(msg.tickets || []); + }); + wsClient.on("SupportTicketGetResult", (msg) => { + if (msg.ticket && currentTicketId && msg.ticket.id !== currentTicketId) { + return; + } + renderTicketView(msg.ticket); + }); + wsClient.on("SupportTicketCreateResult", (msg) => { + const modalEl = document.getElementById("newTicketModal"); + if (modalEl) { + bootstrap.Modal.getOrCreateInstance(modalEl).hide(); + } + refreshTickets(); + if (msg.ticket && msg.ticket.id) { + openTicket(msg.ticket.id); + } + }); + wsClient.on("SupportTicketAddCommentResult", (msg) => { + if (!msg.ok) { + setAlert("ticketViewAlert", "Failed to add comment."); + return; + } + if (currentTicketId) { + wsClient.send({ SupportTicketGet: { ticket_id: currentTicketId } }); + } + }); + wsClient.on("Error", (msg) => { + const message = msg && msg.message ? String(msg.message) : "Unknown error"; + if ( + message.includes("Support tickets require an Insight subscription") || + message.includes("Insight not enabled") + ) { + if (noInsight) noInsight.classList.remove("d-none"); + if (yesInsight) yesInsight.classList.add("d-none"); + if (btnNew) btnNew.disabled = true; + if (btnRefresh) btnRefresh.disabled = true; + } + setAlert("ticketViewAlert", message); + setAlert("newTicketAlert", message); + const list = document.getElementById("ticketList"); + if (list) { + list.innerHTML = `
+ ${escapeHtml(message)} +
`; + } + }); + + refreshTickets(); +} // Perform wireups $("#btnDave").click(displayDaveMemorial); +$("#btnNewTicket").on("click", openNewTicketModal); +$("#btnSubmitTicket").on("click", submitNewTicket); +$("#btnRefreshTickets").on("click", () => refreshTickets()); +$("#btnSubmitComment").on("click", submitComment); + +initSupportTickets(); diff --git a/src/rust/lqosd/src/node_manager/local_api/lts.rs b/src/rust/lqosd/src/node_manager/local_api/lts.rs index e77e981d..9d12686f 100644 --- a/src/rust/lqosd/src/node_manager/local_api/lts.rs +++ b/src/rust/lqosd/src/node_manager/local_api/lts.rs @@ -39,6 +39,16 @@ pub(crate) async fn insight_gate() -> Result<(), StatusCode> { } } +pub(crate) async fn support_ticket_gate() -> Result<(), StatusCode> { + let (status, _) = crate::lts2_sys::get_lts_license_status_async().await; + match status { + LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full => { + Ok(()) + } + _ => Err(StatusCode::FORBIDDEN), + } +} + pub async fn lts_trial_signup_data(license_key: String) -> Result<(), StatusCode> { info!("Received license key, enabling free trial: {}", license_key); if license_key == "FAIL" { diff --git a/src/rust/lqosd/src/node_manager/static2/help.html b/src/rust/lqosd/src/node_manager/static2/help.html index d02a7d0f..99cedb52 100644 --- a/src/rust/lqosd/src/node_manager/static2/help.html +++ b/src/rust/lqosd/src/node_manager/static2/help.html @@ -1,28 +1,64 @@ -
-
+
+
-
Help & Support
+
+
+
+ Support Tickets +
+
+ Priority support is available to Insight subscribers. +
+
+
+ + +
+
-

+

- Priority support is given to Insight subscribers and donors. Other support is - best effort, with volunteers trying to help as best we can. -

-

- - Support LibreQoS Today - - -

+ To submit a support ticket you need an Insight subscription. + Start an Insight free trial. +
+ +
+
+
-
-
+
+
+
+
Help & Support
+ +
+ + Priority support is given to Insight subscribers and donors. Other support is + best effort, with volunteers trying to help as best we can. +
+ +
+ + Support LibreQoS Today + + +
+
+
+
+ +
+
Documentation

The documentation is a great place to start!

@@ -31,8 +67,8 @@
-
-
+
+
Chat

@@ -43,8 +79,8 @@

-
-
+
+
Learn

@@ -55,37 +91,116 @@

-
-
+
+
Connect
-

+

We're on social media! Connect with us at: -

-
-
-
- -
-
-
-
Tools
-

- Support tool retired. The legacy support tool and sanity checks are no longer - available in the UI. Use the configuration pages and logs when troubleshooting.

- - +
- + + + + + diff --git a/src/rust/lqosd/src/node_manager/template.rs b/src/rust/lqosd/src/node_manager/template.rs index 5dd650c5..7a151366 100644 --- a/src/rust/lqosd/src/node_manager/template.rs +++ b/src/rust/lqosd/src/node_manager/template.rs @@ -115,6 +115,10 @@ pub async fn apply_templates( // Change the LTS part of the template let (lts_status, _) = crate::lts2_sys::get_lts_license_status_async().await; trial_link = INSIGHT_LINK_OFFER_TRIAL.to_string(); + let script_has_support_tickets = matches!( + lts_status, + LtsStatus::AlwaysFree | LtsStatus::FreeTrial | LtsStatus::SelfHosted | LtsStatus::Full + ); match lts_status { LtsStatus::Invalid | LtsStatus::NotChecked => {} _ => { @@ -135,9 +139,10 @@ pub async fn apply_templates( // "LTS script" - which is increasingly becoming a misnomer let lts_script = format!( - "", + "", js_tf(script_has_lts), js_tf(script_has_insight), + js_tf(script_has_support_tickets), js_tf(new_version), node_id_js ); diff --git a/src/rust/lqosd/src/node_manager/ws.rs b/src/rust/lqosd/src/node_manager/ws.rs index 56a6198d..ec52f7c3 100644 --- a/src/rust/lqosd/src/node_manager/ws.rs +++ b/src/rust/lqosd/src/node_manager/ws.rs @@ -561,6 +561,295 @@ async fn receive_channel_message( return true; } } + WsRequest::SupportTicketList => { + if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Support tickets require an Insight subscription".to_string(), + }, + ) + .await + { + return true; + } + } else { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + let control_tx = private_state.control_tx(); + if control_tx + .send( + crate::lts2_sys::control_channel::ControlChannelCommand::SupportTicketList { + responder: reply_tx, + }, + ) + .await + .is_err() + { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Insight control channel unavailable".to_string(), + }, + ) + .await + { + return true; + } + } else { + let result = tokio::time::timeout(std::time::Duration::from_secs(30), reply_rx) + .await; + match result { + Ok(Ok(Ok(tickets))) => { + if send_ws_response(&tx, WsResponse::SupportTicketListResult { tickets }) + .await + { + return true; + } + } + _ => { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Support ticket list request failed".to_string(), + }, + ) + .await + { + return true; + } + } + } + } + } + } + WsRequest::SupportTicketGet { ticket_id } => { + if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Support tickets require an Insight subscription".to_string(), + }, + ) + .await + { + return true; + } + } else { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + let control_tx = private_state.control_tx(); + if control_tx + .send( + crate::lts2_sys::control_channel::ControlChannelCommand::SupportTicketGet { + ticket_id, + responder: reply_tx, + }, + ) + .await + .is_err() + { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Insight control channel unavailable".to_string(), + }, + ) + .await + { + return true; + } + } else { + let result = tokio::time::timeout(std::time::Duration::from_secs(30), reply_rx) + .await; + match result { + Ok(Ok(Ok(ticket))) => { + if send_ws_response(&tx, WsResponse::SupportTicketGetResult { ticket }) + .await + { + return true; + } + } + _ => { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Support ticket request failed".to_string(), + }, + ) + .await + { + return true; + } + } + } + } + } + } + WsRequest::SupportTicketCreate { + subject, + priority, + body, + commentor, + } => { + if *login != LoginResult::Admin { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Unauthorized".to_string(), + }, + ) + .await + { + return true; + } + } else if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Support tickets require an Insight subscription".to_string(), + }, + ) + .await + { + return true; + } + } else { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + let control_tx = private_state.control_tx(); + if control_tx + .send( + crate::lts2_sys::control_channel::ControlChannelCommand::SupportTicketCreate { + subject, + priority, + body, + commentor, + responder: reply_tx, + }, + ) + .await + .is_err() + { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Insight control channel unavailable".to_string(), + }, + ) + .await + { + return true; + } + } else { + let result = tokio::time::timeout(std::time::Duration::from_secs(30), reply_rx) + .await; + match result { + Ok(Ok(Ok(ticket))) => { + if send_ws_response(&tx, WsResponse::SupportTicketCreateResult { ticket }) + .await + { + return true; + } + } + _ => { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Support ticket creation failed".to_string(), + }, + ) + .await + { + return true; + } + } + } + } + } + } + WsRequest::SupportTicketAddComment { + ticket_id, + commentor, + body, + } => { + if *login != LoginResult::Admin { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Unauthorized".to_string(), + }, + ) + .await + { + return true; + } + } else if let Err(StatusCode::FORBIDDEN) = lts::support_ticket_gate().await { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Support tickets require an Insight subscription".to_string(), + }, + ) + .await + { + return true; + } + } else { + let date = lqos_utils::unix_time::unix_now() + .ok() + .map(|t| t as i64) + .unwrap_or(0); + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + let control_tx = private_state.control_tx(); + if control_tx + .send( + crate::lts2_sys::control_channel::ControlChannelCommand::SupportTicketAddComment { + ticket_id, + commentor, + body, + date, + responder: reply_tx, + }, + ) + .await + .is_err() + { + if send_ws_response( + &tx, + WsResponse::Error { + message: "Insight control channel unavailable".to_string(), + }, + ) + .await + { + return true; + } + } else { + let result = tokio::time::timeout(std::time::Duration::from_secs(30), reply_rx) + .await; + match result { + Ok(Ok(Ok(()))) => { + if send_ws_response( + &tx, + WsResponse::SupportTicketAddCommentResult { ok: true }, + ) + .await + { + return true; + } + } + _ => { + if send_ws_response( + &tx, + WsResponse::SupportTicketAddCommentResult { ok: false }, + ) + .await + { + return true; + } + } + } + } + } + } WsRequest::Search { term } => { let results = search::search_results(search::SearchRequest { term: term.clone() }); let response = WsResponse::SearchResults { term, results }; diff --git a/src/rust/lqosd/src/node_manager/ws/messages.rs b/src/rust/lqosd/src/node_manager/ws/messages.rs index cfb22e9e..4a39ff6e 100644 --- a/src/rust/lqosd/src/node_manager/ws/messages.rs +++ b/src/rust/lqosd/src/node_manager/ws/messages.rs @@ -19,6 +19,7 @@ use crate::node_manager::local_api::{ ThroughputData as LtsThroughputData, Top10Circuit, Worst10RttCircuit, Worst10RxmitCircuit, }, }; +use crate::lts2_sys::control_channel::{SupportTicket, SupportTicketSummary}; use crate::throughput_tracker::flow_data::{ AsnCountryListEntry, AsnListEntry, AsnProtocolListEntry, }; @@ -152,6 +153,19 @@ pub enum WsRequest { UnknownIps, UnknownIpsClear, UnknownIpsCsv, + SupportTicketList, + SupportTicketGet { ticket_id: i64 }, + SupportTicketCreate { + subject: String, + priority: u8, + body: String, + commentor: String, + }, + SupportTicketAddComment { + ticket_id: i64, + commentor: String, + body: String, + }, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -456,6 +470,10 @@ pub enum WsResponse { data: QueueStoreTransit, }, ChatbotChunk { text: String }, + SupportTicketListResult { tickets: Vec }, + SupportTicketGetResult { ticket: Option }, + SupportTicketCreateResult { ticket: SupportTicket }, + SupportTicketAddCommentResult { ok: bool }, } pub fn encode_ws_message(message: &T) -> Result>, serde_cbor::Error> diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs index 01bc0a8c..742a1bb4 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels.rs @@ -45,6 +45,12 @@ impl PrivateState { } } + pub fn control_tx( + &self, + ) -> tokio::sync::mpsc::Sender { + self.control_tx.clone() + } + pub async fn handle_request(&mut self, request: PrivateRequest) { match request { PrivateRequest::CircuitWatcher { circuit } => {