diff --git a/backend/migrations/20251210045929_tags.sql b/backend/migrations/20251210045929_tags.sql new file mode 100644 index 00000000..55122ccf --- /dev/null +++ b/backend/migrations/20251210045929_tags.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS tags ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + color TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tags_organization ON tags(organization_id); +CREATE UNIQUE INDEX idx_tags_org_name ON tags(organization_id, name); + +ALTER TABLE users ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; +ALTER TABLE discovery ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; +ALTER TABLE hosts ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; +ALTER TABLE networks ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; +ALTER TABLE subnets ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; +ALTER TABLE groups ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; +ALTER TABLE daemons ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; +ALTER TABLE services ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; +ALTER TABLE api_keys ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; +ALTER TABLE topologies ADD COLUMN tags UUID[] NOT NULL DEFAULT '{}'; \ No newline at end of file diff --git a/backend/src/daemon/discovery/service/base.rs b/backend/src/daemon/discovery/service/base.rs index b4d7ed6b..8efac0d6 100644 --- a/backend/src/daemon/discovery/service/base.rs +++ b/backend/src/daemon/discovery/service/base.rs @@ -387,6 +387,7 @@ pub trait DiscoversNetworkedEntities: name: "Unknown Device".to_string(), hostname: hostname.clone(), target: HostTarget::None, + tags: Vec::new(), network_id, description: None, interfaces: vec![interface.clone()], diff --git a/backend/src/daemon/discovery/service/docker.rs b/backend/src/daemon/discovery/service/docker.rs index c0be762b..16fae97d 100644 --- a/backend/src/daemon/discovery/service/docker.rs +++ b/backend/src/daemon/discovery/service/docker.rs @@ -273,6 +273,7 @@ impl DiscoveryRunner { service_definition: Box::new(docker_service_definition), bindings: vec![], host_id, + tags: Vec::new(), network_id, virtualization: None, source: EntitySource::DiscoveryWithMatch { diff --git a/backend/src/daemon/discovery/service/self_report.rs b/backend/src/daemon/discovery/service/self_report.rs index d9e8a6ba..8dae51da 100644 --- a/backend/src/daemon/discovery/service/self_report.rs +++ b/backend/src/daemon/discovery/service/self_report.rs @@ -203,6 +203,7 @@ impl RunsDiscovery for DiscoveryRunner { hostname, network_id, description: Some("NetVisor daemon".to_string()), + tags: Vec::new(), target: HostTarget::Hostname, services: Vec::new(), interfaces: interfaces.clone(), @@ -229,6 +230,7 @@ impl RunsDiscovery for DiscoveryRunner { let daemon_service = Service::new(ServiceBase { name: ServiceDefinition::name(&daemon_service_definition).to_string(), service_definition: Box::new(daemon_service_definition), + tags: Vec::new(), network_id, bindings: daemon_service_bound_interfaces .iter() diff --git a/backend/src/daemon/utils/base.rs b/backend/src/daemon/utils/base.rs index 2c9b1417..79cca867 100644 --- a/backend/src/daemon/utils/base.rs +++ b/backend/src/daemon/utils/base.rs @@ -181,6 +181,7 @@ pub trait DaemonUtils { return Some(Subnet::new(SubnetBase { cidr: IpCidr::from_str(cidr).ok()?, description: None, + tags: Vec::new(), network_id, name: network_name.clone(), subnet_type: SubnetType::DockerBridge, diff --git a/backend/src/server/api_keys/impl/base.rs b/backend/src/server/api_keys/impl/base.rs index ec63ea31..990544a1 100644 --- a/backend/src/server/api_keys/impl/base.rs +++ b/backend/src/server/api_keys/impl/base.rs @@ -15,6 +15,8 @@ pub struct ApiKeyBase { pub expires_at: Option>, pub network_id: Uuid, pub is_enabled: bool, + #[serde(default)] + pub tags: Vec, } fn serialize_api_key_status(_key: &String, serializer: S) -> Result diff --git a/backend/src/server/api_keys/impl/storage.rs b/backend/src/server/api_keys/impl/storage.rs index 18667496..a7fcdb9a 100644 --- a/backend/src/server/api_keys/impl/storage.rs +++ b/backend/src/server/api_keys/impl/storage.rs @@ -59,6 +59,7 @@ impl StorableEntity for ApiKey { expires_at, network_id, is_enabled, + tags, }, } = self.clone(); @@ -73,6 +74,7 @@ impl StorableEntity for ApiKey { "name", "is_enabled", "key", + "tags", ], vec![ SqlValue::Uuid(id), @@ -84,6 +86,7 @@ impl StorableEntity for ApiKey { SqlValue::String(name), SqlValue::Bool(is_enabled), SqlValue::String(key), + SqlValue::UuidArray(tags), ], )) } @@ -100,6 +103,7 @@ impl StorableEntity for ApiKey { key: row.get("key"), is_enabled: row.get("is_enabled"), network_id: row.get("network_id"), + tags: row.get("tags"), }, }) } diff --git a/backend/src/server/daemons/handlers.rs b/backend/src/server/daemons/handlers.rs index 91486fb3..f70344ef 100644 --- a/backend/src/server/daemons/handlers.rs +++ b/backend/src/server/daemons/handlers.rs @@ -81,6 +81,7 @@ async fn register_daemon( last_seen: Utc::now(), mode: request.mode, name: request.name, + tags: Vec::new(), }); daemon.id = request.daemon_id; @@ -139,6 +140,7 @@ async fn register_daemon( name: self_report_discovery_type.to_string(), daemon_id: request.daemon_id, network_id: request.network_id, + tags: Vec::new(), }), AuthenticatedEntity::System, ) @@ -166,6 +168,7 @@ async fn register_daemon( name: docker_discovery_type.to_string(), daemon_id: request.daemon_id, network_id: request.network_id, + tags: Vec::new(), }), AuthenticatedEntity::System, ) @@ -193,6 +196,7 @@ async fn register_daemon( name: network_discovery_type.to_string(), daemon_id: request.daemon_id, network_id: request.network_id, + tags: Vec::new(), }), AuthenticatedEntity::System, ) diff --git a/backend/src/server/daemons/impl/base.rs b/backend/src/server/daemons/impl/base.rs index 7da3dd3e..a7b28db5 100644 --- a/backend/src/server/daemons/impl/base.rs +++ b/backend/src/server/daemons/impl/base.rs @@ -20,6 +20,8 @@ pub struct DaemonBase { pub capabilities: DaemonCapabilities, pub mode: DaemonMode, pub name: String, + #[serde(default)] + pub tags: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] diff --git a/backend/src/server/daemons/impl/storage.rs b/backend/src/server/daemons/impl/storage.rs index 5f5a34a1..67729286 100644 --- a/backend/src/server/daemons/impl/storage.rs +++ b/backend/src/server/daemons/impl/storage.rs @@ -63,6 +63,7 @@ impl StorableEntity for Daemon { mode, url, name, + tags, }, } = self.clone(); @@ -78,6 +79,7 @@ impl StorableEntity for Daemon { "url", "name", "mode", + "tags", ], vec![ SqlValue::Uuid(id), @@ -90,6 +92,7 @@ impl StorableEntity for Daemon { SqlValue::String(url), SqlValue::String(name), SqlValue::DaemonMode(mode), + SqlValue::UuidArray(tags), ], )) } @@ -114,6 +117,7 @@ impl StorableEntity for Daemon { name: row.get("name"), mode, capabilities, + tags: row.get("tags"), }, }) } diff --git a/backend/src/server/discovery/impl/base.rs b/backend/src/server/discovery/impl/base.rs index c540210e..a81e787c 100644 --- a/backend/src/server/discovery/impl/base.rs +++ b/backend/src/server/discovery/impl/base.rs @@ -16,6 +16,8 @@ pub struct DiscoveryBase { pub name: String, pub daemon_id: Uuid, pub network_id: Uuid, + #[serde(default)] + pub tags: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] diff --git a/backend/src/server/discovery/impl/storage.rs b/backend/src/server/discovery/impl/storage.rs index 40bb18f1..357e7918 100644 --- a/backend/src/server/discovery/impl/storage.rs +++ b/backend/src/server/discovery/impl/storage.rs @@ -61,6 +61,7 @@ impl StorableEntity for Discovery { name, daemon_id, network_id, + tags, }, } = self.clone(); @@ -74,6 +75,7 @@ impl StorableEntity for Discovery { "daemon_id", "run_type", "discovery_type", + "tags", ], vec![ SqlValue::Uuid(id), @@ -84,6 +86,7 @@ impl StorableEntity for Discovery { SqlValue::Uuid(daemon_id), SqlValue::RunType(run_type), SqlValue::DiscoveryType(discovery_type), + SqlValue::UuidArray(tags), ], )) } @@ -106,6 +109,7 @@ impl StorableEntity for Discovery { network_id: row.get("network_id"), run_type, discovery_type, + tags: row.get("tags"), }, }) } diff --git a/backend/src/server/discovery/service.rs b/backend/src/server/discovery/service.rs index 2042458f..ae317bf9 100644 --- a/backend/src/server/discovery/service.rs +++ b/backend/src/server/discovery/service.rs @@ -519,6 +519,7 @@ impl DiscoveryService { daemon_id: session.daemon_id, network_id: session.network_id, name: session.discovery_type.to_string(), + tags: Vec::new(), discovery_type: session.discovery_type.clone(), run_type: RunType::Historical { results: session.clone(), @@ -726,6 +727,7 @@ impl DiscoveryService { base: crate::server::discovery::r#impl::base::DiscoveryBase { daemon_id: session.daemon_id, network_id: session.network_id, + tags: Vec::new(), name: "Discovery Run (Cancellation Failed)".to_string(), discovery_type: session.discovery_type.clone(), run_type: RunType::Historical { @@ -898,6 +900,7 @@ impl DiscoveryService { base: crate::server::discovery::r#impl::base::DiscoveryBase { daemon_id: session.daemon_id, network_id: session.network_id, + tags: Vec::new(), name: "Discovery Run (Stalled)".to_string(), discovery_type: session.discovery_type.clone(), run_type: RunType::Historical { results: session }, diff --git a/backend/src/server/groups/impl/base.rs b/backend/src/server/groups/impl/base.rs index ae106f9e..4a5e3e6c 100644 --- a/backend/src/server/groups/impl/base.rs +++ b/backend/src/server/groups/impl/base.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use validator::Validate; -#[derive(Debug, Clone, Serialize, Validate, Deserialize, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Serialize, Validate, Deserialize, PartialEq, Eq, Hash, Default)] pub struct GroupBase { #[validate(length(min = 0, max = 100))] pub name: String, @@ -25,6 +25,8 @@ pub struct GroupBase { pub color: String, #[serde(default)] pub edge_style: EdgeStyle, + #[serde(default)] + pub tags: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] diff --git a/backend/src/server/groups/impl/storage.rs b/backend/src/server/groups/impl/storage.rs index 87b5265e..a65ff8b6 100644 --- a/backend/src/server/groups/impl/storage.rs +++ b/backend/src/server/groups/impl/storage.rs @@ -67,6 +67,7 @@ impl StorableEntity for Group { source, color, edge_style, + tags, }, } = self.clone(); @@ -82,6 +83,7 @@ impl StorableEntity for Group { "group_type", "color", "edge_style", + "tags", ], vec![ SqlValue::Uuid(id), @@ -94,6 +96,7 @@ impl StorableEntity for Group { SqlValue::GroupType(group_type), SqlValue::String(color), SqlValue::String(serde_json::to_string(&edge_style)?), + SqlValue::UuidArray(tags), ], )) } @@ -122,6 +125,7 @@ impl StorableEntity for Group { edge_style, group_type, color: row.get("color"), + tags: row.get("tags"), }, }) } diff --git a/backend/src/server/groups/impl/types.rs b/backend/src/server/groups/impl/types.rs index e5617f2d..0375607b 100644 --- a/backend/src/server/groups/impl/types.rs +++ b/backend/src/server/groups/impl/types.rs @@ -28,6 +28,14 @@ pub enum GroupType { }, } +impl Default for GroupType { + fn default() -> Self { + Self::RequestPath { + service_bindings: Vec::new(), + } + } +} + impl HasId for GroupTypeDiscriminants { fn id(&self) -> &'static str { self.into() diff --git a/backend/src/server/hosts/impl/base.rs b/backend/src/server/hosts/impl/base.rs index d3593b0d..9daaaa1e 100644 --- a/backend/src/server/hosts/impl/base.rs +++ b/backend/src/server/hosts/impl/base.rs @@ -36,6 +36,8 @@ pub struct HostBase { pub source: EntitySource, pub virtualization: Option, pub hidden: bool, + #[serde(default)] + pub tags: Vec, } impl Default for HostBase { @@ -52,6 +54,7 @@ impl Default for HostBase { source: EntitySource::Unknown, virtualization: None, hidden: false, + tags: Vec::new(), } } } diff --git a/backend/src/server/hosts/impl/storage.rs b/backend/src/server/hosts/impl/storage.rs index eae64632..a83780f0 100644 --- a/backend/src/server/hosts/impl/storage.rs +++ b/backend/src/server/hosts/impl/storage.rs @@ -73,6 +73,7 @@ impl StorableEntity for Host { services, ports, virtualization, + tags, }, } = self.clone(); @@ -92,6 +93,7 @@ impl StorableEntity for Host { "ports", "virtualization", "interfaces", + "tags", ], vec![ SqlValue::Uuid(id), @@ -108,6 +110,7 @@ impl StorableEntity for Host { SqlValue::Ports(ports), SqlValue::OptionalHostVirtualization(virtualization), SqlValue::Interfaces(interfaces), + SqlValue::UuidArray(tags), ], )) } @@ -144,6 +147,7 @@ impl StorableEntity for Host { ports, virtualization, interfaces, + tags: row.get("tags"), }, }) } diff --git a/backend/src/server/mod.rs b/backend/src/server/mod.rs index 5cb92c2c..7b568333 100644 --- a/backend/src/server/mod.rs +++ b/backend/src/server/mod.rs @@ -14,5 +14,6 @@ pub mod organizations; pub mod services; pub mod shared; pub mod subnets; +pub mod tags; pub mod topology; pub mod users; diff --git a/backend/src/server/networks/impl.rs b/backend/src/server/networks/impl.rs index dbf73eeb..3b475ead 100644 --- a/backend/src/server/networks/impl.rs +++ b/backend/src/server/networks/impl.rs @@ -19,6 +19,8 @@ pub struct NetworkBase { pub name: String, pub is_default: bool, pub organization_id: Uuid, + #[serde(default)] + pub tags: Vec, } impl NetworkBase { @@ -27,6 +29,7 @@ impl NetworkBase { name: "My Network".to_string(), is_default: false, organization_id, + tags: Vec::new(), } } } @@ -107,6 +110,7 @@ impl StorableEntity for Network { name, organization_id, is_default, + tags, }, } = self.clone(); @@ -118,6 +122,7 @@ impl StorableEntity for Network { "name", "organization_id", "is_default", + "tags", ], vec![ SqlValue::Uuid(id), @@ -126,6 +131,7 @@ impl StorableEntity for Network { SqlValue::String(name), SqlValue::Uuid(organization_id), SqlValue::Bool(is_default), + SqlValue::UuidArray(tags), ], )) } @@ -139,6 +145,7 @@ impl StorableEntity for Network { name: row.get("name"), organization_id: row.get("organization_id"), is_default: row.get("is_default"), + tags: row.get("tags"), }, }) } diff --git a/backend/src/server/services/impl/base.rs b/backend/src/server/services/impl/base.rs index 089b5f7b..690968ba 100644 --- a/backend/src/server/services/impl/base.rs +++ b/backend/src/server/services/impl/base.rs @@ -32,6 +32,8 @@ pub struct ServiceBase { pub bindings: Vec, pub virtualization: Option, pub source: EntitySource, + #[serde(default)] + pub tags: Vec, } impl Default for ServiceBase { @@ -44,6 +46,7 @@ impl Default for ServiceBase { bindings: Vec::new(), virtualization: None, source: EntitySource::Unknown, + tags: Vec::new(), } } } @@ -412,6 +415,7 @@ impl Service { service_definition, name, virtualization: virtualization.clone(), + tags: Vec::new(), bindings, source: EntitySource::DiscoveryWithMatch { metadata: vec![discovery_metadata], diff --git a/backend/src/server/services/impl/storage.rs b/backend/src/server/services/impl/storage.rs index 1186ecdc..384e5be4 100644 --- a/backend/src/server/services/impl/storage.rs +++ b/backend/src/server/services/impl/storage.rs @@ -68,6 +68,7 @@ impl StorableEntity for Service { virtualization, bindings, source, + tags, }, } = self.clone(); @@ -83,6 +84,7 @@ impl StorableEntity for Service { "virtualization", "bindings", "source", + "tags", ], vec![ SqlValue::Uuid(id), @@ -95,6 +97,7 @@ impl StorableEntity for Service { SqlValue::OptionalServiceVirtualization(virtualization), SqlValue::Bindings(bindings), SqlValue::EntitySource(source), + SqlValue::UuidArray(tags), ], )) } @@ -124,6 +127,7 @@ impl StorableEntity for Service { service_definition, virtualization, bindings, + tags: row.get("tags"), source, }, }) diff --git a/backend/src/server/shared/entities.rs b/backend/src/server/shared/entities.rs index 4eb4ff45..63b2b1f2 100644 --- a/backend/src/server/shared/entities.rs +++ b/backend/src/server/shared/entities.rs @@ -1,10 +1,10 @@ -use crate::server::groups::r#impl::base::Group; use crate::server::hosts::r#impl::interfaces::Interface; use crate::server::hosts::r#impl::ports::Port; use crate::server::organizations::r#impl::invites::Invite; use crate::server::services::r#impl::base::Service; use crate::server::subnets::r#impl::base::Subnet; use crate::server::topology::types::base::Topology; +use crate::server::{groups::r#impl::base::Group, tags::r#impl::base::Tag}; use serde::{Deserialize, Serialize}; use strum_macros::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; @@ -43,6 +43,8 @@ pub enum Entity { Network(Network), ApiKey(ApiKey), User(User), + Tag(Tag), + Discovery(Discovery), Daemon(Daemon), @@ -72,6 +74,7 @@ impl EntityMetadataProvider for EntityDiscriminants { EntityDiscriminants::ApiKey => "yellow", EntityDiscriminants::User => "blue", EntityDiscriminants::Invite => "green", + EntityDiscriminants::Tag => "yellow", EntityDiscriminants::Host => "blue", EntityDiscriminants::Service => "purple", @@ -89,6 +92,7 @@ impl EntityMetadataProvider for EntityDiscriminants { EntityDiscriminants::Organization => "Building", EntityDiscriminants::Network => "Globe", EntityDiscriminants::User => "User", + EntityDiscriminants::Tag => "Tag", EntityDiscriminants::Invite => "UserPlus", EntityDiscriminants::ApiKey => "Key", EntityDiscriminants::Daemon => "SatelliteDish", @@ -187,3 +191,9 @@ impl From for Entity { Self::Topology(value) } } + +impl From for Entity { + fn from(value: Tag) -> Self { + Self::Tag(value) + } +} diff --git a/backend/src/server/shared/handlers/factory.rs b/backend/src/server/shared/handlers/factory.rs index c692b4c6..19bda484 100644 --- a/backend/src/server/shared/handlers/factory.rs +++ b/backend/src/server/shared/handlers/factory.rs @@ -28,8 +28,8 @@ use crate::server::{ groups::handlers as group_handlers, hosts::handlers as host_handlers, networks::handlers as network_handlers, organizations::handlers as organization_handlers, services::handlers as service_handlers, shared::types::api::ApiResponse, - subnets::handlers as subnet_handlers, topology::handlers as topology_handlers, - users::handlers as user_handlers, + subnets::handlers as subnet_handlers, tags::handlers as tag_handlers, + topology::handlers as topology_handlers, users::handlers as user_handlers, }; use anyhow::anyhow; use axum::extract::State; @@ -58,6 +58,7 @@ pub fn create_router() -> Router> { .nest("/api/billing", billing_handlers::create_router()) .nest("/api/auth", auth_handlers::create_router()) .nest("/api/organizations", organization_handlers::create_router()) + .nest("/api/tags", tag_handlers::create_router()) .route("/api/health", get(get_health)) .route("/api/onboarding", post(onboarding)) // Group cacheable routes together @@ -207,6 +208,7 @@ pub async fn onboarding( expires_at: None, network_id: network.id, is_enabled: true, + tags: Vec::new(), }), AuthenticatedEntity::System, ) diff --git a/backend/src/server/shared/services/factory.rs b/backend/src/server/shared/services/factory.rs index 61f7017c..7d0d1d55 100644 --- a/backend/src/server/shared/services/factory.rs +++ b/backend/src/server/shared/services/factory.rs @@ -14,6 +14,7 @@ use crate::server::{ services::service::ServiceService, shared::{events::bus::EventBus, storage::factory::StorageFactory}, subnets::service::SubnetService, + tags::service::TagService, topology::service::main::TopologyService, users::service::UserService, }; @@ -38,6 +39,7 @@ pub struct ServiceFactory { pub email_service: Option>, pub event_bus: Arc, pub logging_service: Arc, + pub tag_service: Arc, } impl ServiceFactory { @@ -60,6 +62,8 @@ impl ServiceFactory { event_bus.clone(), )); + let tag_service = Arc::new(TagService::new(storage.tags.clone(), event_bus.clone())); + // Already implements Arc internally due to scheduler + sessions let discovery_service = DiscoveryService::new( storage.discovery.clone(), @@ -202,6 +206,7 @@ impl ServiceFactory { email_service, event_bus, logging_service, + tag_service, }) } } diff --git a/backend/src/server/shared/storage/factory.rs b/backend/src/server/shared/storage/factory.rs index 1571c8a0..89e1b48c 100644 --- a/backend/src/server/shared/storage/factory.rs +++ b/backend/src/server/shared/storage/factory.rs @@ -9,7 +9,8 @@ use crate::server::{ discovery::r#impl::base::Discovery, groups::r#impl::base::Group, hosts::r#impl::base::Host, networks::r#impl::Network, organizations::r#impl::base::Organization, services::r#impl::base::Service, shared::storage::generic::GenericPostgresStorage, - subnets::r#impl::base::Subnet, topology::types::base::Topology, users::r#impl::base::User, + subnets::r#impl::base::Subnet, tags::r#impl::base::Tag, topology::types::base::Topology, + users::r#impl::base::User, }; pub struct StorageFactory { @@ -25,6 +26,7 @@ pub struct StorageFactory { pub organizations: Arc>, pub discovery: Arc>, pub topologies: Arc>, + pub tags: Arc>, } pub async fn create_session_store( @@ -64,6 +66,7 @@ impl StorageFactory { subnets: Arc::new(GenericPostgresStorage::new(pool.clone())), services: Arc::new(GenericPostgresStorage::new(pool.clone())), topologies: Arc::new(GenericPostgresStorage::new(pool.clone())), + tags: Arc::new(GenericPostgresStorage::new(pool.clone())), }) } } diff --git a/backend/src/server/shared/storage/seed_data.rs b/backend/src/server/shared/storage/seed_data.rs index 7ee72d76..c33ab379 100644 --- a/backend/src/server/shared/storage/seed_data.rs +++ b/backend/src/server/shared/storage/seed_data.rs @@ -39,6 +39,7 @@ pub fn create_wan_subnet(network_id: Uuid) -> Subnet { let base = SubnetBase { name: "Internet".to_string(), network_id, + tags: Vec::new(), cidr: cidr::IpCidr::V4( Ipv4Cidr::new(Ipv4Addr::new(0, 0, 0, 0), 0).expect("Cidr for internet subnet"), ), @@ -58,6 +59,7 @@ pub fn create_remote_subnet(network_id: Uuid) -> Subnet { let base = SubnetBase { name: "Remote Network".to_string(), network_id, + tags: Vec::new(), cidr: cidr::IpCidr::V4( Ipv4Cidr::new(Ipv4Addr::new(0, 0, 0, 0), 0).expect("Cidr for internet subnet"), ), @@ -85,6 +87,7 @@ pub fn create_remote_host(remote_subnet: &Subnet, network_id: Uuid) -> (Host, Se name: "Mobile Device".to_string(), // Device type in name, not service hostname: None, network_id, + tags: Vec::new(), description: Some("A mobile device connecting from a remote network".to_string()), interfaces: vec![interface], ports: vec![dynamic_port], @@ -100,6 +103,7 @@ pub fn create_remote_host(remote_subnet: &Subnet, network_id: Uuid) -> (Host, Se let client_service = Service::new(ServiceBase { host_id: host.id, network_id, + tags: Vec::new(), name: "Mobile Device".to_string(), service_definition: Box::new(Client), bindings: vec![binding], @@ -126,6 +130,7 @@ pub fn create_internet_connectivity_host( let base = HostBase { name: "Google.com".to_string(), network_id, + tags: Vec::new(), hostname: None, description: None, interfaces: vec![interface], @@ -143,6 +148,7 @@ pub fn create_internet_connectivity_host( host_id: host.id, name: "Google.com".to_string(), network_id, + tags: Vec::new(), service_definition: Box::new(WebService), bindings: vec![binding], virtualization: None, @@ -168,6 +174,7 @@ pub fn create_public_dns_host(internet_subnet: &Subnet, network_id: Uuid) -> (Ho hostname: None, network_id, description: None, + tags: Vec::new(), target: HostTarget::None, interfaces: vec![interface], ports: vec![dns_udp_port], @@ -182,6 +189,7 @@ pub fn create_public_dns_host(internet_subnet: &Subnet, network_id: Uuid) -> (Ho let dns_service = Service::new(ServiceBase { host_id: host.id, network_id, + tags: Vec::new(), name: "Cloudflare DNS".to_string(), service_definition: Box::new(DnsServer), bindings: vec![binding], diff --git a/backend/src/server/shared/storage/tests.rs b/backend/src/server/shared/storage/tests.rs index 2e9490c3..0bf6367a 100644 --- a/backend/src/server/shared/storage/tests.rs +++ b/backend/src/server/shared/storage/tests.rs @@ -3,7 +3,8 @@ use crate::server::{ discovery::r#impl::base::Discovery, groups::r#impl::base::Group, hosts::r#impl::base::Host, networks::r#impl::Network, organizations::r#impl::base::Organization, services::r#impl::base::Service, shared::storage::traits::StorableEntity, - subnets::r#impl::base::Subnet, topology::types::base::Topology, users::r#impl::base::User, + subnets::r#impl::base::Subnet, tags::r#impl::base::Tag, topology::types::base::Topology, + users::r#impl::base::User, }; use sqlx::postgres::PgRow; use std::collections::HashMap; @@ -105,6 +106,14 @@ fn get_entity_deserializers() -> HashMap<&'static str, DeserializeFn> { }), ); + map.insert( + Tag::table_name(), + Box::new(|row| { + Tag::from_row(row)?; + Ok(()) + }), + ); + map } @@ -203,6 +212,27 @@ pub async fn test_database_schema_backward_compatibility() { // Verify tables exist using the deserializers map let deserializers = get_entity_deserializers(); for table_name in deserializers.keys() { + // Check if table exists in the old schema + let table_exists: bool = sqlx::query_scalar( + "SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + )", + ) + .bind(table_name) + .fetch_one(&pool) + .await + .unwrap(); + + if !table_exists { + println!( + "Table '{}' doesn't exist in old schema (new entity), skipping", + table_name + ); + continue; + } + assert!( sqlx::query(&format!("SELECT * FROM {}", table_name)) .fetch_all(&pool) diff --git a/backend/src/server/shared/types/entities.rs b/backend/src/server/shared/types/entities.rs index 5a715b08..70349aeb 100644 --- a/backend/src/server/shared/types/entities.rs +++ b/backend/src/server/shared/types/entities.rs @@ -7,11 +7,12 @@ use serde::{Deserialize, Serialize}; use strum_macros::EnumDiscriminants; use uuid::Uuid; -#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash, EnumDiscriminants)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, Eq, PartialEq, Hash, EnumDiscriminants)] #[strum_discriminants(derive(Hash))] #[serde(tag = "type")] pub enum EntitySource { Manual, + #[default] System, // Used with hosts and subnets Discovery { diff --git a/backend/src/server/subnets/impl/base.rs b/backend/src/server/subnets/impl/base.rs index 7433ec86..3a22c80e 100644 --- a/backend/src/server/subnets/impl/base.rs +++ b/backend/src/server/subnets/impl/base.rs @@ -28,6 +28,8 @@ pub struct SubnetBase { pub description: Option, pub subnet_type: SubnetType, pub source: EntitySource, + #[serde(default)] + pub tags: Vec, } impl Default for SubnetBase { @@ -39,6 +41,7 @@ impl Default for SubnetBase { description: None, subnet_type: SubnetType::Unknown, source: EntitySource::Manual, + tags: Vec::new(), } } } @@ -93,6 +96,7 @@ impl Subnet { cidr, network_id, description: None, + tags: Vec::new(), name: cidr.to_string(), subnet_type, source: EntitySource::Discovery { diff --git a/backend/src/server/subnets/impl/storage.rs b/backend/src/server/subnets/impl/storage.rs index 3bcdc1c0..3f115a1f 100644 --- a/backend/src/server/subnets/impl/storage.rs +++ b/backend/src/server/subnets/impl/storage.rs @@ -66,6 +66,7 @@ impl StorableEntity for Subnet { cidr, subnet_type, description, + tags, }, } = self.clone(); @@ -80,6 +81,7 @@ impl StorableEntity for Subnet { "network_id", "created_at", "updated_at", + "tags", ], vec![ SqlValue::Uuid(id), @@ -91,6 +93,7 @@ impl StorableEntity for Subnet { SqlValue::Uuid(network_id), SqlValue::Timestamp(created_at), SqlValue::Timestamp(updated_at), + SqlValue::UuidArray(tags), ], )) } @@ -116,6 +119,7 @@ impl StorableEntity for Subnet { source, cidr, subnet_type, + tags: row.get("tags"), }, }) } diff --git a/backend/src/server/tags/handlers.rs b/backend/src/server/tags/handlers.rs new file mode 100644 index 00000000..ab131ab1 --- /dev/null +++ b/backend/src/server/tags/handlers.rs @@ -0,0 +1,83 @@ +use crate::server::auth::middleware::auth::AuthenticatedUser; +use crate::server::auth::middleware::permissions::RequireAdmin; +use crate::server::shared::handlers::traits::{ + BulkDeleteResponse, CrudHandlers, bulk_delete_handler, create_handler, delete_handler, + get_by_id_handler, update_handler, +}; +use crate::server::shared::services::traits::CrudService; +use crate::server::shared::storage::filter::EntityFilter; +use crate::server::shared::storage::traits::StorableEntity; +use crate::server::shared::types::api::ApiError; +use crate::server::tags::r#impl::base::Tag; +use crate::server::{ + config::AppState, + shared::types::api::{ApiResponse, ApiResult}, +}; +use axum::extract::Path; +use axum::routing::{delete, get, post, put}; +use axum::{Router, extract::State, response::Json}; +use std::sync::Arc; +use uuid::Uuid; + +pub fn create_router() -> Router> { + Router::new() + .route("/", post(create_tag)) + .route("/", get(get_all_tags)) + .route("/{id}", put(update_tag)) + .route("/{id}", delete(delete_tag)) + .route("/{id}", get(get_by_id_handler::)) + .route("/bulk-delete", post(bulk_delete_tag)) +} + +pub async fn get_all_tags( + State(state): State>, + user: AuthenticatedUser, +) -> ApiResult>>> { + let organization_filter = EntityFilter::unfiltered().organization_id(&user.organization_id); + + let service = Tag::get_service(&state); + let entities = service.get_all(organization_filter).await.map_err(|e| { + tracing::error!( + entity_type = Tag::table_name(), + user_id = %user.user_id, + error = %e, + "Failed to fetch entities" + ); + ApiError::internal_error(&e.to_string()) + })?; + + Ok(Json(ApiResponse::success(entities))) +} + +pub async fn create_tag( + state: State>, + admin: RequireAdmin, + json: Json, +) -> ApiResult>> { + create_handler::(state, admin.into(), json).await +} + +pub async fn delete_tag( + state: State>, + admin: RequireAdmin, + id: Path, +) -> ApiResult>> { + delete_handler::(state, admin.into(), id).await +} + +pub async fn update_tag( + state: State>, + admin: RequireAdmin, + id: Path, + json: Json, +) -> ApiResult>> { + update_handler::(state, admin.into(), id, json).await +} + +pub async fn bulk_delete_tag( + state: State>, + admin: RequireAdmin, + json: Json>, +) -> ApiResult>> { + bulk_delete_handler::(state, admin.into(), json).await +} diff --git a/backend/src/server/tags/impl/base.rs b/backend/src/server/tags/impl/base.rs new file mode 100644 index 00000000..238d5191 --- /dev/null +++ b/backend/src/server/tags/impl/base.rs @@ -0,0 +1,53 @@ +use std::fmt::Display; + +use crate::server::shared::{ + entities::ChangeTriggersTopologyStaleness, types::api::deserialize_empty_string_as_none, +}; +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; +use validator::Validate; + +#[derive(Debug, Clone, Validate, Serialize, Deserialize, Eq, PartialEq, Hash)] +pub struct TagBase { + #[validate(length(min = 0, max = 100))] + pub name: String, + #[serde(deserialize_with = "deserialize_empty_string_as_none")] + pub description: Option, + pub color: String, + pub organization_id: Uuid, +} + +impl Default for TagBase { + fn default() -> Self { + Self { + name: "New Tag".to_string(), + description: None, + color: "yellow".to_string(), + organization_id: Uuid::nil(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)] +pub struct Tag { + pub id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(flatten)] + pub base: TagBase, +} + +impl ChangeTriggersTopologyStaleness for Tag { + fn triggers_staleness(&self, _other: Option) -> bool { + false + } +} + +impl Display for Tag { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Tag {}: {}", self.base.name, self.id) + } +} diff --git a/backend/src/server/tags/impl/handlers.rs b/backend/src/server/tags/impl/handlers.rs new file mode 100644 index 00000000..2b889eae --- /dev/null +++ b/backend/src/server/tags/impl/handlers.rs @@ -0,0 +1,12 @@ +use crate::server::{ + shared::handlers::traits::CrudHandlers, + tags::{r#impl::base::Tag, service::TagService}, +}; + +impl CrudHandlers for Tag { + type Service = TagService; + + fn get_service(state: &crate::server::config::AppState) -> &Self::Service { + &state.services.tag_service + } +} diff --git a/backend/src/server/tags/impl/mod.rs b/backend/src/server/tags/impl/mod.rs new file mode 100644 index 00000000..d38a3d5d --- /dev/null +++ b/backend/src/server/tags/impl/mod.rs @@ -0,0 +1,3 @@ +pub mod base; +pub mod handlers; +pub mod storage; diff --git a/backend/src/server/tags/impl/storage.rs b/backend/src/server/tags/impl/storage.rs new file mode 100644 index 00000000..16d82941 --- /dev/null +++ b/backend/src/server/tags/impl/storage.rs @@ -0,0 +1,98 @@ +use chrono::{DateTime, Utc}; +use sqlx::Row; +use sqlx::postgres::PgRow; +use uuid::Uuid; + +use crate::server::{ + shared::storage::traits::{SqlValue, StorableEntity}, + tags::r#impl::base::{Tag, TagBase}, +}; + +impl StorableEntity for Tag { + type BaseData = TagBase; + + fn table_name() -> &'static str { + "tags" + } + + fn get_base(&self) -> Self::BaseData { + self.base.clone() + } + + fn new(base: Self::BaseData) -> Self { + let now = chrono::Utc::now(); + + Self { + id: Uuid::new_v4(), + created_at: now, + updated_at: now, + base, + } + } + + fn id(&self) -> Uuid { + self.id + } + + fn created_at(&self) -> DateTime { + self.created_at + } + + fn updated_at(&self) -> DateTime { + self.updated_at + } + + fn set_updated_at(&mut self, time: DateTime) { + self.updated_at = time; + } + + fn to_params(&self) -> Result<(Vec<&'static str>, Vec), anyhow::Error> { + let Self { + id, + created_at, + updated_at, + base: + Self::BaseData { + name, + description, + color, + organization_id, + }, + } = self.clone(); + + Ok(( + vec![ + "id", + "name", + "description", + "color", + "organization_id", + "created_at", + "updated_at", + ], + vec![ + SqlValue::Uuid(id), + SqlValue::String(name), + SqlValue::OptionalString(description), + SqlValue::String(color), + SqlValue::Uuid(organization_id), + SqlValue::Timestamp(created_at), + SqlValue::Timestamp(updated_at), + ], + )) + } + + fn from_row(row: &PgRow) -> Result { + Ok(Tag { + id: row.get("id"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + base: TagBase { + name: row.get("name"), + description: row.get("description"), + organization_id: row.get("organization_id"), + color: row.get("color"), + }, + }) + } +} diff --git a/backend/src/server/tags/mod.rs b/backend/src/server/tags/mod.rs new file mode 100644 index 00000000..cfb50050 --- /dev/null +++ b/backend/src/server/tags/mod.rs @@ -0,0 +1,3 @@ +pub mod handlers; +pub mod r#impl; +pub mod service; diff --git a/backend/src/server/tags/service.rs b/backend/src/server/tags/service.rs new file mode 100644 index 00000000..b76bc478 --- /dev/null +++ b/backend/src/server/tags/service.rs @@ -0,0 +1,41 @@ +use uuid::Uuid; + +use crate::server::{ + shared::{ + events::bus::EventBus, + services::traits::{CrudService, EventBusService}, + storage::generic::GenericPostgresStorage, + }, + tags::r#impl::base::Tag, +}; +use std::sync::Arc; + +pub struct TagService { + storage: Arc>, + event_bus: Arc, +} + +impl EventBusService for TagService { + fn event_bus(&self) -> &Arc { + &self.event_bus + } + + fn get_network_id(&self, _entity: &Tag) -> Option { + None + } + fn get_organization_id(&self, _entity: &Tag) -> Option { + None + } +} + +impl CrudService for TagService { + fn storage(&self) -> &Arc> { + &self.storage + } +} + +impl TagService { + pub fn new(storage: Arc>, event_bus: Arc) -> Self { + Self { storage, event_bus } + } +} diff --git a/backend/src/server/topology/types/base.rs b/backend/src/server/topology/types/base.rs index 0b2b79ff..a28a45eb 100644 --- a/backend/src/server/topology/types/base.rs +++ b/backend/src/server/topology/types/base.rs @@ -44,6 +44,8 @@ pub struct TopologyBase { pub removed_services: Vec, pub removed_groups: Vec, pub parent_id: Option, + #[serde(default)] + pub tags: Vec, } impl TopologyBase { @@ -68,6 +70,7 @@ impl TopologyBase { removed_services: vec![], removed_groups: vec![], parent_id: None, + tags: vec![], } } } diff --git a/backend/src/server/topology/types/storage.rs b/backend/src/server/topology/types/storage.rs index 8fc56a2f..0fa9d433 100644 --- a/backend/src/server/topology/types/storage.rs +++ b/backend/src/server/topology/types/storage.rs @@ -79,6 +79,7 @@ impl StorableEntity for Topology { removed_subnets, removed_groups, parent_id, + tags, }, } = self.clone(); @@ -106,6 +107,7 @@ impl StorableEntity for Topology { "removed_subnets", "removed_groups", "parent_id", + "tags", ], vec![ SqlValue::Uuid(id), @@ -130,6 +132,7 @@ impl StorableEntity for Topology { SqlValue::UuidArray(removed_subnets), SqlValue::UuidArray(removed_groups), SqlValue::OptionalUuid(parent_id), + SqlValue::UuidArray(tags), ], )) } @@ -179,6 +182,7 @@ impl StorableEntity for Topology { services, groups, options, + tags: row.get("tags"), }, }) } diff --git a/backend/src/server/users/impl/permissions.rs b/backend/src/server/users/impl/permissions.rs index ab3a2873..8c30a0f1 100644 --- a/backend/src/server/users/impl/permissions.rs +++ b/backend/src/server/users/impl/permissions.rs @@ -129,16 +129,17 @@ impl TypeMetadataProvider for UserOrgPermissions { } fn metadata(&self) -> serde_json::Value { - let can_manage: Vec = match self { + let can_manage_user_permissions: Vec = match self { UserOrgPermissions::Owner => UserOrgPermissions::iter().collect(), _ => UserOrgPermissions::iter().filter(|p| p < self).collect(), }; - let network_permissions: bool = + let manage_org_entities: bool = matches!(self, UserOrgPermissions::Owner | UserOrgPermissions::Admin); + serde_json::json!({ - "can_manage": can_manage, - "network_permissions": network_permissions, + "can_manage_user_permissions": can_manage_user_permissions, + "manage_org_entities": manage_org_entities, "counts_towards_seats": self.counts_towards_seats() }) } diff --git a/backend/src/tests/mod.rs b/backend/src/tests/mod.rs index 634f9cc2..4b542fb5 100644 --- a/backend/src/tests/mod.rs +++ b/backend/src/tests/mod.rs @@ -102,6 +102,7 @@ pub fn host(network_id: &Uuid) -> Host { source: EntitySource::System, virtualization: None, hidden: false, + tags: Vec::new(), }) } @@ -122,6 +123,7 @@ pub fn subnet(network_id: &Uuid) -> Subnet { cidr: IpCidr::V4(Ipv4Cidr::new(Ipv4Addr::new(192, 168, 1, 0), 24).unwrap()), subnet_type: SubnetType::Lan, source: EntitySource::System, + tags: Vec::new(), }) } @@ -137,6 +139,7 @@ pub fn service(network_id: &Uuid, host_id: &Uuid) -> Service { service_definition: service_def, virtualization: None, source: EntitySource::System, + tags: Vec::new(), }) } @@ -151,6 +154,7 @@ pub fn group(network_id: &Uuid) -> Group { }, source: EntitySource::System, edge_style: EdgeStyle::Bezier, + tags: Vec::new(), }) } @@ -158,6 +162,7 @@ pub fn daemon(network_id: &Uuid, host_id: &Uuid) -> Daemon { Daemon::new(DaemonBase { host_id: *host_id, network_id: *network_id, + tags: Vec::new(), name: "daemon".to_string(), url: "http://192.168.1.50:60073".to_string(), last_seen: Utc::now(), diff --git a/backend/tests/integration.rs b/backend/tests/integration.rs index 77d554bb..b1c45a55 100644 --- a/backend/tests/integration.rs +++ b/backend/tests/integration.rs @@ -3,16 +3,21 @@ use netvisor::server::auth::r#impl::api::{LoginRequest, RegisterRequest}; use netvisor::server::daemons::r#impl::api::DiscoveryUpdatePayload; use netvisor::server::daemons::r#impl::base::Daemon; use netvisor::server::discovery::r#impl::types::DiscoveryType; +use netvisor::server::groups::r#impl::base::{Group, GroupBase}; use netvisor::server::networks::r#impl::Network; use netvisor::server::organizations::r#impl::base::Organization; use netvisor::server::services::definitions::home_assistant::HomeAssistant; use netvisor::server::services::r#impl::base::Service; use netvisor::server::shared::handlers::factory::OnboardingRequest; +use netvisor::server::shared::storage::traits::StorableEntity; use netvisor::server::shared::types::api::ApiResponse; use netvisor::server::shared::types::metadata::HasId; +use netvisor::server::tags::r#impl::base::{Tag, TagBase}; use netvisor::server::users::r#impl::base::User; +use serde::Serialize; use std::process::{Child, Command}; +use uuid::Uuid; const BASE_URL: &str = "http://localhost:60072"; const TEST_PASSWORD: &str = "TestPassword123!"; @@ -161,6 +166,24 @@ impl TestClient { .await } + /// Generic POST request + async fn post( + &self, + path: &str, + body: T, + ) -> Result { + let response = self + .client + .post(format!("{}{}", BASE_URL, path)) + .json(&body) + .send() + .await + .map_err(|e| format!("GET {} failed: {}", path, e))?; + + self.parse_response(response, &format!("GET {}", path)) + .await + } + /// Onboarding request async fn onboard_request(&self) -> Result { let response = self @@ -402,6 +425,38 @@ async fn verify_home_assistant_discovered(client: &TestClient) -> Result Result { + println!("\n=== Creating Group ==="); + + let mut group = Group::new(GroupBase::default()); + group.base.network_id = network_id; + + retry("create Group", 10, 3, || async { + let created_group = client.post("/api/groups", group.clone()).await?; + + println!("✅ Created group"); + + Ok(created_group) + }) + .await +} + +async fn create_tag(client: &TestClient, organization_id: Uuid) -> Result { + println!("\n=== Creating Tag ==="); + + let mut tag = Tag::new(TagBase::default()); + tag.base.organization_id = organization_id; + + retry("create Tag", 10, 3, || async { + let created_tag = client.post("/api/tags", tag.clone()).await?; + + println!("✅ Created Tag"); + + Ok(created_tag) + }) + .await +} + #[tokio::test] async fn test_full_integration() { // Start containers @@ -452,6 +507,13 @@ async fn test_full_integration() { .await .expect("Failed to find Home Assistant"); + let _group = create_group(&client, network.id) + .await + .expect("Failed to create group"); + let _tag = create_tag(&client, organization.id) + .await + .expect("Failed to create tag"); + #[cfg(feature = "generate-fixtures")] { generate_fixtures().await; diff --git a/backend/tests/mod.rs b/backend/tests/mod.rs deleted file mode 100644 index 5155b774..00000000 --- a/backend/tests/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod integration; diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c1f61aed..612bb676 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -16,6 +16,7 @@ Complete guide to using NetVisor's features for network discovery, organization, - [Daemons](#daemons) - [API Keys](#api-keys) - [Discovery](#discovery) +- [Tags](#tags) - [Topology Visualization](#topology-visualization) - [Multi-VLAN Setup](#multi-vlan-setup) - [FAQ](#faq) @@ -682,6 +683,125 @@ When discovering hosts, NetVisor determines names using this priority: Configure this per-discovery in the discovery type settings. +## Tags + +Tags provide organization-wide labels for categorizing and filtering entities. Apply tags to hosts, services, subnets, networks, groups, and daemons to create custom taxonomies that work across your entire infrastructure. + +### What Tags Are For + +- Categorizing infrastructure by environment (production, staging, development) +- Marking criticality levels (critical, high, medium, low) +- Identifying ownership (team-a, team-b, contractor) +- Tracking lifecycle status (deprecated, migrating, new) +- Custom organization-specific classifications + +### Tag Properties + +- **Name**: Display name for the tag (unique within organization) +- **Description**: Optional notes about when to use this tag +- **Color**: Visual color for the tag chip + +### Tag Scope + +Tags are defined at the **organization level**, meaning: +- The same tag can be applied to entities across all networks +- Tag definitions are shared by all users in the organization +- Deleting a tag removes it from all entities that had it applied + +### Managing Tags + +**Viewing tags**: +- Navigate to **Manage > Tags** +- See all organization tags with their colors +- Filter and search by name + +**Creating a tag**: +1. Navigate to **Manage > Tags** +2. Click **"Create Tag"** +3. Enter name and optional description +4. Select a color +5. Save + +**Creating a tag inline**: +1. While editing any entity (host, service, subnet, etc.) +2. Type a new tag name in the tag picker +3. Click **"Create [tag name]"** (requires Admin or Owner role) +4. The tag is created and automatically applied + +**Editing a tag**: +1. Click on a tag card +2. Modify name, description, or color +3. Save changes + +**Deleting a tag**: Click the delete icon. The tag is removed from all entities that had it applied. + +

+ Tags Tab +

+ +### Applying Tags to Entities + +Tags can be applied to any entity that supports them: + +**From the entity edit modal**: +1. Open any host, service, subnet, network, group, or daemon +2. Find the **Tags** field +3. Click to open the tag picker +4. Type to filter or select from available tags +5. Press Enter or click to add +6. Save the entity + +**Removing tags**: +- Click the **X** on any tag chip to remove it +- Save the entity to persist changes + +

+ Tag Picker +

+ +### Filtering by Tags + +Use tags to filter entity lists: + +1. Navigate to any entity list (Hosts, Services, Subnets, etc.) +2. Click **"Filters"** +3. Select one or more tags from the Tags filter +4. Items matching **any** selected tag are shown + +### Permissions + +| Action | Minimum Role | +|--------|-------------| +| View tags | Visualizer | +| Apply/remove tags on entities | Member | +| Create, edit, delete tags | Admin | + +### Use Cases + +**Environment classification**: +``` +Tags: production, staging, development, testing +Apply to: Hosts, Services, Networks +``` + +**Criticality tracking**: +``` +Tags: critical, high-priority, standard, low-priority +Apply to: Hosts, Services +``` + +**Team ownership**: +``` +Tags: platform-team, frontend-team, data-team, external +Apply to: Hosts, Services, Groups +``` + +**Compliance and auditing**: +``` +Tags: pci-scope, hipaa, gdpr, internal-only +Apply to: Hosts, Subnets, Services +``` + ## Topology Visualization The topology view generates an interactive diagram of your network structure. diff --git a/media/tags_picker.png b/media/tags_picker.png new file mode 100644 index 00000000..88de64a4 Binary files /dev/null and b/media/tags_picker.png differ diff --git a/media/tags_tab.png b/media/tags_tab.png new file mode 100644 index 00000000..e48cea3a Binary files /dev/null and b/media/tags_tab.png differ diff --git a/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte b/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte index 613c59b3..5c7614b3 100644 --- a/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte +++ b/ui/src/lib/features/api_keys/components/ApiKeyCard.svelte @@ -4,6 +4,7 @@ import { formatTimestamp } from '$lib/shared/utils/formatting'; import { Edit, Trash2 } from 'lucide-svelte'; import type { ApiKey } from '../types/base'; + import { tags } from '$lib/features/tags/store'; export let apiKey: ApiKey; export let onDelete: (apiKey: ApiKey) => void = () => {}; @@ -37,6 +38,15 @@ { label: 'Enabled', value: apiKey.is_enabled ? 'Yes' : 'No' + }, + { + label: 'Tags', + value: apiKey.tags.map((t) => { + const tag = $tags.find((tag) => tag.id == t); + return tag + ? { id: tag.id, color: tag.color, label: tag.name } + : { id: t, color: 'gray', label: 'Unknown Tag' }; + }) } ], actions: [ diff --git a/ui/src/lib/features/api_keys/components/ApiKeyDetailsForm.svelte b/ui/src/lib/features/api_keys/components/ApiKeyDetailsForm.svelte index c863c0ee..4b9379f3 100644 --- a/ui/src/lib/features/api_keys/components/ApiKeyDetailsForm.svelte +++ b/ui/src/lib/features/api_keys/components/ApiKeyDetailsForm.svelte @@ -8,6 +8,7 @@ import SelectNetwork from '$lib/features/networks/components/SelectNetwork.svelte'; import type { ApiKey } from '../types/base'; import Checkbox from '$lib/shared/components/forms/input/Checkbox.svelte'; + import TagPicker from '$lib/features/tags/components/TagPicker.svelte'; export let formApi: FormApi; export let formData: ApiKey; @@ -46,6 +47,8 @@ + + n.id == item.network_id)?.name || 'Unknown Network'; } + }, + { + key: 'tags', + label: 'Tags', + type: 'array', + searchable: true, + filterable: true, + sortable: false, + getValue: (entity) => { + // Return tag names for search/filter display + return entity.tags + .map((id) => $tags.find((t) => t.id === id)?.name) + .filter((name): name is string => !!name); + } } ]; diff --git a/ui/src/lib/features/api_keys/store.ts b/ui/src/lib/features/api_keys/store.ts index 8f406be5..6c86e6bc 100644 --- a/ui/src/lib/features/api_keys/store.ts +++ b/ui/src/lib/features/api_keys/store.ts @@ -83,7 +83,8 @@ export function createEmptyApiKeyFormData(): ApiKey { last_used: null, network_id: get(networks)[0].id || '', key: '', - is_enabled: true + is_enabled: true, + tags: [] }; } diff --git a/ui/src/lib/features/api_keys/types/base.ts b/ui/src/lib/features/api_keys/types/base.ts index ead81ab8..efe60470 100644 --- a/ui/src/lib/features/api_keys/types/base.ts +++ b/ui/src/lib/features/api_keys/types/base.ts @@ -8,4 +8,5 @@ export interface ApiKey { network_id: string; name: string; is_enabled: boolean; + tags: string[]; } diff --git a/ui/src/lib/features/daemons/components/DaemonCard.svelte b/ui/src/lib/features/daemons/components/DaemonCard.svelte index a014e781..9f88d0f9 100644 --- a/ui/src/lib/features/daemons/components/DaemonCard.svelte +++ b/ui/src/lib/features/daemons/components/DaemonCard.svelte @@ -9,6 +9,7 @@ import { getHostFromId } from '$lib/features/hosts/store'; import { Trash2 } from 'lucide-svelte'; import { subnets } from '$lib/features/subnets/store'; + import { tags } from '$lib/features/tags/store'; export let daemon: Daemon; export let onDelete: (daemon: Daemon) => void = () => {}; @@ -83,7 +84,17 @@ label: 'No subnet interfaces', color: 'gray' } - ] + ], + emptyText: 'No subnet interfaces' + }, + { + label: 'Tags', + value: daemon.tags.map((t) => { + const tag = $tags.find((tag) => tag.id == t); + return tag + ? { id: tag.id, color: tag.color, label: tag.name } + : { id: t, color: 'gray', label: 'Unknown Tag' }; + }) } ], actions: [ diff --git a/ui/src/lib/features/daemons/components/DaemonTab.svelte b/ui/src/lib/features/daemons/components/DaemonTab.svelte index c1f6beb1..db16d959 100644 --- a/ui/src/lib/features/daemons/components/DaemonTab.svelte +++ b/ui/src/lib/features/daemons/components/DaemonTab.svelte @@ -17,6 +17,7 @@ import type { FieldConfig } from '$lib/shared/components/data/types'; import DataControls from '$lib/shared/components/data/DataControls.svelte'; import { Plus } from 'lucide-svelte'; + import { tags } from '$lib/features/tags/store'; const loading = loadData([getNetworks, getDaemons, getHosts]); @@ -54,6 +55,20 @@ filterable: false, sortable: true }, + { + key: 'tags', + label: 'Tags', + type: 'array', + searchable: true, + filterable: true, + sortable: false, + getValue: (entity) => { + // Return tag names for search/filter display + return entity.tags + .map((id) => $tags.find((t) => t.id === id)?.name) + .filter((name): name is string => !!name); + } + }, { key: 'network_id', type: 'string', diff --git a/ui/src/lib/features/daemons/types/base.ts b/ui/src/lib/features/daemons/types/base.ts index 5e935655..8d490d15 100644 --- a/ui/src/lib/features/daemons/types/base.ts +++ b/ui/src/lib/features/daemons/types/base.ts @@ -9,6 +9,7 @@ export interface DaemonBase { has_docker_socket: boolean; interfaced_subnet_ids: string[]; }; + tags: string[]; } export interface Daemon extends DaemonBase { diff --git a/ui/src/lib/features/discovery/components/DiscoveryModal/DiscoveryDetailsForm.svelte b/ui/src/lib/features/discovery/components/DiscoveryModal/DiscoveryDetailsForm.svelte index 6df7c3f0..b6d2f300 100644 --- a/ui/src/lib/features/discovery/components/DiscoveryModal/DiscoveryDetailsForm.svelte +++ b/ui/src/lib/features/discovery/components/DiscoveryModal/DiscoveryDetailsForm.svelte @@ -8,6 +8,7 @@ import { DaemonDisplay } from '$lib/shared/components/forms/selection/display/DaemonDisplay.svelte'; import type { Discovery } from '../../types/base'; import type { Daemon } from '$lib/features/daemons/types/base'; + import TagPicker from '$lib/features/tags/components/TagPicker.svelte'; export let formApi: FormApi; export let formData: Discovery; @@ -54,4 +55,6 @@ />

The daemon that will execute this discovery

+ + diff --git a/ui/src/lib/features/discovery/components/cards/DiscoveryScheduledCard.svelte b/ui/src/lib/features/discovery/components/cards/DiscoveryScheduledCard.svelte index 57257ae1..4a9d99bc 100644 --- a/ui/src/lib/features/discovery/components/cards/DiscoveryScheduledCard.svelte +++ b/ui/src/lib/features/discovery/components/cards/DiscoveryScheduledCard.svelte @@ -6,6 +6,7 @@ import { daemons } from '$lib/features/daemons/store'; import { parseCronToHours } from '../../store'; import { formatTimestamp } from '$lib/shared/utils/formatting'; + import { tags } from '$lib/features/tags/store'; export let viewMode: 'card' | 'list'; export let discovery: Discovery; @@ -41,6 +42,15 @@ discovery.run_type.type != 'Historical' && discovery.run_type.last_run ? formatTimestamp(discovery.run_type.last_run) : 'Never' + }, + { + label: 'Tags', + value: discovery.tags.map((t) => { + const tag = $tags.find((tag) => tag.id == t); + return tag + ? { id: tag.id, color: tag.color, label: tag.name } + : { id: t, color: 'gray', label: 'Unknown Tag' }; + }) } ], actions: [ diff --git a/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte b/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte index f34b226b..c933cb07 100644 --- a/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte +++ b/ui/src/lib/features/discovery/components/tabs/DiscoveryScheduledTab.svelte @@ -22,6 +22,7 @@ import DiscoveryRunCard from '../cards/DiscoveryScheduledCard.svelte'; import type { FieldConfig } from '$lib/shared/components/data/types'; import { Plus } from 'lucide-svelte'; + import { tags } from '$lib/features/tags/store'; const loading = loadData([getDiscoveries, getDaemons, getSubnets, getHosts]); @@ -87,6 +88,20 @@ filterable: true, sortable: true, getValue: (item) => item.run_type.type + }, + { + key: 'tags', + label: 'Tags', + type: 'array', + searchable: true, + filterable: true, + sortable: false, + getValue: (entity) => { + // Return tag names for search/filter display + return entity.tags + .map((id) => $tags.find((t) => t.id === id)?.name) + .filter((name): name is string => !!name); + } } ]; diff --git a/ui/src/lib/features/discovery/store.ts b/ui/src/lib/features/discovery/store.ts index 595d18f7..b227a160 100644 --- a/ui/src/lib/features/discovery/store.ts +++ b/ui/src/lib/features/discovery/store.ts @@ -56,6 +56,7 @@ export function createEmptyDiscoveryFormData(daemon: Daemon | null): Discovery { id: uuidv4Sentinel, created_at: utcTimeZoneSentinel, updated_at: utcTimeZoneSentinel, + tags: [], discovery_type: { type: 'Network', subnet_ids: daemon ? daemon.capabilities.interfaced_subnet_ids : [], diff --git a/ui/src/lib/features/discovery/types/base.ts b/ui/src/lib/features/discovery/types/base.ts index 239df2ba..3721c40c 100644 --- a/ui/src/lib/features/discovery/types/base.ts +++ b/ui/src/lib/features/discovery/types/base.ts @@ -9,6 +9,7 @@ export interface Discovery { name: string; daemon_id: string; network_id: string; + tags: string[]; } export type RunType = HistoricalRun | ScheduledRun | AdHocRun; diff --git a/ui/src/lib/features/groups/components/GroupCard.svelte b/ui/src/lib/features/groups/components/GroupCard.svelte index a0e83d2b..020480c4 100644 --- a/ui/src/lib/features/groups/components/GroupCard.svelte +++ b/ui/src/lib/features/groups/components/GroupCard.svelte @@ -4,6 +4,7 @@ import type { Group } from '../types/base'; import { entities, groupTypes } from '$lib/shared/stores/metadata'; import { formatServiceLabels, getServicesForGroup } from '$lib/features/services/store'; + import { tags } from '$lib/features/tags/store'; export let group: Group; export let onEdit: (group: Group) => void = () => {}; @@ -70,6 +71,15 @@ }; }), emptyText: 'No services in group' + }, + { + label: 'Tags', + value: group.tags.map((t) => { + const tag = $tags.find((tag) => tag.id == t); + return tag + ? { id: tag.id, color: tag.color, label: tag.name } + : { id: t, color: 'gray', label: 'Unknown Tag' }; + }) } ], diff --git a/ui/src/lib/features/groups/components/GroupEditModal/GroupDetailsForm.svelte b/ui/src/lib/features/groups/components/GroupEditModal/GroupDetailsForm.svelte index 27c79a67..24e54e9e 100644 --- a/ui/src/lib/features/groups/components/GroupEditModal/GroupDetailsForm.svelte +++ b/ui/src/lib/features/groups/components/GroupEditModal/GroupDetailsForm.svelte @@ -8,6 +8,7 @@ import TextArea from '$lib/shared/components/forms/input/TextArea.svelte'; import { groupTypes } from '$lib/shared/stores/metadata'; import SelectNetwork from '$lib/features/networks/components/SelectNetwork.svelte'; + import TagPicker from '$lib/features/tags/components/TagPicker.svelte'; export let formApi: FormApi; export let formData: Group; @@ -56,4 +57,6 @@ placeholder="Describe the data flow or purpose of this group..." field={description} /> + + diff --git a/ui/src/lib/features/groups/components/GroupTab.svelte b/ui/src/lib/features/groups/components/GroupTab.svelte index 22c785f9..8de6b37f 100644 --- a/ui/src/lib/features/groups/components/GroupTab.svelte +++ b/ui/src/lib/features/groups/components/GroupTab.svelte @@ -19,6 +19,7 @@ import type { FieldConfig } from '$lib/shared/components/data/types'; import { networks } from '$lib/features/networks/store'; import { Plus } from 'lucide-svelte'; + import { tags } from '$lib/features/tags/store'; const loading = loadData([getServices, getGroups]); @@ -112,6 +113,20 @@ getValue(item) { return $networks.find((n) => n.id == item.network_id)?.name || 'Unknown Network'; } + }, + { + key: 'tags', + label: 'Tags', + type: 'array', + searchable: true, + filterable: true, + sortable: false, + getValue: (entity) => { + // Return tag names for search/filter display + return entity.tags + .map((id) => $tags.find((t) => t.id === id)?.name) + .filter((name): name is string => !!name); + } } ]; diff --git a/ui/src/lib/features/groups/store.ts b/ui/src/lib/features/groups/store.ts index 0116faa2..edc9ff44 100644 --- a/ui/src/lib/features/groups/store.ts +++ b/ui/src/lib/features/groups/store.ts @@ -82,7 +82,8 @@ export function createEmptyGroupFormData(): Group { }, network_id: get(networks)[0].id || '', color: entities.getColorHelper('Group').string, - edge_style: 'Straight' + edge_style: 'Straight', + tags: [] }; } diff --git a/ui/src/lib/features/groups/types/base.ts b/ui/src/lib/features/groups/types/base.ts index 0c0833ea..051d0d49 100644 --- a/ui/src/lib/features/groups/types/base.ts +++ b/ui/src/lib/features/groups/types/base.ts @@ -14,6 +14,7 @@ interface BaseGroup { network_id: string; color: string; edge_style: 'Straight' | 'SmoothStep' | 'Step' | 'Bezier' | 'SimpleBezier'; + tags: string[]; } export interface RequestPathGroup extends BaseGroup { diff --git a/ui/src/lib/features/hosts/components/HostCard.svelte b/ui/src/lib/features/hosts/components/HostCard.svelte index e28a7821..43d69867 100644 --- a/ui/src/lib/features/hosts/components/HostCard.svelte +++ b/ui/src/lib/features/hosts/components/HostCard.svelte @@ -7,6 +7,7 @@ import type { Group } from '$lib/features/groups/types/base'; import { getServiceById, getServicesForHost } from '$lib/features/services/store'; import { daemons } from '$lib/features/daemons/store'; + import { tags } from '$lib/features/tags/store'; let { host, @@ -151,6 +152,15 @@ }; }), emptyText: 'No interfaces' + }, + { + label: 'Tags', + value: host.tags.map((t) => { + const tag = $tags.find((tag) => tag.id == t); + return tag + ? { id: tag.id, color: tag.color, label: tag.name } + : { id: t, color: 'gray', label: 'Unknown Tag' }; + }) } ], actions: [ diff --git a/ui/src/lib/features/hosts/components/HostEditModal/Details/HostDetailsForm.svelte b/ui/src/lib/features/hosts/components/HostEditModal/Details/HostDetailsForm.svelte index e4b5f468..a9217566 100644 --- a/ui/src/lib/features/hosts/components/HostEditModal/Details/HostDetailsForm.svelte +++ b/ui/src/lib/features/hosts/components/HostEditModal/Details/HostDetailsForm.svelte @@ -12,6 +12,7 @@ import TextArea from '$lib/shared/components/forms/input/TextArea.svelte'; import EntityMetadataSection from '$lib/shared/components/forms/EntityMetadataSection.svelte'; import SelectNetwork from '$lib/features/networks/components/SelectNetwork.svelte'; + import TagPicker from '$lib/features/tags/components/TagPicker.svelte'; export let host: Host | null = null; export let formApi: FormApi; @@ -64,6 +65,8 @@ field={description} /> + + {#if isEditing} diff --git a/ui/src/lib/features/hosts/components/HostEditModal/Services/ServiceConfigPanel.svelte b/ui/src/lib/features/hosts/components/HostEditModal/Services/ServiceConfigPanel.svelte index 62a29683..b54302ec 100644 --- a/ui/src/lib/features/hosts/components/HostEditModal/Services/ServiceConfigPanel.svelte +++ b/ui/src/lib/features/hosts/components/HostEditModal/Services/ServiceConfigPanel.svelte @@ -16,6 +16,7 @@ import MatchDetails from './MatchDetails.svelte'; import type { Host } from '$lib/features/hosts/types/base'; import { get, readable, type Readable } from 'svelte/store'; + import TagPicker from '$lib/features/tags/components/TagPicker.svelte'; export let formApi: FormApi; export let host: Host; @@ -301,6 +302,8 @@ field={nameField} /> {/if} + +
diff --git a/ui/src/lib/features/hosts/components/HostTab.svelte b/ui/src/lib/features/hosts/components/HostTab.svelte index c769b6fb..658ce410 100644 --- a/ui/src/lib/features/hosts/components/HostTab.svelte +++ b/ui/src/lib/features/hosts/components/HostTab.svelte @@ -24,6 +24,7 @@ import { networks } from '$lib/features/networks/store'; import { get } from 'svelte/store'; import { Plus } from 'lucide-svelte'; + import { tags } from '$lib/features/tags/store'; const loading = loadData([getHosts, getGroups, getServices, getDaemons]); @@ -102,6 +103,20 @@ getValue(item) { return $networks.find((n) => n.id == item.network_id)?.name || 'Unknown Network'; } + }, + { + key: 'tags', + label: 'Tags', + type: 'array', + searchable: true, + filterable: true, + sortable: false, + getValue: (entity) => { + // Return tag names for search/filter display + return entity.tags + .map((id) => $tags.find((t) => t.id === id)?.name) + .filter((name): name is string => !!name); + } } ]; diff --git a/ui/src/lib/features/hosts/store.ts b/ui/src/lib/features/hosts/store.ts index 63823014..affe464a 100644 --- a/ui/src/lib/features/hosts/store.ts +++ b/ui/src/lib/features/hosts/store.ts @@ -78,6 +78,7 @@ export function createEmptyHostFormData(): Host { updated_at: utcTimeZoneSentinel, name: '', description: '', + tags: [], hostname: '', target: { type: 'None' diff --git a/ui/src/lib/features/hosts/types/base.ts b/ui/src/lib/features/hosts/types/base.ts index efacead5..d6f611ae 100644 --- a/ui/src/lib/features/hosts/types/base.ts +++ b/ui/src/lib/features/hosts/types/base.ts @@ -23,6 +23,7 @@ export interface Host { source: EntitySource; network_id: string; hidden: boolean; + tags: string[]; } export interface ProxmoxVirtualization { diff --git a/ui/src/lib/features/networks/components/NetworkCard.svelte b/ui/src/lib/features/networks/components/NetworkCard.svelte index 96aacfad..b13b8ce1 100644 --- a/ui/src/lib/features/networks/components/NetworkCard.svelte +++ b/ui/src/lib/features/networks/components/NetworkCard.svelte @@ -8,6 +8,7 @@ import { subnets } from '$lib/features/subnets/store'; import { groups } from '$lib/features/groups/store'; import { currentUser } from '$lib/features/auth/store'; + import { tags } from '$lib/features/tags/store'; export let network: Network; export let onDelete: (network: Network) => void = () => {}; @@ -22,7 +23,7 @@ $: networkGroups = $groups.filter((g) => g.network_id == network.id); $: canManageNetworks = - $currentUser && permissions.getMetadata($currentUser.permissions).network_permissions; + $currentUser && permissions.getMetadata($currentUser.permissions).manage_org_entities; // Build card data $: cardData = { @@ -69,6 +70,15 @@ color: entities.getColorHelper('Group').string }; }) + }, + { + label: 'Tags', + value: network.tags.map((t) => { + const tag = $tags.find((tag) => tag.id == t); + return tag + ? { id: tag.id, color: tag.color, label: tag.name } + : { id: t, color: 'gray', label: 'Unknown Tag' }; + }) } ], diff --git a/ui/src/lib/features/networks/components/NetworkDetailsForm.svelte b/ui/src/lib/features/networks/components/NetworkDetailsForm.svelte index d9497539..2b1dd06c 100644 --- a/ui/src/lib/features/networks/components/NetworkDetailsForm.svelte +++ b/ui/src/lib/features/networks/components/NetworkDetailsForm.svelte @@ -5,6 +5,7 @@ import type { FormApi } from '$lib/shared/components/forms/types'; import TextInput from '$lib/shared/components/forms/input/TextInput.svelte'; import type { Network } from '../types'; + import TagPicker from '$lib/features/tags/components/TagPicker.svelte'; export let formApi: FormApi; export let formData: Network; @@ -38,4 +39,6 @@ placeholder="Describe the data flow or purpose of this service chain..." field={description} /> --> + +
diff --git a/ui/src/lib/features/networks/components/NetworksTab.svelte b/ui/src/lib/features/networks/components/NetworksTab.svelte index 5209a278..801b9d62 100644 --- a/ui/src/lib/features/networks/components/NetworksTab.svelte +++ b/ui/src/lib/features/networks/components/NetworksTab.svelte @@ -21,6 +21,7 @@ import DataControls from '$lib/shared/components/data/DataControls.svelte'; import type { FieldConfig } from '$lib/shared/components/data/types'; import { Plus } from 'lucide-svelte'; + import { tags } from '$lib/features/tags/store'; const loading = loadData([getNetworks, getHosts, getDaemons, getSubnets, getGroups]); @@ -83,6 +84,20 @@ searchable: true, filterable: false, sortable: true + }, + { + key: 'tags', + label: 'Tags', + type: 'array', + searchable: true, + filterable: true, + sortable: false, + getValue: (entity) => { + // Return tag names for search/filter display + return entity.tags + .map((id) => $tags.find((t) => t.id === id)?.name) + .filter((name): name is string => !!name); + } } ]; diff --git a/ui/src/lib/features/networks/store.ts b/ui/src/lib/features/networks/store.ts index b4eb9cfb..c506db93 100644 --- a/ui/src/lib/features/networks/store.ts +++ b/ui/src/lib/features/networks/store.ts @@ -67,6 +67,7 @@ export function createEmptyNetworkFormData(): Network { created_at: utcTimeZoneSentinel, updated_at: utcTimeZoneSentinel, is_default: false, - organization_id: uuidv4Sentinel + organization_id: uuidv4Sentinel, + tags: [] }; } diff --git a/ui/src/lib/features/networks/types.ts b/ui/src/lib/features/networks/types.ts index 2149df03..ef903899 100644 --- a/ui/src/lib/features/networks/types.ts +++ b/ui/src/lib/features/networks/types.ts @@ -5,4 +5,5 @@ export interface Network { name: string; is_default: boolean; organization_id: string; + tags: string[]; } diff --git a/ui/src/lib/features/services/components/ServiceCard.svelte b/ui/src/lib/features/services/components/ServiceCard.svelte index e23a60e0..838fa0ca 100644 --- a/ui/src/lib/features/services/components/ServiceCard.svelte +++ b/ui/src/lib/features/services/components/ServiceCard.svelte @@ -8,6 +8,7 @@ import { formatInterface } from '$lib/features/hosts/store'; import { matchConfidenceColor, matchConfidenceLabel } from '$lib/shared/types'; import { SvelteMap } from 'svelte/reactivity'; + import { tags } from '$lib/features/tags/store'; export let service: Service; export let host: Host; @@ -92,6 +93,15 @@ } ], emptyText: 'Confidence value unavailable' + }, + { + label: 'Tags', + value: service.tags.map((t) => { + const tag = $tags.find((tag) => tag.id == t); + return tag + ? { id: tag.id, color: tag.color, label: tag.name } + : { id: t, color: 'gray', label: 'Unknown Tag' }; + }) } ], actions: [ diff --git a/ui/src/lib/features/services/components/ServiceTab.svelte b/ui/src/lib/features/services/components/ServiceTab.svelte index 165ddfa1..af7e4590 100644 --- a/ui/src/lib/features/services/components/ServiceTab.svelte +++ b/ui/src/lib/features/services/components/ServiceTab.svelte @@ -18,6 +18,7 @@ import ServiceCard from './ServiceCard.svelte'; import { matchConfidenceLabel } from '$lib/shared/types'; import ServiceEditModal from './ServiceEditModal.svelte'; + import { tags } from '$lib/features/tags/store'; const loading = loadData([getServices, getHosts]); @@ -127,6 +128,20 @@ ? matchConfidenceLabel(item.source.details) : 'N/A (Not a discovered service)'; } + }, + { + key: 'tags', + label: 'Tags', + type: 'array', + searchable: true, + filterable: true, + sortable: false, + getValue: (entity) => { + // Return tag names for search/filter display + return entity.tags + .map((id) => $tags.find((t) => t.id === id)?.name) + .filter((name): name is string => !!name); + } } ]; diff --git a/ui/src/lib/features/services/store.ts b/ui/src/lib/features/services/store.ts index bafdfb48..9b425c67 100644 --- a/ui/src/lib/features/services/store.ts +++ b/ui/src/lib/features/services/store.ts @@ -61,6 +61,7 @@ export function createDefaultService( network_id: host_network_id, host_id, is_gateway: false, + tags: [], service_definition: serviceType, name: serviceType, bindings: [], diff --git a/ui/src/lib/features/services/types/base.ts b/ui/src/lib/features/services/types/base.ts index 4ba162a7..72a4f5ce 100644 --- a/ui/src/lib/features/services/types/base.ts +++ b/ui/src/lib/features/services/types/base.ts @@ -14,6 +14,7 @@ export interface Service { virtualization: ServiceVirtualization | null; source: EntitySource; network_id: string; + tags: string[]; } export type ServiceWithVMs = Omit & { diff --git a/ui/src/lib/features/subnets/components/SubnetCard.svelte b/ui/src/lib/features/subnets/components/SubnetCard.svelte index 106ac8d4..e88e618a 100644 --- a/ui/src/lib/features/subnets/components/SubnetCard.svelte +++ b/ui/src/lib/features/subnets/components/SubnetCard.svelte @@ -6,6 +6,7 @@ import { isContainerSubnet } from '../store'; import type { Subnet } from '../types/base'; import { get } from 'svelte/store'; + import { tags } from '$lib/features/tags/store'; export let subnet: Subnet; export let onEdit: (subnet: Subnet) => void = () => {}; @@ -40,33 +41,6 @@ ], emptyText: 'No type specified' }, - // { - // label: 'DNS Resolvers', - // value: dnsLabels.map(({ id, label }) => ({ - // id, - // label, - // color: entities.getColorString('Dns') - // })), - // emptyText: 'No DNS resolvers' - // }, - // { - // label: 'Gateways', - // value: gatewayLabels.map(({ id, label }) => ({ - // id, - // label, - // color: entities.getColorString('Gateway') - // })), - // emptyText: 'No gateways' - // }, - // { - // label: 'Reverse Proxies', - // value: reverseProxyLabels.map(({ id, label }) => ({ - // id, - // label, - // color: entities.getColorString('ReverseProxy') - // })), - // emptyText: 'No reverse proxies' - // }, { label: 'Services', value: serviceLabels.map(({ id, label }) => ({ @@ -75,6 +49,15 @@ color: entities.getColorString('Service') })), emptyText: 'No services' + }, + { + label: 'Tags', + value: subnet.tags.map((t) => { + const tag = $tags.find((tag) => tag.id == t); + return tag + ? { id: tag.id, color: tag.color, label: tag.name } + : { id: t, color: 'gray', label: 'Unknown Tag' }; + }) } ], diff --git a/ui/src/lib/features/subnets/components/SubnetEditModal/SubnetDetailsForm.svelte b/ui/src/lib/features/subnets/components/SubnetEditModal/SubnetDetailsForm.svelte index ad1579e9..81774278 100644 --- a/ui/src/lib/features/subnets/components/SubnetEditModal/SubnetDetailsForm.svelte +++ b/ui/src/lib/features/subnets/components/SubnetEditModal/SubnetDetailsForm.svelte @@ -10,6 +10,7 @@ import type { Subnet } from '../../types/base'; import { get } from 'svelte/store'; import SelectNetwork from '$lib/features/networks/components/SelectNetwork.svelte'; + import TagPicker from '$lib/features/tags/components/TagPicker.svelte'; export let formApi: FormApi; export let formData: Subnet; @@ -75,4 +76,6 @@ placeholder="Describe the purpose of this subnet..." field={description} /> + + diff --git a/ui/src/lib/features/subnets/components/SubnetTab.svelte b/ui/src/lib/features/subnets/components/SubnetTab.svelte index a7c72bf8..2960de05 100644 --- a/ui/src/lib/features/subnets/components/SubnetTab.svelte +++ b/ui/src/lib/features/subnets/components/SubnetTab.svelte @@ -20,6 +20,7 @@ import type { FieldConfig } from '$lib/shared/components/data/types'; import { networks } from '$lib/features/networks/store'; import { Plus } from 'lucide-svelte'; + import { tags } from '$lib/features/tags/store'; let showSubnetEditor = false; let editingSubnet: Subnet | null = null; @@ -113,6 +114,20 @@ getValue(item) { return $networks.find((n) => n.id == item.network_id)?.name || 'Unknown Network'; } + }, + { + key: 'tags', + label: 'Tags', + type: 'array', + searchable: true, + filterable: true, + sortable: false, + getValue: (entity) => { + // Return tag names for search/filter display + return entity.tags + .map((id) => $tags.find((t) => t.id === id)?.name) + .filter((name): name is string => !!name); + } } ]; diff --git a/ui/src/lib/features/subnets/store.ts b/ui/src/lib/features/subnets/store.ts index 04d75863..9749ce27 100644 --- a/ui/src/lib/features/subnets/store.ts +++ b/ui/src/lib/features/subnets/store.ts @@ -67,6 +67,7 @@ export function createEmptySubnetFormData(): Subnet { id: uuidv4Sentinel, created_at: utcTimeZoneSentinel, updated_at: utcTimeZoneSentinel, + tags: [], name: '', network_id: get(networks)[0].id || '', cidr: '', diff --git a/ui/src/lib/features/subnets/types/base.ts b/ui/src/lib/features/subnets/types/base.ts index 29839eb7..fc5ae41c 100644 --- a/ui/src/lib/features/subnets/types/base.ts +++ b/ui/src/lib/features/subnets/types/base.ts @@ -10,4 +10,5 @@ export interface Subnet { network_id: string; source: EntitySource; subnet_type: string; + tags: string[]; } diff --git a/ui/src/lib/features/tags/components/TagCard.svelte b/ui/src/lib/features/tags/components/TagCard.svelte new file mode 100644 index 00000000..b778b0e5 --- /dev/null +++ b/ui/src/lib/features/tags/components/TagCard.svelte @@ -0,0 +1,53 @@ + + + diff --git a/ui/src/lib/features/tags/components/TagDetailsForm.svelte b/ui/src/lib/features/tags/components/TagDetailsForm.svelte new file mode 100644 index 00000000..0282fc2a --- /dev/null +++ b/ui/src/lib/features/tags/components/TagDetailsForm.svelte @@ -0,0 +1,61 @@ + + +
+

Tag Details

+ + + +