Merge pull request #252 from mayanayza/feat/tags

feat: tags
This commit is contained in:
Maya
2025-12-10 02:14:27 -05:00
committed by GitHub
103 changed files with 1667 additions and 66 deletions

View File

@@ -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 '{}';

View File

@@ -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()],

View File

@@ -273,6 +273,7 @@ impl DiscoveryRunner<DockerScanDiscovery> {
service_definition: Box::new(docker_service_definition),
bindings: vec![],
host_id,
tags: Vec::new(),
network_id,
virtualization: None,
source: EntitySource::DiscoveryWithMatch {

View File

@@ -203,6 +203,7 @@ impl RunsDiscovery for DiscoveryRunner<SelfReportDiscovery> {
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<SelfReportDiscovery> {
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()

View File

@@ -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,

View File

@@ -15,6 +15,8 @@ pub struct ApiKeyBase {
pub expires_at: Option<DateTime<Utc>>,
pub network_id: Uuid,
pub is_enabled: bool,
#[serde(default)]
pub tags: Vec<Uuid>,
}
fn serialize_api_key_status<S>(_key: &String, serializer: S) -> Result<S::Ok, S::Error>

View File

@@ -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"),
},
})
}

View File

@@ -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,
)

View File

@@ -20,6 +20,8 @@ pub struct DaemonBase {
pub capabilities: DaemonCapabilities,
pub mode: DaemonMode,
pub name: String,
#[serde(default)]
pub tags: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]

View File

@@ -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"),
},
})
}

View File

@@ -16,6 +16,8 @@ pub struct DiscoveryBase {
pub name: String,
pub daemon_id: Uuid,
pub network_id: Uuid,
#[serde(default)]
pub tags: Vec<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]

View File

@@ -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"),
},
})
}

View File

@@ -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 },

View File

@@ -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<Uuid>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]

View File

@@ -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"),
},
})
}

View File

@@ -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()

View File

@@ -36,6 +36,8 @@ pub struct HostBase {
pub source: EntitySource,
pub virtualization: Option<HostVirtualization>,
pub hidden: bool,
#[serde(default)]
pub tags: Vec<Uuid>,
}
impl Default for HostBase {
@@ -52,6 +54,7 @@ impl Default for HostBase {
source: EntitySource::Unknown,
virtualization: None,
hidden: false,
tags: Vec::new(),
}
}
}

View File

@@ -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"),
},
})
}

View File

@@ -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;

View File

@@ -19,6 +19,8 @@ pub struct NetworkBase {
pub name: String,
pub is_default: bool,
pub organization_id: Uuid,
#[serde(default)]
pub tags: Vec<Uuid>,
}
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"),
},
})
}

View File

@@ -32,6 +32,8 @@ pub struct ServiceBase {
pub bindings: Vec<Binding>,
pub virtualization: Option<ServiceVirtualization>,
pub source: EntitySource,
#[serde(default)]
pub tags: Vec<Uuid>,
}
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],

View File

@@ -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,
},
})

View File

@@ -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<Topology> for Entity {
Self::Topology(value)
}
}
impl From<Tag> for Entity {
fn from(value: Tag) -> Self {
Self::Tag(value)
}
}

View File

@@ -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<Arc<AppState>> {
.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,
)

View File

@@ -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<Arc<EmailService>>,
pub event_bus: Arc<EventBus>,
pub logging_service: Arc<LoggingService>,
pub tag_service: Arc<TagService>,
}
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,
})
}
}

View File

@@ -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<GenericPostgresStorage<Organization>>,
pub discovery: Arc<GenericPostgresStorage<Discovery>>,
pub topologies: Arc<GenericPostgresStorage<Topology>>,
pub tags: Arc<GenericPostgresStorage<Tag>>,
}
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())),
})
}
}

View File

@@ -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],

View File

@@ -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)

View File

@@ -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 {

View File

@@ -28,6 +28,8 @@ pub struct SubnetBase {
pub description: Option<String>,
pub subnet_type: SubnetType,
pub source: EntitySource,
#[serde(default)]
pub tags: Vec<Uuid>,
}
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 {

View File

@@ -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"),
},
})
}

View File

