mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-11-02 20:30:15 -06:00
Merge pull request #944 from LibreQoE/insight_support_tickets
Insight support tickets
This commit is contained in:
9 files changed
+1362
-46
No files matched your search
@@ -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("&", "&")
|
||||
.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 = `<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" {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
Reference in new issue
Block a user