Compare commits

..

20 Commits

Author SHA1 Message Date
James Brotosky 23529f1f94 Merge pull request 'Fixing timeout issue' (#60) from fix/timeout into master
Reviewed-on: brotoskyj/AirlockTools#60
2026-01-16 16:04:22 -05:00
brotoskyj 963aa3bcfb Fixing timeout issue
Build Library / Build Library (push) Has been cancelled
2026-01-16 13:37:58 -05:00
James Brotosky a609a088d5 Merge pull request 'Fixed History Logger' (#58) from fix/HistoryLogging into master
Reviewed-on: brotoskyj/AirlockTools#58
2026-01-08 15:46:57 -05:00
brotoskyj ccfdf4e7ab Refactor master branch to match last tag. There was an issue with formatting for some reason 2026-01-08 15:46:25 -05:00
brotoskyj a860dce421 Fixed History Logger 2026-01-08 15:39:58 -05:00
brotoskyj e19b748bac Closes #56 2026-01-08 15:22:05 -05:00
James Brotosky 01b80429f8 Merge pull request 'Fixed Telemetry Exporter' (#57) from fix/TelemetryExporter into master
Reviewed-on: brotoskyj/AirlockTools#57
2026-01-08 15:21:22 -05:00
brotoskyj 6a76c2ded7 Fixed Telemetry Exporter
Closes #56
2026-01-08 15:20:54 -05:00
brotoskyj f1c5080c97 Merge branch 'fix/loxidelibs' 2026-01-07 17:11:34 -05:00
brotoskyj 72681218e7 Fix for API Data Retrieval
Performance Optimization - History Logging was spawning a new tokio runtime every function call, it now uses the global run time
Documentation - Added a lot more span events for better logging
2026-01-07 17:01:33 -05:00
James Brotosky ededef6d30 Merge pull request 'RustLinting' (#54) from RustLinting into master
Reviewed-on: brotoskyj/AirlockTools#54
2026-01-05 10:18:38 -05:00
brotoskyj 1aae27b0f4 Changed File so Build Activates
Build Library / Build Library (push) Failing after 1m22s
2026-01-05 10:12:43 -05:00
brotoskyj 663dbc3cc2 Changed YAML Build File 2026-01-05 10:11:50 -05:00
brotoskyj d2d181de4a Removed old crate imports 2026-01-05 10:08:41 -05:00
brotoskyj 31d45ca1db Styling - Progress Bar
Changed glyphs in progress bar for better printing to console
closes #51
2025-12-22 12:42:59 -05:00
brotoskyj a086956b48 Features - Optional Policy Name in Execution Histories
closes #48
2025-12-22 12:42:59 -05:00
brotoskyj 76cfe62f08 Styling - Progress Bar
Changed progress bar indicators and added policy name to the progress bar
closes #50
2025-12-22 12:42:59 -05:00
brotoskyj 5555747422 Styling - Progress Bar
Build Library / Build Library (push) Successful in 4m55s
Changed glyphs in progress bar for better printing to console
closes #51
2025-12-18 12:58:51 -05:00
brotoskyj 729b45f52a Features - Optional Policy Name in Execution Histories
Build Library / Build Library (push) Successful in 4m51s
closes #48
2025-12-18 10:14:58 -05:00
brotoskyj 66bb21ed88 Styling - Progress Bar
Build Library / Build Library (push) Successful in 5m21s
Changed progress bar indicators and added policy name to the progress bar
closes #50
2025-12-17 16:38:53 -05:00
10 changed files with 100 additions and 207 deletions
+2 -2
View File
@@ -2,8 +2,8 @@ name: Build Library
run-name: ${{ gitea.actor }}
on:
push:
branches:
- RustImplementation
branches-ignore:
- master
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("")
+1 -176
View File
@@ -23,10 +23,8 @@ import webbrowser
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Button, Input, Rule, Static, Switch
from textual.widgets import Button, Rule, Static
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,
@@ -83,36 +81,6 @@ 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):
@@ -178,40 +146,6 @@ 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"
@@ -297,85 +231,6 @@ 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:
@@ -642,36 +497,6 @@ 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 = "6.1.1"
version = "7.4.1"
dependencies = [
"chrono",
"crossbeam",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "airlock_libs"
version = "6.1.1"
version = "7.4.1"
edition = "2024"
[dependencies]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "airlock_libs"
version = "6.1.1"
version = "7.4.1"
description = "Airlock Digital API Wrapper"
readme = "README.md"
license = { text = "AGPL-3.0-only" }
+89 -21
View File
@@ -1,7 +1,6 @@
use serde_json::json;
use crate::modules::datatypes::*;
use crate::prelude::*;
use opentelemetry::trace::SpanContext;
#[pyfunction]
pub fn pull_policy_exec_histories(
@@ -75,8 +74,8 @@ pub fn pull_policy_exec_histories(
.set_draw_target(ProgressDrawTarget::stderr());
progress_bar.lock().unwrap().set_style(
ProgressStyle::default_bar()
.template("Total Completion: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len} {message}")
.unwrap(),
.template("Total - Policy Name: {msg}: {spinner:.green} [{elapsed_precise}] [{bar:40.green/blue}] {pos}/{len}")
.unwrap().progress_chars("⣿⣦⣀")
);
let client: Client = tracer.in_span("Building HTTP Client", |cx| {
let client_result: Result<Client, reqwest::Error> = build_client(headers);
@@ -108,9 +107,10 @@ pub fn pull_policy_exec_histories(
});
let cutoff: chrono::NaiveDateTime =
Local::now().naive_local() - chrono::Duration::days(days);
let (tx, rx) = unbounded::<Vec<Group>>();
let (tx, rx) = unbounded::<(SpanContext, 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,7 +139,22 @@ pub fn pull_policy_exec_histories(
} else {
HashMap::new()
};
while let Ok(parsed_responses) = rx.recv() {
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(),
)],
);
for executions in parsed_responses {
if executions.checkpoint.is_empty() || executions.datetime.is_empty() {
continue;
@@ -168,11 +183,28 @@ 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(_) => {}
Ok(_) => {
cx.span().add_event(
"Writing Data to File",
vec![KeyValue::new("Success", "Ok".to_string())],
);
}
Err(e) => {
println!("Failed to write to: {:?}: {}", &writeable_filepath, 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"));
}
}
cx.span().add_event(
"Finished Deduplicating Data",
vec![KeyValue::new(
"Items Successfully Processed",
seen.len().to_string(),
)],
);
}
});
let mut first_date: Option<NaiveDate> = None;
@@ -181,21 +213,42 @@ 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_default(),
policy_names
.clone()
.unwrap_or("Statistics Monitoring".to_string()),
));
loop {
let execution_histories = tracer.in_span(checkpoint_number.to_string(), |cx| {
let results: ApiResponse = history_logging(
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(
&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(),
@@ -206,7 +259,16 @@ pub fn pull_policy_exec_histories(
if parsed_responses.is_empty() {
break;
}
tx.send(parsed_responses.clone()).unwrap();
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"))
}
}
checkpoint_number = parsed_responses.last().unwrap().checkpoint.clone();
if let Some(last_item) = parsed_responses.last()
&& let Ok(last_date) = NaiveDate::parse_from_str(
@@ -255,11 +317,10 @@ 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()
}
#[tokio::main]
#[tracing::instrument(name = "history_logging")]
async fn history_logging(
base_url: &String,
exec_types: &String,
@@ -267,21 +328,28 @@ async fn history_logging(
policy_names: &Option<String>,
client: &Client,
) -> ApiResponse {
let payload = json!({
"type": exec_types,
"checkpoint": checkpoint_number,
"policy": policy_names.as_ref().map(|p| vec![p]),
});
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 res: Result<reqwest::Response, reqwest::Error> = client
.post(format!("{}/v1/logging/exechistories", base_url))
.json(&payload)
.body(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");
return first_response;
first_response
}
Err(_res) => {
let failed_response: ApiResponse = ApiResponse {
@@ -290,7 +358,7 @@ async fn history_logging(
exechistories: vec![],
},
};
return failed_response;
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 to Airlock server
- Outbound HTTPS (port 443) 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==6.1.1
airlock_libs==7.4.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.1.0"
__version__ = "1.0.0"
# Gitea release API configuration
GITEA_API_BASE = "https://git.racooncity.org/api/v1"