Private
Public Access
1
0

feat: add host editing endpoint and frontend UI
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Successful in 1m12s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Successful in 3m51s

This commit is contained in:
2026-05-18 21:52:00 +00:00
parent b3ae42215b
commit f70c5e53f9
26 changed files with 254 additions and 65 deletions

View File

@ -105,7 +105,7 @@ impl AgentClient {
.add_root_certificate(ca_cert) .add_root_certificate(ca_cert)
.timeout(REQUEST_TIMEOUT) .timeout(REQUEST_TIMEOUT)
.build() .build()
.map_err(|e| AgentClientError::Request(e))?; .map_err(AgentClientError::Request)?;
let clean_ip = host_ip.split('/').next().unwrap_or(host_ip); let clean_ip = host_ip.split('/').next().unwrap_or(host_ip);
let base_url = format!("https://{}:{}/api/v1", clean_ip, port); let base_url = format!("https://{}:{}/api/v1", clean_ip, port);

View File

@ -121,6 +121,7 @@ pub fn load_verify_key(path: &str) -> Result<String, JwtError> {
} }
#[cfg(test)] #[cfg(test)]
#[allow(dead_code)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -40,6 +40,7 @@ pub enum UserRole {
} }
impl UserRole { impl UserRole {
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> { pub fn from_str(s: &str) -> Option<Self> {
match s { match s {
"admin" => Some(Self::Admin), "admin" => Some(Self::Admin),

View File

@ -69,6 +69,7 @@ pub struct SessionUser {
/// Database user row fetched during login. /// Database user row fetched during login.
#[derive(Debug, sqlx::FromRow)] #[derive(Debug, sqlx::FromRow)]
#[allow(dead_code)]
struct DbUser { struct DbUser {
id: Uuid, id: Uuid,
username: String, username: String,

View File

@ -97,6 +97,7 @@ impl AuditAction {
/// Computes a hash chain entry using the previous row's hash. /// Computes a hash chain entry using the previous row's hash.
/// Non-fatal: logs errors but does not propagate them to avoid /// Non-fatal: logs errors but does not propagate them to avoid
/// disrupting the primary operation. /// disrupting the primary operation.
#[allow(clippy::too_many_arguments)]
pub async fn log_event( pub async fn log_event(
pool: &PgPool, pool: &PgPool,
action: AuditAction, action: AuditAction,
@ -126,6 +127,7 @@ pub async fn log_event(
} }
} }
#[allow(clippy::too_many_arguments)]
async fn write_audit_row( async fn write_audit_row(
pool: &PgPool, pool: &PgPool,
action: AuditAction, action: AuditAction,

View File

@ -29,7 +29,7 @@ pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(CryptoError::Io)?; fs::create_dir_all(parent).map_err(CryptoError::Io)?;
} }
fs::write(path, &key).map_err(CryptoError::Io)?; fs::write(path, key).map_err(CryptoError::Io)?;
// Set permissions to 0600 (owner read/write only) // Set permissions to 0600 (owner read/write only)
#[cfg(unix)] #[cfg(unix)]
{ {

View File

@ -72,9 +72,9 @@ pub async fn create_enrollment_request(
EnrollmentRequest, EnrollmentRequest,
>( >(
r#" r#"
INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token) INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token, hostname)
VALUES ($1, $2, $3::inet, $4, $5) VALUES ($1, $2, $3::inet, $4, $5, $6)
RETURNING id, machine_id, fqdn, ip_address::text, os_details, polling_token, created_at, expires_at RETURNING id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at
"#, "#,
) )
.bind(req.machine_id) .bind(req.machine_id)
@ -82,6 +82,7 @@ pub async fn create_enrollment_request(
.bind(req.ip_address) .bind(req.ip_address)
.bind(req.os_details) .bind(req.os_details)
.bind(token_hash) .bind(token_hash)
.bind(&req.hostname)
.fetch_one(pool) .fetch_one(pool)
.await .await
} }
@ -90,7 +91,7 @@ pub async fn list_enrollment_requests(
pool: &PgPool, pool: &PgPool,
) -> Result<Vec<EnrollmentRequest>, sqlx::Error> { ) -> Result<Vec<EnrollmentRequest>, sqlx::Error> {
sqlx::query_as::<_, EnrollmentRequest>( sqlx::query_as::<_, EnrollmentRequest>(
"SELECT id, machine_id, fqdn, ip_address::text, os_details, polling_token, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC", "SELECT id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at FROM enrollment_requests ORDER BY created_at DESC",
) )
.fetch_all(pool) .fetch_all(pool)
.await .await

View File

@ -107,6 +107,14 @@ pub struct CreateHostRequest {
pub group_ids: Option<Vec<Uuid>>, pub group_ids: Option<Vec<Uuid>>,
} }
/// Payload for updating an existing host.
#[derive(Debug, Deserialize)]
pub struct UpdateHostRequest {
pub fqdn: Option<String>,
pub ip_address: Option<String>,
pub display_name: Option<String>,
}
/// Host list item (lighter projection for list views) /// Host list item (lighter projection for list views)
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct HostSummary { pub struct HostSummary {
@ -135,6 +143,8 @@ pub struct EnrollmentRequest {
pub ip_address: String, pub ip_address: String,
pub os_details: serde_json::Value, pub os_details: serde_json::Value,
pub polling_token: String, pub polling_token: String,
/// Short hostname provided during enrollment (optional).
pub hostname: Option<String>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>, pub expires_at: DateTime<Utc>,
} }
@ -146,6 +156,8 @@ pub struct CreateEnrollmentRequest {
pub fqdn: String, pub fqdn: String,
pub ip_address: String, pub ip_address: String,
pub os_details: serde_json::Value, pub os_details: serde_json::Value,
/// Short hostname (from /etc/hostname, optional).
pub hostname: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -77,7 +77,7 @@ ORDER BY compliance_pct ASC
}; };
let mut wtr = csv::Writer::from_writer(vec![]); let mut wtr = csv::Writer::from_writer(vec![]);
wtr.write_record(&[ wtr.write_record([
"host_id", "host_id",
"display_name", "display_name",
"fqdn", "fqdn",
@ -115,7 +115,7 @@ ORDER BY compliance_pct ASC
])?; ])?;
} }
Ok(wtr.into_inner().context("csv flush failed")?) wtr.into_inner().context("csv flush failed")
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -152,7 +152,7 @@ ORDER BY pjh.started_at DESC
.context("patch history query failed")?; .context("patch history query failed")?;
let mut wtr = csv::Writer::from_writer(vec![]); let mut wtr = csv::Writer::from_writer(vec![]);
wtr.write_record(&[ wtr.write_record([
"job_id", "job_id",
"job_kind", "job_kind",
"job_status", "job_status",
@ -194,7 +194,7 @@ ORDER BY pjh.started_at DESC
])?; ])?;
} }
Ok(wtr.into_inner().context("csv flush failed")?) wtr.into_inner().context("csv flush failed")
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -203,7 +203,7 @@ ORDER BY pjh.started_at DESC
async fn vulnerability_csv(pool: &sqlx::PgPool, params: &ReportParams) -> anyhow::Result<Vec<u8>> { async fn vulnerability_csv(pool: &sqlx::PgPool, params: &ReportParams) -> anyhow::Result<Vec<u8>> {
let mut wtr = csv::Writer::from_writer(vec![]); let mut wtr = csv::Writer::from_writer(vec![]);
wtr.write_record(&[ wtr.write_record([
"host_id", "host_id",
"display_name", "display_name",
"fqdn", "fqdn",
@ -279,7 +279,7 @@ ORDER BY
}, },
} }
Ok(wtr.into_inner().context("csv flush failed")?) wtr.into_inner().context("csv flush failed")
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -312,7 +312,7 @@ LIMIT 10000
.context("audit query failed")?; .context("audit query failed")?;
let mut wtr = csv::Writer::from_writer(vec![]); let mut wtr = csv::Writer::from_writer(vec![]);
wtr.write_record(&[ wtr.write_record([
"id", "id",
"created_at", "created_at",
"action", "action",
@ -347,5 +347,5 @@ LIMIT 10000
])?; ])?;
} }
Ok(wtr.into_inner().context("csv flush failed")?) wtr.into_inner().context("csv flush failed")
} }

View File

@ -169,6 +169,7 @@ impl PdfBuilder {
self.current_y -= ROW_H; self.current_y -= ROW_H;
} }
#[allow(clippy::too_many_arguments)]
fn embed_image( fn embed_image(
&self, &self,
raw_rgb: Vec<u8>, raw_rgb: Vec<u8>,

View File

@ -174,7 +174,7 @@ async fn probe_and_store(pool: sqlx::PgPool, scan_id: Uuid, ip: IpAddr, port: u1
/// Simple reverse DNS lookup. /// Simple reverse DNS lookup.
fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> { fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> {
use std::net::{SocketAddr, ToSocketAddrs}; use std::net::{SocketAddr, ToSocketAddrs};
let addr = SocketAddr::new(ip, 0); let _addr = SocketAddr::new(ip, 0);
// Standard library doesn't have reverse lookup; use getaddrinfo via format // Standard library doesn't have reverse lookup; use getaddrinfo via format
let host = format!("{ip}"); let host = format!("{ip}");
// Best-effort: try to resolve numeric address to hostname // Best-effort: try to resolve numeric address to hostname

View File

@ -1,6 +1,6 @@
use crate::AppState; use crate::AppState;
use axum::{ use axum::{
extract::{ConnectInfo, Path, State}, extract::{Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{delete, get, post}, routing::{delete, get, post},
@ -16,7 +16,7 @@ use pm_core::{
}; };
use rand::{distributions::Alphanumeric, Rng}; use rand::{distributions::Alphanumeric, Rng};
use serde::Serialize; use serde::Serialize;
use std::net::{IpAddr, SocketAddr}; use std::net::IpAddr;
use std::time::Instant; use std::time::Instant;
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
@ -167,7 +167,7 @@ async fn list_admin_enrollments(
db::list_enrollment_requests(&state.db) db::list_enrollment_requests(&state.db)
.await .await
.map(|requests| Json(requests)) .map(Json)
.map_err(|e| { .map_err(|e| {
tracing::error!(error = %e, "Failed to list enrollment requests"); tracing::error!(error = %e, "Failed to list enrollment requests");
( (
@ -212,7 +212,7 @@ async fn approve_enrollment(
"SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet" "SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
) )
.bind(&enrollment_request.fqdn) .bind(&enrollment_request.fqdn)
.bind(&enrollment_request.ip_address.to_string()) .bind(enrollment_request.ip_address.to_string())
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await .await
.map_err(|e| { .map_err(|e| {
@ -231,16 +231,21 @@ async fn approve_enrollment(
.get("name") .get("name")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
let display_name = enrollment_request
.hostname
.clone()
.unwrap_or_else(|| enrollment_request.fqdn.clone());
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO hosts (id, fqdn, ip_address, os_name, registered_at, updated_at) INSERT INTO hosts (id, fqdn, ip_address, os_name, display_name, registered_at, updated_at)
VALUES ($1, $2, $3::inet, $4, NOW(), NOW()) VALUES ($1, $2, $3::inet, $4, $5, NOW(), NOW())
"#, "#,
) )
.bind(enrollment_request.id) .bind(enrollment_request.id)
.bind(&enrollment_request.fqdn) .bind(&enrollment_request.fqdn)
.bind(&enrollment_request.ip_address.to_string()) .bind(enrollment_request.ip_address.to_string())
.bind(os_name) .bind(os_name)
.bind(&display_name)
.execute(&state.db) .execute(&state.db)
.await .await
.map_err(|e| { .map_err(|e| {

View File

@ -12,7 +12,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::Json, response::Json,
routing::{delete, get, post, put}, routing::{get, post},
Router, Router,
}; };
use pm_auth::rbac::AuthUser; use pm_auth::rbac::AuthUser;

View File

@ -11,7 +11,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::Json, response::Json,
routing::{delete, get, post, put}, routing::{get, post},
Router, Router,
}; };
use pm_auth::rbac::AuthUser; use pm_auth::rbac::AuthUser;
@ -24,7 +24,7 @@ use pm_core::{
}, },
}; };
use reqwest::tls::Version; use reqwest::tls::Version;
use serde::{Deserialize, Serialize}; use serde::Serialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::path::PathBuf; use std::path::PathBuf;
use uuid::Uuid; use uuid::Uuid;
@ -631,7 +631,6 @@ async fn update_health_check(
set_clauses.push(format!("basic_auth_pass_encrypted = ${}", param_idx)); set_clauses.push(format!("basic_auth_pass_encrypted = ${}", param_idx));
param_idx += 1; param_idx += 1;
set_clauses.push(format!("basic_auth_pass_nonce = ${}", param_idx)); set_clauses.push(format!("basic_auth_pass_nonce = ${}", param_idx));
param_idx += 1;
} }
if set_clauses.is_empty() { if set_clauses.is_empty() {
@ -644,7 +643,7 @@ async fn update_health_check(
} }
// Always update updated_at // Always update updated_at
set_clauses.push(format!("updated_at = NOW()")); set_clauses.push("updated_at = NOW()".to_string());
// Use a simpler approach: query the current row, apply changes, update // Use a simpler approach: query the current row, apply changes, update
// This avoids complex dynamic SQL binding issues // This avoids complex dynamic SQL binding issues
@ -945,7 +944,7 @@ async fn run_service_check(check: &HealthCheck, state: &AppState) -> CheckResult
} }
CheckResult { CheckResult {
healthy: false, healthy: false,
detail: format!("Failed to parse agent response"), detail: "Failed to parse agent response".to_string(),
latency_ms: Some(latency), latency_ms: Some(latency),
} }
}, },

View File

@ -4,6 +4,7 @@
//! POST /api/v1/hosts — register new host (admin only) //! POST /api/v1/hosts — register new host (admin only)
//! GET /api/v1/hosts/{id} — get host detail //! GET /api/v1/hosts/{id} — get host detail
//! DELETE /api/v1/hosts/{id} — remove host (admin only) //! DELETE /api/v1/hosts/{id} — remove host (admin only)
//! PUT /api/v1/hosts/{id} — update host (write access)
//! GET /api/v1/hosts/{id}/groups — list groups for host //! GET /api/v1/hosts/{id}/groups — list groups for host
//! POST /api/v1/hosts/{id}/groups — assign host to group //! POST /api/v1/hosts/{id}/groups — assign host to group
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group //! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group
@ -19,7 +20,7 @@ use axum::{
use pm_auth::rbac::AuthUser; use pm_auth::rbac::AuthUser;
use pm_core::{ use pm_core::{
audit::{log_event, AuditAction}, audit::{log_event, AuditAction},
models::{CreateHostRequest, Group, HostSummary}, models::{CreateHostRequest, Group, HostSummary, UpdateHostRequest},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@ -30,7 +31,7 @@ use crate::AppState;
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_hosts).post(register_host)) .route("/", get(list_hosts).post(register_host))
.route("/{id}", get(get_host).delete(remove_host)) .route("/{id}", get(get_host).put(update_host).delete(remove_host))
.route( .route(
"/{id}/groups", "/{id}/groups",
get(list_host_groups).post(add_host_to_group), get(list_host_groups).post(add_host_to_group),
@ -42,6 +43,7 @@ pub fn router() -> Router<AppState> {
// ── Query params ───────────────────────────────────────────────────────────── // ── Query params ─────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub struct HostListQuery { pub struct HostListQuery {
pub group_id: Option<Uuid>, pub group_id: Option<Uuid>,
pub health_status: Option<String>, pub health_status: Option<String>,
@ -398,6 +400,69 @@ async fn remove_host(
Ok(Json(json!({ "message": "Host removed" }))) Ok(Json(json!({ "message": "Host removed" })))
} }
// ── PUT /api/v1/hosts/:id ─────────────────────────────────────────────────────
async fn update_host(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(req): Json<UpdateHostRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
if !auth.role.can_write() {
return Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden", "message": "Write access required" } })),
));
}
// Update only fields that were provided; COALESCE preserves existing values.
let host = sqlx::query_scalar(
r#"
WITH updated AS (
UPDATE hosts SET
fqdn = COALESCE($1, fqdn),
ip_address = COALESCE($2::inet, ip_address),
display_name = COALESCE($3, display_name),
updated_at = NOW()
WHERE id = $4
RETURNING id
)
SELECT row_to_json(h) FROM (
SELECT id, fqdn, host(ip_address)::text AS ip_address, display_name,
os_family, os_name, arch, agent_version, health_status,
last_health_at, last_patch_at, agent_port, notes,
registered_at, updated_at
FROM hosts WHERE id = (SELECT id FROM updated)
) h
"#,
)
.bind(&req.fqdn)
.bind(&req.ip_address)
.bind(&req.display_name)
.bind(id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, host_id = %id, "Failed to update host");
let msg = if e.to_string().contains("unique") {
"A host with this FQDN and IP already exists".to_string()
} else {
"Database error".to_string()
};
(
StatusCode::CONFLICT,
Json(json!({ "error": { "code": "conflict", "message": msg } })),
)
})?;
host.map(Json).ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
)
})
}
// ── GET /api/v1/hosts/:id/groups ────────────────────────────────────────────── // ── GET /api/v1/hosts/:id/groups ──────────────────────────────────────────────
async fn list_host_groups( async fn list_host_groups(

View File

@ -438,7 +438,7 @@ async fn cancel_job(
// Only admin or the job creator may cancel. // Only admin or the job creator may cancel.
if !auth.role.can_write() { if !auth.role.can_write() {
let is_creator = creator_id.map_or(false, |cid| cid == auth.user_id); let is_creator = creator_id == Some(auth.user_id);
if !is_creator { if !is_creator {
return Err(err( return Err(err(
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,

View File

@ -115,6 +115,7 @@ pub struct OidcDiscoveryRequest {
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
#[allow(dead_code)]
pub struct OidcDiscoveryResult { pub struct OidcDiscoveryResult {
pub issuer: String, pub issuer: String,
pub authorization_endpoint: String, pub authorization_endpoint: String,
@ -558,7 +559,7 @@ async fn update_settings(
// ============================================================ // ============================================================
async fn discover_oidc( async fn discover_oidc(
State(state): State<AppState>, State(_state): State<AppState>,
auth: AuthUser, auth: AuthUser,
Json(req): Json<OidcDiscoveryRequest>, Json(req): Json<OidcDiscoveryRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> { ) -> Result<Json<Value>, (StatusCode, Json<Value>)> {

View File

@ -95,22 +95,13 @@ pub struct OidcDiscovery {
} }
/// Cache for OIDC discovery documents and JWKS with TTL-based refresh. /// Cache for OIDC discovery documents and JWKS with TTL-based refresh.
#[derive(Default)]
pub struct OidcCache { pub struct OidcCache {
pub discovery: Option<OidcDiscovery>, pub discovery: Option<OidcDiscovery>,
pub jwks: Option<serde_json::Value>, pub jwks: Option<serde_json::Value>,
pub jwks_fetched_at: Option<chrono::DateTime<Utc>>, pub jwks_fetched_at: Option<chrono::DateTime<Utc>>,
} }
impl Default for OidcCache {
fn default() -> Self {
Self {
discovery: None,
jwks: None,
jwks_fetched_at: None,
}
}
}
/// JWKS cache TTL in seconds (1 hour). /// JWKS cache TTL in seconds (1 hour).
const JWKS_CACHE_TTL_SECS: i64 = 3600; const JWKS_CACHE_TTL_SECS: i64 = 3600;
/// Discovery cache TTL in seconds (1 hour). /// Discovery cache TTL in seconds (1 hour).
@ -492,10 +483,11 @@ async fn sso_callback(
DbUserForSso { DbUserForSso {
id: existing.id, id: existing.id,
username: existing.username.clone(), username: existing.username.clone(),
display_name: name display_name: if name.is_empty() {
.is_empty() existing.display_name.clone()
.then(|| existing.display_name.clone()) } else {
.unwrap_or(name), name
},
role: existing.role.clone(), role: existing.role.clone(),
is_active: existing.is_active, is_active: existing.is_active,
mfa_enabled: existing.mfa_enabled, mfa_enabled: existing.mfa_enabled,

View File

@ -13,7 +13,7 @@ use axum::{
}; };
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use pm_auth::rbac::AuthUser; use pm_auth::rbac::AuthUser;
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use sqlx::postgres::PgListener; use sqlx::postgres::PgListener;
use ulid::Ulid; use ulid::Ulid;
@ -188,11 +188,9 @@ async fn handle_browser_ws(mut socket: WebSocket, db: sqlx::PgPool, ticket: WsTi
tracing::info!(user_id = %ticket.user_id, "Browser WS closed by client"); tracing::info!(user_id = %ticket.user_id, "Browser WS closed by client");
break; break;
} }
Some(Ok(Message::Ping(data))) => { Some(Ok(Message::Ping(data))) if socket.send(Message::Pong(data.clone())).await.is_err() => {
if socket.send(Message::Pong(data)).await.is_err() {
break; break;
} }
}
Some(Err(e)) => { Some(Err(e)) => {
tracing::debug!(error = %e, user_id = %ticket.user_id, "Browser WS recv error"); tracing::debug!(error = %e, user_id = %ticket.user_id, "Browser WS recv error");
break; break;

View File

@ -9,7 +9,6 @@ use lettre::{
transport::smtp::authentication::Credentials, transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
}; };
use serde_json;
use sqlx::PgPool; use sqlx::PgPool;
use pm_core::audit::{log_event, AuditAction}; use pm_core::audit::{log_event, AuditAction};
@ -290,6 +289,7 @@ pub async fn send_job_completion_email(
} }
/// Send a maintenance window reminder email. /// Send a maintenance window reminder email.
#[allow(dead_code)]
pub async fn send_maintenance_window_reminder_email( pub async fn send_maintenance_window_reminder_email(
pool: &PgPool, pool: &PgPool,
host_fqdn: &str, host_fqdn: &str,

View File

@ -22,6 +22,7 @@ use pm_agent_client::{AgentClient, AgentClientError};
/// Row fetched for each enabled health check, joined with host connection info. /// Row fetched for each enabled health check, joined with host connection info.
#[derive(FromRow)] #[derive(FromRow)]
#[allow(dead_code)]
struct HealthCheckRow { struct HealthCheckRow {
id: Uuid, id: Uuid,
host_id: Uuid, host_id: Uuid,

View File

@ -45,6 +45,7 @@ struct AutoApplyWindow {
} }
#[derive(Debug, FromRow)] #[derive(Debug, FromRow)]
#[allow(dead_code)]
struct PendingPatchHost { struct PendingPatchHost {
host_id: Uuid, host_id: Uuid,
patch_count: i32, patch_count: i32,

View File

@ -152,6 +152,8 @@ export const hostsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }), list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }),
get: (id: string) => apiClient.get(`/hosts/${id}`), get: (id: string) => apiClient.get(`/hosts/${id}`),
register: (body: CreateHostRequest) => apiClient.post('/hosts', body), register: (body: CreateHostRequest) => apiClient.post('/hosts', body),
update: (id: string, body: Record<string, string | undefined>) =>
apiClient.put(`/hosts/${id}`, body),
delete: (id: string) => apiClient.delete(`/hosts/${id}`), delete: (id: string) => apiClient.delete(`/hosts/${id}`),
refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`), refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`),
} }

View File

@ -614,6 +614,46 @@ export default function HostDetailPage() {
// Hosts list for target_host_id dropdown // Hosts list for target_host_id dropdown
const [hosts, setHosts] = useState<{ id: string; display_name: string; fqdn: string }[]>([]) const [hosts, setHosts] = useState<{ id: string; display_name: string; fqdn: string }[]>([])
// ── Host editing state ────────────────────────────────────────────────────
const [editing, setEditing] = useState(false)
const [editFqdn, setEditFqdn] = useState('')
const [editIp, setEditIp] = useState('')
const [editDisplayName, setEditDisplayName] = useState('')
const [savingHost, setSavingHost] = useState(false)
const enterEdit = () => {
setEditFqdn(String(host?.fqdn ?? ''))
setEditIp(String(host?.ip_address ?? ''))
setEditDisplayName(String(host?.display_name ?? ''))
setEditing(true)
}
const cancelEdit = () => {
setEditing(false)
setSavingHost(false)
}
const handleSaveHost = async () => {
if (!id) return
setSavingHost(true)
try {
const res = await hostsApi.update(id, {
fqdn: editFqdn !== String(host?.fqdn ?? '') ? editFqdn : undefined,
ip_address: editIp !== String(host?.ip_address ?? '') ? editIp : undefined,
display_name: editDisplayName !== String(host?.display_name ?? '') ? editDisplayName : undefined,
})
setHost(res.data)
setEditing(false)
showSnack('Host updated', 'success')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ?? 'Failed to update host'
showSnack(msg, 'error')
} finally {
setSavingHost(false)
}
}
// ── Fetch host ──────────────────────────────────────────────────────────── // ── Fetch host ────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (id === 'new') { setLoading(false); return } if (id === 'new') { setLoading(false); return }
@ -899,7 +939,39 @@ export default function HostDetailPage() {
{String(host?.fqdn ?? '')} {String(host?.fqdn ?? '')}
</Typography> </Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{canWrite && !certExists && ( {canWrite && !editing && (
<Button
variant="outlined"
size="small"
startIcon={<EditIcon />}
onClick={enterEdit}
>
Edit
</Button>
)}
{canWrite && editing && (
<>
<Button
variant="contained"
size="small"
startIcon={<CheckCircleIcon />}
onClick={handleSaveHost}
disabled={savingHost}
>
{savingHost ? <CircularProgress size={16} /> : 'Save'}
</Button>
<Button
variant="outlined"
size="small"
startIcon={<CancelIcon />}
onClick={cancelEdit}
disabled={savingHost}
>
Cancel
</Button>
</>
)}
{!editing && canWrite && !certExists && (
<Button <Button
variant="contained" variant="contained"
size="small" size="small"
@ -909,7 +981,7 @@ export default function HostDetailPage() {
Issue Certificate Issue Certificate
</Button> </Button>
)} )}
{canWrite && certExists && ( {!editing && canWrite && certExists && (
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
@ -920,12 +992,36 @@ export default function HostDetailPage() {
Re-issue Certificate Re-issue Certificate
</Button> </Button>
)} )}
</Box> </Box>
</Box> </Box>
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
<Grid container spacing={2}> <Grid container spacing={2}>
{host && Object.entries(host).map(([k, v]) => {host && (<>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Typography variant="caption" color="text.secondary" display="block">FQDN</Typography>
{editing ? (
<TextField size="small" fullWidth value={editFqdn} onChange={e => setEditFqdn(e.target.value)} />
) : (
<Typography variant="body2">{String(host.fqdn)}</Typography>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Typography variant="caption" color="text.secondary" display="block">IP ADDRESS</Typography>
{editing ? (
<TextField size="small" fullWidth value={editIp} onChange={e => setEditIp(e.target.value)} />
) : (
<Typography variant="body2">{String(host.ip_address)}</Typography>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Typography variant="caption" color="text.secondary" display="block">DISPLAY NAME</Typography>
{editing ? (
<TextField size="small" fullWidth value={editDisplayName} onChange={e => setEditDisplayName(e.target.value)} />
) : (
<Typography variant="body2">{String(host.display_name)}</Typography>
)}
</Grid>
{Object.entries(host).filter(([k]) => !['fqdn','ip_address','display_name'].includes(k)).map(([k, v]) =>
v !== null && v !== '' ? ( v !== null && v !== '' ? (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}> <Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}>
<Typography variant="caption" color="text.secondary" display="block"> <Typography variant="caption" color="text.secondary" display="block">
@ -935,6 +1031,7 @@ export default function HostDetailPage() {
</Grid> </Grid>
) : null ) : null
)} )}
</>)}
</Grid> </Grid>
</Paper> </Paper>

View File

@ -37,6 +37,12 @@ export interface CreateHostRequest {
group_ids?: string[] group_ids?: string[]
} }
export interface UpdateHostRequest {
fqdn?: string
ip_address?: string
display_name?: string
}
export interface Group { export interface Group {
id: string id: string
name: string name: string

View File

@ -0,0 +1,3 @@
-- Migration: 018_add_hostname_to_enrollment
-- Add hostname column to enrollment_requests for proper display name
ALTER TABLE enrollment_requests ADD COLUMN IF NOT EXISTS hostname TEXT;