From ddec5340c5eb168c732e652cd89bd765e43bfb93 Mon Sep 17 00:00:00 2001 From: Brian Duenas Date: Wed, 4 Mar 2026 15:09:56 -0800 Subject: [PATCH 1/2] feat(services): implements dynamic service loading --- src/web/mod.rs | 4 + src/web/route/auth/mod.rs | 3 +- src/web/route/health/mod.rs | 5 +- src/web/route/mcp/mod.rs | 6 +- src/web/route/portal/group_admin/mod.rs | 19 +- src/web/route/portal/helper.rs | 2 +- src/web/route/portal/mod.rs | 6 +- src/web/route/portal/system_admin/htmx.rs | 4 +- src/web/route/portal/system_admin/mod.rs | 41 ++- src/web/route/portal/user.rs | 14 +- src/web/route/portal/user_htmx.rs | 19 +- src/web/route/proxy/mod.rs | 4 +- src/web/route/proxy/services.rs | 363 +++++++++++----------- src/web/route/resource/mod.rs | 8 +- templates/components/systems_group.html | 7 + 15 files changed, 273 insertions(+), 232 deletions(-) diff --git a/src/web/mod.rs b/src/web/mod.rs index 3b83794a..c63720b0 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -75,6 +75,10 @@ pub async fn start_server(with_tls: bool) -> Result<()> { // Singletons openid::init_once().await; + if let Err(e) = services::init_once() { + eprintln!("{e}"); + exit(1); + } if let Err(e) = DB::init_once(&DBNAME).await { eprintln!("{e}"); exit(1); diff --git a/src/web/route/auth/mod.rs b/src/web/route/auth/mod.rs index 3d01c9bc..81966533 100644 --- a/src/web/route/auth/mod.rs +++ b/src/web/route/auth/mod.rs @@ -18,6 +18,7 @@ use crate::{ COOKIE_NAME, openid::{OpenID, OpenIDProvider, get_openid_provider}, }, + config::CONFIG, db::{ Database, keydb::CacheDB, @@ -31,7 +32,7 @@ use crate::{ }, }; #[cfg(feature = "observe")] -use crate::{config::CONFIG, logger::MESSAGE_DELIMITER}; +use crate::logger::MESSAGE_DELIMITER; pub use self::oauth::generate_token_with_cookie; use self::{ diff --git a/src/web/route/health/mod.rs b/src/web/route/health/mod.rs index 0e0ab012..8e991296 100644 --- a/src/web/route/health/mod.rs +++ b/src/web/route/health/mod.rs @@ -11,7 +11,7 @@ use tracing::{error, instrument}; use crate::{ db::keydb::CacheDB, errors::Result, - web::{bridge_middleware::Htmx, helper, services::CATALOG_URLS}, + web::{bridge_middleware::Htmx, helper, services}, }; mod inference_services; @@ -32,8 +32,9 @@ async fn status( client: Data, cache: Data>, ) -> Result { + let catalog_urls = services::get_service_health_urls(); let is = inference_services::InferenceServicesHealth::new( - &CATALOG_URLS, + &catalog_urls, client.as_ref().clone(), **cache, ); diff --git a/src/web/route/mcp/mod.rs b/src/web/route/mcp/mod.rs index f1a79d1c..aea8d94f 100644 --- a/src/web/route/mcp/mod.rs +++ b/src/web/route/mcp/mod.rs @@ -12,7 +12,7 @@ use crate::{ errors::{BridgeError, Result}, web::{ helper::{self, forwarding::Config}, - services::CATALOG, + services, }, }; @@ -38,7 +38,7 @@ async fn forward( if let Some(mcp) = path.split('/').next() { let path = path.strip_prefix(mcp).unwrap_or(path); - if !CATALOG.is_service_mcp(mcp)? { + if !services::is_service_mcp(mcp)? { return Err(BridgeError::ServiceDoesNotExist(mcp.to_string())); } @@ -62,7 +62,7 @@ async fn forward( )); } - let mut new_url = helper::log_with_level!(CATALOG.get_service(mcp), error)?; + let mut new_url = helper::log_with_level!(services::get_service(mcp), error)?; // fastmcp temp(?) fix if mcp.ends_with(MCP_SUFFIX) { new_url.set_path(&format!("{path}/")); diff --git a/src/web/route/portal/group_admin/mod.rs b/src/web/route/portal/group_admin/mod.rs index e0e59903..70521fac 100644 --- a/src/web/route/portal/group_admin/mod.rs +++ b/src/web/route/portal/group_admin/mod.rs @@ -29,7 +29,7 @@ use crate::{ bridge_middleware::{HTMX_ERROR_RES, Htmx}, helper::{self, bson}, route::portal::{helper::check_admin, user_htmx::Subscription}, - services::CATALOG, + services, }, }; @@ -91,15 +91,20 @@ pub(super) async fn group( let (subs, group_created_at, group_updated_at, group_last_updated) = match subscriptions { Ok(g) => ( { + let catalog_all = services::get_all(); let mut sub_details: Vec = Vec::new(); g.subscriptions.iter().for_each(|name| { - if let Some(sub) = CATALOG.get_all().get(name.as_str()) { + if let Some(sub) = catalog_all.get(name.as_str()) { sub_details.push(Subscription { name: name.to_owned(), - kind: sub.0, - kind_designation: if sub.1 { "mcp" } else { "inference" }, - description: sub.2, + kind: sub.kind.clone(), + kind_designation: if sub.mcp { + "mcp".to_string() + } else { + "inference".to_string() + }, + description: sub.description.clone(), }); } }); @@ -148,9 +153,7 @@ pub(super) async fn group( let resources: Vec<(&String, bool)> = resources .iter() .map(|r| { - let show = CATALOG - .get_details("resources", r, "show") - .map(|v| v.as_bool().unwrap_or(false)); + let show = services::get_detail("resources", r, "show").and_then(|v| v.as_bool()); (r, show.unwrap_or(false)) }) .collect(); diff --git a/src/web/route/portal/helper.rs b/src/web/route/portal/helper.rs index d4ac0903..e5974c61 100644 --- a/src/web/route/portal/helper.rs +++ b/src/web/route/portal/helper.rs @@ -92,7 +92,7 @@ pub(super) async fn notebook_bookkeeping<'c>( nsc: Option>, bc: &mut BridgeCookie, ctx: &mut Context, - subscription: &Vec>, + subscription: &Vec, ) -> Result; 2]>> { // Check is user is allowed to access the notebook if subscription diff --git a/src/web/route/portal/mod.rs b/src/web/route/portal/mod.rs index 6a4dc14c..25d5cc46 100644 --- a/src/web/route/portal/mod.rs +++ b/src/web/route/portal/mod.rs @@ -26,7 +26,7 @@ use crate::{ web::{ bridge_middleware::{CookieCheck, HTMX_ERROR_RES, Htmx}, helper::log_with_level, - services::CATALOG, + services, }, }; @@ -51,7 +51,7 @@ async fn index(data: Option>, db: Data<&DB>) -> Result>, db: Data<&DB>) -> Result>() }); diff --git a/src/web/route/portal/system_admin/htmx.rs b/src/web/route/portal/system_admin/htmx.rs index ea2c3516..e0b3597e 100644 --- a/src/web/route/portal/system_admin/htmx.rs +++ b/src/web/route/portal/system_admin/htmx.rs @@ -4,7 +4,7 @@ use tera::Tera; use crate::errors::Result; pub struct GroupContent { - pub items: Vec<&'static str>, + pub items: Vec, } pub(super) static VIEW_GROUP: &str = "components/group_view.html"; @@ -16,7 +16,7 @@ impl GroupContent { Self { items: Vec::new() } } - pub fn add(&mut self, item: &'static str) { + pub fn add(&mut self, item: String) { self.items.push(item); } diff --git a/src/web/route/portal/system_admin/mod.rs b/src/web/route/portal/system_admin/mod.rs index 9fed4aaa..15deac6a 100644 --- a/src/web/route/portal/system_admin/mod.rs +++ b/src/web/route/portal/system_admin/mod.rs @@ -35,7 +35,7 @@ use crate::{ helper::{check_admin, get_all_groups}, user_htmx::Subscription, }, - services::CATALOG, + services, }, }; @@ -96,15 +96,20 @@ pub(super) async fn system( let (subs, group_created_at, group_updated_at, group_last_updated) = match subscriptions { Ok(g) => ( { + let catalog_all = services::get_all(); let mut sub_details: Vec = Vec::new(); g.subscriptions.iter().for_each(|name| { - if let Some(sub) = CATALOG.get_all().get(name.as_str()) { + if let Some(sub) = catalog_all.get(name.as_str()) { sub_details.push(Subscription { name: name.to_owned(), - kind: sub.0, - kind_designation: if sub.1 { "mcp" } else { "inference" }, - description: sub.2, + kind: sub.kind.clone(), + kind_designation: if sub.mcp { + "mcp".to_string() + } else { + "inference".to_string() + }, + description: sub.description.clone(), }); } }); @@ -153,9 +158,7 @@ pub(super) async fn system( let resources: Vec<(&String, bool)> = resources .iter() .map(|r| { - let show = CATALOG - .get_details("resources", r, "show") - .map(|v| v.as_bool().unwrap_or(false)); + let show = services::get_detail("resources", r, "show").and_then(|v| v.as_bool()); (r, show.unwrap_or(false)) }) .collect(); @@ -394,11 +397,12 @@ async fn system_tab_htmx( | AdminTab::GroupCreate | AdminTab::GroupView => { let mut group_form = GroupContent::new(); + let catalog_all = services::get_all(); // TODO: Move this into some cache so you don't do this over and over. For now the only // cost is creation of a Vec in the GroupContent struct where each item is &'static str - CATALOG.get_all().iter().for_each(|(&name, (_, _, _))| { - group_form.add(name); + catalog_all.iter().for_each(|(name, _)| { + group_form.add(name.clone()); }); match tab.tab { @@ -460,8 +464,8 @@ async fn system_tab_htmx( let mut selections = group_form .items .iter() - .map(|&v| (v, group_info.subscriptions.iter().any(|s| s.eq(v)))) - .collect::>(); + .map(|v| (v.clone(), group_info.subscriptions.iter().any(|s| s.eq(v)))) + .collect::>(); selections.sort_by_key(|(_, b)| !*b); group_form.render( @@ -518,6 +522,16 @@ async fn system_tab_htmx( .body(content)) } +#[post("reload-services")] +async fn system_reload_services(subject: Option>) -> Result { + let _ = check_admin(subject, UserType::SystemAdmin)?; + services::reload()?; + + Ok(HttpResponse::Ok() + .content_type(ContentType::form_url_encoded()) + .body("

Service catalog reloaded

")) +} + pub fn config_system(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/system_admin").service(system).service( @@ -527,7 +541,8 @@ pub fn config_system(cfg: &mut web::ServiceConfig) { .service(system_create_group) .service(system_update_group) .service(system_update_user) - .service(system_delete_user), + .service(system_delete_user) + .service(system_reload_services), ), // .service(system_delete_group) ); diff --git a/src/web/route/portal/user.rs b/src/web/route/portal/user.rs index 26aa34d4..50cfd9c8 100644 --- a/src/web/route/portal/user.rs +++ b/src/web/route/portal/user.rs @@ -24,7 +24,7 @@ use crate::{ web::{ helper, route::portal::user_htmx::{Profile, Subscription}, - services::CATALOG, + services, }, }; @@ -85,17 +85,17 @@ pub(super) async fn user( profile.add_group(group.to_string()); }); group.subscriptions.into_iter().for_each(|subscription| { - if let Some(subscription_detail) = CATALOG.get_all().get(subscription.as_str()) { + if let Some(subscription_detail) = services::get_all().get(subscription.as_str()) { profile.add_subscription(Subscription { name: subscription, - kind: subscription_detail.0, - kind_designation: if subscription_detail.1 { + kind: subscription_detail.kind.clone(), + kind_designation: if subscription_detail.mcp { // TODO: remove this hardcode - "mcp" + "mcp".to_string() } else { - "inference" + "inference".to_string() }, - description: subscription_detail.2, + description: subscription_detail.description.clone(), }); } }); diff --git a/src/web/route/portal/user_htmx.rs b/src/web/route/portal/user_htmx.rs index cd236d54..2df0dc1d 100644 --- a/src/web/route/portal/user_htmx.rs +++ b/src/web/route/portal/user_htmx.rs @@ -8,23 +8,23 @@ use tera::{Context, Tera}; use crate::{ db::models::{BridgeCookie, NotebookStatusCookie, OWUICookie, User}, errors::Result, - web::services::CATALOG, + web::services, }; #[cfg(feature = "notebook")] use super::helper::notebook_bookkeeping; #[derive(Serialize, Clone)] -pub struct Subscription<'s> { +pub struct Subscription { pub name: String, - pub kind: &'s str, - pub kind_designation: &'s str, - pub description: &'s str, + pub kind: String, + pub kind_designation: String, + pub description: String, } pub struct Profile<'p> { pub groups: Vec, - pub subscriptions: Vec>, + pub subscriptions: Vec, user: &'p User, } @@ -44,7 +44,7 @@ impl<'p> Profile<'p> { self.groups.push(group); } - pub fn add_subscription(&mut self, subscription: Subscription<'p>) { + pub fn add_subscription(&mut self, subscription: Subscription) { self.subscriptions.push(subscription); } @@ -80,9 +80,8 @@ impl<'p> Profile<'p> { let resources: Vec<(&String, bool)> = resources .iter() .map(|r| { - let show = CATALOG - .get_details("resources", r, "show") - .map(|v| v.as_bool().unwrap_or(false)); + let show = services::get_detail("resources", r, "show") + .and_then(|v| v.as_bool().map(|b| b.to_owned())); (r, show.unwrap_or(false)) }) .collect(); diff --git a/src/web/route/proxy/mod.rs b/src/web/route/proxy/mod.rs index cf35cc84..26e42cfc 100644 --- a/src/web/route/proxy/mod.rs +++ b/src/web/route/proxy/mod.rs @@ -10,8 +10,6 @@ use crate::{ }, }; -use self::services::CATALOG; - pub mod services; const BRIDGE_PREFIX: &str = "/proxy"; @@ -46,7 +44,7 @@ async fn forward( })?; // look up service and get url - let mut new_url = helper::log_with_level!(CATALOG.get_service(service), error)?; + let mut new_url = helper::log_with_level!(self::services::get_service(service), error)?; new_url.set_path(path); new_url.set_query(req.uri().query()); diff --git a/src/web/route/proxy/services.rs b/src/web/route/proxy/services.rs index 0bfd4c3c..481492bd 100644 --- a/src/web/route/proxy/services.rs +++ b/src/web/route/proxy/services.rs @@ -1,158 +1,82 @@ use std::collections::HashMap; use std::sync::LazyLock; -use std::{fs::read_to_string, path::PathBuf, str::FromStr}; +use std::{env, fs::read_to_string, path::PathBuf, str::FromStr}; +use parking_lot::RwLock; use toml::Value; use url::Url; use crate::errors::{BridgeError, Result}; -pub struct Catalog(pub toml::Table); +const SERVICES_CONFIG_PATH_ENV: &str = "BRIDGE_SERVICES_CONFIG_PATH"; -// TODO: move this out of proxy mod... perhaps in the parent mod to this +#[derive(Debug, Clone)] +pub struct CatalogEntry { + pub kind: String, + pub mcp: bool, + pub description: String, +} -pub static CATALOG: LazyLock = LazyLock::new(|| { - let service_config = if cfg!(debug_assertions) { - "config/services_sample.toml" - } else { - "config/services.toml" - }; +#[derive(Default)] +struct CatalogState { + catalog: toml::Table, + all: HashMap, + all_resource_names: Vec, + health_urls: Vec<(Url, String)>, +} - Catalog( - toml::from_str(&read_to_string(PathBuf::from_str(service_config).unwrap()).unwrap()) - .unwrap(), - ) -}); -pub static CATALOG_URLS: LazyLock> = - LazyLock::new(|| Into::::into(LazyLock::force(&CATALOG)).into()); -static CATALOG_ALL: LazyLock> = LazyLock::new(|| { - let mut names = HashMap::new(); - - let service_iter = LazyLock::force(&CATALOG) - .0 - .get("services") - .and_then(|v| v.as_table()) - .expect("services not found in config") - .iter() - .map(|e| { - let mcp = e.1.get("mcp").and_then(|v| v.as_bool()).unwrap_or_default(); - let description = - e.1.get("description") - .and_then(|v| v.as_str()) - .unwrap_or_default(); +impl CatalogState { + fn from_catalog(catalog: toml::Table) -> Self { + let mut all = HashMap::new(); + let mut all_resource_names = Vec::new(); - (e.0.as_str(), "service", mcp, description) - }); - let resource_iter = LazyLock::force(&CATALOG) - .0 - .get("resources") - .and_then(|v| v.as_table()) - .expect("resources not found in config") - .iter() - .map(|v| { - let description = - v.1.get("description") - .and_then(|v| v.as_str()) + if let Some(services) = catalog.get("services").and_then(Value::as_table) { + for (name, service) in services { + let mcp = service + .get("mcp") + .and_then(Value::as_bool) .unwrap_or_default(); - (v.0.as_str(), "resource", false, description) - }); - - service_iter - .into_iter() - .chain(resource_iter) - .for_each(|entry| { - names.insert(entry.0, (entry.1, entry.2, entry.3)); - }); - - names -}); -static ALL_RESOURCE_NAMES: LazyLock> = LazyLock::new(|| { - LazyLock::force(&CATALOG) - .0 - .get("resources") - .and_then(|v| v.as_table()) - .expect("resources not found in config") - .keys() - .map(|k| k.as_ref()) - .collect() -}); - -impl Catalog { - #[inline] - fn get_inner(&self, type_: &str, name: &str) -> Result { - let catalog = self.0.get(type_).ok_or_else(|| { - BridgeError::GeneralError("services definition not found in config".to_string()) - })?; - let service = catalog - .get(name) - .ok_or_else(|| BridgeError::ServiceDoesNotExist(name.to_string()))?; - let url = service.get("url").ok_or_else(|| { - BridgeError::GeneralError("url not found in service definition".to_string()) - })?; - - Url::parse( - url.as_str() - .ok_or_else(|| BridgeError::GeneralError("url not a string".to_string()))?, - ) - .map_err(|e| BridgeError::GeneralError(e.to_string())) - } - - pub fn get_service(&self, service_name: &str) -> Result { - self.get_inner("services", service_name) - } + let description = service + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + all.insert( + name.to_string(), + CatalogEntry { + kind: "service".to_string(), + mcp, + description, + }, + ); + } + } + + if let Some(resources) = catalog.get("resources").and_then(Value::as_table) { + for (name, resource) in resources { + let description = resource + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + all.insert( + name.to_string(), + CatalogEntry { + kind: "resource".to_string(), + mcp: false, + description, + }, + ); + all_resource_names.push(name.to_string()); + } + } - #[cfg(feature = "mcp")] - pub fn is_service_mcp(&self, service_name: &str) -> Result { - Ok(self - .0 + let health_urls: Vec<(Url, String)> = catalog .get("services") - .ok_or_else(|| { - BridgeError::GeneralError("services definition not found in config".to_string()) - })? - .get(service_name) - .ok_or_else(|| BridgeError::ServiceDoesNotExist(service_name.to_string()))? - .get("mcp") - .and_then(Value::as_bool) - .unwrap_or(false)) - } - - pub fn get_resource(&self, resource_name: &str) -> Result { - self.get_inner("resources", resource_name) - } - - pub fn get_details(&self, type_: &str, name: &str, field: &str) -> Option<&Value> { - self.0.get(type_)?.get(name)?.get(field) - } - - pub fn get_all_resources_by_name(&self) -> &'static Vec<&str> { - &ALL_RESOURCE_NAMES - } - - // get all service and resources by their (in this order) name, kind, whether or not mcp, and description - pub fn get_all(&self) -> &'static HashMap<&str, (&str, bool, &str)> { - &CATALOG_ALL - } -} - -pub struct ResourceCatalog(Vec<(Url, String)>); -impl From for Vec<(Url, String)> { - fn from(value: ResourceCatalog) -> Self { - value.0 - } -} -pub struct ServiceCatalog(Vec<(Url, String)>); -impl From for Vec<(Url, String)> { - fn from(value: ServiceCatalog) -> Self { - value.0 - } -} - -// For services -impl From<&Catalog> for ServiceCatalog { - fn from(value: &Catalog) -> Self { - Self(match value.0.get("services").and_then(|v| v.as_table()) { - Some(map) => { - map.iter() + .and_then(Value::as_table) + .map(|services| { + services + .iter() .filter_map(|(name, service)| { // In the services.toml, there are entries that are not services with health // endpoints, such as notebooks. We need to filter them out. @@ -174,81 +98,170 @@ impl From<&Catalog> for ServiceCatalog { .and_then(Value::as_str) .and_then(|url| Url::parse(url).ok()) .and_then(|url| url.join(health_endpoint).ok()); + url.map(|url| (url, name.to_string())) }) .collect() - } - None => vec![], - }) + }) + .unwrap_or_default(); + + Self { + catalog, + all, + all_resource_names, + health_urls, + } } } -impl From<&Catalog> for ResourceCatalog { - fn from(value: &Catalog) -> Self { - Self(match value.0.get("resources").and_then(|v| v.as_table()) { - Some(map) => map - .iter() - .filter_map(|(name, service)| { - let url = service - .get("url") - .and_then(Value::as_str) - .and_then(|url| Url::parse(url).ok()); - url.map(|url| (url, name.to_string())) - }) - .collect(), - None => vec![], - }) +// TODO: move this out of proxy mod... perhaps in the parent mod to this + +static CATALOG_STATE: LazyLock> = + LazyLock::new(|| RwLock::new(CatalogState::default())); + +fn default_service_config_path() -> &'static str { + if cfg!(debug_assertions) { + "config/services_sample.toml" + } else { + "config/services.toml" } } +fn current_service_config_path() -> String { + env::var(SERVICES_CONFIG_PATH_ENV).unwrap_or_else(|_| default_service_config_path().to_string()) +} + +fn load_from_path(path: &str) -> Result { + let catalog: toml::Table = toml::from_str(&read_to_string(PathBuf::from_str(path).map_err( + |e| BridgeError::GeneralError(format!("Invalid config path '{path}': {e}")), + )?)?)?; + + Ok(CatalogState::from_catalog(catalog)) +} + +pub fn init_once() -> Result<()> { + reload() +} + +pub fn reload() -> Result<()> { + let path = current_service_config_path(); + let next_state = load_from_path(&path)?; + let mut guard = CATALOG_STATE.write(); + *guard = next_state; + Ok(()) +} + +#[inline] +fn get_inner(type_: &str, name: &str) -> Result { + let guard = CATALOG_STATE.read(); + let catalog = guard.catalog.get(type_).ok_or_else(|| { + BridgeError::GeneralError("services definition not found in config".to_string()) + })?; + let service = catalog + .get(name) + .ok_or_else(|| BridgeError::ServiceDoesNotExist(name.to_string()))?; + let url = service + .get("url") + .ok_or_else(|| BridgeError::GeneralError("url not found in service definition".to_string()))?; + + Url::parse( + url.as_str() + .ok_or_else(|| BridgeError::GeneralError("url not a string".to_string()))?, + ) + .map_err(|e| BridgeError::GeneralError(e.to_string())) +} + +pub fn get_service(service_name: &str) -> Result { + get_inner("services", service_name) +} + +#[cfg(feature = "mcp")] +pub fn is_service_mcp(service_name: &str) -> Result { + let guard = CATALOG_STATE.read(); + Ok(guard + .catalog + .get("services") + .ok_or_else(|| { + BridgeError::GeneralError("services definition not found in config".to_string()) + })? + .get(service_name) + .ok_or_else(|| BridgeError::ServiceDoesNotExist(service_name.to_string()))? + .get("mcp") + .and_then(Value::as_bool) + .unwrap_or(false)) +} + +pub fn get_resource(resource_name: &str) -> Result { + get_inner("resources", resource_name) +} + +pub fn get_detail(type_: &str, name: &str, field: &str) -> Option { + let guard = CATALOG_STATE.read(); + guard.catalog.get(type_)?.get(name)?.get(field).cloned() +} + +pub fn get_all_resources_by_name() -> Vec { + CATALOG_STATE.read().all_resource_names.clone() +} + +// get all service and resources by their (in this order) name, kind, whether or not mcp, and description +pub fn get_all() -> HashMap { + CATALOG_STATE.read().all.clone() +} + +pub fn get_service_health_urls() -> Vec<(Url, String)> { + CATALOG_STATE.read().health_urls.clone() +} + #[cfg(test)] mod test { use super::*; #[test] fn test_catalog() { - let catalog = &CATALOG; - let service = catalog.get_service("postman").unwrap(); + let service = get_service("postman"); + assert!(service.is_err()); // not initialized yet + + init_once().unwrap(); + let service = get_service("postman").unwrap(); assert_eq!(service.as_str(), "https://postman-echo.com/"); - let resource = catalog.get_resource("example").unwrap(); + let resource = get_resource("example").unwrap(); assert_eq!(resource.as_str(), "https://www.example.com/"); - let service = catalog.get_service("notebook"); + let service = get_service("notebook"); assert!(service.is_err()); } #[test] - fn test_catalog_into() { - let catalog = &CATALOG; - let services: ServiceCatalog = LazyLock::force(catalog).into(); - assert_ne!(services.0.len(), 0); - - let resources: ResourceCatalog = Into::::into(LazyLock::force(&CATALOG)); - assert!(resources.0.len().ge(&1)); + fn test_catalog_health_urls() { + init_once().unwrap(); + let services = get_service_health_urls(); + assert_ne!(services.len(), 0); - let postman = services.0.iter().find(|(_, name)| name == "postman"); + let postman = services.iter().find(|(_, name)| name == "postman"); assert!(postman.is_some()); } #[cfg(feature = "mcp")] #[test] fn test_mcp_bool() { - let catalog = &CATALOG; - let mcp = catalog.is_service_mcp("postman").unwrap(); + init_once().unwrap(); + let mcp = is_service_mcp("postman").unwrap(); assert!(!mcp); } #[test] fn test_catalog_all_names() { - let names = CATALOG.get_all(); + init_once().unwrap(); + let names = get_all(); assert!(names.len() >= 2); } #[test] fn test_get_details() { - let catalog = &CATALOG; - let Value::Boolean(b) = *catalog.get_details("resources", "example", "show").unwrap() + init_once().unwrap(); + let Value::Boolean(b) = get_detail("resources", "example", "show").unwrap() else { panic!("show not found"); }; diff --git a/src/web/route/resource/mod.rs b/src/web/route/resource/mod.rs index 91cb5cd0..267ee929 100644 --- a/src/web/route/resource/mod.rs +++ b/src/web/route/resource/mod.rs @@ -22,7 +22,7 @@ use crate::{ web::{ bridge_middleware::ResourceCookieCheck, helper::{self, forwarding::Config}, - services::CATALOG, + services, }, }; @@ -82,7 +82,7 @@ pub async fn resource_http( None }; - let mut new_url = helper::log_with_level!(CATALOG.get_resource(&resource), error)?; + let mut new_url = helper::log_with_level!(services::get_resource(&resource), error)?; new_url.set_path(path); new_url.set_query(req.uri().query()); @@ -113,7 +113,7 @@ async fn resource_ws( let (_, resource) = resource.into_inner(); let (_, path) = webpath.into_inner(); - let mut new_url = helper::log_with_level!(CATALOG.get_resource(&resource), error)?; + let mut new_url = helper::log_with_level!(services::get_resource(&resource), error)?; helper::log_with_level!( new_url @@ -138,7 +138,7 @@ async fn resource_wss( ) -> Result { let (_, resource) = resource.into_inner(); - let mut new_url = helper::log_with_level!(CATALOG.get_resource(&resource), error)?; + let mut new_url = helper::log_with_level!(services::get_resource(&resource), error)?; helper::log_with_level!( new_url diff --git a/templates/components/systems_group.html b/templates/components/systems_group.html index 9c5cac10..1275dcaf 100644 --- a/templates/components/systems_group.html +++ b/templates/components/systems_group.html @@ -1,5 +1,11 @@

Group Management

From 2e5ac8998093fc7b019e91e932d1e99bfb3cfbc1 Mon Sep 17 00:00:00 2001 From: Brian Duenas Date: Wed, 4 Mar 2026 19:46:55 -0800 Subject: [PATCH 2/2] lint --- src/web/route/portal/helper.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/route/portal/helper.rs b/src/web/route/portal/helper.rs index e5974c61..55fde4af 100644 --- a/src/web/route/portal/helper.rs +++ b/src/web/route/portal/helper.rs @@ -92,7 +92,7 @@ pub(super) async fn notebook_bookkeeping<'c>( nsc: Option>, bc: &mut BridgeCookie, ctx: &mut Context, - subscription: &Vec, + subscription: &[Subscription], ) -> Result; 2]>> { // Check is user is allowed to access the notebook if subscription