Private
Public Access
1
0

feat(jobs): add host_names to job list API and UI (#24)
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Successful in 1m7s
CI Pipeline / Security Audit (push) Successful in 5s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped

* feat(jobs): add host_names to job list API and UI

Closes #23

* fix(jobs): add mut for host_names merge loop
This commit is contained in:
Draco-Lunaris-Echo
2026-06-04 12:49:53 -05:00
committed by GitHub
parent b9fb3427e0
commit fda70ecf9e
5 changed files with 61 additions and 4 deletions

View File

@ -505,6 +505,10 @@ pub struct PatchJobSummary {
pub status: JobStatus,
pub immediate: bool,
pub host_count: i64,
/// Display names of hosts targeted by this job (falls back to fqdn).
#[serde(default)]
#[sqlx(skip)]
pub host_names: Vec<String>,
pub succeeded_count: i64,
pub failed_count: i64,
pub notes: String,

44
crates/pm-web/src/routes/jobs.rs Executable file → Normal file
View File

@ -20,6 +20,7 @@ use pm_core::{
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use uuid::Uuid;
use crate::AppState;
@ -52,6 +53,13 @@ struct JobListResponse {
offset: i64,
}
/// Helper struct for the host_names aggregation query.
#[derive(Debug, sqlx::FromRow)]
struct JobHostNames {
id: Uuid,
host_names: Vec<String>,
}
/// Per-host row included in `GET /api/v1/jobs/{id}` response.
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
struct JobHostRow {
@ -229,7 +237,7 @@ async fn list_jobs(
let limit = q.limit.unwrap_or(50).min(200);
let offset = q.offset.unwrap_or(0);
let jobs: Vec<PatchJobSummary> = if auth.role.is_admin() {
let mut jobs: Vec<PatchJobSummary> = if auth.role.is_admin() {
// Admins see every job.
sqlx::query_as(
r#"
@ -298,6 +306,40 @@ async fn list_jobs(
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
})?;
// Fetch host names for all jobs in this page.
let job_ids: Vec<Uuid> = jobs.iter().map(|j| j.id).collect();
let host_names_rows: Vec<JobHostNames> = if job_ids.is_empty() {
Vec::new()
} else {
sqlx::query_as(
r#"
SELECT pjh.job_id AS id,
array_agg(COALESCE(NULLIF(h.display_name, ''), h.fqdn)
ORDER BY h.fqdn) AS host_names
FROM patch_job_hosts pjh
JOIN hosts h ON h.id = pjh.host_id
WHERE pjh.job_id = ANY($1)
GROUP BY pjh.job_id
"#,
)
.bind(&job_ids)
.fetch_all(&state.db)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "list_jobs: host_names query failed, using empty defaults");
Vec::new()
})
};
// Merge host_names into summaries.
let mut host_names_map: HashMap<Uuid, Vec<String>> = host_names_rows
.into_iter()
.map(|r| (r.id, r.host_names))
.collect();
for job in &mut jobs {
job.host_names = host_names_map.remove(&job.id).unwrap_or_default();
}
// Total count for pagination metadata.
let total: i64 = if auth.role.is_admin() {
sqlx::query_scalar("SELECT COUNT(*) FROM patch_jobs")

View File

@ -70,12 +70,16 @@ Security: JWT Bearer Token (except Public Endpoints)
## 7. Jobs & Patch Deployment
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/jobs` | List patch jobs |
| GET | `/jobs` | List patch jobs (includes `host_names` per job) |
| POST | `/jobs` | Create new patch job |
| GET | `/jobs/{id}` | Get job status/details |
| POST | `/jobs/{id}/cancel` | Cancel running job |
| POST | `/jobs/{id}/rollback` | Rollback completed job |
### GET /jobs Response Fields
Each job summary object includes:
- `host_names`: Array of display names for hosts targeted by this job. Falls back to `fqdn` when `display_name` is empty. Single-host jobs show one name; multi-host jobs show all names sorted alphabetically.
## 8. Maintenance Windows
*Scoped to host.*
| Method | Endpoint | Description |

View File

@ -194,7 +194,13 @@ function JobRow({
<TableCell>
<StatusChip status={job.status} />
</TableCell>
<TableCell align="right">{job.host_count}</TableCell>
<TableCell>
{job.host_names.length === 1
? job.host_names[0]
: job.host_names.length > 1
? <Tooltip title={job.host_names.join(', ')}><span>{job.host_names[0]} +{job.host_names.length - 1}</span></Tooltip>
: '—'}
</TableCell>
<TableCell align="right">
<Typography color="success.main" fontWeight={600}>
{job.succeeded_count}
@ -512,7 +518,7 @@ export default function JobsPage() {
<TableCell>Created</TableCell>
<TableCell>Kind</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Hosts</TableCell>
<TableCell>Hosts</TableCell>
<TableCell align="right">Succeeded</TableCell>
<TableCell align="right">Failed</TableCell>
<TableCell>Schedule</TableCell>

View File

@ -144,6 +144,7 @@ export interface PatchJobSummary {
status: JobStatus
immediate: boolean
host_count: number
host_names: string[]
succeeded_count: number
failed_count: number
notes: string