deny: operator can attach a reason that reaches the manager

clicking DENY on the dashboard now prompts for an optional reason
('reason for denying (optional, sent to manager):'). the value
rides along as a hidden 'note' form field; backend chain:

  POST /deny/{id} { note }
    → actions::deny(coord, id, Some(note))
    → Approvals::mark_denied writes it to the row
    → HelperEvent::ApprovalResolved { ..., note: Some("...") }

manager already had note: Option<String> on the event, just never
populated for denials before. host admin socket (hive-c0re deny)
still passes None.

generalized the prompt-on-submit pattern: any form with a
data-prompt attribute pops a window.prompt() before the POST and
stashes the answer in a hidden input named by data-prompt-field
(default 'note'). reusable for future opt-in note fields.
This commit is contained in:
müde 2026-05-15 21:58:42 +02:00
parent 91c78d626f
commit 2029840671
5 changed files with 52 additions and 12 deletions

View file

@ -178,17 +178,17 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()>
Ok(())
}
pub fn deny(coord: &Coordinator, id: i64) -> Result<()> {
pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> {
let approval = coord.approvals.get(id)?;
coord.approvals.mark_denied(id)?;
tracing::info!(%id, "approval denied");
coord.approvals.mark_denied(id, note)?;
tracing::info!(%id, note, "approval denied");
if let Some(a) = approval {
coord.notify_manager(&HelperEvent::ApprovalResolved {
id: a.id,
agent: a.agent,
commit_ref: a.commit_ref,
status: ApprovalStatus::Denied,
note: None,
note: note.map(String::from),
});
}
Ok(())

View file

@ -142,12 +142,12 @@ impl Approvals {
})
}
pub fn mark_denied(&self, id: i64) -> Result<()> {
pub fn mark_denied(&self, id: i64, note: Option<&str>) -> Result<()> {
let conn = self.conn.lock().unwrap();
let affected = conn.execute(
"UPDATE approvals SET status = 'denied', resolved_at = ?1
WHERE id = ?2 AND status = 'pending'",
params![now_unix(), id],
"UPDATE approvals SET status = 'denied', resolved_at = ?1, note = ?2
WHERE id = ?3 AND status = 'pending'",
params![now_unix(), note, id],
)?;
if affected == 0 {
bail!("approval {id} not pending");

View file

@ -410,8 +410,23 @@ async fn post_approve(State(state): State<AppState>, AxumPath(id): AxumPath<i64>
}
}
async fn post_deny(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
match actions::deny(&state.coord, id) {
#[derive(Deserialize, Default)]
struct DenyForm {
#[serde(default)]
note: Option<String>,
}
async fn post_deny(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
Form(form): Form<DenyForm>,
) -> Response {
let note = form
.note
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty());
match actions::deny(&state.coord, id, note) {
Ok(()) => Redirect::to("/").into_response(),
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
}

View file

@ -144,7 +144,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostResponse::success()
}
HostRequest::Deny { id } => {
actions::deny(&coord, *id)?;
actions::deny(&coord, *id, None)?;
HostResponse::success()
}
})