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
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:
@ -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);
|
||||
|
||||
@ -121,6 +121,7 @@ pub fn load_verify_key(path: &str) -> Result<String, JwtError> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(dead_code)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@ pub enum UserRole {
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"admin" => Some(Self::Admin),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)]
|
||||
{
|
||||
|
||||
@ -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<Vec<EnrollmentRequest>, 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
|
||||
|
||||
@ -107,6 +107,14 @@ pub struct CreateHostRequest {
|
||||
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)
|
||||
#[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<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@ -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<Vec<u8>> {
|
||||
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")
|
||||
}
|
||||
|
||||
@ -169,6 +169,7 @@ impl PdfBuilder {
|
||||
self.current_y -= ROW_H;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn embed_image(
|
||||
&self,
|
||||
raw_rgb: Vec<u8>,
|
||||
|
||||
@ -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<String> {
|
||||
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
|
||||
|
||||
@ -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| {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
}
|
||||
},
|
||||
|
||||
@ -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<AppState> {
|
||||
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<AppState> {
|
||||
// ── Query params ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct HostListQuery {
|
||||
pub group_id: Option<Uuid>,
|
||||
pub health_status: Option<String>,
|
||||
@ -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<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 ──────────────────────────────────────────────
|
||||
|
||||
async fn list_host_groups(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<AppState>,
|
||||
State(_state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(req): Json<OidcDiscoveryRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
|
||||
@ -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<OidcDiscovery>,
|
||||
pub jwks: Option<serde_json::Value>,
|
||||
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).
|
||||
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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -45,6 +45,7 @@ struct AutoApplyWindow {
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct PendingPatchHost {
|
||||
host_id: Uuid,
|
||||
patch_count: i32,
|
||||
|
||||
@ -152,6 +152,8 @@ export const hostsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }),
|
||||
get: (id: string) => apiClient.get(`/hosts/${id}`),
|
||||
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}`),
|
||||
refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`),
|
||||
}
|
||||
|
||||
@ -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 ?? '')}
|
||||
</Typography>
|
||||
<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
|
||||
variant="contained"
|
||||
size="small"
|
||||
@ -909,7 +981,7 @@ export default function HostDetailPage() {
|
||||
Issue Certificate
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && certExists && (
|
||||
{!editing && canWrite && certExists && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
@ -920,21 +992,46 @@ export default function HostDetailPage() {
|
||||
Re-issue Certificate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Grid container spacing={2}>
|
||||
{host && Object.entries(host).map(([k, v]) =>
|
||||
v !== null && v !== '' ? (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{k.replace(/_/g, ' ').toUpperCase()}
|
||||
</Typography>
|
||||
<Typography variant="body2">{String(v)}</Typography>
|
||||
</Grid>
|
||||
) : null
|
||||
)}
|
||||
{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 !== '' ? (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
{k.replace(/_/g, ' ').toUpperCase()}
|
||||
</Typography>
|
||||
<Typography variant="body2">{String(v)}</Typography>
|
||||
</Grid>
|
||||
) : null
|
||||
)}
|
||||
</>)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
|
||||
@ -37,6 +37,12 @@ export interface CreateHostRequest {
|
||||
group_ids?: string[]
|
||||
}
|
||||
|
||||
export interface UpdateHostRequest {
|
||||
fqdn?: string
|
||||
ip_address?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
3
migrations/018_add_hostname_to_enrollment.sql
Normal file
3
migrations/018_add_hostname_to_enrollment.sql
Normal 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;
|
||||
Reference in New Issue
Block a user