Merge pull request #944 from LibreQoE/insight_support_tickets

Insight support tickets
This commit is contained in:
Robert Chacón authored and GitHub committed 2026-02-26 14:30:34 -05:00
commit 0932dd68a5
9 files changed
+1362 -46

No files matched your search

+480 -1
View File
@@ -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<Result<Vec<messages::SupportTicketSummary>, ()>>,
},
SupportTicketGet {
ticket_id: i64,
responder: oneshot::Sender<Result<Option<messages::SupportTicket>, ()>>,
},
SupportTicketCreate {
subject: String,
priority: u8,
body: String,
commentor: String,
responder: oneshot::Sender<Result<messages::SupportTicket, ()>>,
},
SupportTicketAddComment {
ticket_id: i64,
commentor: String,
body: String,
date: i64,
responder: oneshot::Sender<Result<(), ()>>,
},
}
pub enum ConnectionCommand {
@@ -70,6 +94,27 @@ pub enum ConnectionCommand {
ChatStop {
request_id: u64,
},
SupportTicketList {
responder: oneshot::Sender<Result<Vec<messages::SupportTicketSummary>, ()>>,
},
SupportTicketGet {
ticket_id: i64,
responder: oneshot::Sender<Result<Option<messages::SupportTicket>, ()>>,
},
SupportTicketCreate {
subject: String,
priority: u8,
body: String,
commentor: String,
responder: oneshot::Sender<Result<messages::SupportTicket, ()>>,
},
SupportTicketAddComment {
ticket_id: i64,
commentor: String,
body: String,
date: i64,
responder: oneshot::Sender<Result<(), ()>>,
},
}
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<Result<HistoryQueryResultPayload, ()>>,
> = HashMap::new();
enum PendingSupportTicket {
List(oneshot::Sender<Result<Vec<messages::SupportTicketSummary>, ()>>),
Get(oneshot::Sender<Result<Option<messages::SupportTicket>, ()>>),
Create(oneshot::Sender<Result<messages::SupportTicket, ()>>),
AddComment(oneshot::Sender<Result<(), ()>>),
}
let mut pending_support: HashMap<u64, PendingSupportTicket> = HashMap::new();
let mut chatbot_streams: HashMap<u64, tokio::sync::mpsc::Sender<Vec<u8>>> =
HashMap::new();
// Queue chatbot control messages until the connection is permitted (Welcome received)
let mut pending_chatbot_messages: Vec<Vec<u8>> = 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;
@@ -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<SupportTicketComment>,
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<SupportTicketSummary>,
},
SupportTicketGetResult {
request_id: u64,
ticket: Option<SupportTicket>,
},
SupportTicketCreateResult {
request_id: u64,
ticket: SupportTicket,
},
SupportTicketAddCommentResult {
request_id: u64,
},
SupportTicketError {
request_id: u64,
message: String,
},
}
#[derive(Serialize, Deserialize)]
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#039;");
}
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 = `<div class="text-body-secondary small px-2 py-3">
<i class="fa fa-inbox me-1"></i> No tickets yet.
</div>`;
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 `<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-start gap-2"
data-ticket-id="${t.id}">
<div class="me-auto">
<div class="fw-semibold">${subject}</div>
<div class="text-body-secondary small">Updated ${escapeHtml(updated)}</div>
</div>
<div class="d-flex flex-column align-items-end gap-1">
<span class="badge ${badge.cls}">${badge.label}</span>
<span class="badge text-bg-light border">P${t.priority ?? 0}</span>
</div>
</button>`;
})
.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(`<div class="text-body-secondary small"><i class="fa fa-spinner fa-spin me-1"></i> Loading…</div>`);
$("#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(`<div class="text-body-secondary small"><i class="fa fa-comment-dots me-1"></i> No comments yet.</div>`);
return;
}
const html = comments
.map((c) => {
const who = escapeHtml(c.commentor || "Unknown");
const when = escapeHtml(fmtUnix(c.date));
const body = escapeHtml(c.body || "");
return `<div class="border rounded p-2">
<div class="d-flex justify-content-between flex-wrap gap-2 mb-1">
<div class="fw-semibold small">${who}</div>
<div class="text-body-secondary small">${when}</div>
</div>
<div class="small" style="white-space: pre-wrap;">${body}</div>
</div>`;
})
.join("");
$("#ticketViewComments").html(html);
}
function refreshTickets() {
$("#ticketList").html(`<div class="text-body-secondary small px-2 py-3">
<i class="fa fa-spinner fa-spin me-1"></i> Loading tickets…
</div>`);
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 = `<div class="text-danger small px-2 py-3">
<i class="fa fa-triangle-exclamation me-1"></i> ${escapeHtml(message)}
</div>`;
}
});
refreshTickets();
}
// Perform wireups
$("#btnDave").click(displayDaveMemorial);
$("#btnNewTicket").on("click", openNewTicketModal);
$("#btnSubmitTicket").on("click", submitNewTicket);
$("#btnRefreshTickets").on("click", () => refreshTickets());
$("#btnSubmitComment").on("click", submitComment);
initSupportTickets();
@@ -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" {
+159 -44
View File
@@ -1,28 +1,64 @@
<div class="row">
<div class="col-4">
<div class="row g-3">
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="fa fa-question-circle"></i> Help & Support</h5>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<h5 class="card-title mb-0">
<i class="fa fa-life-ring"></i> Support Tickets
</h5>
<div class="text-body-secondary small">
Priority support is available to Insight subscribers.
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" type="button" id="btnRefreshTickets">
<i class="fa fa-rotate"></i> Refresh
</button>
<button class="btn btn-primary" type="button" id="btnNewTicket">
<i class="fa fa-plus"></i> New Ticket
</button>
</div>
</div>
<p class="alert-warning">
<div id="ticketGateNoInsight" class="alert alert-info mt-3 mb-0 d-none">
<i class="fa fa-info-circle"></i>
Priority support is given to Insight subscribers and donors. Other support is
best effort, with volunteers trying to help as best we can.
</p>
<p>
<a href="https://github.com/sponsors/LibreQoE/" class="btn btn-primary">
<i class="fa fa-dollar-sign"></i> Support LibreQoS Today
</a>
<button class="btn btn-outline-primary mt-2" type="button" id="btnDave">
In Memory of Dave Täht
</button>
</p>
To submit a support ticket you need an <strong>Insight</strong> subscription.
<a class="alert-link" href="lts_trial.html">Start an Insight free trial</a>.
</div>
<div id="ticketGateInsight" class="mt-3 d-none">
<div class="list-group" id="ticketList"></div>
</div>
</div>
</div>
</div>
<div class="col-4">
<div class="card">
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title"><i class="fa fa-question-circle"></i> Help & Support</h5>
<div class="alert alert-warning">
<i class="fa fa-info-circle"></i>
Priority support is given to Insight subscribers and donors. Other support is
best effort, with volunteers trying to help as best we can.
</div>
<div class="d-flex flex-wrap gap-2">
<a href="https://github.com/sponsors/LibreQoE/" class="btn btn-primary d-inline-flex align-items-center gap-2">
<i class="fa fa-dollar-sign"></i> Support LibreQoS Today
</a>
<button class="btn btn-outline-primary d-inline-flex align-items-center" type="button" id="btnDave">
In Memory of Dave Täht
</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title"><i class="fa fa-book"></i> Documentation</h5>
<p>The documentation is a great place to start!</p>
@@ -31,8 +67,8 @@
</div>
</div>
<div class="col-4">
<div class="card">
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title"><i class="fa fa-user-circle"></i> Chat</h5>
<p>
@@ -43,8 +79,8 @@
</div>
</div>
<div class="col-4">
<div class="card">
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title"><i class="fab fa-youtube"></i> Learn</h5>
<p>
@@ -55,37 +91,116 @@
</div>
</div>
<div class="col-4">
<div class="card">
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title"><i class="fab fa-connectdevelop"></i> Connect</h5>
<p>
<p class="mb-2">
We're on social media! Connect with us at:
<ul>
<li><a href="https://www.linkedin.com/company/libreqos/"><i class="fab fa-linkedin"></i> LinkedIn</a></li>
<li><a href="https://twitter.com/libreqos"><i class="fab fa-twitter"></i> Twitter</a></li>
<li><a href="https://www.facebook.com/libreqos"><i class="fab fa-facebook"></i> Facebook</a></li>
<li><a href="https://fosstodon.org/@LibreQoS/"><i class="fa fa-link"></i> Fosstodon (Free Open Source Mastadon)</a></li>
</ul>
</div>
</div>
</div>
<div class="col-4">
<div class="card">
<div class="card-body">
<h5 class="card-title"><i class="fa fa-wrench"></i> Tools</h5>
<p>
<strong>Support tool retired.</strong> The legacy support tool and sanity checks are no longer
available in the UI. Use the configuration pages and logs when troubleshooting.
</p>
<!-- Support dump download/submission removed; server discontinued -->
<ul class="mb-0">
<li><a href="https://www.linkedin.com/company/libreqos/"><i class="fab fa-linkedin"></i> LinkedIn</a></li>
<li><a href="https://twitter.com/libreqos"><i class="fab fa-twitter"></i> Twitter</a></li>
<li><a href="https://www.facebook.com/libreqos"><i class="fab fa-facebook"></i> Facebook</a></li>
<li><a href="https://fosstodon.org/@LibreQoS/"><i class="fa fa-link"></i> Fosstodon (Free Open Source Mastodon)</a></li>
</ul>
</div>
</div>
</div>
</div>
<!-- Removed support modals: sanity + support dump -->
<!-- Ticket View Modal -->
<div class="modal fade" id="ticketViewModal" tabindex="-1" aria-labelledby="ticketViewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ticketViewModalLabel">
<i class="fa fa-ticket"></i> Ticket
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="ticketViewAlert" class="alert alert-danger d-none"></div>
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-3">
<div>
<div class="fw-semibold" id="ticketViewSubject"></div>
<div class="text-body-secondary small" id="ticketViewMeta"></div>
</div>
<div class="d-flex gap-2 align-items-center">
<span class="badge" id="ticketViewStatus"></span>
<span class="badge text-bg-light border" id="ticketViewPriority"></span>
</div>
</div>
<div class="mb-3">
<div class="text-body-secondary small mb-1">Description</div>
<div class="border rounded p-2 bg-body-tertiary">
<pre class="mb-0 small" style="white-space: pre-wrap;" id="ticketViewBody"></pre>
</div>
</div>
<div class="mb-3">
<div class="text-body-secondary small mb-2">Comments</div>
<div id="ticketViewComments" class="vstack gap-2"></div>
</div>
<hr />
<div>
<label class="form-label text-body-secondary small" for="ticketCommentBody">Add a comment</label>
<textarea class="form-control" id="ticketCommentBody" rows="3" placeholder="Type your reply..."></textarea>
<div class="d-flex justify-content-end mt-2">
<button class="btn btn-primary" type="button" id="btnSubmitComment">
<i class="fa fa-paper-plane"></i> Send
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- New Ticket Modal -->
<div class="modal fade" id="newTicketModal" tabindex="-1" aria-labelledby="newTicketModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="newTicketModalLabel">
<i class="fa fa-plus"></i> New Support Ticket
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="newTicketAlert" class="alert alert-danger d-none"></div>
<div class="mb-3">
<label class="form-label" for="newTicketSubject">Subject</label>
<input class="form-control" id="newTicketSubject" type="text" placeholder="Brief summary" maxlength="200" />
</div>
<div class="mb-3">
<label class="form-label" for="newTicketPriority">Priority (0-5)</label>
<select class="form-select" id="newTicketPriority">
<option value="0">0 - Lowest</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3" selected>3</option>
<option value="4">4</option>
<option value="5">5 - Highest</option>
</select>
</div>
<div class="mb-0">
<label class="form-label" for="newTicketBody">Details</label>
<textarea class="form-control" id="newTicketBody" rows="6" placeholder="Describe the issue, what you expected, and what happened."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-primary" type="button" id="btnSubmitTicket">
<i class="fa fa-paper-plane"></i> Submit Ticket
</button>
</div>
</div>
</div>
</div>
<script src="help.js%CACHEBUSTERS%"></script>
+6 -1
View File
@@ -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!(
"<script>window.hasLts = {}; window.hasInsight = {}; window.newVersion = {}; window.nodeId = '{}';</script>",
"<script>window.hasLts = {}; window.hasInsight = {}; window.hasSupportTickets = {}; window.newVersion = {}; window.nodeId = '{}';</script>",
js_tf(script_has_lts),
js_tf(script_has_insight),
js_tf(script_has_support_tickets),
js_tf(new_version),
node_id_js
);
+289
View File
@@ -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 };
@@ -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<SupportTicketSummary> },
SupportTicketGetResult { ticket: Option<SupportTicket> },
SupportTicketCreateResult { ticket: SupportTicket },
SupportTicketAddCommentResult { ok: bool },
}
pub fn encode_ws_message<T>(message: &T) -> Result<std::sync::Arc<Vec<u8>>, serde_cbor::Error>
@@ -45,6 +45,12 @@ impl PrivateState {
}
}
pub fn control_tx(
&self,
) -> tokio::sync::mpsc::Sender<crate::lts2_sys::control_channel::ControlChannelCommand> {
self.control_tx.clone()
}
pub async fn handle_request(&mut self, request: PrivateRequest) {
match request {
PrivateRequest::CircuitWatcher { circuit } => {