mirror of
https://github.com/mayanayza/netvisor.git
synced 2025-12-10 08:24:08 -06:00
23
backend/migrations/20251210045929_tags.sql
Normal file
23
backend/migrations/20251210045929_tags.sql
Normal 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 '{}';
|
||||
@@ -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()],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
83
backend/src/server/tags/handlers.rs
Normal file
83
backend/src/server/tags/handlers.rs
Normal 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
|
||||
}
|
||||
53
backend/src/server/tags/impl/base.rs
Normal file
53
backend/src/server/tags/impl/base.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
12
backend/src/server/tags/impl/handlers.rs
Normal file
12
backend/src/server/tags/impl/handlers.rs
Normal 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
|
||||
}
|
||||
}
|
||||
3
backend/src/server/tags/impl/mod.rs
Normal file
3
backend/src/server/tags/impl/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod base;
|
||||
pub mod handlers;
|
||||
pub mod storage;
|
||||
98
backend/src/server/tags/impl/storage.rs
Normal file
98
backend/src/server/tags/impl/storage.rs
Normal 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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
3
backend/src/server/tags/mod.rs
Normal file
3
backend/src/server/tags/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod handlers;
|
||||
pub mod r#impl;
|
||||
pub mod service;
|
||||
41
backend/src/server/tags/service.rs
Normal file
41
backend/src/server/tags/service.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pub mod integration;
|
||||
@@ -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
BIN
media/tags_picker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
media/tags_tab.png
Normal file
BIN
media/tags_tab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
@@ -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: [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,4 +8,5 @@ export interface ApiKey {
|
||||
network_id: string;
|
||||
name: string;
|
||||
is_enabled: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface DaemonBase {
|
||||
has_docker_socket: boolean;
|
||||
interfaced_subnet_ids: string[];
|
||||
};
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface Daemon extends DaemonBase {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 : [],
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Discovery {
|
||||
name: string;
|
||||
daemon_id: string;
|
||||
network_id: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type RunType = HistoricalRun | ScheduledRun | AdHocRun;
|
||||
|
||||
@@ -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' };
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ export function createEmptyHostFormData(): Host {
|
||||
updated_at: utcTimeZoneSentinel,
|
||||
name: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
hostname: '',
|
||||
target: {
|
||||
type: 'None'
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface Host {
|
||||
source: EntitySource;
|
||||
network_id: string;
|
||||
hidden: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface ProxmoxVirtualization {
|
||||
|
||||
@@ -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' };
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -67,6 +67,7 @@ export function createEmptyNetworkFormData(): Network {
|
||||
created_at: utcTimeZoneSentinel,
|
||||
updated_at: utcTimeZoneSentinel,
|
||||
is_default: false,
|
||||
organization_id: uuidv4Sentinel
|
||||
organization_id: uuidv4Sentinel,
|
||||
tags: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export interface Network {
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
organization_id: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -61,6 +61,7 @@ export function createDefaultService(
|
||||
network_id: host_network_id,
|
||||
host_id,
|
||||
is_gateway: false,
|
||||
tags: [],
|
||||
service_definition: serviceType,
|
||||
name: serviceType,
|
||||
bindings: [],
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Service {
|
||||
virtualization: ServiceVirtualization | null;
|
||||
source: EntitySource;
|
||||
network_id: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type ServiceWithVMs = Omit<Service, 'vms'> & {
|
||||
|
||||
@@ -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' };
|
||||
})
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -10,4 +10,5 @@ export interface Subnet {
|
||||
network_id: string;
|
||||
source: EntitySource;
|
||||
subnet_type: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
53
ui/src/lib/features/tags/components/TagCard.svelte
Normal file
53
ui/src/lib/features/tags/components/TagCard.svelte
Normal 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} />
|
||||
61
ui/src/lib/features/tags/components/TagDetailsForm.svelte
Normal file
61
ui/src/lib/features/tags/components/TagDetailsForm.svelte
Normal 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>
|
||||
109
ui/src/lib/features/tags/components/TagEditModal.svelte
Normal file
109
ui/src/lib/features/tags/components/TagEditModal.svelte
Normal 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>
|
||||
216
ui/src/lib/features/tags/components/TagPicker.svelte
Normal file
216
ui/src/lib/features/tags/components/TagPicker.svelte
Normal 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>
|
||||
150
ui/src/lib/features/tags/components/TagTab.svelte
Normal file
150
ui/src/lib/features/tags/components/TagTab.svelte
Normal 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}
|
||||
/>
|
||||
59
ui/src/lib/features/tags/store.ts
Normal file
59
ui/src/lib/features/tags/store.ts
Normal 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;
|
||||
}
|
||||
23
ui/src/lib/features/tags/types/base.ts
Normal file
23
ui/src/lib/features/tags/types/base.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -291,6 +291,7 @@ export function createEmptyTopologyFormData(): Topology {
|
||||
removed_subnets: [],
|
||||
locked_at: null,
|
||||
locked_by: null,
|
||||
parent_id: null
|
||||
parent_id: null,
|
||||
tags: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Topology {
|
||||
removed_subnets: string[];
|
||||
removed_groups: string[];
|
||||
parent_id: string | null;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface NodeBase {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user