Compare commits

..

1 Commits

Author SHA1 Message Date
Zarithas 8fe3a05d8b Added Telemetry Toggle 2025-12-24 12:26:34 -05:00
10 changed files with 207 additions and 100 deletions
+2 -2
View File
@@ -2,8 +2,8 @@ name: Build Library
run-name: ${{ gitea.actor }}
on:
push:
branches-ignore:
- master
branches:
- RustImplementation
paths:
- airlock_libs/**
+2 -2
View File
@@ -58,7 +58,7 @@ class MultiAgentSelector(Widget):
def compose(self):
yield Header(show_clock=True, icon="")
title_text = Static("🖥️ Agent Selector", id="selector_title")
title_text = Static("🖥️ Agent Selector", id="selector_title")
title_text.styles.margin = (0, 0, 0, 1)
yield title_text
@@ -175,7 +175,7 @@ class MultiAgentSelector(Widget):
match_list.add_option((name, name))
unmatched_label = self.query_one("#unmatched_label", Static)
if unmatched:
unmatched_label.update(f"⚠️ No matches for: {', '.join(unmatched)}")
unmatched_label.update(f"No matches for: {', '.join(unmatched)}")
else:
unmatched_label.update("")
+176 -1
View File
@@ -23,8 +23,10 @@ import webbrowser
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Button, Rule, Static
from textual.widgets import Button, Input, Rule, Static, Switch
from utils.configmanager import get_user_value, save_user_config
from utils.setup import get_base_directory
from utils.versionchecker import (
RELEASES_PAGE_URL,
UpdateCheckResult,
@@ -81,6 +83,36 @@ class SettingsWidget(Widget):
#light_themes_col {
margin-left: 1;
}
/* Telemetry section */
#telemetry_row {
height: auto;
margin: 1 0;
}
#telemetry_label {
width: auto;
margin-right: 1;
}
#telemetry_url_container {
height: auto;
margin: 1 0;
}
#telemetry_url_label {
width: auto;
margin-right: 1;
}
#telemetry_url_input {
width: 1fr;
}
.settings_description {
color: $text-muted;
margin-bottom: 1;
}
"""
class ThemeSelected(Message):
@@ -146,6 +178,40 @@ class SettingsWidget(Widget):
yield Rule()
# Telemetry Section
with Vertical(id="telemetry_section") as telemetry:
telemetry.styles.height = "auto"
yield Static(
"📊 Telemetry Settings",
id="telemetry_title",
classes="settings_section_title",
)
telemetry_enabled = get_user_value("TELEMETRY", bool, False)
with Horizontal(id="telemetry_row"):
yield Static("Enable Telemetry: ", id="telemetry_label")
yield Switch(value=telemetry_enabled, id="telemetry_switch")
# URL input container - shown only when telemetry is enabled
if telemetry_enabled:
telemetry_url = get_user_value("TELEM_URL", str, "")
with Horizontal(id="telemetry_url_container"):
yield Static("Telemetry URL: ", id="telemetry_url_label")
yield Input(
value=telemetry_url,
placeholder="https://your-telemetry-endpoint.com",
id="telemetry_url_input",
)
yield Static(
"Help improve Loxide by sending anonymous usage data",
id="telemetry_description",
classes="settings_description",
)
yield Rule()
# Theme Section - Three columns: Dark 1, Dark 2, Light
with Vertical(id="themes_section") as themes:
themes.styles.height = "auto"
@@ -231,6 +297,85 @@ class SettingsWidget(Widget):
self.post_message(self.ThemeSelected(theme_name))
event.stop()
def on_switch_changed(self, event: Switch.Changed) -> None:
"""Handle telemetry switch toggle."""
if event.switch.id == "telemetry_switch":
enabled = event.value
self._persist_telemetry_setting(enabled)
if enabled:
self._mount_telemetry_url_input()
else:
self._unmount_telemetry_url_input()
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle telemetry URL input changes."""
if event.input.id == "telemetry_url_input":
self._persist_telemetry_url(event.value)
def _persist_telemetry_setting(self, enabled: bool) -> None:
"""Store the telemetry opt-in/out setting in the user's config."""
base_dir = get_base_directory()
config_dir = base_dir / "config"
try:
save_user_config(config_dir, {"TELEMETRY": enabled})
logger.debug("Updated user config with TELEMETRY=%s", enabled)
status = "enabled" if enabled else "disabled"
self.app.notify(f"📊 Telemetry {status}", timeout=2)
except Exception as exc:
logger.error("Failed to save TELEMETRY setting: %s", exc)
self.app.notify(f"⚠️ Failed to save setting: {exc}", severity="warning")
def _persist_telemetry_url(self, url: str) -> None:
"""Store the telemetry URL in the user's config."""
base_dir = get_base_directory()
config_dir = base_dir / "config"
try:
save_user_config(config_dir, {"TELEM_URL": url})
logger.debug("Updated user config with TELEM_URL=%s", url)
except Exception as exc:
logger.error("Failed to save TELEM_URL setting: %s", exc)
def _mount_telemetry_url_input(self) -> None:
"""Mount the telemetry URL input container."""
try:
# Check if already mounted
self.query_one("#telemetry_url_container")
return
except Exception:
pass
telemetry_url = get_user_value("TELEM_URL", str, "")
# Create container with children
container = Horizontal(
Static("Telemetry URL: ", id="telemetry_url_label"),
Input(
value=telemetry_url,
placeholder="https://your-telemetry-endpoint.com",
id="telemetry_url_input",
),
id="telemetry_url_container",
)
# Mount after the telemetry_row within telemetry_section
try:
telemetry_section = self.query_one("#telemetry_section")
telemetry_row = self.query_one("#telemetry_row")
telemetry_section.mount(container, after=telemetry_row)
except Exception as exc:
logger.error("Failed to mount telemetry URL input: %s", exc)
def _unmount_telemetry_url_input(self) -> None:
"""Unmount the telemetry URL input container."""
try:
container = self.query_one("#telemetry_url_container")
container.remove()
except Exception:
pass
def _check_for_updates(self) -> None:
"""Check for updates and update UI."""
if self._checking:
@@ -497,6 +642,36 @@ class ThemeSelector(Widget):
#light_themes_col {
margin-left: 1;
}
/* Telemetry section */
#telemetry_row {
height: auto;
margin: 1 0;
}
#telemetry_label {
width: auto;
margin-right: 1;
}
#telemetry_url_container {
height: auto;
margin: 1 0;
}
#telemetry_url_label {
width: auto;
margin-right: 1;
}
#telemetry_url_input {
width: 1fr;
}
.settings_description {
color: $text-muted;
margin-bottom: 1;
}
"""
class ThemeSelected(Message):
+1 -1
View File
@@ -26,7 +26,7 @@ dependencies = [
[[package]]
name = "airlock_libs"
version = "7.4.1"
version = "6.1.1"
dependencies = [
"chrono",
"crossbeam",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "airlock_libs"
version = "7.4.1"
version = "6.1.1"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "airlock_libs"
version = "7.4.1"
version = "6.1.1"
description = "Airlock Digital API Wrapper"
readme = "README.md"
license = { text = "AGPL-3.0-only" }
+21 -89
View File
@@ -1,6 +1,7 @@
use serde_json::json;
use crate::modules::datatypes::*;
use crate::prelude::*;
use opentelemetry::trace::SpanContext;
#[pyfunction]
pub fn pull_policy_exec_histories(
@@ -74,8 +75,8 @@ pub fn pull_policy_exec_histories(
.set_draw_target(ProgressDrawTarget::stderr());
progress_bar.lock().unwrap().set_style(
ProgressStyle::default_bar()
.template("Total - Policy Name: {msg}: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len}")
.unwrap().progress_chars("⣿⣦⣀")
.template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len} {message}")
.unwrap(),
);
let client: Client = tracer.in_span("Building HTTP Client", |cx| {
let client_result: Result<Client, reqwest::Error> = build_client(headers);
@@ -107,10 +108,9 @@ pub fn pull_policy_exec_histories(
});
let cutoff: chrono::NaiveDateTime =
Local::now().naive_local() - chrono::Duration::days(days);
let (tx, rx) = unbounded::<(SpanContext, Vec<Group>)>();
let (tx, rx) = unbounded::<Vec<Group>>();
let pb_clone = progress_bar.clone();
thread::spawn(move || {
let tracer = global::tracer("loxide");
let mut seen: HashMap<(String, String, String), Group> = if writeable_filepath.exists()
{
let contents: String = fs::read_to_string(&writeable_filepath).unwrap_or_default();
@@ -139,22 +139,7 @@ pub fn pull_policy_exec_histories(
} else {
HashMap::new()
};
while let Ok((parent_spancontext, parsed_responses)) = rx.recv() {
let parent_ctx = Context::new().with_remote_span_context(parent_spancontext);
let span = tracer.build_with_context(
tracer
.span_builder("Deduplicate and Write")
.with_kind(trace::SpanKind::Consumer),
&parent_ctx,
);
let cx = Context::current_with_span(span);
cx.span().add_event(
"Received Data from Producer",
vec![KeyValue::new(
"Items to Process",
parsed_responses.len().to_string(),
)],
);
while let Ok(parsed_responses) = rx.recv() {
for executions in parsed_responses {
if executions.checkpoint.is_empty() || executions.datetime.is_empty() {
continue;
@@ -183,28 +168,11 @@ pub fn pull_policy_exec_histories(
};
let data_write: String = serde_json::to_string_pretty(&final_response).unwrap();
match fs::write(&writeable_filepath, data_write) {
Ok(_) => {
cx.span().add_event(
"Writing Data to File",
vec![KeyValue::new("Success", "Ok".to_string())],
);
}
Ok(_) => {}
Err(e) => {
cx.span().add_event(
"Writing Data to File",
vec![KeyValue::new("Failed", e.to_string())],
);
cx.span()
.set_status(Status::error("Failed to Write to File"));
println!("Failed to write to: {:?}: {}", &writeable_filepath, e);
}
}
cx.span().add_event(
"Finished Deduplicating Data",
vec![KeyValue::new(
"Items Successfully Processed",
seen.len().to_string(),
)],
);
}
});
let mut first_date: Option<NaiveDate> = None;
@@ -213,42 +181,21 @@ pub fn pull_policy_exec_histories(
.lock()
.unwrap()
.enable_steady_tick(std::time::Duration::from_millis(100));
pb_clone
.lock()
.unwrap()
.set_message(policy_names.clone().unwrap_or("Statistics".to_string()));
let span: opentelemetry::trace::SpanRef<'_> = cx.span();
span.set_attribute(KeyValue::new("Days", days.to_string()));
span.set_attribute(KeyValue::new(
"Policy Name",
policy_names
.clone()
.unwrap_or("Statistics Monitoring".to_string()),
policy_names.clone().unwrap_or_default(),
));
loop {
let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| {
cx.span().add_event(
"Retrieving Responses from API",
vec![KeyValue::new(
"Checkpoint Number",
checkpoint_number.to_string(),
)],
);
let results: ApiResponse = rt.block_on(history_logging(
let results: ApiResponse = history_logging(
&base_url,
&exec_types,
&checkpoint_number,
&policy_names,
&client,
));
cx.span().add_event(
"Got Responses from API",
vec![KeyValue::new(
"Items in Response",
results.response.exechistories.len().to_string(),
)],
);
cx.span().set_status(Status::Ok);
cx.span().set_attribute(KeyValue::new(
"items_in_response",
results.response.exechistories.len().to_string(),
@@ -259,16 +206,7 @@ pub fn pull_policy_exec_histories(
if parsed_responses.is_empty() {
break;
}
match tx.send((cx.span().span_context().clone(), parsed_responses.clone())) {
Ok(_) => {}
Err(e) => {
cx.span().add_event(
"Failed to Send Items to Processor",
vec![KeyValue::new("Response from Processor", e.to_string())],
);
cx.span().set_status(Status::error("Processor Failed"))
}
}
tx.send(parsed_responses.clone()).unwrap();
checkpoint_number = parsed_responses.last().unwrap().checkpoint.clone();
if let Some(last_item) = parsed_responses.last()
&& let Ok(last_date) = NaiveDate::parse_from_str(
@@ -317,10 +255,11 @@ fn build_client(headers: HeaderMap) -> Result<reqwest::Client, reqwest::Error> {
Client::builder()
.danger_accept_invalid_certs(true)
.default_headers(headers)
.timeout(std::time::Duration::from_secs(300))
.build()
}
#[tracing::instrument(name = "history_logging")]
#[tokio::main]
async fn history_logging(
base_url: &String,
exec_types: &String,
@@ -328,28 +267,21 @@ async fn history_logging(
policy_names: &Option<String>,
client: &Client,
) -> ApiResponse {
let policy_json = match policy_names {
Some(name) => format!(r#"[ "{}" ]"#, name), // JSON array with one element
None => "[]".to_string(), // Empty JSON array
};
let payload = format!(
r#"{{
"type": {},
"checkpoint": "{}",
"policy": {}
}}"#,
exec_types, checkpoint_number, policy_json
);
let payload = json!({
"type": exec_types,
"checkpoint": checkpoint_number,
"policy": policy_names.as_ref().map(|p| vec![p]),
});
let res: Result<reqwest::Response, reqwest::Error> = client
.post(format!("{}/v1/logging/exechistories", base_url))
.body(payload)
.json(&payload)
.send()
.await;
match res {
Ok(res) => {
let first_response: ApiResponse = serde_json::from_str(&res.text().await.unwrap())
.expect("Failed to retrieve response from API");
first_response
return first_response;
}
Err(_res) => {
let failed_response: ApiResponse = ApiResponse {
@@ -358,7 +290,7 @@ async fn history_logging(
exechistories: vec![],
},
};
failed_response
return failed_response;
}
}
}
@@ -357,7 +357,7 @@ Loxide is designed for three primary user classes with varying levels of experti
#### 2.4.3 Network Requirements
- Outbound HTTPS (port 443) to Airlock server
- Outbound to Airlock server
- Outbound HTTPS to Gitea instance (development only)
- No inbound connections required
- Proxy support via standard environment variables
+1 -1
View File
@@ -23,4 +23,4 @@ pyperclip==1.11.0
# Custom/Private packages
--extra-index-url https://git.racooncity.org/api/packages/brotoskyj/pypi/simple/
airlock_libs==7.4.1
airlock_libs==6.1.1
+1 -1
View File
@@ -34,7 +34,7 @@ import requests
logger = logging.getLogger(__name__)
# Current application version - UPDATE THIS ON EACH RELEASE
__version__ = "1.0.0"
__version__ = "1.1.0"
# Gitea release API configuration
GITEA_API_BASE = "https://git.racooncity.org/api/v1"