From f70c5e53f9044c2af5e8c5bd5eb4d97cc79401a9 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 18 May 2026 21:52:00 +0000 Subject: [PATCH] feat: add host editing endpoint and frontend UI --- crates/pm-agent-client/src/client.rs | 2 +- crates/pm-auth/src/jwt.rs | 1 + crates/pm-auth/src/rbac.rs | 1 + crates/pm-auth/src/session.rs | 1 + crates/pm-core/src/audit.rs | 2 + crates/pm-core/src/crypto.rs | 2 +- crates/pm-core/src/db.rs | 9 +- crates/pm-core/src/models.rs | 12 ++ crates/pm-reports/src/csv.rs | 16 +-- crates/pm-reports/src/pdf.rs | 1 + crates/pm-web/src/routes/discovery.rs | 2 +- crates/pm-web/src/routes/enrollment.rs | 19 ++- crates/pm-web/src/routes/groups.rs | 2 +- crates/pm-web/src/routes/health_checks.rs | 9 +- crates/pm-web/src/routes/hosts.rs | 69 +++++++++- crates/pm-web/src/routes/jobs.rs | 2 +- crates/pm-web/src/routes/settings.rs | 3 +- crates/pm-web/src/routes/sso.rs | 20 +-- crates/pm-web/src/routes/ws.rs | 8 +- crates/pm-worker/src/email.rs | 2 +- crates/pm-worker/src/health_check_poller.rs | 1 + crates/pm-worker/src/maintenance_scheduler.rs | 1 + frontend/src/api/client.ts | 2 + frontend/src/pages/HostDetailPage.tsx | 123 ++++++++++++++++-- frontend/src/types/index.ts | 6 + migrations/018_add_hostname_to_enrollment.sql | 3 + 26 files changed, 254 insertions(+), 65 deletions(-) create mode 100644 migrations/018_add_hostname_to_enrollment.sql diff --git a/crates/pm-agent-client/src/client.rs b/crates/pm-agent-client/src/client.rs index c579e3b..a6ba781 100644 --- a/crates/pm-agent-client/src/client.rs +++ b/crates/pm-agent-client/src/client.rs @@ -105,7 +105,7 @@ impl AgentClient { .add_root_certificate(ca_cert) .timeout(REQUEST_TIMEOUT) .build() - .map_err(|e| AgentClientError::Request(e))?; + .map_err(AgentClientError::Request)?; let clean_ip = host_ip.split('/').next().unwrap_or(host_ip); let base_url = format!("https://{}:{}/api/v1", clean_ip, port); diff --git a/crates/pm-auth/src/jwt.rs b/crates/pm-auth/src/jwt.rs index f4ef247..6934199 100644 --- a/crates/pm-auth/src/jwt.rs +++ b/crates/pm-auth/src/jwt.rs @@ -121,6 +121,7 @@ pub fn load_verify_key(path: &str) -> Result { } #[cfg(test)] +#[allow(dead_code)] mod tests { use super::*; diff --git a/crates/pm-auth/src/rbac.rs b/crates/pm-auth/src/rbac.rs index 0a528db..d6551ac 100644 --- a/crates/pm-auth/src/rbac.rs +++ b/crates/pm-auth/src/rbac.rs @@ -40,6 +40,7 @@ pub enum UserRole { } impl UserRole { + #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Option { match s { "admin" => Some(Self::Admin), diff --git a/crates/pm-auth/src/session.rs b/crates/pm-auth/src/session.rs index 1763eba..16a5602 100644 --- a/crates/pm-auth/src/session.rs +++ b/crates/pm-auth/src/session.rs @@ -69,6 +69,7 @@ pub struct SessionUser { /// Database user row fetched during login. #[derive(Debug, sqlx::FromRow)] +#[allow(dead_code)] struct DbUser { id: Uuid, username: String, diff --git a/crates/pm-core/src/audit.rs b/crates/pm-core/src/audit.rs index ab8deb3..1a03ea9 100644 --- a/crates/pm-core/src/audit.rs +++ b/crates/pm-core/src/audit.rs @@ -97,6 +97,7 @@ impl AuditAction { /// Computes a hash chain entry using the previous row's hash. /// Non-fatal: logs errors but does not propagate them to avoid /// disrupting the primary operation. +#[allow(clippy::too_many_arguments)] pub async fn log_event( pool: &PgPool, action: AuditAction, @@ -126,6 +127,7 @@ pub async fn log_event( } } +#[allow(clippy::too_many_arguments)] async fn write_audit_row( pool: &PgPool, action: AuditAction, diff --git a/crates/pm-core/src/crypto.rs b/crates/pm-core/src/crypto.rs index 5eb60bb..93dba67 100644 --- a/crates/pm-core/src/crypto.rs +++ b/crates/pm-core/src/crypto.rs @@ -29,7 +29,7 @@ pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], CryptoError> { if let Some(parent) = path.parent() { 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) #[cfg(unix)] { diff --git a/crates/pm-core/src/db.rs b/crates/pm-core/src/db.rs index b5fcdb1..3060279 100644 --- a/crates/pm-core/src/db.rs +++ b/crates/pm-core/src/db.rs @@ -72,9 +72,9 @@ pub async fn create_enrollment_request( EnrollmentRequest, >( r#" - INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token) - VALUES ($1, $2, $3::inet, $4, $5) - RETURNING id, machine_id, fqdn, ip_address::text, os_details, polling_token, created_at, expires_at + INSERT INTO enrollment_requests (machine_id, fqdn, ip_address, os_details, polling_token, hostname) + VALUES ($1, $2, $3::inet, $4, $5, $6) + RETURNING id, machine_id, fqdn, ip_address::text, os_details, polling_token, hostname, created_at, expires_at "#, ) .bind(req.machine_id) @@ -82,6 +82,7 @@ pub async fn create_enrollment_request( .bind(req.ip_address) .bind(req.os_details) .bind(token_hash) + .bind(&req.hostname) .fetch_one(pool) .await } @@ -90,7 +91,7 @@ pub async fn list_enrollment_requests( pool: &PgPool, ) -> Result, sqlx::Error> { 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) .await diff --git a/crates/pm-core/src/models.rs b/crates/pm-core/src/models.rs index 5ac4eab..e70f960 100644 --- a/crates/pm-core/src/models.rs +++ b/crates/pm-core/src/models.rs @@ -107,6 +107,14 @@ pub struct CreateHostRequest { pub group_ids: Option>, } +/// Payload for updating an existing host. +#[derive(Debug, Deserialize)] +pub struct UpdateHostRequest { + pub fqdn: Option, + pub ip_address: Option, + pub display_name: Option, +} + /// Host list item (lighter projection for list views) #[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct HostSummary { @@ -135,6 +143,8 @@ pub struct EnrollmentRequest { pub ip_address: String, pub os_details: serde_json::Value, pub polling_token: String, + /// Short hostname provided during enrollment (optional). + pub hostname: Option, pub created_at: DateTime, pub expires_at: DateTime, } @@ -146,6 +156,8 @@ pub struct CreateEnrollmentRequest { pub fqdn: String, pub ip_address: String, pub os_details: serde_json::Value, + /// Short hostname (from /etc/hostname, optional). + pub hostname: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/pm-reports/src/csv.rs b/crates/pm-reports/src/csv.rs index b0a073e..9a17373 100644 --- a/crates/pm-reports/src/csv.rs +++ b/crates/pm-reports/src/csv.rs @@ -77,7 +77,7 @@ ORDER BY compliance_pct ASC }; let mut wtr = csv::Writer::from_writer(vec![]); - wtr.write_record(&[ + wtr.write_record([ "host_id", "display_name", "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")?; let mut wtr = csv::Writer::from_writer(vec![]); - wtr.write_record(&[ + wtr.write_record([ "job_id", "job_kind", "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> { let mut wtr = csv::Writer::from_writer(vec![]); - wtr.write_record(&[ + wtr.write_record([ "host_id", "display_name", "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")?; let mut wtr = csv::Writer::from_writer(vec![]); - wtr.write_record(&[ + wtr.write_record([ "id", "created_at", "action", @@ -347,5 +347,5 @@ LIMIT 10000 ])?; } - Ok(wtr.into_inner().context("csv flush failed")?) + wtr.into_inner().context("csv flush failed") } diff --git a/crates/pm-reports/src/pdf.rs b/crates/pm-reports/src/pdf.rs index 9868ea0..ea02444 100644 --- a/crates/pm-reports/src/pdf.rs +++ b/crates/pm-reports/src/pdf.rs @@ -169,6 +169,7 @@ impl PdfBuilder { self.current_y -= ROW_H; } + #[allow(clippy::too_many_arguments)] fn embed_image( &self, raw_rgb: Vec, diff --git a/crates/pm-web/src/routes/discovery.rs b/crates/pm-web/src/routes/discovery.rs index 4d940e4..80c25d6 100644 --- a/crates/pm-web/src/routes/discovery.rs +++ b/crates/pm-web/src/routes/discovery.rs @@ -174,7 +174,7 @@ async fn probe_and_store(pool: sqlx::PgPool, scan_id: Uuid, ip: IpAddr, port: u1 /// Simple reverse DNS lookup. fn dns_lookup_for_ip(ip: IpAddr) -> Option { 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 let host = format!("{ip}"); // Best-effort: try to resolve numeric address to hostname diff --git a/crates/pm-web/src/routes/enrollment.rs b/crates/pm-web/src/routes/enrollment.rs index c223f7a..024c069 100644 --- a/crates/pm-web/src/routes/enrollment.rs +++ b/crates/pm-web/src/routes/enrollment.rs @@ -1,6 +1,6 @@ use crate::AppState; use axum::{ - extract::{ConnectInfo, Path, State}, + extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, routing::{delete, get, post}, @@ -16,7 +16,7 @@ use pm_core::{ }; use rand::{distributions::Alphanumeric, Rng}; use serde::Serialize; -use std::net::{IpAddr, SocketAddr}; +use std::net::IpAddr; use std::time::Instant; #[derive(Debug, Clone, Serialize)] @@ -167,7 +167,7 @@ async fn list_admin_enrollments( db::list_enrollment_requests(&state.db) .await - .map(|requests| Json(requests)) + .map(Json) .map_err(|e| { 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" ) .bind(&enrollment_request.fqdn) - .bind(&enrollment_request.ip_address.to_string()) + .bind(enrollment_request.ip_address.to_string()) .fetch_optional(&state.db) .await .map_err(|e| { @@ -231,16 +231,21 @@ async fn approve_enrollment( .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let display_name = enrollment_request + .hostname + .clone() + .unwrap_or_else(|| enrollment_request.fqdn.clone()); sqlx::query( r#" - INSERT INTO hosts (id, fqdn, ip_address, os_name, registered_at, updated_at) - VALUES ($1, $2, $3::inet, $4, NOW(), NOW()) + INSERT INTO hosts (id, fqdn, ip_address, os_name, display_name, registered_at, updated_at) + VALUES ($1, $2, $3::inet, $4, $5, NOW(), NOW()) "#, ) .bind(enrollment_request.id) .bind(&enrollment_request.fqdn) - .bind(&enrollment_request.ip_address.to_string()) + .bind(enrollment_request.ip_address.to_string()) .bind(os_name) + .bind(&display_name) .execute(&state.db) .await .map_err(|e| { diff --git a/crates/pm-web/src/routes/groups.rs b/crates/pm-web/src/routes/groups.rs index fe0edf9..493a6a2 100644 --- a/crates/pm-web/src/routes/groups.rs +++ b/crates/pm-web/src/routes/groups.rs @@ -12,7 +12,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::Json, - routing::{delete, get, post, put}, + routing::{get, post}, Router, }; use pm_auth::rbac::AuthUser; diff --git a/crates/pm-web/src/routes/health_checks.rs b/crates/pm-web/src/routes/health_checks.rs index 2ce42ff..489e260 100644 --- a/crates/pm-web/src/routes/health_checks.rs +++ b/crates/pm-web/src/routes/health_checks.rs @@ -11,7 +11,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, response::Json, - routing::{delete, get, post, put}, + routing::{get, post}, Router, }; use pm_auth::rbac::AuthUser; @@ -24,7 +24,7 @@ use pm_core::{ }, }; use reqwest::tls::Version; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::{json, Value}; use std::path::PathBuf; use uuid::Uuid; @@ -631,7 +631,6 @@ async fn update_health_check( set_clauses.push(format!("basic_auth_pass_encrypted = ${}", param_idx)); param_idx += 1; set_clauses.push(format!("basic_auth_pass_nonce = ${}", param_idx)); - param_idx += 1; } if set_clauses.is_empty() { @@ -644,7 +643,7 @@ async fn update_health_check( } // 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 // This avoids complex dynamic SQL binding issues @@ -945,7 +944,7 @@ async fn run_service_check(check: &HealthCheck, state: &AppState) -> CheckResult } CheckResult { healthy: false, - detail: format!("Failed to parse agent response"), + detail: "Failed to parse agent response".to_string(), latency_ms: Some(latency), } }, diff --git a/crates/pm-web/src/routes/hosts.rs b/crates/pm-web/src/routes/hosts.rs index d723010..d9341d6 100644 --- a/crates/pm-web/src/routes/hosts.rs +++ b/crates/pm-web/src/routes/hosts.rs @@ -4,6 +4,7 @@ //! POST /api/v1/hosts — register new host (admin only) //! GET /api/v1/hosts/{id} — get host detail //! 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 //! POST /api/v1/hosts/{id}/groups — assign host to 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_core::{ audit::{log_event, AuditAction}, - models::{CreateHostRequest, Group, HostSummary}, + models::{CreateHostRequest, Group, HostSummary, UpdateHostRequest}, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -30,7 +31,7 @@ use crate::AppState; pub fn router() -> Router { Router::new() .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( "/{id}/groups", get(list_host_groups).post(add_host_to_group), @@ -42,6 +43,7 @@ pub fn router() -> Router { // ── Query params ───────────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] +#[allow(dead_code)] pub struct HostListQuery { pub group_id: Option, pub health_status: Option, @@ -398,6 +400,69 @@ async fn remove_host( Ok(Json(json!({ "message": "Host removed" }))) } +// ── PUT /api/v1/hosts/:id ───────────────────────────────────────────────────── + +async fn update_host( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + 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 ────────────────────────────────────────────── async fn list_host_groups( diff --git a/crates/pm-web/src/routes/jobs.rs b/crates/pm-web/src/routes/jobs.rs index c1af3a8..a24e4b6 100644 --- a/crates/pm-web/src/routes/jobs.rs +++ b/crates/pm-web/src/routes/jobs.rs @@ -438,7 +438,7 @@ async fn cancel_job( // Only admin or the job creator may cancel. 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 { return Err(err( StatusCode::FORBIDDEN, diff --git a/crates/pm-web/src/routes/settings.rs b/crates/pm-web/src/routes/settings.rs index 0559dee..a33aab4 100644 --- a/crates/pm-web/src/routes/settings.rs +++ b/crates/pm-web/src/routes/settings.rs @@ -115,6 +115,7 @@ pub struct OidcDiscoveryRequest { } #[derive(Debug, Serialize)] +#[allow(dead_code)] pub struct OidcDiscoveryResult { pub issuer: String, pub authorization_endpoint: String, @@ -558,7 +559,7 @@ async fn update_settings( // ============================================================ async fn discover_oidc( - State(state): State, + State(_state): State, auth: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { diff --git a/crates/pm-web/src/routes/sso.rs b/crates/pm-web/src/routes/sso.rs index fc2defb..d850866 100644 --- a/crates/pm-web/src/routes/sso.rs +++ b/crates/pm-web/src/routes/sso.rs @@ -95,22 +95,13 @@ pub struct OidcDiscovery { } /// Cache for OIDC discovery documents and JWKS with TTL-based refresh. +#[derive(Default)] pub struct OidcCache { pub discovery: Option, pub jwks: Option, pub jwks_fetched_at: Option>, } -impl Default for OidcCache { - fn default() -> Self { - Self { - discovery: None, - jwks: None, - jwks_fetched_at: None, - } - } -} - /// JWKS cache TTL in seconds (1 hour). const JWKS_CACHE_TTL_SECS: i64 = 3600; /// Discovery cache TTL in seconds (1 hour). @@ -492,10 +483,11 @@ async fn sso_callback( DbUserForSso { id: existing.id, username: existing.username.clone(), - display_name: name - .is_empty() - .then(|| existing.display_name.clone()) - .unwrap_or(name), + display_name: if name.is_empty() { + existing.display_name.clone() + } else { + name + }, role: existing.role.clone(), is_active: existing.is_active, mfa_enabled: existing.mfa_enabled, diff --git a/crates/pm-web/src/routes/ws.rs b/crates/pm-web/src/routes/ws.rs index 633f763..bf9dbe7 100644 --- a/crates/pm-web/src/routes/ws.rs +++ b/crates/pm-web/src/routes/ws.rs @@ -13,7 +13,7 @@ use axum::{ }; use chrono::{Duration, Utc}; use pm_auth::rbac::AuthUser; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::{json, Value}; use sqlx::postgres::PgListener; use ulid::Ulid; @@ -188,10 +188,8 @@ 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"); break; } - Some(Ok(Message::Ping(data))) => { - if socket.send(Message::Pong(data)).await.is_err() { - break; - } + Some(Ok(Message::Ping(data))) if socket.send(Message::Pong(data.clone())).await.is_err() => { + break; } Some(Err(e)) => { tracing::debug!(error = %e, user_id = %ticket.user_id, "Browser WS recv error"); diff --git a/crates/pm-worker/src/email.rs b/crates/pm-worker/src/email.rs index 115991c..abc8169 100644 --- a/crates/pm-worker/src/email.rs +++ b/crates/pm-worker/src/email.rs @@ -9,7 +9,6 @@ use lettre::{ transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, }; -use serde_json; use sqlx::PgPool; use pm_core::audit::{log_event, AuditAction}; @@ -290,6 +289,7 @@ pub async fn send_job_completion_email( } /// Send a maintenance window reminder email. +#[allow(dead_code)] pub async fn send_maintenance_window_reminder_email( pool: &PgPool, host_fqdn: &str, diff --git a/crates/pm-worker/src/health_check_poller.rs b/crates/pm-worker/src/health_check_poller.rs index c54775b..222bea2 100644 --- a/crates/pm-worker/src/health_check_poller.rs +++ b/crates/pm-worker/src/health_check_poller.rs @@ -22,6 +22,7 @@ use pm_agent_client::{AgentClient, AgentClientError}; /// Row fetched for each enabled health check, joined with host connection info. #[derive(FromRow)] +#[allow(dead_code)] struct HealthCheckRow { id: Uuid, host_id: Uuid, diff --git a/crates/pm-worker/src/maintenance_scheduler.rs b/crates/pm-worker/src/maintenance_scheduler.rs index 50762de..7b36def 100644 --- a/crates/pm-worker/src/maintenance_scheduler.rs +++ b/crates/pm-worker/src/maintenance_scheduler.rs @@ -45,6 +45,7 @@ struct AutoApplyWindow { } #[derive(Debug, FromRow)] +#[allow(dead_code)] struct PendingPatchHost { host_id: Uuid, patch_count: i32, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c92071d..2eb5a57 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -152,6 +152,8 @@ export const hostsApi = { list: (params?: Record) => apiClient.get('/hosts', { params }), get: (id: string) => apiClient.get(`/hosts/${id}`), register: (body: CreateHostRequest) => apiClient.post('/hosts', body), + update: (id: string, body: Record) => + apiClient.put(`/hosts/${id}`, body), delete: (id: string) => apiClient.delete(`/hosts/${id}`), refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`), } diff --git a/frontend/src/pages/HostDetailPage.tsx b/frontend/src/pages/HostDetailPage.tsx index 3c82dab..6c4fea5 100644 --- a/frontend/src/pages/HostDetailPage.tsx +++ b/frontend/src/pages/HostDetailPage.tsx @@ -614,6 +614,46 @@ export default function HostDetailPage() { // Hosts list for target_host_id dropdown 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 ──────────────────────────────────────────────────────────── useEffect(() => { if (id === 'new') { setLoading(false); return } @@ -899,7 +939,39 @@ export default function HostDetailPage() { {String(host?.fqdn ?? '')} - {canWrite && !certExists && ( + {canWrite && !editing && ( + + )} + {canWrite && editing && ( + <> + + + + )} + {!editing && canWrite && !certExists && (