feat(hosts): add host detail page with identity edit, port management and delete
- Repository: add update_host (name, IP, network reassignment with CIDR validation) - API: get_host_detail (host + resolved network + ports), update_host, add_host_port, remove_host_port server functions - Client: HostDetailPage at /hosts/:id — identity form, ports list with per-port Remove button, Add port input, delete confirmation modal with navigation back to /hosts on success - CSS: detail-section cards, port-row list, btn-danger-solid, back-link Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
123
src/api/hosts.rs
123
src/api/hosts.rs
@@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::Host;
|
||||
|
||||
use crate::models::Port;
|
||||
|
||||
// ─── Presentation types ───────────────────────────────────────────────────────
|
||||
|
||||
// A host row enriched with its network CIDR and pre-computed counts.
|
||||
@@ -30,8 +32,54 @@ pub struct HostsPage {
|
||||
pub total_pages: i64, // ceil(total / per_page); always ≥ 1
|
||||
}
|
||||
|
||||
// Full host detail: identity fields + resolved network + open ports.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HostDetail {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
pub network_id: i64,
|
||||
pub network_name: String,
|
||||
pub network_cidr: String,
|
||||
pub ports: Vec<Port>,
|
||||
}
|
||||
|
||||
// ─── Queries ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns full detail for a single host: identity, network, and open ports.
|
||||
#[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};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
let host = host_repo::find_host(&pool, id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))?;
|
||||
|
||||
let network = networks::find_network(&pool, host.network_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Network {} not found", host.network_id)))?;
|
||||
|
||||
let ports = port_repo::list_ports_for_host(&pool, id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
Ok(HostDetail {
|
||||
id: host.id,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
network_id: host.network_id,
|
||||
network_name: network.name,
|
||||
network_cidr: network.cidr,
|
||||
ports,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns all hosts belonging to a given network.
|
||||
#[server]
|
||||
pub async fn get_hosts_by_network(network_id: i64) -> Result<Vec<Host>, ServerFnError> {
|
||||
@@ -220,6 +268,81 @@ pub async fn create_host(
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Updates a host's name, IP address, and network assignment.
|
||||
///
|
||||
/// Validates that the new IP falls within the CIDR of the new network.
|
||||
#[server]
|
||||
pub async fn update_host(
|
||||
id: i64,
|
||||
name: String,
|
||||
ip: String,
|
||||
network_id: i64,
|
||||
) -> Result<Host, ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::{
|
||||
repository::{hosts as host_repo, networks},
|
||||
validation::validate_ip_in_network,
|
||||
};
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
if name.trim().is_empty() {
|
||||
return Err(ServerFnError::new("Name must not be empty"));
|
||||
}
|
||||
|
||||
let network = networks::find_network(&pool, network_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Network {network_id} not found")))?;
|
||||
|
||||
validate_ip_in_network(&ip, &network.cidr)
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
|
||||
host_repo::update_host(&pool, id, name.trim(), ip.trim(), network_id)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||
.ok_or_else(|| ServerFnError::new(format!("Host {id} not found")))
|
||||
}
|
||||
|
||||
/// Opens a single port on a host.
|
||||
///
|
||||
/// Auto-registers the port in the global catalog if not already present.
|
||||
/// If the port is already open on this host, the call is a no-op.
|
||||
#[server]
|
||||
pub async fn add_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::ports as port_repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
if !(1..=65535).contains(&port_number) {
|
||||
return Err(ServerFnError::new("Port number must be between 1 and 65535"));
|
||||
}
|
||||
|
||||
port_repo::add_port_to_host(&pool, host_id, port_number as u16)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Closes a port on a host (removes the host-port association).
|
||||
///
|
||||
/// The port entry in the global catalog is not deleted.
|
||||
/// If the port was not open on this host, the call is a no-op.
|
||||
#[server]
|
||||
pub async fn remove_host_port(host_id: i64, port_number: i64) -> Result<(), ServerFnError> {
|
||||
use sqlx::AnyPool;
|
||||
use crate::server::repository::ports as port_repo;
|
||||
|
||||
let pool = use_context::<AnyPool>()
|
||||
.ok_or_else(|| ServerFnError::new("Database pool not found in context"))?;
|
||||
|
||||
port_repo::remove_port_from_host(&pool, host_id, port_number as u16)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))
|
||||
}
|
||||
|
||||
/// Deletes a host by id.
|
||||
///
|
||||
/// Also removes all its port associations (via `ON DELETE CASCADE`).
|
||||
|
||||
@@ -11,7 +11,13 @@ use leptos_router::{
|
||||
path,
|
||||
};
|
||||
|
||||
use crate::client::{home::HomePage, hosts::HostsPage, networks::NetworksPage, theme::ThemeToggle};
|
||||
use crate::client::{
|
||||
home::HomePage,
|
||||
host_detail::HostDetailPage,
|
||||
hosts::HostsPage,
|
||||
networks::NetworksPage,
|
||||
theme::ThemeToggle,
|
||||
};
|
||||
|
||||
// Shell — full HTML document rendered by the Axum server.
|
||||
//
|
||||
@@ -103,6 +109,7 @@ pub fn App() -> impl IntoView {
|
||||
<Route path=path!("/") view=HomePage/>
|
||||
<Route path=path!("/networks") view=NetworksPage/>
|
||||
<Route path=path!("/hosts") view=HostsPage/>
|
||||
<Route path=path!("/hosts/:id") view=HostDetailPage/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
|
||||
321
src/client/host_detail.rs
Normal file
321
src/client/host_detail.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
// 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
|
||||
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::hooks::{use_navigate, use_params_map};
|
||||
|
||||
use crate::api::{
|
||||
hosts::{AddHostPort, DeleteHost, RemoveHostPort, UpdateHost, get_host_detail},
|
||||
networks::get_networks,
|
||||
};
|
||||
|
||||
// ─── Delete confirmation modal ────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn DeleteModal(
|
||||
host_name: String,
|
||||
delete_action: ServerAction<DeleteHost>,
|
||||
host_id: i64,
|
||||
show_modal: RwSignal<bool>,
|
||||
) -> impl IntoView {
|
||||
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>"Delete host"</h2>
|
||||
<button class="modal__close" type="button" aria-label="Close"
|
||||
on:click=move |_| show_modal.set(false)>
|
||||
"×"
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<p class="warning">
|
||||
"Are you sure you want to delete "
|
||||
<strong>{host_name}</strong>
|
||||
"? All port associations will also be removed."
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal__actions">
|
||||
<button class="btn-secondary" type="button"
|
||||
on:click=move |_| show_modal.set(false)>
|
||||
"Cancel"
|
||||
</button>
|
||||
<button class="btn-danger" type="button"
|
||||
on:click=move |_| { delete_action.dispatch(DeleteHost { id: host_id }); }>
|
||||
"Delete"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
|
||||
// ─── Main page component ──────────────────────────────────────────────────────
|
||||
|
||||
#[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")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let update_action = ServerAction::<UpdateHost>::new();
|
||||
let add_port_action = ServerAction::<AddHostPort>::new();
|
||||
let remove_port_action = ServerAction::<RemoveHostPort>::new();
|
||||
let delete_action = ServerAction::<DeleteHost>::new();
|
||||
|
||||
let show_delete_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),
|
||||
);
|
||||
|
||||
// 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.
|
||||
let name_sig = RwSignal::new(String::new());
|
||||
let ip_sig = RwSignal::new(String::new());
|
||||
let net_id_sig = RwSignal::new(0i64);
|
||||
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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() {
|
||||
navigate("/hosts", Default::default());
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="host-detail-page">
|
||||
<Suspense fallback=|| view! { <p class="empty">"Loading host…"</p> }>
|
||||
{move || host.get().map(|result| match result {
|
||||
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;
|
||||
|
||||
// Pre-built ports view — consumes `ports` once, not reactively.
|
||||
let ports_list = if ports.is_empty() {
|
||||
view! {
|
||||
<p class="empty">"No ports open on this host."</p>
|
||||
}.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<div class="port-list">
|
||||
{ports.into_iter().map(|port| {
|
||||
let num = port.number;
|
||||
view! {
|
||||
<div class="port-row">
|
||||
<span class="port-row__number">{num}</span>
|
||||
<span class="port-row__desc">
|
||||
{port.description.unwrap_or_default()}
|
||||
</span>
|
||||
<button
|
||||
class="btn-danger"
|
||||
type="button"
|
||||
on:click=move |_| {
|
||||
remove_port_action.dispatch(
|
||||
RemoveHostPort { host_id: id, port_number: num as i64 }
|
||||
);
|
||||
}
|
||||
>
|
||||
"Remove"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}).collect_view()}
|
||||
</div>
|
||||
}.into_any()
|
||||
};
|
||||
|
||||
view! {
|
||||
// ── Page header ──────────────────────────────────
|
||||
<div class="page-header">
|
||||
<div class="page-header__left">
|
||||
<a class="back-link" href="/hosts">"← Hosts"</a>
|
||||
<h1>{move || name_sig.get()}</h1>
|
||||
</div>
|
||||
<button
|
||||
class="btn-danger-solid"
|
||||
type="button"
|
||||
on:click=move |_| show_delete_modal.set(true)
|
||||
>
|
||||
"Delete host"
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// ── Identity form ─────────────────────────────────
|
||||
<section class="detail-section">
|
||||
<h2 class="detail-section__title">"Identity"</h2>
|
||||
<div class="detail-form">
|
||||
<label class="detail-field">
|
||||
"Name"
|
||||
<input
|
||||
type="text"
|
||||
prop:value=move || name_sig.get()
|
||||
on:input=move |e| name_sig.set(event_target_value(&e))
|
||||
/>
|
||||
</label>
|
||||
<label class="detail-field">
|
||||
"IP address"
|
||||
<input
|
||||
type="text"
|
||||
prop:value=move || ip_sig.get()
|
||||
on:input=move |e| ip_sig.set(event_target_value(&e))
|
||||
/>
|
||||
</label>
|
||||
<label class="detail-field">
|
||||
"Network"
|
||||
<select on:change=move |e| {
|
||||
net_id_sig.set(
|
||||
event_target_value(&e).parse().unwrap_or(0)
|
||||
);
|
||||
}>
|
||||
{move || networks_res.get()
|
||||
.and_then(|r| (*r).clone().ok())
|
||||
.map(|nets| {
|
||||
let current = net_id_sig.get();
|
||||
nets.into_iter().map(|n| {
|
||||
let label = format!("{} - {}", n.name, n.cidr);
|
||||
view! {
|
||||
<option
|
||||
value=n.id.to_string()
|
||||
selected=(n.id == current)
|
||||
>
|
||||
{label}
|
||||
</option>
|
||||
}
|
||||
}).collect_view()
|
||||
})
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{move || update_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! { <p class="error">{e.to_string()}</p> })
|
||||
}
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
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(),
|
||||
});
|
||||
}
|
||||
>
|
||||
"Save changes"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
// ── Ports section ─────────────────────────────────
|
||||
<section class="detail-section">
|
||||
<h2 class="detail-section__title">
|
||||
{format!("Open ports ({})", port_count)}
|
||||
</h2>
|
||||
|
||||
{ports_list}
|
||||
|
||||
{move || remove_port_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! {
|
||||
<p class="error">"Remove failed: " {e.to_string()}</p>
|
||||
})
|
||||
}
|
||||
|
||||
// Add port row
|
||||
<div class="port-add-row">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder="Port number (1–65535)"
|
||||
prop:value=move || new_port.get()
|
||||
on:input=move |e| new_port.set(event_target_value(&e))
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click=move |_| {
|
||||
let raw = new_port.get_untracked();
|
||||
if let Ok(n) = raw.trim().parse::<i64>() {
|
||||
if (1..=65535).contains(&n) {
|
||||
add_port_action.dispatch(AddHostPort {
|
||||
host_id: id,
|
||||
port_number: n,
|
||||
});
|
||||
new_port.set(String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
"Add port"
|
||||
</button>
|
||||
{move || add_port_action.value().get()
|
||||
.and_then(|r| r.err())
|
||||
.map(|e| view! {
|
||||
<p class="error">"Add failed: " {e.to_string()}</p>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
// ── Delete modal (conditional) ────────────────────
|
||||
{move || show_delete_modal.get().then(|| view! {
|
||||
<DeleteModal
|
||||
host_name=modal_name.clone()
|
||||
delete_action=delete_action
|
||||
host_id=id
|
||||
show_modal=show_delete_modal
|
||||
/>
|
||||
})}
|
||||
}.into_any()
|
||||
}
|
||||
})}
|
||||
</Suspense>
|
||||
</div>
|
||||
}.into_any()
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
// without guarding it with `#[cfg(target_arch = "wasm32")]`.
|
||||
|
||||
pub mod home; // Home page
|
||||
pub mod host_detail; // Host detail: identity, ports, edit, delete
|
||||
pub mod hosts; // Hosts list with filters and pagination
|
||||
pub mod networks; // Networks list and creation
|
||||
pub mod theme; // Theme toggle component (light / dark / system)
|
||||
|
||||
@@ -62,6 +62,33 @@ pub async fn create_host(
|
||||
Ok(row_to_host(&row))
|
||||
}
|
||||
|
||||
/// Updates a host's name, IP address, and network assignment.
|
||||
///
|
||||
/// Returns the updated host, or `None` if the id does not exist.
|
||||
/// The caller must validate that `ip` falls within the CIDR range of the
|
||||
/// new `network_id` before calling this function.
|
||||
pub async fn update_host(
|
||||
pool: &AnyPool,
|
||||
id: i64,
|
||||
name: &str,
|
||||
ip: &str,
|
||||
network_id: i64,
|
||||
) -> Result<Option<Host>, DbError> {
|
||||
let row = sqlx::query(
|
||||
"UPDATE hosts SET name = $1, ip = $2, network_id = $3
|
||||
WHERE id = $4
|
||||
RETURNING id, name, ip, network_id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(ip)
|
||||
.bind(network_id)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.as_ref().map(row_to_host))
|
||||
}
|
||||
|
||||
/// Deletes a host and all its port associations (via `ON DELETE CASCADE`).
|
||||
///
|
||||
/// Returns `true` if a row was deleted, `false` if the id did not exist.
|
||||
|
||||
@@ -1021,6 +1021,39 @@ td.col-actions {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Solid danger button — used for prominent destructive actions (page header) */
|
||||
.btn-danger-solid {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
border-color: var(--danger);
|
||||
font-size: var(--font-sm);
|
||||
padding: 8px var(--size-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-danger-solid:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* Left cluster inside page header (back link + title) */
|
||||
.page-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-md);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Delete button inside hosts table */
|
||||
.hosts-page td button {
|
||||
background: transparent;
|
||||
@@ -1037,3 +1070,107 @@ td.col-actions {
|
||||
background: var(--danger-light);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
HOST DETAIL PAGE
|
||||
============================================================ */
|
||||
|
||||
.host-detail-page {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
/* Card-like section grouping related fields */
|
||||
.detail-section {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--size-lg);
|
||||
margin-bottom: var(--size-lg);
|
||||
}
|
||||
|
||||
.detail-section__title {
|
||||
font-size: var(--font-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 var(--size-md);
|
||||
}
|
||||
|
||||
/* Stack of label + input pairs */
|
||||
.detail-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-md);
|
||||
}
|
||||
|
||||
.detail-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-xs);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-field input,
|
||||
.detail-field select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Save button aligned to the right */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--size-xs);
|
||||
}
|
||||
|
||||
/* ── Ports list ─────────────────────────────────────────────── */
|
||||
|
||||
.port-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-xs);
|
||||
margin-bottom: var(--size-md);
|
||||
}
|
||||
|
||||
.port-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-md);
|
||||
padding: var(--size-sm) var(--size-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.port-row__number {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 600;
|
||||
min-width: 4ch;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.port-row__desc {
|
||||
flex: 1;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Add port row: input + button side by side */
|
||||
.port-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-sm);
|
||||
padding-top: var(--size-sm);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: var(--size-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.port-add-row input[type="number"] {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user