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)
.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);

View File

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

View File

@ -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),

View File

@ -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,

View File

@ -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,

View File

@ -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)]
{

View File

@ -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

View File

@ -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)]

View File

@ -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")
}

View File

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

View File

@ -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| {

View File

@ -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;

View File

@ -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),
}
},

View File

@ -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(

View File

@ -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,

View File

@ -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>)> {

View File

@ -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,

View File

@ -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,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");
break;
}
Some(Ok(Message::Ping(data))) => {
if socket.send(Message::Pong(data)).await.is_err() {
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");
break;

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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`),
}

View File

@ -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,12 +992,36 @@ 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]) =>
{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">
@ -935,6 +1031,7 @@ export default function HostDetailPage() {
</Grid>
) : null
)}
</>)}
</Grid>
</Paper>

View File

@ -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

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;