Merge pull request #1017 from LibreQoE/insight_signup_flow

Feat: add Insight signup handoff
This commit is contained in:
Robert Chacón authored and GitHub committed 2026-03-30 13:03:38 -06:00
commit 3ab6154e2c
10 files changed
+593 -66

No files matched your search

@@ -14,6 +14,7 @@ flow_map.js
all_tree_sankey.js
asn_analysis.js
lts_trial.js
lts_trial_setup.js
config_general.js
config_rtt.js
config_tuning.js
@@ -1,37 +1,5 @@
import {PLACEHOLDER_TEASERS} from "./lts_teasers_shared";
import {get_ws_client} from "./pubsub/ws";
const wsClient = get_ws_client();
const listenOnce = (eventName, handler) => {
const wrapped = (msg) => {
wsClient.off(eventName, wrapped);
handler(msg);
};
wsClient.on(eventName, wrapped);
};
function sendWsRequest(responseEvent, request) {
return new Promise((resolve, reject) => {
let done = false;
const onResponse = (msg) => {
if (done) return;
done = true;
wsClient.off(responseEvent, onResponse);
wsClient.off("Error", onError);
resolve(msg);
};
const onError = (msg) => {
if (done) return;
done = true;
wsClient.off(responseEvent, onResponse);
wsClient.off("Error", onError);
reject(msg);
};
wsClient.on(responseEvent, onResponse);
wsClient.on("Error", onError);
wsClient.send(request);
});
}
import {buildSignupApiUrl, sendWsRequest, wsClient} from "./lts_trial_shared";
// Paddle-compatible countries (excluding embargoed nations)
const ALLOWED_COUNTRIES = [
@@ -288,6 +256,7 @@ $(document).ready(async function() {
loadTeasers(); // Then load teasers with the correct URL
fetchCircuitCount();
attachEventHandlers();
openInitialViewFromQuery();
// Apply dark mode if needed
if (isDarkMode()) {
@@ -434,7 +403,7 @@ function displayTeasers() {
// Add click handlers to CTA buttons
$('.btn-teaser-cta').on('click', function() {
showSection('signupSection');
redirectToTrialSetup();
});
// Reinitialize carousel if needed
@@ -448,19 +417,7 @@ function displayTeasers() {
// Helper function to construct full LTS URLs
function getLtsUrl(endpoint) {
// Ensure ltsBaseUrl starts with http:// or https://, otherwise prefix with https://
let baseUrl = ltsBaseUrl;
if (!/^https?:\/\//i.test(baseUrl)) {
baseUrl = 'https://' + baseUrl;
}
// Ensure baseUrl ends with a single slash
let base = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
// Ensure 'signup-api/' is appended exactly once
base += base.endsWith('signup-api/') ? '' : 'signup-api/';
// Remove any leading slash from endpoint
endpoint = endpoint.replace(/^\/+/, '');
//console.log("Base: ", base, "Endpoint:", endpoint);
return base + endpoint;
return buildSignupApiUrl(ltsBaseUrl, endpoint);
}
// Fetch node ID and LTS URL from configuration
@@ -517,6 +474,29 @@ function showSection(sectionId) {
$(`#${sectionId}`).fadeIn();
}
function redirectToTrialSetup() {
window.location.href = 'lts_trial_setup.html';
}
function openInitialViewFromQuery() {
const params = new URLSearchParams(window.location.search);
const view = params.get('view');
if (view === 'signup') {
showSection('signupSection');
return;
}
if (view === 'license') {
showSection('licenseKeySection');
return;
}
if (view === 'recover') {
showSection('licenseRecoverySection');
}
}
// Show loading modal
function showLoading(message = 'Processing...') {
$('#loadingMessage').text(message);
@@ -579,7 +559,7 @@ function attachEventHandlers() {
// Analytics tracking for new customer path
const trackingImg = new Image();
trackingImg.src = getLtsUrl('signupPing') + '?t=' + Date.now() + '&type=new';
showSection('signupSection');
redirectToTrialSetup();
});
$('#btnBackFromLicense').on('click', () => showSection('teaserSection'));
$('#btnBackFromSignup').on('click', () => showSection('teaserSection'));
@@ -0,0 +1,97 @@
import {buildInsightUrl, sendWsRequest} from "./lts_trial_shared";
const setupStatusClasses = [
'alert-primary',
'alert-success',
'alert-warning',
'alert-danger',
'alert-secondary',
];
const defaultInsightBaseUrl = 'https://insight.libreqos.com/';
let insightBaseUrl = defaultInsightBaseUrl;
function setSetupStatus(message, tone = 'primary', spinning = true) {
const status = $('#signupSetupStatus');
const statusText = $('#signupSetupStatusText');
const spinner = status.find('.spinner-border');
if (!status.length || !statusText.length) {
return;
}
status.removeClass(setupStatusClasses.join(' ')).addClass(`alert-${tone}`);
statusText.text(message);
if (spinning) {
spinner.show();
} else {
spinner.hide();
}
}
function setSetupHelp(message) {
$('#signupSetupHelp').text(message);
}
function getErrorMessage(error, fallback) {
if (error && typeof error.message === 'string' && error.message.trim().length > 0) {
return error.message.trim();
}
return fallback;
}
async function fetchTrialConfig() {
try {
const response = await sendWsRequest("LtsTrialConfigResult", { LtsTrialConfig: {} });
const data = response && response.data ? response.data : {};
if (typeof data.lts_url === 'string' && data.lts_url.trim().length > 0) {
insightBaseUrl = data.lts_url;
}
} catch (error) {
console.warn('Unable to load LTS trial config, using default Insight URL.', error);
insightBaseUrl = defaultInsightBaseUrl;
}
}
function redirectToInsightSignup(claimId) {
window.location.href = buildInsightUrl(
insightBaseUrl,
`su/signup1.html?${encodeURIComponent(claimId)}`,
);
}
async function startSignupSession() {
setSetupStatus('Creating your Insight signup session…');
setSetupHelp('LibreQoS is contacting Insight and preparing your claim.');
await fetchTrialConfig();
try {
const response = await sendWsRequest("LtsStartSignupResult", { LtsStartSignup: {} });
const claimId = response && typeof response.claim_id === 'string'
? response.claim_id.trim()
: '';
if (!claimId) {
throw new Error('Missing claim ID from signup response.');
}
setSetupStatus('Signup session ready. Redirecting to Insight…', 'success', false);
setSetupHelp('Your signup session is ready. Continuing to Insight now.');
redirectToInsightSignup(claimId);
} catch (error) {
console.error('Failed to start Insight signup session.', error);
setSetupStatus(
getErrorMessage(error, 'Unable to start the Insight signup session right now.'),
'danger',
false,
);
setSetupHelp(
'Retry this page, use the direct signup form, or validate an existing license key below.',
);
}
}
$(document).ready(() => {
setSetupStatus('Preparing the Insight signup handoff…');
void startSignupSession();
});
@@ -0,0 +1,58 @@
import {get_ws_client} from "./pubsub/ws";
const DEFAULT_INSIGHT_BASE_URL = 'https://insight.libreqos.com/';
const wsClient = get_ws_client();
function normalizeInsightBaseUrl(rawBaseUrl) {
let baseUrl = (rawBaseUrl || DEFAULT_INSIGHT_BASE_URL).trim();
if (!baseUrl) {
baseUrl = DEFAULT_INSIGHT_BASE_URL;
}
if (!/^https?:\/\//i.test(baseUrl)) {
baseUrl = `https://${baseUrl}`;
}
baseUrl = baseUrl.replace(/\/+$/, '');
if (baseUrl.endsWith('/signup-api')) {
baseUrl = baseUrl.slice(0, -'/signup-api'.length);
}
return `${baseUrl}/`;
}
function buildInsightUrl(rawBaseUrl, endpoint) {
const path = String(endpoint || '').replace(/^\/+/, '');
return `${normalizeInsightBaseUrl(rawBaseUrl)}${path}`;
}
function buildSignupApiUrl(rawBaseUrl, endpoint) {
const path = String(endpoint || '').replace(/^\/+/, '');
return buildInsightUrl(rawBaseUrl, `signup-api/${path}`);
}
function sendWsRequest(responseEvent, request) {
return new Promise((resolve, reject) => {
let done = false;
const onResponse = (msg) => {
if (done) return;
done = true;
wsClient.off(responseEvent, onResponse);
wsClient.off("Error", onError);
resolve(msg);
};
const onError = (msg) => {
if (done) return;
done = true;
wsClient.off(responseEvent, onResponse);
wsClient.off("Error", onError);
reject(msg);
};
wsClient.on(responseEvent, onResponse);
wsClient.on("Error", onError);
wsClient.send(request);
});
}
export {buildInsightUrl, buildSignupApiUrl, normalizeInsightBaseUrl, sendWsRequest, wsClient};
+334 -18
View File
@@ -1,8 +1,10 @@
mod last_24_hours;
mod shaper_status;
use crate::lts2_sys::lts2_client::{LicenseStatus, set_license_status};
use crate::lts2_sys::shared_types::LtsStatus;
use crate::node_manager::auth::LoginResult;
use crate::node_manager::local_api::circuit_count;
use axum::http::StatusCode;
pub use last_24_hours::*;
use lqos_bus::{BusRequest, bus_request};
@@ -10,9 +12,14 @@ use lqos_config::load_config;
use serde::{Deserialize, Serialize};
pub use shaper_status::ShaperStatus;
pub use shaper_status::shaper_status_data;
use std::ops::Deref;
use std::process::Command;
use tracing::{info, warn};
use std::time::Duration;
use tracing::{debug, info, warn};
use uuid::Uuid;
const SIGNUP_POLL_INTERVAL: Duration = Duration::from_secs(10);
const INSIGHT_FREE_TRIAL_STATUS_CODE: i32 = 2;
const INSIGHT_TRIAL_DAYS_HINT: i32 = 30;
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct LtsTrialConfig {
@@ -20,6 +27,37 @@ pub struct LtsTrialConfig {
pub lts_url: Option<String>,
}
#[derive(Debug, Serialize)]
struct SignupStartRequest {
node_id: String,
node_name: String,
active_circuits: usize,
}
#[derive(Debug, Deserialize)]
struct SignupStartResponse {
claim_id: Uuid,
}
#[derive(Debug, Serialize)]
struct SignupCheckRequest {
claim_id: String,
}
#[derive(Debug, Deserialize)]
struct SignupCheckResponse {
status: String,
#[serde(default)]
account_key: Option<Uuid>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SignupCheckState {
Pending,
Provisioned { account_key: Uuid },
Expired,
}
pub fn lts_trial_config_data(login: LoginResult) -> Result<LtsTrialConfig, StatusCode> {
if login != LoginResult::Admin {
return Err(StatusCode::FORBIDDEN);
@@ -31,6 +69,221 @@ pub fn lts_trial_config_data(login: LoginResult) -> Result<LtsTrialConfig, Statu
})
}
fn normalize_insight_base_url(lts_url: Option<&str>) -> String {
let mut base_url = lts_url
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("https://insight.libreqos.com/")
.to_string();
if !base_url.starts_with("http://") && !base_url.starts_with("https://") {
base_url = format!("https://{base_url}");
}
while base_url.ends_with('/') {
base_url.pop();
}
if let Some(stripped) = base_url.strip_suffix("/signup-api") {
base_url = stripped.to_string();
}
format!("{base_url}/")
}
fn build_insight_url(lts_url: Option<&str>, path: &str) -> String {
let path = path.trim_start_matches('/');
format!("{}{}", normalize_insight_base_url(lts_url), path)
}
fn insight_http_client() -> Result<reqwest::Client, StatusCode> {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(15))
.danger_accept_invalid_certs(true)
.build()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
fn classify_signup_check_response(response: SignupCheckResponse) -> Result<SignupCheckState, ()> {
match response.status.as_str() {
"pending" => Ok(SignupCheckState::Pending),
"expired" => Ok(SignupCheckState::Expired),
"provisioned" => response
.account_key
.map(|account_key| SignupCheckState::Provisioned { account_key })
.ok_or(()),
_ => Err(()),
}
}
async fn apply_insight_license(license_key: String, restart_lqos_api: bool) -> Result<(), StatusCode> {
let license_key = license_key.trim().to_string();
if license_key.is_empty() {
return Err(StatusCode::BAD_GATEWAY);
}
let mut cfg = load_config()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.as_ref()
.clone();
cfg.long_term_stats.gather_stats = true;
cfg.long_term_stats.license_key = Some(license_key.clone());
bus_request(vec![BusRequest::UpdateLqosdConfig(Box::new(cfg))])
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
set_license_status(LicenseStatus {
license_type: INSIGHT_FREE_TRIAL_STATUS_CODE,
trial_expires: INSIGHT_TRIAL_DAYS_HINT,
});
info!("LQOSD configuration updated with new Insight license key.");
if restart_lqos_api {
let _ = Command::new("/bin/systemctl")
.args(["restart", "lqos_api"])
.output();
}
Ok(())
}
pub async fn lts_trial_start_signup_data() -> Result<String, StatusCode> {
let config = load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let start_url = build_insight_url(config.long_term_stats.lts_url.as_deref(), "/su/start");
let request = SignupStartRequest {
node_id: config.node_id.clone(),
node_name: config.node_name.clone(),
active_circuits: circuit_count::circuit_count_data().count,
};
debug!(
node_id = %request.node_id,
active_circuits = request.active_circuits,
url = %start_url,
"starting Insight signup session"
);
let client = insight_http_client()?;
let response = client
.post(&start_url)
.json(&request)
.send()
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
if !response.status().is_success() {
warn!(
status = %response.status(),
url = %start_url,
"Insight signup start request failed"
);
return Err(StatusCode::BAD_GATEWAY);
}
let response = response
.json::<SignupStartResponse>()
.await
.map_err(|_| StatusCode::BAD_GATEWAY)?;
Ok(response.claim_id.to_string())
}
pub fn spawn_signup_poll_loop(claim_id: String) {
tokio::spawn(async move {
if let Err(status) = poll_signup_until_complete(claim_id.clone()).await {
warn!(
claim_id = %claim_id,
status = %status,
"Insight signup poll loop stopped with an error"
);
}
});
}
async fn poll_signup_until_complete(claim_id: String) -> Result<(), StatusCode> {
let config = load_config().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let check_url = build_insight_url(config.long_term_stats.lts_url.as_deref(), "/su/check");
let client = insight_http_client()?;
let request = SignupCheckRequest {
claim_id: claim_id.clone(),
};
loop {
tokio::time::sleep(SIGNUP_POLL_INTERVAL).await;
let response = match client.post(&check_url).json(&request).send().await {
Ok(response) => response,
Err(error) => {
warn!(
claim_id = %claim_id,
error = ?error,
"Insight signup status poll failed; retrying"
);
continue;
}
};
if !response.status().is_success() {
warn!(
claim_id = %claim_id,
status = %response.status(),
"Insight signup status poll returned non-success; retrying"
);
continue;
}
let response = match response.json::<SignupCheckResponse>().await {
Ok(response) => response,
Err(error) => {
warn!(
claim_id = %claim_id,
error = ?error,
"Insight signup status poll returned invalid JSON; retrying"
);
continue;
}
};
match classify_signup_check_response(response) {
Ok(SignupCheckState::Pending) => {}
Ok(SignupCheckState::Expired) => {
debug!(claim_id = %claim_id, "Insight signup claim expired");
return Ok(());
}
Ok(SignupCheckState::Provisioned { account_key }) => {
match apply_insight_license(account_key.to_string(), false).await {
Ok(()) => {
info!(
claim_id = %claim_id,
account_key = %account_key,
"Applied Insight license from signup flow"
);
return Ok(());
}
Err(status) => {
warn!(
claim_id = %claim_id,
account_key = %account_key,
status = %status,
"Failed to apply Insight license; stopping signup poll loop"
);
return Err(status);
}
}
}
Err(()) => {
warn!(
claim_id = %claim_id,
"Insight signup status poll returned an unexpected payload"
);
return Err(StatusCode::BAD_GATEWAY);
}
}
}
}
pub(crate) async fn insight_gate() -> Result<(), StatusCode> {
let (status, _) = crate::lts2_sys::get_lts_license_status_async().await;
match status {
@@ -56,20 +309,83 @@ pub async fn lts_trial_signup_data(license_key: String) -> Result<(), StatusCode
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
info!("Free trial request succeeded, license key: {}", license_key);
let mut cfg = load_config()
.expect("Unable to load LibreQoS config")
.deref()
.clone();
cfg.long_term_stats.gather_stats = true;
cfg.long_term_stats.license_key = Some(license_key);
bus_request(vec![BusRequest::UpdateLqosdConfig(Box::new(cfg))])
.await
.expect("Unable to update lqosd config");
info!("LQOSD configuration updated with new license key.");
// Best-effort: ensure the bundled lqos_api also reloads to pick up the new license
// Ignore errors if systemctl isn't present or permission is denied.
let _ = Command::new("/bin/systemctl")
.args(["restart", "lqos_api"])
.output();
Ok(())
apply_insight_license(license_key, true).await
}
#[cfg(test)]
mod tests {
use super::{
SignupCheckResponse, SignupCheckState, build_insight_url, classify_signup_check_response,
normalize_insight_base_url,
};
use uuid::Uuid;
#[test]
fn normalize_insight_base_url_defaults_to_hosted_insight() {
assert_eq!(
normalize_insight_base_url(None),
"https://insight.libreqos.com/"
);
}
#[test]
fn normalize_insight_base_url_adds_scheme_and_trailing_slash() {
assert_eq!(
normalize_insight_base_url(Some("insight.example.com")),
"https://insight.example.com/"
);
}
#[test]
fn normalize_insight_base_url_strips_signup_api_suffix() {
assert_eq!(
normalize_insight_base_url(Some("https://insight.example.com/signup-api/")),
"https://insight.example.com/"
);
}
#[test]
fn build_insight_url_joins_paths_cleanly() {
assert_eq!(
build_insight_url(Some("insight.example.com"), "/su/start"),
"https://insight.example.com/su/start"
);
}
#[test]
fn classify_signup_check_response_handles_pending() {
let response = SignupCheckResponse {
status: "pending".to_string(),
account_key: None,
};
assert_eq!(
classify_signup_check_response(response),
Ok(SignupCheckState::Pending)
);
}
#[test]
fn classify_signup_check_response_requires_account_key_for_provisioned() {
let response = SignupCheckResponse {
status: "provisioned".to_string(),
account_key: None,
};
assert!(classify_signup_check_response(response).is_err());
}
#[test]
fn classify_signup_check_response_returns_provisioned_account_key() {
let account_key = Uuid::new_v4();
let response = SignupCheckResponse {
status: "provisioned".to_string(),
account_key: Some(account_key),
};
assert_eq!(
classify_signup_check_response(response),
Ok(SignupCheckState::Provisioned { account_key })
);
}
}
@@ -164,6 +164,11 @@ body.dark-mode .card-header.bg-light {
<button id="btnNewCustomer" class="btn btn-lg btn-outline-primary mb-3">
<i class="fas fa-rocket"></i> Start my free trial
</button>
<div class="mt-2">
<a id="lnkLegacySignup" class="link-secondary" href="lts_trial.html?view=signup">
Need the direct signup form instead?
</a>
</div>
</div>
</div>
</div>
@@ -0,0 +1,37 @@
<div class="row justify-content-center py-4">
<div class="col-xl-7 col-lg-8">
<div class="card shadow border-primary-subtle">
<div class="card-header bg-light">
<h4 class="mb-0">
<i class="fas fa-satellite-dish text-primary"></i>
Preparing Your Insight Sign-Up Session
</h4>
</div>
<div class="card-body">
<p class="lead mb-3">
LibreQoS will create a short-lived Insight claim and then hand your browser to the hosted signup flow.
</p>
<div id="signupSetupStatus" class="alert alert-primary d-flex align-items-center mb-3" role="status">
<span class="spinner-border spinner-border-sm text-primary me-2" aria-hidden="true"></span>
<span id="signupSetupStatusText">Preparing the Insight signup handoff…</span>
</div>
<p id="signupSetupHelp" class="text-muted mb-4">
Existing license validation and the direct signup form remain available if you need them.
</p>
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
<a class="btn btn-outline-success" href="lts_trial.html?view=license">
<i class="fas fa-key"></i> I have a license key
</a>
<a class="btn btn-outline-secondary" href="lts_trial.html?view=signup">
<i class="fas fa-user-plus"></i> Use the direct signup form
</a>
<a class="btn btn-outline-secondary" href="lts_trial.html">
<i class="fas fa-arrow-left"></i> Back
</a>
</div>
</div>
</div>
</div>
</div>
<script src="lts_trial_setup.js%CACHEBUSTERS%"></script>
@@ -45,6 +45,7 @@ pub(super) fn static_routes() -> Result<Router> {
"api_docs.html",
"chatbot.html",
"lts_trial.html",
"lts_trial_setup.html",
"lts_trial_success.html",
"lts_trial_fail.html",
"executive_worst_sites.html",
+28
View File
@@ -1036,6 +1036,34 @@ async fn receive_channel_message(
return true;
}
}
WsRequest::LtsStartSignup => {
let result = lts::lts_trial_start_signup_data().await;
match result {
Ok(claim_id) => {
lts::spawn_signup_poll_loop(claim_id.clone());
let response = WsResponse::LtsStartSignupResult { claim_id };
if send_ws_response(&tx, response).await {
return true;
}
}
Err(StatusCode::FORBIDDEN) => {
let response = WsResponse::Error {
message: "Unauthorized".to_string(),
};
if send_ws_response(&tx, response).await {
return true;
}
}
Err(_) => {
let response = WsResponse::Error {
message: "Unable to start the Insight signup session.".to_string(),
};
if send_ws_response(&tx, response).await {
return true;
}
}
}
}
WsRequest::LtsSignUp { license_key } => {
let result = lts::lts_trial_signup_data(license_key).await;
let (ok, message) = match result {
@@ -132,6 +132,7 @@ pub enum WsRequest {
ReloadLibreQoS,
LtsTrialConfig,
CircuitCount,
LtsStartSignup,
LtsSignUp {
license_key: String,
},
@@ -693,6 +694,9 @@ pub enum WsResponse {
CircuitCountResult {
data: CircuitCount,
},
LtsStartSignupResult {
claim_id: String,
},
LtsSignUpResult {
ok: bool,
message: String,