@@ -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<Arc<AppState>> {
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::<Tag>))
.route("/bulk-delete", post(bulk_delete_tag))
}
pub async fn get_all_tags(
State(state): State<Arc<AppState>>,
user: AuthenticatedUser,
) -> ApiResult<Json<ApiResponse<Vec<Tag>>>> {
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<Arc<AppState>>,
admin: RequireAdmin,
json: Json<Tag>,
) -> ApiResult<Json<ApiResponse<Tag>>> {
create_handler::<Tag>(state, admin.into(), json).await
}
pub async fn delete_tag(
state: State<Arc<AppState>>,
admin: RequireAdmin,
id: Path<Uuid>,
) -> ApiResult<Json<ApiResponse<()>>> {
delete_handler::<Tag>(state, admin.into(), id).await
}
pub async fn update_tag(
state: State<Arc<AppState>>,
admin: RequireAdmin,
id: Path<Uuid>,
json: Json<Tag>,
) -> ApiResult<Json<ApiResponse<Tag>>> {
update_handler::<Tag>(state, admin.into(), id, json).await
}
pub async fn bulk_delete_tag(
state: State<Arc<AppState>>,
admin: RequireAdmin,
json: Json<Vec<Uuid>>,
) -> ApiResult<Json<ApiResponse<BulkDeleteResponse>>> {
bulk_delete_handler::<Tag>(state, admin.into(), json).await
}

View File

@@ -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<String>,
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<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(flatten)]
pub base: TagBase,
}
impl ChangeTriggersTopologyStaleness<Tag> for Tag {
fn triggers_staleness(&self, _other: Option<Tag>) -> 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)
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,3 @@
pub mod base;
pub mod handlers;
pub mod storage;

View File

@@ -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<Utc> {
self.created_at
}
fn updated_at(&self) -> DateTime<Utc> {
self.updated_at
}
fn set_updated_at(&mut self, time: DateTime<Utc>) {
self.updated_at = time;
}
fn to_params(&self) -> Result<(Vec<&'static str>, Vec<SqlValue>), 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<Self, anyhow::Error> {
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"),
},
})
}
}

View File

@@ -0,0 +1,3 @@
pub mod handlers;
pub mod r#impl;
pub mod service;

View File

@@ -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<GenericPostgresStorage<Tag>>,
event_bus: Arc<EventBus>,
}
impl EventBusService<Tag> for TagService {
fn event_bus(&self) -> &Arc<EventBus> {
&self.event_bus
}
fn get_network_id(&self, _entity: &Tag) -> Option<Uuid> {
None
}
fn get_organization_id(&self, _entity: &Tag) -> Option<Uuid> {
None
}
}
impl CrudService<Tag> for TagService {
fn storage(&self) -> &Arc<GenericPostgresStorage<Tag>> {
&self.storage
}
}
impl TagService {
pub fn new(storage: Arc<GenericPostgresStorage<Tag>>, event_bus: Arc<EventBus>) -> Self {
Self { storage, event_bus }
}
}

View File

@@ -44,6 +44,8 @@ pub struct TopologyBase {
pub removed_services: Vec<Uuid>,
pub removed_groups: Vec<Uuid>,
pub parent_id: Option<Uuid>,
#[serde(default)]
pub tags: Vec<Uuid>,
}
impl TopologyBase {
@@ -68,6 +70,7 @@ impl TopologyBase {
removed_services: vec![],
removed_groups: vec![],
parent_id: None,
tags: vec![],
}
}
}

View File

@@ -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"),
},
})
}

View File

@@ -129,16 +129,17 @@ impl TypeMetadataProvider for UserOrgPermissions {
}
fn metadata(&self) -> serde_json::Value {
let can_manage: Vec<UserOrgPermissions> = match self {
let can_manage_user_permissions: Vec<UserOrgPermissions> = 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()
})
}

View File

@@ -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(),

View File

@@ -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<T: serde::de::DeserializeOwned + Serialize>(
&self,
path: &str,
body: T,
) -> Result<T, String> {
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<Organization, String> {
let response = self
@@ -402,6 +425,38 @@ async fn verify_home_assistant_discovered(client: &TestClient) -> Result<Service
.await
}
async fn create_group(client: &TestClient, network_id: Uuid) -> Result<Group, String> {
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<Tag, String> {
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;

View File

@@ -1 +0,0 @@
pub mod integration;

View File

@@ -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.
<p align="center">
<img src="../media/tags_tab.png" width="800" alt="Tags Tab">
</p>
### 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
<p align="center">
<img src="../media/tag_picker.png" width="500" alt="Tag Picker">
</p>
### 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.

BIN
media/tags_picker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
media/tags_tab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View File

@@ -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: [

View File

@@ -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 @@
<SelectNetwork bind:selectedNetworkId disabled={isEditing} />
<TagPicker bind:selectedTagIds={formData.tags} />
<DateInput
label="Expiration Date (Optional)"
id="expires_at"

View File

@@ -12,6 +12,7 @@
import { apiKeys, bulkDeleteApiKeys, deleteApiKey, getApiKeys, updateApiKey } from '../store';
import ApiKeyCard from './ApiKeyCard.svelte';
import { Plus } from 'lucide-svelte';
import { tags } from '$lib/features/tags/store';
const loading = loadData([getApiKeys, getDaemons]);
@@ -70,6 +71,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);
}
}
];
</script>

