diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84ae6456..6b859474 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,6 +83,7 @@ jobs: mv backend/src/tests/daemon_config-next.json backend/src/tests/daemon_config.json mv ui/static/services-next.json ui/static/services.json mv docs/SERVICES-NEXT.md docs/SERVICES.md + mv ui/static/billing-plans-next.json ui/static/billing-plans.json echo "✅ Updated test fixtures" - name: Commit and push fixture updates @@ -94,7 +95,7 @@ jobs: git add backend/Cargo.toml backend/Cargo.lock ui/package.json ui/package-lock.json # Stage fixture updates - git add backend/src/tests/netvisor.sql backend/src/tests/daemon_config.json ui/static/services.json docs/SERVICES.md + git add backend/src/tests/netvisor.sql backend/src/tests/daemon_config.json ui/static/services.json docs/SERVICES.md ui/static/billing-plans.json if git diff --staged --quiet; then echo "No fixture changes to commit" diff --git a/backend/migrations/20251129180942_nfs-consolidate.sql b/backend/migrations/20251129180942_nfs-consolidate.sql new file mode 100644 index 00000000..a8f5ba99 --- /dev/null +++ b/backend/migrations/20251129180942_nfs-consolidate.sql @@ -0,0 +1,22 @@ +-- Migration: Convert NasDevice service definitions to NFS +-- The NasDevice and NFSServer definitions are duplicates; consolidating to NFSServer + +-- Step 1: Update services table +UPDATE services +SET service_definition = 'NFS' +WHERE service_definition = 'Nas Device'; + +-- Step 2: Update services embedded in topologies +UPDATE topologies +SET services = ( + SELECT jsonb_agg( + CASE + WHEN service->>'service_definition' = 'Nas Device' THEN + jsonb_set(service, '{service_definition}', '"NFS"') + ELSE + service + END + ) + FROM jsonb_array_elements(services) AS service +) +WHERE services @> '[{"service_definition": "Nas Device"}]'; \ No newline at end of file diff --git a/backend/src/bin/server.rs b/backend/src/bin/server.rs index ba15836d..7de24301 100644 --- a/backend/src/bin/server.rs +++ b/backend/src/bin/server.rs @@ -12,7 +12,7 @@ use netvisor::server::{ auth::AuthenticatedEntity, logging::request_logging_middleware, rate_limit::rate_limit_middleware, }, - billing::types::base::{BillingPlan, BillingRate, PlanConfig}, + billing::plans::get_all_plans, config::{AppState, ServerCli, ServerConfig}, organizations::r#impl::base::{Organization, OrganizationBase}, shared::{ @@ -207,46 +207,7 @@ async fn main() -> anyhow::Result<()> { let all_users = user_service.get_all(EntityFilter::unfiltered()).await?; if let Some(billing_service) = billing_service { - billing_service - .initialize_products(vec![ - BillingPlan::Starter(PlanConfig { - base_cents: 1499, - rate: BillingRate::Month, - trial_days: 0, - seat_cents: None, - network_cents: None, - included_seats: Some(1), - included_networks: Some(1), - }), - BillingPlan::Pro(PlanConfig { - base_cents: 2499, - rate: BillingRate::Month, - trial_days: 7, - seat_cents: None, - network_cents: None, - included_seats: Some(1), - included_networks: Some(3), - }), - BillingPlan::Team(PlanConfig { - base_cents: 7999, - rate: BillingRate::Month, - trial_days: 7, - seat_cents: Some(1000), - network_cents: Some(800), - included_seats: Some(5), - included_networks: Some(5), - }), - BillingPlan::Business(PlanConfig { - base_cents: 14999, - rate: BillingRate::Month, - trial_days: 14, - seat_cents: Some(800), - network_cents: Some(500), - included_seats: Some(10), - included_networks: Some(25), - }), - ]) - .await?; + billing_service.initialize_products(get_all_plans()).await?; } // First load - populate user and org diff --git a/backend/src/server/billing/mod.rs b/backend/src/server/billing/mod.rs index 21e9499f..fb2d4519 100644 --- a/backend/src/server/billing/mod.rs +++ b/backend/src/server/billing/mod.rs @@ -1,4 +1,5 @@ pub mod handlers; +pub mod plans; pub mod service; pub mod subscriber; pub mod types; diff --git a/backend/src/server/billing/plans.rs b/backend/src/server/billing/plans.rs new file mode 100644 index 00000000..11a9f2f2 --- /dev/null +++ b/backend/src/server/billing/plans.rs @@ -0,0 +1,71 @@ +use super::types::base::{BillingPlan, BillingRate, PlanConfig}; + +/// Returns the canonical list of billing plans for NetVisor. +/// This is the single source of truth for plan definitions. +fn get_default_plans() -> Vec { + vec![ + BillingPlan::Starter(PlanConfig { + base_cents: 1499, + rate: BillingRate::Month, + trial_days: 0, + seat_cents: None, + network_cents: None, + included_seats: Some(1), + included_networks: Some(1), + }), + BillingPlan::Pro(PlanConfig { + base_cents: 2499, + rate: BillingRate::Month, + trial_days: 7, + seat_cents: None, + network_cents: None, + included_seats: Some(1), + included_networks: Some(3), + }), + BillingPlan::Team(PlanConfig { + base_cents: 7999, + rate: BillingRate::Month, + trial_days: 7, + seat_cents: Some(1000), + network_cents: Some(800), + included_seats: Some(5), + included_networks: Some(5), + }), + BillingPlan::Business(PlanConfig { + base_cents: 14999, + rate: BillingRate::Month, + trial_days: 14, + seat_cents: Some(800), + network_cents: Some(500), + included_seats: Some(10), + included_networks: Some(25), + }), + ] +} + +fn get_enterprise_plan() -> BillingPlan { + BillingPlan::Enterprise(PlanConfig { + base_cents: 0, + rate: BillingRate::Month, + trial_days: 14, + seat_cents: None, + network_cents: None, + included_seats: None, + included_networks: None, + }) +} + +/// Returns both monthly and yearly versions of all plans. +/// Yearly plans get a 20% discount. +pub fn get_all_plans() -> Vec { + let monthly_plans = get_default_plans(); + let mut all_plans = monthly_plans.clone(); + all_plans.push(get_enterprise_plan()); + + // Add yearly versions with 20% discount + for plan in monthly_plans { + all_plans.push(plan.to_yearly(0.20)); + } + + all_plans +} diff --git a/backend/src/server/billing/service.rs b/backend/src/server/billing/service.rs index 70806585..c005b987 100644 --- a/backend/src/server/billing/service.rs +++ b/backend/src/server/billing/service.rs @@ -102,15 +102,8 @@ impl BillingService { pub async fn initialize_products(&self, plans: Vec) -> Result<(), Error> { let mut created_plans = Vec::new(); - let all_plans: Vec = plans - .clone() - .iter() - .map(|p| p.to_yearly(0.20)) - .chain(plans) - .collect(); - tracing::info!( - plan_count = all_plans.len(), + plan_count = plans.len(), "Initializing Stripe products and prices" ); @@ -157,7 +150,7 @@ impl BillingService { } }; - for plan in all_plans { + for plan in plans { // Check if product exists, create if not let product_id = plan.stripe_product_id(); let product = match RetrieveProduct::new(product_id.clone()) @@ -731,6 +724,7 @@ impl BillingService { } BillingPlan::Team { .. } => {} BillingPlan::Business { .. } => {} + BillingPlan::Enterprise { .. } => {} } organization.base.plan_status = Some(sub.status.to_string()); diff --git a/backend/src/server/billing/types/base.rs b/backend/src/server/billing/types/base.rs index 3394215f..d9c0666e 100644 --- a/backend/src/server/billing/types/base.rs +++ b/backend/src/server/billing/types/base.rs @@ -27,6 +27,7 @@ pub enum BillingPlan { Pro(PlanConfig), Team(PlanConfig), Business(PlanConfig), + Enterprise(PlanConfig), } impl PartialEq for BillingPlan { @@ -128,6 +129,7 @@ impl BillingPlan { "pro" => Some(Self::Pro(plan_config)), "team" => Some(Self::Team(plan_config)), "business" => Some(Self::Business(plan_config)), + "enterprise" => Some(Self::Enterprise(plan_config)), _ => None, } } @@ -139,6 +141,7 @@ impl BillingPlan { BillingPlan::Pro(plan_config) => *plan_config, BillingPlan::Team(plan_config) => *plan_config, BillingPlan::Business(plan_config) => *plan_config, + BillingPlan::Enterprise(plan_config) => *plan_config, } } @@ -149,11 +152,15 @@ impl BillingPlan { BillingPlan::Pro(plan_config) => *plan_config = config, BillingPlan::Team(plan_config) => *plan_config = config, BillingPlan::Business(plan_config) => *plan_config = config, + BillingPlan::Enterprise(plan_config) => *plan_config = config, } } pub fn is_commercial(&self) -> bool { - matches!(self, BillingPlan::Team(_) | BillingPlan::Business(_)) + matches!( + self, + BillingPlan::Team(_) | BillingPlan::Business(_) | BillingPlan::Enterprise(_) + ) } pub fn stripe_product_id(&self) -> String { @@ -238,6 +245,15 @@ impl BillingPlan { audit_logs: true, remove_powered_by: true, }, + BillingPlan::Enterprise { .. } => BillingPlanFeatures { + share_views: true, + onboarding_call: true, + dedicated_support_channel: true, + commercial_license: true, + api_access: true, + audit_logs: true, + remove_powered_by: true, + }, } } } @@ -302,7 +318,8 @@ impl EntityMetadataProvider for BillingPlan { BillingPlan::Starter { .. } => "ThumbsUp", BillingPlan::Pro { .. } => "Zap", BillingPlan::Team { .. } => "Users", - BillingPlan::Business { .. } => "Building", + BillingPlan::Business { .. } => "Briefcase", + BillingPlan::Enterprise { .. } => "Building", } } @@ -312,7 +329,8 @@ impl EntityMetadataProvider for BillingPlan { BillingPlan::Starter { .. } => "blue", BillingPlan::Pro { .. } => "yellow", BillingPlan::Team { .. } => "orange", - BillingPlan::Business { .. } => "gray", + BillingPlan::Business { .. } => "brown", + BillingPlan::Enterprise { .. } => "gray", } } } @@ -325,6 +343,7 @@ impl TypeMetadataProvider for BillingPlan { BillingPlan::Pro { .. } => "Pro", BillingPlan::Team { .. } => "Team", BillingPlan::Business { .. } => "Business", + BillingPlan::Enterprise { .. } => "Enterprise", } } @@ -341,11 +360,25 @@ impl TypeMetadataProvider for BillingPlan { BillingPlan::Business { .. } => { "Manage multi-site and multi-customer documentation with advanced features" } + BillingPlan::Enterprise { .. } => { + "Deploy NetVisor with enterprise-grade features and functionality" + } } } fn metadata(&self) -> serde_json::Value { + let config = self.config(); + serde_json::json!({ + // Pricing information + "base_cents": config.base_cents, + "rate": config.rate, + "trial_days": config.trial_days, + "seat_cents": config.seat_cents, + "network_cents": config.network_cents, + "included_seats": config.included_seats, + "included_networks": config.included_networks, + // Feature flags and metadata "features": self.features(), "is_commercial": self.is_commercial() }) diff --git a/backend/src/server/services/definitions/jira_server.rs b/backend/src/server/services/definitions/jira_server.rs index 84b716a2..6ab86cb7 100644 --- a/backend/src/server/services/definitions/jira_server.rs +++ b/backend/src/server/services/definitions/jira_server.rs @@ -25,6 +25,9 @@ impl ServiceDefinition for JiraServer { Some(200..300), ) } + fn logo_url(&self) -> &'static str { + "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jira.svg" + } } inventory::submit!(ServiceDefinitionFactory::new(create_service::)); diff --git a/backend/src/server/services/definitions/mod.rs b/backend/src/server/services/definitions/mod.rs index 3eb3ce5d..380f4097 100644 --- a/backend/src/server/services/definitions/mod.rs +++ b/backend/src/server/services/definitions/mod.rs @@ -108,7 +108,6 @@ pub mod ceph; pub mod file_server; pub mod filezilla_server; pub mod minio; -pub mod nas_device; pub mod next_cloud; pub mod nfs_server; pub mod open_media_vault; diff --git a/backend/src/server/services/definitions/nas_device.rs b/backend/src/server/services/definitions/nas_device.rs deleted file mode 100644 index b41aa171..00000000 --- a/backend/src/server/services/definitions/nas_device.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::server::hosts::r#impl::ports::PortBase; -use crate::server::services::definitions::{ServiceDefinitionFactory, create_service}; -use crate::server::services::r#impl::categories::ServiceCategory; -use crate::server::services::r#impl::definitions::ServiceDefinition; -use crate::server::services::r#impl::patterns::Pattern; - -#[derive(Default, Clone, Eq, PartialEq, Hash)] -pub struct NasDevice; - -impl ServiceDefinition for NasDevice { - fn name(&self) -> &'static str { - "Nas Device" - } - fn description(&self) -> &'static str { - "A generic network storage devices" - } - fn category(&self) -> ServiceCategory { - ServiceCategory::Storage - } - - fn discovery_pattern(&self) -> Pattern<'_> { - Pattern::Port(PortBase::Nfs) - } - - fn is_generic(&self) -> bool { - true - } -} - -inventory::submit!(ServiceDefinitionFactory::new(create_service::)); diff --git a/backend/src/server/services/definitions/netvisor_daemon.rs b/backend/src/server/services/definitions/netvisor_daemon.rs index e6f837ee..480dd366 100644 --- a/backend/src/server/services/definitions/netvisor_daemon.rs +++ b/backend/src/server/services/definitions/netvisor_daemon.rs @@ -23,7 +23,7 @@ impl ServiceDefinition for NetvisorDaemon { } fn logo_url(&self) -> &'static str { - "/logos/netvisor-logo.png" + "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/netvisor.png" } } diff --git a/backend/src/server/services/definitions/netvisor_server.rs b/backend/src/server/services/definitions/netvisor_server.rs index ab8af1cc..4323b102 100644 --- a/backend/src/server/services/definitions/netvisor_server.rs +++ b/backend/src/server/services/definitions/netvisor_server.rs @@ -23,7 +23,7 @@ impl ServiceDefinition for NetvisorServer { } fn logo_url(&self) -> &'static str { - "/logos/netvisor-logo.png" + "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/netvisor.png" } } diff --git a/backend/src/server/services/definitions/restic.rs b/backend/src/server/services/definitions/restic.rs index 5e5d68ed..6fe619b9 100644 --- a/backend/src/server/services/definitions/restic.rs +++ b/backend/src/server/services/definitions/restic.rs @@ -25,10 +25,9 @@ impl ServiceDefinition for Restic { ]) } - // Does not support SVG - // fn icon(&self) -> &'static str { - // "restic" - // } + fn logo_url(&self) -> &'static str { + "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/restic.png" + } } inventory::submit!(ServiceDefinitionFactory::new(create_service::)); diff --git a/backend/src/server/services/impl/patterns.rs b/backend/src/server/services/impl/patterns.rs index 9842462b..86aad83f 100644 --- a/backend/src/server/services/impl/patterns.rs +++ b/backend/src/server/services/impl/patterns.rs @@ -158,6 +158,45 @@ impl Vendor { pub const ROKU: &'static str = "Roku, Inc"; } +impl PartialEq for Pattern<'_> { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Pattern::AnyOf(a), Pattern::AnyOf(b)) => a == b, + (Pattern::AllOf(a), Pattern::AllOf(b)) => a == b, + (Pattern::Not(a), Pattern::Not(b)) => a == b, + (Pattern::Port(a), Pattern::Port(b)) => a == b, + ( + Pattern::Endpoint(port_a, path_a, match_a, range_a), + Pattern::Endpoint(port_b, path_b, match_b, range_b), + ) => port_a == port_b && path_a == path_b && match_a == match_b && range_a == range_b, + ( + Pattern::Header(port_a, header_a, value_a, range_a), + Pattern::Header(port_b, header_b, value_b, range_b), + ) => { + port_a == port_b && header_a == header_b && value_a == value_b && range_a == range_b + } + (Pattern::SubnetIsType(a), Pattern::SubnetIsType(b)) => a == b, + (Pattern::IsGateway, Pattern::IsGateway) => true, + (Pattern::MacVendor(a), Pattern::MacVendor(b)) => a == b, + ( + Pattern::Custom(fn_a, match_a, no_match_a, conf_a), + Pattern::Custom(fn_b, match_b, no_match_b, conf_b), + ) => { + // Compare function pointers by address and compare other fields + (*fn_a as usize) == (*fn_b as usize) + && match_a == match_b + && no_match_a == no_match_b + && conf_a == conf_b + } + (Pattern::DockerContainer, Pattern::DockerContainer) => true, + (Pattern::None, Pattern::None) => true, + _ => false, + } + } +} + +impl Eq for Pattern<'_> {} + impl Display for Pattern<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -175,7 +214,7 @@ impl Display for Pattern<'_> { if let Some(range) = range { write!( f, - "Endpoint response status is between {} and {}, and response body from :{}{} contains {}", + "Endpoint response status is between {} and {}, and response body from :{}{} contains \"{}\"", range.start, range.end, port_base.number(), @@ -185,7 +224,7 @@ impl Display for Pattern<'_> { } else { write!( f, - "Endpoint response body from :{}{} contains {}", + "Endpoint response body from :{}{} contains \"{}\"", port_base.number(), path, match_string @@ -201,13 +240,13 @@ impl Display for Pattern<'_> { if let Some(range) = range { write!( f, - "Endpoint response status is between {} and {}, and response from {} has header {} with value {}", + "Endpoint response status is between {} and {}, and response from {} has header \"{}\" with value \"{}\"", range.start, range.end, ip_str, header, value ) } else { write!( f, - "Endpoint response from {} has header {} with value {}", + "Endpoint response from {} has header \"{}\" with value \"{}\"", ip_str, header, value ) } diff --git a/backend/src/server/services/impl/tests.rs b/backend/src/server/services/impl/tests.rs index aefd2588..d1fc0d00 100644 --- a/backend/src/server/services/impl/tests.rs +++ b/backend/src/server/services/impl/tests.rs @@ -207,6 +207,45 @@ fn test_service_definition_has_required_fields() { } } +#[test] +fn test_no_duplicate_discovery_patterns() { + let registry: Vec<_> = ServiceDefinitionRegistry::all_service_definitions() + .into_iter() + .filter(|s| !matches!(s.discovery_pattern(), Pattern::None)) + .collect(); + + let mut duplicates: Vec = Vec::new(); + + for (i, service_a) in registry.iter().enumerate() { + let pattern_a = service_a.discovery_pattern(); + + for service_b in registry.iter().skip(i + 1) { + let pattern_b = service_b.discovery_pattern(); + + if pattern_a == pattern_b { + duplicates.push(format!( + " '{}' and '{}' share pattern: {}", + service_a.name(), + service_b.name(), + pattern_a + )); + } + } + } + + if !duplicates.is_empty() { + panic!( + "Duplicate discovery patterns found! Multiple services cannot share the same pattern:\n\n{}\n\n\ + Each service must have a unique discovery pattern to avoid ambiguous matches.\n\ + Consider:\n\ + 1. Removing one of the duplicate service definitions\n\ + 2. Adding additional criteria (AllOf with extra port, endpoint path, etc.)\n\ + 3. Using a more specific endpoint match string", + duplicates.join("\n") + ); + } +} + #[test] fn test_service_patterns_use_appropriate_port_types() { let registry = ServiceDefinitionRegistry::all_service_definitions(); diff --git a/backend/tests/integration.rs b/backend/tests/integration.rs index 7880d37b..435f2c23 100644 --- a/backend/tests/integration.rs +++ b/backend/tests/integration.rs @@ -5,12 +5,9 @@ use netvisor::server::daemons::r#impl::base::Daemon; use netvisor::server::discovery::r#impl::types::DiscoveryType; use netvisor::server::networks::r#impl::Network; use netvisor::server::organizations::r#impl::base::Organization; -#[cfg(feature = "generate-fixtures")] -use netvisor::server::services::definitions::ServiceDefinitionRegistry; + use netvisor::server::services::definitions::home_assistant::HomeAssistant; use netvisor::server::services::r#impl::base::Service; -#[cfg(feature = "generate-fixtures")] -use netvisor::server::services::r#impl::definitions::ServiceDefinition; use netvisor::server::shared::handlers::factory::OnboardingRequest; use netvisor::server::shared::types::api::ApiResponse; use netvisor::server::shared::types::metadata::HasId; @@ -407,6 +404,100 @@ async fn verify_home_assistant_discovered(client: &TestClient) -> Result Result<(), Box> { let output = std::process::Command::new("docker") @@ -543,14 +634,16 @@ async fn generate_services_markdown() -> Result<(), Box> // Add category header markdown.push_str(&format!("## {}\n\n", category)); - // Use HTML table for better control - markdown.push_str("\n"); + // Use HTML table with dark theme styling + markdown.push_str("
\n"); markdown.push_str("\n"); - markdown.push_str("\n"); - markdown.push_str("\n"); - markdown.push_str("\n"); - markdown.push_str("\n"); - markdown.push_str("\n"); + markdown.push_str( + "\n", + ); + markdown.push_str("\n"); + markdown.push_str("\n"); + markdown.push_str("\n"); + markdown.push_str("\n"); markdown.push_str("\n"); markdown.push_str("\n"); markdown.push_str("\n"); @@ -575,11 +668,20 @@ async fn generate_services_markdown() -> Result<(), Box> "—".to_string() }; - markdown.push_str("\n"); - markdown.push_str(&format!("\n", logo)); - markdown.push_str(&format!("\n", name)); - markdown.push_str(&format!("\n", description)); - markdown.push_str(&format!("\n", pattern)); + markdown.push_str("\n"); + markdown.push_str(&format!( + "\n", + logo + )); + markdown.push_str(&format!( + "\n", + name + )); + markdown.push_str(&format!( + "\n", + description + )); + markdown.push_str(&format!("\n", pattern)); markdown.push_str("\n"); } @@ -593,81 +695,32 @@ async fn generate_services_markdown() -> Result<(), Box> Ok(()) } -#[tokio::test] -async fn test_full_integration() { - // Start containers - let mut container_manager = ContainerManager::new(); - container_manager - .start() - .expect("Failed to start containers"); - - let client = TestClient::new(); - - // Authenticate - let user = setup_authenticated_user(&client) - .await - .expect("Failed to authenticate user"); - println!("✅ Authenticated as: {}", user.base.email); - - // Wait for organization - println!("\n=== Waiting for Organization ==="); - let organization = wait_for_organization(&client) - .await - .expect("Failed to find organization"); - println!("✅ Organization: {}", organization.base.name); - - // Onboard - println!("\n=== Onboarding ==="); - onboard(&client).await.expect("Failed to onboard"); - println!("✅ Onboarded"); - - // Wait for network - println!("\n=== Waiting for Network ==="); - let network = wait_for_network(&client) - .await - .expect("Failed to find network"); - println!("✅ Network: {}", network.base.name); - - // Wait for daemon - println!("\n=== Waiting for Daemon ==="); - let daemon = wait_for_daemon(&client) - .await - .expect("Failed to find daemon"); - println!("✅ Daemon registered: {}", daemon.id); - - // Run discovery - run_discovery(&client).await.expect("Discovery failed"); - - // Verify service discovered - let _service = verify_home_assistant_discovered(&client) - .await - .expect("Failed to find Home Assistant"); +#[cfg(feature = "generate-fixtures")] +async fn generate_billing_plans_json() -> Result<(), Box> { + use netvisor::server::billing::plans::get_all_plans; + use netvisor::server::billing::types::features::Feature; + use netvisor::server::shared::types::metadata::MetadataProvider; + use strum::IntoEnumIterator; - #[cfg(feature = "generate-fixtures")] - { - generate_db_fixture() - .await - .expect("Failed to generate db fixture"); + // Get all plans (monthly + yearly) + let plans = get_all_plans(); - generate_daemon_config_fixture() - .await - .expect("Failed to generate daemon config fixture"); + // Convert to metadata format (same as API returns) + let plan_metadata: Vec = plans.iter().map(|p| p.to_metadata()).collect(); - generate_services_json() - .await - .expect("Failed to generate services json"); + // Get all features metadata + let feature_metadata: Vec = Feature::iter().map(|f| f.to_metadata()).collect(); - generate_services_markdown() - .await - .expect("Failed to generate services markdown"); + // Combine into a single structure + let fixture = serde_json::json!({ + "billing_plans": plan_metadata, + "features": feature_metadata, + }); - println!("✅ Generated test fixtures"); - } + let json_string = serde_json::to_string_pretty(&fixture)?; + let path = std::path::Path::new("../ui/static/billing-plans-next.json"); + tokio::fs::write(path, json_string).await?; - println!("\n✅ All integration tests passed!"); - println!(" ✓ User authenticated"); - println!(" ✓ Network created"); - println!(" ✓ Daemon registered"); - println!(" ✓ Discovery completed"); - println!(" ✓ Home Assistant discovered"); + println!("✅ Generated billing-plans-next.json"); + Ok(()) } diff --git a/backend/tests/mod.rs b/backend/tests/mod.rs new file mode 100644 index 00000000..5155b774 --- /dev/null +++ b/backend/tests/mod.rs @@ -0,0 +1 @@ +pub mod integration; diff --git a/ui/static/billing-plans.json b/ui/static/billing-plans.json new file mode 100644 index 00000000..d14893a8 --- /dev/null +++ b/ui/static/billing-plans.json @@ -0,0 +1,326 @@ +{ + "billing_plans": [ + { + "category": null, + "color": "blue", + "description": "Automatically create living documentation of your network", + "icon": "ThumbsUp", + "id": "Starter", + "metadata": { + "base_cents": 1499, + "features": { + "api_access": false, + "audit_logs": false, + "commercial_license": false, + "dedicated_support_channel": false, + "onboarding_call": false, + "remove_powered_by": false, + "share_views": false + }, + "included_networks": 1, + "included_seats": 1, + "is_commercial": false, + "network_cents": null, + "rate": "Month", + "seat_cents": null, + "trial_days": 0 + }, + "name": "Starter" + }, + { + "category": null, + "color": "yellow", + "description": "Visualize multiple networks and share network diagrams", + "icon": "Zap", + "id": "Pro", + "metadata": { + "base_cents": 2499, + "features": { + "api_access": false, + "audit_logs": false, + "commercial_license": false, + "dedicated_support_channel": false, + "onboarding_call": false, + "remove_powered_by": false, + "share_views": true + }, + "included_networks": 3, + "included_seats": 1, + "is_commercial": false, + "network_cents": null, + "rate": "Month", + "seat_cents": null, + "trial_days": 7 + }, + "name": "Pro" + }, + { + "category": null, + "color": "orange", + "description": "Collaborate on infrastructure documentation with your team", + "icon": "Users", + "id": "Team", + "metadata": { + "base_cents": 7999, + "features": { + "api_access": false, + "audit_logs": false, + "commercial_license": true, + "dedicated_support_channel": true, + "onboarding_call": true, + "remove_powered_by": true, + "share_views": true + }, + "included_networks": 5, + "included_seats": 5, + "is_commercial": true, + "network_cents": 800, + "rate": "Month", + "seat_cents": 1000, + "trial_days": 7 + }, + "name": "Team" + }, + { + "category": null, + "color": "brown", + "description": "Manage multi-site and multi-customer documentation with advanced features", + "icon": "Briefcase", + "id": "Business", + "metadata": { + "base_cents": 14999, + "features": { + "api_access": true, + "audit_logs": true, + "commercial_license": true, + "dedicated_support_channel": true, + "onboarding_call": true, + "remove_powered_by": true, + "share_views": true + }, + "included_networks": 25, + "included_seats": 10, + "is_commercial": true, + "network_cents": 500, + "rate": "Month", + "seat_cents": 800, + "trial_days": 14 + }, + "name": "Business" + }, + { + "category": null, + "color": "gray", + "description": "Deploy NetVisor with enterprise-grade features and functionality", + "icon": "Building", + "id": "Enterprise", + "metadata": { + "base_cents": 0, + "features": { + "api_access": true, + "audit_logs": true, + "commercial_license": true, + "dedicated_support_channel": true, + "onboarding_call": true, + "remove_powered_by": true, + "share_views": true + }, + "included_networks": null, + "included_seats": null, + "is_commercial": true, + "network_cents": null, + "rate": "Month", + "seat_cents": null, + "trial_days": 14 + }, + "name": "Enterprise" + }, + { + "category": null, + "color": "blue", + "description": "Automatically create living documentation of your network", + "icon": "ThumbsUp", + "id": "Starter", + "metadata": { + "base_cents": 14400, + "features": { + "api_access": false, + "audit_logs": false, + "commercial_license": false, + "dedicated_support_channel": false, + "onboarding_call": false, + "remove_powered_by": false, + "share_views": false + }, + "included_networks": 1, + "included_seats": 1, + "is_commercial": false, + "network_cents": null, + "rate": "Year", + "seat_cents": null, + "trial_days": 0 + }, + "name": "Starter" + }, + { + "category": null, + "color": "yellow", + "description": "Visualize multiple networks and share network diagrams", + "icon": "Zap", + "id": "Pro", + "metadata": { + "base_cents": 24000, + "features": { + "api_access": false, + "audit_logs": false, + "commercial_license": false, + "dedicated_support_channel": false, + "onboarding_call": false, + "remove_powered_by": false, + "share_views": true + }, + "included_networks": 3, + "included_seats": 1, + "is_commercial": false, + "network_cents": null, + "rate": "Year", + "seat_cents": null, + "trial_days": 7 + }, + "name": "Pro" + }, + { + "category": null, + "color": "orange", + "description": "Collaborate on infrastructure documentation with your team", + "icon": "Users", + "id": "Team", + "metadata": { + "base_cents": 76800, + "features": { + "api_access": false, + "audit_logs": false, + "commercial_license": true, + "dedicated_support_channel": true, + "onboarding_call": true, + "remove_powered_by": true, + "share_views": true + }, + "included_networks": 5, + "included_seats": 5, + "is_commercial": true, + "network_cents": 7700, + "rate": "Year", + "seat_cents": 9600, + "trial_days": 7 + }, + "name": "Team" + }, + { + "category": null, + "color": "brown", + "description": "Manage multi-site and multi-customer documentation with advanced features", + "icon": "Briefcase", + "id": "Business", + "metadata": { + "base_cents": 144000, + "features": { + "api_access": true, + "audit_logs": true, + "commercial_license": true, + "dedicated_support_channel": true, + "onboarding_call": true, + "remove_powered_by": true, + "share_views": true + }, + "included_networks": 25, + "included_seats": 10, + "is_commercial": true, + "network_cents": 4800, + "rate": "Year", + "seat_cents": 7700, + "trial_days": 14 + }, + "name": "Business" + } + ], + "features": [ + { + "category": "Features", + "color": null, + "description": "Share live network diagrams with others", + "icon": null, + "id": "share_views", + "metadata": { + "is_coming_soon": false + }, + "name": "Share Views" + }, + { + "category": "Support & Licensing", + "color": null, + "description": "30 minute onboarding call to ensure you're getting the most out of NetVisor", + "icon": null, + "id": "onboarding_call", + "metadata": { + "is_coming_soon": false + }, + "name": "Onboarding Call" + }, + { + "category": "Support & Licensing", + "color": null, + "description": "A dedicated discord channel for support and questions", + "icon": null, + "id": "dedicated_support_channel", + "metadata": { + "is_coming_soon": false + }, + "name": "Dedicated Discord Channel" + }, + { + "category": "Support & Licensing", + "color": null, + "description": "Use NetVisor under a commercial license", + "icon": null, + "id": "commercial_license", + "metadata": { + "is_coming_soon": false + }, + "name": "Commercial License" + }, + { + "category": "Features", + "color": null, + "description": "Comprehensive logs of all access and data modification actions performed in NetVisor", + "icon": null, + "id": "audit_logs", + "metadata": { + "is_coming_soon": true + }, + "name": "Audit Logs" + }, + { + "category": "Features", + "color": null, + "description": "Access NetVisor APIs programmatically to bring your data into other applications", + "icon": null, + "id": "api_access", + "metadata": { + "is_coming_soon": true + }, + "name": "API Access" + }, + { + "category": "Features", + "color": null, + "description": "Remove 'Powered By NetVisor' in bottom right corner of visualization", + "icon": null, + "id": "remove_powered_by", + "metadata": { + "is_coming_soon": true + }, + "name": "Remove 'Powered By'" + } + ] +} \ No newline at end of file
LogoNameDescriptionDiscovery Pattern
LogoNameDescriptionDiscovery Pattern
{}{}{}{}
{}{}{}{}