Compare commits

..

5 Commits

8 changed files with 608 additions and 65 deletions

View File

@@ -6,6 +6,15 @@ edition = "2021"
# Leptos nécessite deux formats de compilation :
# - rlib : bibliothèque normale, utilisée par le serveur Axum
# - cdylib : bibliothèque dynamique compilée en WebAssembly pour le navigateur
[[bin]]
name = "rust-ipam"
path = "src/main.rs"
[[bin]]
name = "seed"
path = "src/bin/seed.rs"
required-features = ["ssr"]
[lib]
crate-type = ["cdylib", "rlib"]

View File

@@ -0,0 +1,8 @@
-- host_applications: direct association between a host and an application.
-- Allows explicitly tagging a host with an application regardless of ports.
-- One application can only be linked once to a given host.
CREATE TABLE IF NOT EXISTS host_applications (
host_id BIGINT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
application_id BIGINT NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
PRIMARY KEY (host_id, application_id)
);

View File

@@ -0,0 +1,8 @@
-- host_applications: direct association between a host and an application.
-- Allows explicitly tagging a host with an application regardless of ports.
-- One application can only be linked once to a given host.
CREATE TABLE IF NOT EXISTS host_applications (
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
PRIMARY KEY (host_id, application_id)
);

View File

@@ -3,9 +3,7 @@
use leptos::prelude::*;
use serde::{Deserialize, Serialize};
use crate::models::Host;
use crate::models::Port;
use crate::models::{Application, Host, Port};
// ─── Presentation types ───────────────────────────────────────────────────────
@@ -32,7 +30,7 @@ pub struct HostsPage {
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
}
// Full host detail: identity fields + resolved network + open ports.
// Full host detail: identity fields + resolved network + open ports + linked applications.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HostDetail {
pub id: i64,
@@ -42,6 +40,7 @@ pub struct HostDetail {
pub network_name: String,
pub network_cidr: String,
pub ports: Vec<Port>,
pub applications: Vec<Application>,
}
// ─── Queries ──────────────────────────────────────────────────────────────────
@@ -50,7 +49,12 @@ pub struct HostDetail {
#[server]
pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::{hosts as host_repo, networks, ports as port_repo};
use crate::server::repository::{
applications as app_repo,
hosts as host_repo,
networks,
ports as port_repo,
};
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
@@ -69,6 +73,10 @@ pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let applications = app_repo::list_applications_for_host(&pool, id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(HostDetail {
id: host.id,
name: host.name,
@@ -77,6 +85,7 @@ pub async fn get_host_detail(id: i64) -> Result<HostDetail, ServerFnError> {
network_name: network.name,
network_cidr: network.cidr,
ports,
applications,
})
}
@@ -343,6 +352,72 @@ pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), Serv
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Returns all applications not yet directly linked to a host.
///
/// Used to populate the "add applications" modal on the host detail page.
#[server]
pub async fn get_applications_not_on_host(
host_id: i64,
) -> Result<Vec<Application>, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::list_applications_not_on_host(&pool, host_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Links one or more applications to a host.
///
/// `application_ids` is a comma-separated string of application IDs (e.g. "1,3,7").
/// Already-linked applications are silently skipped (no-op).
#[server]
pub async fn add_host_applications(
host_id: i64,
application_ids: String,
) -> Result<(), ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
let ids: Vec<i64> = application_ids
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.collect();
for application_id in ids {
repo::add_application_to_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
Ok(())
}
/// Removes the direct link between a host and an application.
///
/// Returns `true` if the association existed and was removed.
#[server]
pub async fn remove_host_application(
host_id: i64,
application_id: i64,
) -> Result<bool, ServerFnError> {
use sqlx::AnyPool;
use crate::server::repository::applications as repo;
let pool = use_context::<AnyPool>()
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
repo::remove_application_from_host(&pool, host_id, application_id)
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Deletes a host by id.
///
/// Also removes all its port associations (via `ON DELETE CASCADE`).

View File

@@ -1,17 +1,177 @@
// client/host_detail.rs — Host detail page
//
// Shows all information for a single host:
// - Identity form : name, IP, network dropdown — editable, saved with "Save changes"
// - Ports section : full list with Remove per port + Add port input
// - Delete button : opens a confirmation modal, then navigates back to /hosts
// - Identity form : name, IP, network dropdown — editable, saved with "Save changes"
// - Ports section : full list with Remove per port + Add port input
// - Applications : directly linked apps with Remove + modal multi-select to add
// - Delete button : opens a confirmation modal, then navigates back to /hosts
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
use crate::api::{
hosts::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail},
hosts::{
AddHostApplications, AddHostPort, DeleteHost, RemoveHostApplication,
RemoveHostPort, UpdateHost, get_applications_not_on_host, get_host_detail,
},
networks::get_networks,
};
use crate::models::Application;
// ─── Add applications modal ───────────────────────────────────────────────────
// Scrollable pick list + selected tags:
// - Top: scrollable list of available apps; clicking one moves it to the
// selected section and removes it from the list.
// - Bottom: selected apps shown as removable tags; clicking × puts the app
// back in the list.
//
// The auto-close Effect lives in the PARENT to avoid the re-trigger bug
// (an Effect inside a conditionally-rendered component fires on mount and
// would immediately close the modal if the action already held a past Ok value).
#[component]
fn AddAppModal(
host_id: i64,
available_apps_res: LocalResource<Result<Vec<Application>, ServerFnError>>,
add_action: ServerAction<AddHostApplications>,
show_modal: RwSignal<bool>,
) -> impl IntoView {
// Full Application structs so names are available in the selected tag list.
let selected: RwSignal<Vec<Application>> = RwSignal::new(vec![]);
view! {
<div class="modal-backdrop" on:click=move |_| show_modal.set(false)>
<div class="modal" on:click=move |e| e.stop_propagation()>
<div class="modal__header">
<h2>"Add applications"</h2>
<button class="modal__close" type="button" aria-label="Close"
on:click=move |_| show_modal.set(false)>
"×"
</button>
</div>
<div class="modal__body">
// ── Scrollable pick list ──────────────────────────────────
{move || match available_apps_res.get() {
None => view! { <p class="empty">"Loading…"</p> }.into_any(),
Some(r) => match (*r).clone() {
Err(e) => view! {
<p class="error">
"Could not load applications: " {e.to_string()}
</p>
}.into_any(),
Ok(apps) => {
// Exclude already-selected apps from the displayed list.
let sel_ids: Vec<i64> = selected.get()
.iter().map(|a| a.id).collect();
let displayed: Vec<Application> = apps.into_iter()
.filter(|a| !sel_ids.contains(&a.id))
.collect();
if displayed.is_empty() && sel_ids.is_empty() {
view! {
<p class="empty">
"All applications are already linked to this host."
</p>
}.into_any()
} else if displayed.is_empty() {
view! {
<p class="empty">
"All available applications have been selected."
</p>
}.into_any()
} else {
view! {
<ul class="app-pick-list">
{displayed.into_iter().map(|app| {
let app_clone = app.clone();
view! {
<li class="app-pick-item"
on:click=move |_| {
selected.update(|v| {
v.push(app_clone.clone());
});
}
>
<span>{app.name}</span>
<span class="app-pick-item__add">"+"</span>
</li>
}
}).collect_view()}
</ul>
}.into_any()
}
}
}
}}
// ── Selected tags (shown once at least one app is chosen) ─
{move || (!selected.get().is_empty()).then(|| {
let sel = selected.get();
view! {
<div class="app-selected-section">
<span class="app-selected-label">"Selected:"</span>
<div class="app-selected-list">
{sel.into_iter().map(|app| {
let app_id = app.id;
view! {
<span class="app-selected-tag">
{app.name}
<button
class="app-selected-tag__remove"
type="button"
aria-label="Remove"
on:click=move |_| {
selected.update(|v| {
v.retain(|x| x.id != app_id);
});
}
>"×"</button>
</span>
}
}).collect_view()}
</div>
</div>
}
})}
{move || add_action.value().get()
.and_then(|r| r.err())
.map(|e| view! { <p class="error">{e.to_string()}</p> })
}
</div>
<div class="modal__actions">
<button class="btn-secondary" type="button"
on:click=move |_| show_modal.set(false)>
"Cancel"
</button>
<button
class="btn-primary"
type="button"
disabled={move || selected.get().is_empty()}
on:click=move |_| {
let ids_str = selected.get_untracked()
.iter()
.map(|a| a.id.to_string())
.collect::<Vec<_>>()
.join(",");
if !ids_str.is_empty() {
add_action.dispatch(AddHostApplications {
host_id,
application_ids: ids_str,
});
}
}
>
"Add selected"
</button>
</div>
</div>
</div>
}.into_any()
}
// ─── Delete confirmation modal ────────────────────────────────────────────────
@@ -36,7 +196,7 @@ fn DeleteModal(
<p class="warning">
"Are you sure you want to delete "
<strong>{host_name}</strong>
"? All port associations will also be removed."
"? All port and application associations will also be removed."
</p>
</div>
<div class="modal__actions">
@@ -58,8 +218,6 @@ fn DeleteModal(
#[component]
pub fn HostDetailPage() -> impl IntoView {
// Read the `:id` segment from the URL.
// `use_params_map()` returns a reactive map of all URL path parameters.
let params = use_params_map();
let host_id = move || {
params.read().get("id")
@@ -67,8 +225,6 @@ pub fn HostDetailPage() -> impl IntoView {
.unwrap_or(0)
};
// Optional `?back=<url>` query parameter — used when arriving from a network
// detail page so the back button returns there instead of the hosts list.
let query = use_query_map();
let back_url = move || {
query.read().get("back")
@@ -82,47 +238,64 @@ pub fn HostDetailPage() -> impl IntoView {
let update_action = ServerAction::<UpdateHost>::new();
let add_port_action = ServerAction::<AddHostPort>::new();
let remove_port_action = ServerAction::<RemoveHostPort>::new();
let add_app_action = ServerAction::<AddHostApplications>::new();
let remove_app_action = ServerAction::<RemoveHostApplication>::new();
let delete_action = ServerAction::<DeleteHost>::new();
let show_delete_modal = RwSignal::new(false);
let show_delete_modal = RwSignal::new(false);
let show_add_app_modal = RwSignal::new(false);
// Reload detail after any mutation that touches this host.
// The resource key includes action versions so it invalidates automatically.
let host = Resource::new(
move || (
host_id(),
update_action.version().get(),
add_port_action.version().get(),
remove_port_action.version().get(),
),
|(id, _, _, _)| get_host_detail(id),
);
// Auto-close the add-app modal after a successful addition.
// Keeping this Effect in the parent avoids the re-trigger bug that would
// occur if the Effect were inside AddAppModal (it would fire on mount
// if the action already held a previous Ok value).
Effect::new(move |_| {
if let Some(Ok(_)) = add_app_action.value().get() {
show_add_app_modal.set(false);
}
});
// LocalResource avoids reading the resource outside <Suspense> during hydration,
// which would cause a mismatch between the SSR-rendered fallback and the content
// the WASM expects to find after the resource resolves.
let host = LocalResource::new(move || {
let _ = update_action.version().get();
let _ = add_port_action.version().get();
let _ = remove_port_action.version().get();
let _ = add_app_action.version().get();
let _ = remove_app_action.version().get();
get_host_detail(host_id())
});
// Networks dropdown — LocalResource avoids SSR/hydration mismatch.
let networks_res = LocalResource::new(|| get_networks());
// Edit-field signals, populated once by the Effect below.
// Using signals (rather than local variables) keeps them stable across
// re-renders and lets the user edit without triggering a resource reload.
// Available apps for the modal: re-fetched whenever add/remove completes.
let add_app_ver = add_app_action.version();
let remove_app_ver = remove_app_action.version();
let available_apps_res = LocalResource::new(move || {
let _ = add_app_ver.get();
let _ = remove_app_ver.get();
get_applications_not_on_host(host_id())
});
let name_sig = RwSignal::new(String::new());
let ip_sig = RwSignal::new(String::new());
let net_id_sig = RwSignal::new(0i64);
let new_port = RwSignal::new(String::new());
// Input value for the "add port" row.
let new_port = RwSignal::new(String::new());
// Sync edit signals whenever the host resource delivers fresh data.
// This runs on initial load and after every successful mutation.
// Sync edit signals whenever fresh host data arrives.
// LocalResource wraps its value in SendWrapper, so we dereference with `*r`.
Effect::new(move |_| {
if let Some(Ok(ref detail)) = host.get() {
name_sig.set(detail.name.clone());
ip_sig.set(detail.ip.clone());
net_id_sig.set(detail.network_id);
if let Some(r) = host.get() {
if let Ok(ref detail) = *r {
name_sig.set(detail.name.clone());
ip_sig.set(detail.ip.clone());
net_id_sig.set(detail.network_id);
}
}
});
// Navigate back to the list after a successful delete.
// `use_navigate()` must be called during component setup (not inside a closure).
let navigate = use_navigate();
Effect::new(move |_| {
if let Some(Ok(true)) = delete_action.value().get() {
@@ -133,16 +306,18 @@ pub fn HostDetailPage() -> impl IntoView {
view! {
<div class="host-detail-page">
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
{move || host.get().map(|result| match result {
{move || host.get().map(|r| match (*r).clone() {
Err(e) => view! {
<p class="error">"Could not load host: " {e.to_string()}</p>
}.into_any(),
Ok(detail) => {
let id = detail.id;
let modal_name = detail.name.clone();
let port_count = detail.ports.len();
let ports = detail.ports;
let id = detail.id;
let modal_name = detail.name.clone();
let port_count = detail.ports.len();
let app_count = detail.applications.len();
let ports = detail.ports;
let applications = detail.applications;
// Pre-built ports view — consumes `ports` once, not reactively.
let ports_list = if ports.is_empty() {
@@ -178,6 +353,37 @@ pub fn HostDetailPage() -> impl IntoView {
}.into_any()
};
// Pre-built applications view.
let apps_list = if applications.is_empty() {
view! {
<p class="empty">"No applications linked to this host."</p>
}.into_any()
} else {
view! {
<div class="app-list">
{applications.into_iter().map(|app| {
let app_id = app.id;
view! {
<div class="app-row">
<span class="app-row__name">{app.name}</span>
<button
class="btn-danger"
type="button"
on:click=move |_| {
remove_app_action.dispatch(
RemoveHostApplication { host_id: id, application_id: app_id }
);
}
>
"Remove"
</button>
</div>
}
}).collect_view()}
</div>
}.into_any()
};
view! {
// ── Page header ──────────────────────────────────
<div class="page-header detail-page-header">
@@ -244,13 +450,13 @@ pub fn HostDetailPage() -> impl IntoView {
class="btn-primary"
type="button"
on:click=move |_| {
update_action.dispatch(UpdateHost {
id,
name: name_sig.get_untracked(),
ip: ip_sig.get_untracked(),
network_id: net_id_sig.get_untracked(),
});
}
update_action.dispatch(UpdateHost {
id,
name: name_sig.get_untracked(),
ip: ip_sig.get_untracked(),
network_id: net_id_sig.get_untracked(),
});
}
>
"Save changes"
</button>
@@ -273,7 +479,6 @@ pub fn HostDetailPage() -> impl IntoView {
})
}
// Add port row
<div class="port-add-row">
<input
type="number"
@@ -310,7 +515,43 @@ pub fn HostDetailPage() -> impl IntoView {
</div>
</section>
// ── Danger zone ──────────────────────────────────
// ── Applications section ──────────────────────────
<section class="detail-section">
<h2 class="detail-section__title">
{format!("Applications ({})", app_count)}
</h2>
{apps_list}
{move || remove_app_action.value().get()
.and_then(|r| r.err())
.map(|e| view! {
<p class="error">"Remove failed: " {e.to_string()}</p>
})
}
<div class="port-add-row">
<button
class="btn-primary"
type="button"
on:click=move |_| show_add_app_modal.set(true)
>
"+ Add applications"
</button>
</div>
</section>
// ── Add applications modal ────────────────────────
{move || show_add_app_modal.get().then(|| view! {
<AddAppModal
host_id=id
available_apps_res=available_apps_res
add_action=add_app_action
show_modal=show_add_app_modal
/>
})}
// ── Danger zone ───────────────────────────────────
<div class="danger-zone">
<button
class="btn-danger-solid"

View File

@@ -287,12 +287,12 @@ fn PaginationBar(
#[component]
fn HostTable(
hosts: Resource<Result<HostsPageData, ServerFnError>>,
hosts: LocalResource<Result<HostsPageData, ServerFnError>>,
pending_delete: RwSignal<Option<(i64, String)>>,
) -> impl IntoView {
view! {
<Suspense fallback=|| view! { <p class="empty">"Loading hosts…"</p> }>
{move || hosts.get().map(|result| match result {
{move || hosts.get().map(|r| match (*r).clone() {
Err(e) => view! {
<p class="error">"Could not load hosts: " {e.to_string()}</p>
}.into_any(),
@@ -380,28 +380,30 @@ pub fn HostsPage() -> impl IntoView {
let page = RwSignal::new(1i64);
let per_page = RwSignal::new(15i64);
let hosts = Resource::new(
move || (
// LocalResource avoids reading a resource outside <Suspense> during hydration.
// All dependencies (filters, pagination, action versions) are client-side only,
// so there is no benefit to SSR for this resource.
let hosts = LocalResource::new(move || {
let _ = create_action.version().get();
let _ = delete_action.version().get();
get_hosts_page(
name_filter.get(),
network_id_filter.get(),
port_filter.get(),
app_id_filter.get(),
page.get(),
per_page.get(),
create_action.version().get(),
delete_action.version().get(),
),
|(name, net, port, app, p, pp, _, _)| get_hosts_page(name, net, port, app, p, pp),
);
)
});
let networks_res = LocalResource::new(|| get_networks());
let applications_res = LocalResource::new(|| get_applications());
let total_pages = Signal::derive(move || {
hosts.get().and_then(|r| r.ok()).map(|p| p.total_pages).unwrap_or(1)
hosts.get().and_then(|r| (*r).clone().ok()).map(|p| p.total_pages).unwrap_or(1)
});
let total = Signal::derive(move || {
hosts.get().and_then(|r| r.ok()).map(|p| p.total).unwrap_or(0)
hosts.get().and_then(|r| (*r).clone().ok()).map(|p| p.total).unwrap_or(0)
});
view! {

View File

@@ -54,6 +54,84 @@ pub async fn delete_application(pool: &AnyPool, id: i64) -> Result<bool, DbError
Ok(result.rows_affected() > 0)
}
// ─── Host-application associations ───────────────────────────────────────────
/// Returns all applications linked directly to a host, ordered by name.
pub async fn list_applications_for_host(
pool: &AnyPool,
host_id: i64,
) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query(
"SELECT a.id, a.name
FROM applications a
JOIN host_applications ha ON ha.application_id = a.id
WHERE ha.host_id = $1
ORDER BY a.name",
)
.bind(host_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Returns all applications NOT yet linked to a host, ordered by name.
pub async fn list_applications_not_on_host(
pool: &AnyPool,
host_id: i64,
) -> Result<Vec<Application>, DbError> {
let rows = sqlx::query(
"SELECT id, name FROM applications
WHERE id NOT IN (
SELECT application_id FROM host_applications WHERE host_id = $1
)
ORDER BY name",
)
.bind(host_id)
.fetch_all(pool)
.await?;
Ok(rows.iter().map(row_to_application).collect())
}
/// Links an application directly to a host.
///
/// If the link already exists, this is a no-op (not an error).
pub async fn add_application_to_host(
pool: &AnyPool,
host_id: i64,
application_id: i64,
) -> Result<(), DbError> {
let result = sqlx::query(
"INSERT INTO host_applications (host_id, application_id) VALUES ($1, $2)",
)
.bind(host_id)
.bind(application_id)
.execute(pool)
.await;
match result {
Ok(_) => Ok(()),
Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()),
Err(e) => Err(DbError::Connection(e)),
}
}
/// Removes the direct link between a host and an application.
///
/// Returns `true` if the association existed and was removed.
pub async fn remove_application_from_host(
pool: &AnyPool,
host_id: i64,
application_id: i64,
) -> Result<bool, DbError> {
let result = sqlx::query(
"DELETE FROM host_applications WHERE host_id = $1 AND application_id = $2",
)
.bind(host_id)
.bind(application_id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
// ─── Application-port associations ───────────────────────────────────────────
/// Returns all port numbers associated with an application, sorted numerically.

View File

@@ -1223,3 +1223,125 @@ td.col-actions {
color: var(--text-secondary);
margin: 0;
}
/* ============================================================
HOST DETAIL — APPLICATIONS SECTION
============================================================ */
/* List of applications linked to a host (mirrors .port-list) */
.app-list {
display: flex;
flex-direction: column;
margin-bottom: var(--size-md);
border-top: 1px solid var(--border);
}
.app-row {
display: flex;
align-items: center;
gap: var(--size-md);
padding: var(--size-sm) var(--size-xs);
border-bottom: 1px solid var(--border);
}
.app-row__name {
flex: 1;
font-size: var(--font-sm);
}
/* ── Pick list (scrollable, click to select) ── */
.app-pick-list {
list-style: none;
margin: 0 0 var(--size-sm);
padding: 0;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
}
.app-pick-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--size-sm) var(--size-md);
cursor: pointer;
font-size: var(--font-sm);
border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.app-pick-item:last-child {
border-bottom: none;
}
.app-pick-item:hover {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
}
.app-pick-item__add {
font-size: 1.1rem;
font-weight: 600;
color: var(--accent);
opacity: 0.7;
}
.app-pick-item:hover .app-pick-item__add {
opacity: 1;
}
/* ── Selected tags (shown below the pick list) ── */
.app-selected-section {
display: flex;
flex-direction: column;
gap: var(--size-xs);
padding-top: var(--size-sm);
border-top: 1px solid var(--border);
margin-top: var(--size-xs);
}
.app-selected-label {
font-size: var(--font-xs, 0.75rem);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
}
.app-selected-list {
display: flex;
flex-wrap: wrap;
gap: var(--size-xs);
}
.app-selected-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 15%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--accent);
font-size: var(--font-sm);
font-weight: 500;
}
.app-selected-tag__remove {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0 2px;
cursor: pointer;
font-size: 0.9rem;
color: var(--accent);
opacity: 0.7;
line-height: 1;
}
.app-selected-tag__remove:hover {
opacity: 1;
}