View File

@@ -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: []
};
}

View File

@@ -8,4 +8,5 @@ export interface ApiKey {
network_id: string;
name: string;
is_enabled: boolean;
tags: string[];
}

View File

@@ -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: [

View File

@@ -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',

View File

@@ -9,6 +9,7 @@ export interface DaemonBase {
has_docker_socket: boolean;
interfaced_subnet_ids: string[];
};
tags: string[];
}
export interface Daemon extends DaemonBase {

View File

@@ -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 @@
/>
<p class="text-tertiary text-xs">The daemon that will execute this discovery</p>
</div>
<TagPicker bind:selectedTagIds={formData.tags} />
</div>

View File

@@ -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: [

View File

@@ -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);
}
}
];
</script>

View File

@@ -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 : [],

View File

@@ -9,6 +9,7 @@ export interface Discovery {
name: string;
daemon_id: string;
network_id: string;
tags: string[];
}
export type RunType = HistoricalRun | ScheduledRun | AdHocRun;

View File

@@ -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' };
})
}
],

View File

@@ -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}
/>
<TagPicker bind:selectedTagIds={formData.tags} />
</div>

View File

@@ -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);
}
}
];
</script>

View File

@@ -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: []
};
}

View File

@@ -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 {

View File

@@ -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: [

View File

@@ -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}
/>
<TagPicker bind:selectedTagIds={formData.tags} />
<TargetConfigForm {form} {formData} />
{#if isEditing}

View File

@@ -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}
<TagPicker bind:selectedTagIds={service.tags} />
</div>
<div>

View File

@@ -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);
}
}
];

View File

@@ -78,6 +78,7 @@ export function createEmptyHostFormData(): Host {
updated_at: utcTimeZoneSentinel,
name: '',
description: '',
tags: [],
hostname: '',
target: {
type: 'None'

View File

@@ -23,6 +23,7 @@ export interface Host {
source: EntitySource;
network_id: string;
hidden: boolean;
tags: string[];
}
export interface ProxmoxVirtualization {

View File

@@ -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' };
})
}
],

View File

@@ -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}
/> -->
<TagPicker bind:selectedTagIds={formData.tags} />
</div>

View File

@@ -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);
}
}
];
</script>

View File

@@ -67,6 +67,7 @@ export function createEmptyNetworkFormData(): Network {
created_at: utcTimeZoneSentinel,
updated_at: utcTimeZoneSentinel,
is_default: false,
organization_id: uuidv4Sentinel
organization_id: uuidv4Sentinel,
tags: []
};
}

View File

@@ -5,4 +5,5 @@ export interface Network {
name: string;
is_default: boolean;
organization_id: string;
tags: string[];
}

View File

@@ -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: [

View File

@@ -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);
}
}
];
</script>

View File

@@ -61,6 +61,7 @@ export function createDefaultService(
network_id: host_network_id,
host_id,
is_gateway: false,
tags: [],
service_definition: serviceType,
name: serviceType,
bindings: [],

View File

@@ -14,6 +14,7 @@ export interface Service {
virtualization: ServiceVirtualization | null;
source: EntitySource;
network_id: string;
tags: string[];
}
export type ServiceWithVMs = Omit<Service, 'vms'> & {

View File

@@ -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' };
})
}
],

View File

@@ -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}
/>
<TagPicker bind:selectedTagIds={formData.tags} />
</div>

View File

@@ -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);
}
}
];
</script>

View File

@@ -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: '',

View File

@@ -10,4 +10,5 @@ export interface Subnet {
network_id: string;
source: EntitySource;
subnet_type: string;
tags: string[];
}

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { Edit, Trash2 } from 'lucide-svelte';
import GenericCard from '$lib/shared/components/data/GenericCard.svelte';
import type { Tag } from '../types/base';
import { createColorHelper } from '$lib/shared/utils/styling';
import { TagIcon } from 'lucide-svelte';
export let tag: Tag;
export let onEdit: (tag: Tag) => void = () => {};
export let onDelete: (tag: Tag) => void = () => {};
export let viewMode: 'card' | 'list';
export let selected: boolean;
export let onSelectionChange: (selected: boolean) => void = () => {};
$: colorHelper = createColorHelper(tag.color);
$: cardData = {
title: tag.name,
iconColor: colorHelper.icon,
Icon: TagIcon,
fields: [
{
label: 'Description',
value: tag.description
},
{
label: 'Color',
value: [
{
id: 'color',
label: tag.color.charAt(0).toUpperCase() + tag.color.slice(1),
color: tag.color
}
]
}
],
actions: [
{
label: 'Delete',
icon: Trash2,
class: 'btn-icon-danger',
onClick: () => onDelete(tag)
},
{
label: 'Edit',
icon: Edit,
onClick: () => onEdit(tag)
}
]
};
</script>
<GenericCard {...cardData} {viewMode} {selected} {onSelectionChange} />

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { field } from 'svelte-forms';
import { required } from 'svelte-forms/validators';
import { maxLength } from '$lib/shared/components/forms/validators';
import type { FormApi } from '$lib/shared/components/forms/types';
import type { Tag } from '../types/base';
import TextInput from '$lib/shared/components/forms/input/TextInput.svelte';
import TextArea from '$lib/shared/components/forms/input/TextArea.svelte';
import { createColorHelper, AVAILABLE_COLORS } from '$lib/shared/utils/styling';
export let formApi: FormApi;
export let formData: Tag;
const name = field('name', formData.name, [required(), maxLength(100)]);
const description = field('description', formData.description || '', [maxLength(500)]);
$: formData.name = $name.value;
$: formData.description = $description.value || null;
</script>
<div class="space-y-4">
<h3 class="text-primary text-lg font-medium">Tag Details</h3>
<TextInput
label="Tag Name"
id="name"
{formApi}
placeholder="e.g., Production, Critical, Staging"
required={true}
field={name}
/>
<TextArea
label="Description"
id="description"
{formApi}
placeholder="Describe what this tag represents..."
field={description}
/>
<!-- Color Selector -->
<div class="space-y-3">
<div class="text-secondary block text-sm font-medium">Color</div>
<div class="grid grid-cols-7 gap-2">
{#each AVAILABLE_COLORS as color (color)}
{@const colorHelper = createColorHelper(color)}
<button
type="button"
onclick={() => (formData.color = color)}
class="group relative aspect-square w-full rounded-lg border-2 transition-all hover:scale-110"
class:border-gray-500={formData.color !== color}
class:border-white={formData.color === color}
class:ring-2={formData.color === color}
class:ring-white={formData.color === color}
style="background-color: {colorHelper.rgb};"
title={color}
></button>
{/each}
</div>
</div>
</div>

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import EditModal from '$lib/shared/components/forms/EditModal.svelte';
import ModalHeaderIcon from '$lib/shared/components/layout/ModalHeaderIcon.svelte';
import EntityMetadataSection from '$lib/shared/components/forms/EntityMetadataSection.svelte';
import type { Tag } from '../types/base';
import { createDefaultTag } from '../types/base';
import TagDetailsForm from './TagDetailsForm.svelte';
import { createColorHelper } from '$lib/shared/utils/styling';
import { TagIcon } from 'lucide-svelte';
import { organization } from '$lib/features/organizations/store';
import { pushError } from '$lib/shared/stores/feedback';
export let tag: Tag | null = null;
export let isOpen = false;
export let onCreate: (data: Tag) => Promise<void> | void;
export let onUpdate: (id: string, data: Tag) => Promise<void> | void;
export let onClose: () => void;
export let onDelete: ((id: string) => Promise<void> | void) | null = null;
let loading = false;
let deleting = false;
$: isEditing = tag !== null;
$: title = isEditing ? `Edit ${tag?.name}` : 'Create Tag';
let formData: Tag = createDefaultTag('');
$: if (isOpen) {
resetForm();
}
function resetForm() {
if (tag) {
formData = { ...tag };
} else if ($organization) {
formData = createDefaultTag($organization.id);
}
}
async function handleSubmit() {
if (!$organization) {
pushError('Could not load organization');
onClose();
return;
}
const tagData: Tag = {
...formData,
name: formData.name.trim(),
description: formData.description?.trim() || null,
organization_id: $organization.id
};
loading = true;
try {
if (isEditing && tag) {
await onUpdate(tag.id, tagData);
} else {
await onCreate(tagData);
}
} finally {
loading = false;
}
}
async function handleDelete() {
if (onDelete && tag) {
deleting = true;
try {
await onDelete(tag.id);
} finally {
deleting = false;
}
}
}
$: saveLabel = isEditing ? 'Update Tag' : 'Create Tag';
$: colorHelper = createColorHelper(formData.color);
</script>
<EditModal
{isOpen}
{title}
{loading}
{deleting}
{saveLabel}
cancelLabel="Cancel"
onSave={handleSubmit}
onCancel={onClose}
onDelete={isEditing ? handleDelete : null}
size="md"
let:formApi
>
<svelte:fragment slot="header-icon">
<ModalHeaderIcon Icon={TagIcon} color={colorHelper.string} />
</svelte:fragment>
<div class="flex h-full flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto">
<div class="space-y-8 p-6">
<TagDetailsForm {formApi} bind:formData />
{#if isEditing}
<EntityMetadataSection entities={[tag]} />
{/if}
</div>
</div>
</div>
</EditModal>

View File

@@ -0,0 +1,216 @@
<script lang="ts">
import { X, Plus } from 'lucide-svelte';
import { tags, createTag } from '$lib/features/tags/store';
import { createDefaultTag } from '$lib/features/tags/types/base';
import { createColorHelper, AVAILABLE_COLORS } from '$lib/shared/utils/styling';
import { currentUser } from '$lib/features/auth/store';
import { permissions } from '$lib/shared/stores/metadata';
import { organization } from '$lib/features/organizations/store';
export let selectedTagIds: string[] = [];
export let label: string = 'Tags';
export let placeholder: string = 'Type to add tags...';
export let disabled: boolean = false;
let inputValue = '';
let isFocused = false;
let inputElement: HTMLInputElement;
let isCreating = false;
// Check if user can create tags
$: canCreateTags =
$currentUser && permissions.getMetadata($currentUser.permissions).manage_org_entities;
// Check if typed value matches an existing tag name exactly
$: exactMatch = $tags.some((t) => t.name.toLowerCase() === inputValue.trim().toLowerCase());
// Show create option if user typed something, can create, and no exact match exists
$: showCreateOption = inputValue.trim().length > 0 && canCreateTags && !exactMatch;
// Get tag by ID, returns null if not found
function getTag(id: string) {
return $tags.find((t) => t.id === id) ?? null;
}
// Filter available tags based on input and exclude already selected
$: availableTags = $tags.filter(
(tag) =>
!selectedTagIds.includes(tag.id) && tag.name.toLowerCase().includes(inputValue.toLowerCase())
);
$: showDropdown = isFocused && (availableTags.length > 0 || showCreateOption);
function getRandomColor(): string {
return AVAILABLE_COLORS[Math.floor(Math.random() * AVAILABLE_COLORS.length)];
}
async function handleCreateTag() {
if (!$organization || isCreating) return;
const name = inputValue.trim();
if (!name) return;
isCreating = true;
try {
const newTag = createDefaultTag($organization.id);
newTag.name = name;
newTag.color = getRandomColor();
const result = await createTag(newTag);
if (result?.success && result.data) {
selectedTagIds = [...selectedTagIds, result.data.id];
inputValue = '';
}
} finally {
isCreating = false;
inputElement?.focus();
}
}
function addTag(tagId: string) {
if (!selectedTagIds.includes(tagId)) {
selectedTagIds = [...selectedTagIds, tagId];
}
inputValue = '';
inputElement?.focus();
}
function removeTag(tagId: string) {
selectedTagIds = selectedTagIds.filter((id) => id !== tagId);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (showCreateOption && availableTags.length === 0) {
// No matches, create new tag
handleCreateTag();
} else if (availableTags.length > 0) {
// Add first matching tag
addTag(availableTags[0].id);
} else if (showCreateOption) {
// Create new tag
handleCreateTag();
}
} else if (e.key === 'Backspace' && inputValue === '' && selectedTagIds.length > 0) {
removeTag(selectedTagIds[selectedTagIds.length - 1]);
} else if (e.key === 'Escape') {
inputValue = '';
inputElement?.blur();
}
}
function handleBlur() {
// Delay to allow click on dropdown item
setTimeout(() => {
isFocused = false;
}, 150);
}
</script>
<div class="space-y-2">
{#if label}
<div class="text-secondary block text-sm font-medium">{label}</div>
{/if}
<div class="relative">
<!-- Input container with selected tags -->
<div
class="input-field flex h-[42px] items-center gap-2 overflow-hidden"
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
>
<!-- Selected tags (horizontal scroll) -->
<div class="flex shrink-0 items-center gap-1.5 overflow-x-auto">
{#each selectedTagIds as tagId (tagId)}
{@const tag = getTag(tagId)}
{#if tag}
{@const colorHelper = createColorHelper(tag.color)}
<span
class="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium {colorHelper.bg} {colorHelper.text}"
>
{tag.name}
{#if !disabled}
<button
type="button"
onclick={() => removeTag(tagId)}
class="rounded-full p-0.5 transition-colors hover:bg-white/20"
>
<X class="h-3 w-3" />
</button>
{/if}
</span>
{:else}
<span
class="inline-flex shrink-0 items-center gap-1 rounded-full bg-gray-600 px-2 py-0.5 text-xs font-medium text-gray-300"
>
Unknown tag
{#if !disabled}
<button
type="button"
onclick={() => removeTag(tagId)}
class="rounded-full p-0.5 transition-colors hover:bg-white/20"
>
<X class="h-3 w-3" />
</button>
{/if}
</span>
{/if}
{/each}
</div>
<!-- Text input -->
<input
bind:this={inputElement}
bind:value={inputValue}
type="text"
placeholder={selectedTagIds.length === 0 ? placeholder : ''}
{disabled}
class="min-w-[80px] flex-1 border-none bg-transparent text-sm text-white placeholder-gray-400 outline-none"
style="--tw-ring-shadow: none; --tw-ring-color: none;"
onfocus={() => (isFocused = true)}
onblur={handleBlur}
onkeydown={handleKeydown}
/>
</div>
<!-- Dropdown -->
{#if showDropdown}
<div
class="absolute left-0 right-0 top-full z-50 mt-1 max-h-48 overflow-y-auto rounded-md border border-gray-600 bg-gray-700 shadow-lg"
>
<!-- Create new tag option -->
{#if showCreateOption}
<button
type="button"
class="flex w-full items-center gap-2 border-b border-gray-600 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-600"
onmousedown={handleCreateTag}
disabled={isCreating}
>
<Plus class="h-4 w-4 shrink-0 text-green-400" />
<span class="text-primary">
{isCreating ? 'Creating...' : `Create "${inputValue.trim()}"`}
</span>
</button>
{/if}
<!-- Existing tags -->
{#each availableTags as tag (tag.id)}
{@const colorHelper = createColorHelper(tag.color)}
<button
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-600"
onmousedown={() => addTag(tag.id)}
>
<span class="h-3 w-3 shrink-0 rounded-full" style="background-color: {colorHelper.rgb};"
></span>
<span class="text-primary">{tag.name}</span>
{#if tag.description}
<span class="text-tertiary truncate text-xs"> {tag.description}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,150 @@
<script lang="ts">
import { bulkDeleteTags, createTag, deleteTag, getTags, tags, updateTag } from '../store';
import TagCard from './TagCard.svelte';
import TagEditModal from './TagEditModal.svelte';
import TabHeader from '$lib/shared/components/layout/TabHeader.svelte';
import Loading from '$lib/shared/components/feedback/Loading.svelte';
import EmptyState from '$lib/shared/components/layout/EmptyState.svelte';
import { loadData } from '$lib/shared/utils/dataLoader';
import type { Tag } from '../types/base';
import DataControls from '$lib/shared/components/data/DataControls.svelte';
import type { FieldConfig } from '$lib/shared/components/data/types';
import { Plus } from 'lucide-svelte';
let showTagEditor = false;
let editingTag: Tag | null = null;
const loading = loadData([getTags]);
function handleCreateTag() {
editingTag = null;
showTagEditor = true;
}
function handleEditTag(tag: Tag) {
editingTag = tag;
showTagEditor = true;
}
function handleDeleteTag(tag: Tag) {
if (confirm(`Are you sure you want to delete "${tag.name}"?`)) {
deleteTag(tag.id);
}
}
async function handleTagCreate(data: Tag) {
const result = await createTag(data);
if (result?.success) {
showTagEditor = false;
editingTag = null;
}
}
async function handleTagUpdate(_id: string, data: Tag) {
const result = await updateTag(data);
if (result?.success) {
showTagEditor = false;
editingTag = null;
}
}
function handleCloseTagEditor() {
showTagEditor = false;
editingTag = null;
}
async function handleBulkDelete(ids: string[]) {
if (confirm(`Are you sure you want to delete ${ids.length} tags?`)) {
await bulkDeleteTags(ids);
}
}
const tagFields: FieldConfig<Tag>[] = [
{
key: 'name',
label: 'Name',
type: 'string',
searchable: true,
filterable: false,
sortable: true
},
{
key: 'description',
label: 'Description',
type: 'string',
searchable: true,
filterable: false,
sortable: false
},
{
key: 'color',
label: 'Color',
type: 'string',
searchable: false,
filterable: true,
sortable: true
},
{
key: 'created_at',
label: 'Created',
type: 'date',
searchable: false,
filterable: false,
sortable: true
}
];
</script>
<div class="space-y-6">
<TabHeader title="Tags" subtitle="Manage organization-wide tags for categorizing entities">
<svelte:fragment slot="actions">
<button class="btn-primary flex items-center" on:click={handleCreateTag}>
<Plus class="h-5 w-5" />Create Tag
</button>
</svelte:fragment>
</TabHeader>
{#if $loading}
<Loading />
{:else if $tags.length === 0}
<EmptyState
title="No tags configured yet"
subtitle="Tags help you organize and filter hosts, services, and other entities"
onClick={handleCreateTag}
cta="Create your first tag"
/>
{:else}
<DataControls
items={$tags}
fields={tagFields}
storageKey="netvisor-tags-table-state"
onBulkDelete={handleBulkDelete}
getItemId={(item) => item.id}
>
{#snippet children(
item: Tag,
viewMode: 'card' | 'list',
isSelected: boolean,
onSelectionChange: (selected: boolean) => void
)}
<TagCard
tag={item}
selected={isSelected}
{onSelectionChange}
{viewMode}
onEdit={handleEditTag}
onDelete={handleDeleteTag}
/>
{/snippet}
</DataControls>
{/if}
</div>
<TagEditModal
isOpen={showTagEditor}
tag={editingTag}
onCreate={handleTagCreate}
onUpdate={handleTagUpdate}
onClose={handleCloseTagEditor}
onDelete={editingTag ? () => handleDeleteTag(editingTag!) : null}
/>

View File

@@ -0,0 +1,59 @@
import { api } from '$lib/shared/utils/api';
import { writable } from 'svelte/store';
import type { Tag } from './types/base';
export const tags = writable<Tag[]>([]);
export async function getTags() {
return await api.request<Tag[]>(`/tags`, tags, (tags) => tags, { method: 'GET' });
}
export async function createTag(tag: Tag) {
const result = await api.request<Tag, Tag[]>(
'/tags',
tags,
(response, currentTags) => [...currentTags, response],
{
method: 'POST',
body: JSON.stringify(tag)
}
);
return result;
}
export async function bulkDeleteTags(ids: string[]) {
const result = await api.request<void, Tag[]>(
`/tags/bulk-delete`,
tags,
(_, current) => current.filter((k) => !ids.includes(k.id)),
{ method: 'POST', body: JSON.stringify(ids) }
);
return result;
}
export async function updateTag(tag: Tag) {
const result = await api.request<Tag, Tag[]>(
`/tags/${tag.id}`,
tags,
(response, currentTags) => currentTags.map((s) => (s.id === tag.id ? response : s)),
{
method: 'PUT',
body: JSON.stringify(tag)
}
);
return result;
}
export async function deleteTag(tagId: string) {
const result = await api.request<void, Tag[]>(
`/tags/${tagId}`,
tags,
(_, currentTags) => currentTags.filter((s) => s.id !== tagId),
{ method: 'DELETE' }
);
return result;
}

View File

@@ -0,0 +1,23 @@
import { utcTimeZoneSentinel, uuidv4Sentinel } from '$lib/shared/utils/formatting';
export interface Tag {
name: string;
description: string | null;
color: string;
id: string;
created_at: string;
updated_at: string;
organization_id: string;
}
export function createDefaultTag(organization_id: string): Tag {
return {
name: 'New Tag',
description: null,
color: 'yellow',
id: uuidv4Sentinel,
created_at: utcTimeZoneSentinel,
updated_at: utcTimeZoneSentinel,
organization_id
};
}

View File

@@ -291,6 +291,7 @@ export function createEmptyTopologyFormData(): Topology {
removed_subnets: [],
locked_at: null,
locked_by: null,
parent_id: null
parent_id: null,
tags: []
};
}

View File

@@ -28,6 +28,7 @@ export interface Topology {
removed_subnets: string[];
removed_groups: string[];
parent_id: string | null;
tags: string[];
}
export interface NodeBase {

View File

@@ -18,7 +18,9 @@
}
$: canManage = $currentUser
? permissions.getMetadata($currentUser.permissions).can_manage.includes(invite.permissions)
? permissions
.getMetadata($currentUser.permissions)
.can_manage_user_permissions.includes(invite.permissions)
: false;
// Build card data

View File

@@ -49,7 +49,9 @@
.getItems()
.filter((p) =>
$currentUser
? permissions.getMetadata($currentUser.permissions).can_manage.includes(p.id)
? permissions
.getMetadata($currentUser.permissions)
.can_manage_user_permissions.includes(p.id)
: false
)
.map((p) => ({ value: p.id, label: p.name, description: p.description }))

View File

@@ -34,7 +34,9 @@
let canManage = $derived(
$currentUser
? permissions.getMetadata($currentUser.permissions).can_manage.includes(user.permissions)
? permissions
.getMetadata($currentUser.permissions)
.can_manage_user_permissions.includes(user.permissions)
: false
);
@@ -86,6 +88,13 @@
color: entities.getColorHelper('Network').string
}))
}
// {
// label: 'Tags',
// value: user.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: canManage
? [

View File

@@ -11,6 +11,7 @@ export interface User {
oidc_linked_at?: string;
permissions: UserOrgPermissions;
network_ids: string[];
tags: string[];
}
export type UserOrgPermissions = 'Owner' | 'Admin' | 'Member' | 'Visualizer' | 'None';

View File

@@ -38,7 +38,7 @@
// Filter state
interface FilterState {
[key: string]: {
type: 'string' | 'boolean';
type: 'string' | 'boolean' | 'array';
values: SvelteSet<string>;
showTrue?: boolean;
showFalse?: boolean;
@@ -73,7 +73,7 @@
searchQuery: string;
filterState: {
[key: string]: {
type: 'string' | 'boolean';
type: 'string' | 'boolean' | 'array';
values: string[];
showTrue?: boolean;
showFalse?: boolean;
@@ -175,6 +175,11 @@
showTrue: true,
showFalse: true
};
} else if (field.type === 'array') {
filterState[field.key] = {
type: 'array',
values: new SvelteSet()
};
} else {
filterState[field.key] = {
type: 'string',
@@ -217,7 +222,10 @@
});
// Get value from item using field config
function getFieldValue(item: T, field: FieldConfig<T>): string | boolean | Date | null {
function getFieldValue(
item: T,
field: FieldConfig<T>
): string | boolean | Date | string[] | null {
if (field.getValue) {
return field.getValue(item);
}
@@ -225,19 +233,27 @@
return (item as any)[field.key] ?? null;
}
// Get unique string values for a field
// Get unique string values for a field (handles arrays by flattening)
function getUniqueValues(field: FieldConfig<T>): string[] {
const values = new SvelteSet<string>();
items.forEach((item) => {
const value = getFieldValue(item, field);
if (value !== null && value !== undefined && value !== '') {
if (value === null || value === undefined) return;
if (field.type === 'array' && Array.isArray(value)) {
value.forEach((v) => {
if (v !== null && v !== undefined && v !== '') {
values.add(String(v));
}
});
} else if (value !== '') {
values.add(String(value));
}
});
return Array.from(values).sort();
}
// Get groupable fields (only string type fields)
// Get groupable fields (only string type fields, not arrays)
let groupableFields = $derived(
fields.filter((f) => f.type === 'string' && f.filterable !== false)
);
@@ -252,6 +268,12 @@
const matchesQ = searchableFields.some((field) => {
const value = getFieldValue(item, field);
if (value === null || value === undefined) return false;
// Handle array values in search
if (field.type === 'array' && Array.isArray(value)) {
return value.some((v) => String(v).toLowerCase().includes(q));
}
return String(value).toLowerCase().includes(q);
});
if (!matchesQ) return false;
@@ -272,6 +294,11 @@
if (boolValue && !filterConfig.showTrue) return false;
if (!boolValue && !filterConfig.showFalse) return false;
return true;
} else if (field.type === 'array') {
// Array filter: item matches if ANY of its values are in the filter set
if (filterConfig.values.size === 0) return true;
if (!Array.isArray(value) || value.length === 0) return false;
return value.some((v) => filterConfig.values.has(String(v)));
} else if (field.type === 'string') {
if (filterConfig.values.size === 0) return true;
if (value === null || value === undefined) return false;
@@ -304,6 +331,17 @@
comparison = aDate.getTime() - bDate.getTime();
} else if (field.type === 'boolean') {
comparison = (aVal ? 1 : 0) - (bVal ? 1 : 0);
} else if (field.type === 'array') {
// Sort arrays by length, then by first element
const aArr = aVal as string[];
const bArr = bVal as string[];
comparison = aArr.length - bArr.length;
if (comparison === 0 && aArr.length > 0 && bArr.length > 0) {
comparison = aArr[0].localeCompare(bArr[0], undefined, {
sensitivity: 'base',
numeric: true
});
}
} else {
// String comparison
comparison = String(aVal).localeCompare(String(bVal), undefined, {
@@ -362,10 +400,10 @@
}
}
// Toggle string filter value
// Toggle string/array filter value
function toggleStringFilter(fieldKey: string, value: string) {
const filter = filterState[fieldKey];
if (!filter || filter.type !== 'string') return;
if (!filter || (filter.type !== 'string' && filter.type !== 'array')) return;
const newValues = new SvelteSet(filter.values);
if (newValues.has(value)) {
@@ -410,6 +448,11 @@
showTrue: true,
showFalse: true
};
} else if (field.type === 'array') {
newFilterState[field.key] = {
type: 'array',
values: new SvelteSet()
};
} else {
newFilterState[field.key] = {
type: 'string',

View File

@@ -41,10 +41,10 @@ export interface CardField {
// Field configuration for data controls
export interface FieldConfig<T> {
key: string;
type: 'string' | 'boolean' | 'date';
searchable?: boolean; // Whether this field should be included in text search
filterable?: boolean; // Whether to show filter controls for this field
sortable?: boolean; // Whether this field can be sorted
getValue?: (item: T) => string | boolean | Date | null; // Custom getter function
type: 'string' | 'boolean' | 'date' | 'array';
searchable?: boolean;
filterable?: boolean;
sortable?: boolean;
getValue?: (item: T) => string | boolean | Date | string[] | null;
label: string;
}

Some files were not shown because too many files have changed in this diff Show More