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:
parent
91c78d626f
commit
2029840671
5 changed files with 52 additions and 12 deletions
|
|
@ -164,6 +164,21 @@
|
||||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
|
||||||
|
if (f.dataset.prompt) {
|
||||||
|
const ans = prompt(f.dataset.prompt, '');
|
||||||
|
if (ans === null) return; // operator hit Cancel
|
||||||
|
// Drop into a hidden input named after `data-prompt-field` (or
|
||||||
|
// 'note' by default) so the value rides along on the POST.
|
||||||
|
const field = f.dataset.promptField || 'note';
|
||||||
|
let input = f.querySelector(`input[name="${field}"]`);
|
||||||
|
if (!input) {
|
||||||
|
input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = field;
|
||||||
|
f.append(input);
|
||||||
|
}
|
||||||
|
input.value = ans;
|
||||||
|
}
|
||||||
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
||||||
const original = btn ? btn.innerHTML : '';
|
const original = btn ? btn.innerHTML : '';
|
||||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner">◐</span>'; }
|
||||||
|
|
@ -616,11 +631,21 @@
|
||||||
'new sub-agent — container will be created on approve'),
|
'new sub-agent — container will be created on approve'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Deny prompts the operator for an optional reason; the
|
||||||
|
// submit handler stashes it into a hidden `note` input that
|
||||||
|
// rides along on the POST and is surfaced to the manager via
|
||||||
|
// HelperEvent::ApprovalResolved { note }.
|
||||||
|
const denyForm = el('form', {
|
||||||
|
method: 'POST', action: '/deny/' + a.id,
|
||||||
|
class: 'inline', 'data-async': '',
|
||||||
|
'data-prompt': 'reason for denying (optional, sent to manager):',
|
||||||
|
});
|
||||||
|
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
|
||||||
row.append(
|
row.append(
|
||||||
' ',
|
' ',
|
||||||
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'),
|
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'),
|
||||||
' ',
|
' ',
|
||||||
form('/deny/' + a.id, 'btn-deny', 'DENY'),
|
denyForm,
|
||||||
);
|
);
|
||||||
li.append(row);
|
li.append(row);
|
||||||
if (a.diff_html) {
|
if (a.diff_html) {
|
||||||
|
|
|
||||||
|
|
@ -178,17 +178,17 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()>
|
||||||
Ok(())
|
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)?;
|
let approval = coord.approvals.get(id)?;
|
||||||
coord.approvals.mark_denied(id)?;
|
coord.approvals.mark_denied(id, note)?;
|
||||||
tracing::info!(%id, "approval denied");
|
tracing::info!(%id, note, "approval denied");
|
||||||
if let Some(a) = approval {
|
if let Some(a) = approval {
|
||||||
coord.notify_manager(&HelperEvent::ApprovalResolved {
|
coord.notify_manager(&HelperEvent::ApprovalResolved {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
agent: a.agent,
|
agent: a.agent,
|
||||||
commit_ref: a.commit_ref,
|
commit_ref: a.commit_ref,
|
||||||
status: ApprovalStatus::Denied,
|
status: ApprovalStatus::Denied,
|
||||||
note: None,
|
note: note.map(String::from),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -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 conn = self.conn.lock().unwrap();
|
||||||
let affected = conn.execute(
|
let affected = conn.execute(
|
||||||
"UPDATE approvals SET status = 'denied', resolved_at = ?1
|
"UPDATE approvals SET status = 'denied', resolved_at = ?1, note = ?2
|
||||||
WHERE id = ?2 AND status = 'pending'",
|
WHERE id = ?3 AND status = 'pending'",
|
||||||
params![now_unix(), id],
|
params![now_unix(), note, id],
|
||||||
)?;
|
)?;
|
||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
bail!("approval {id} not pending");
|
bail!("approval {id} not pending");
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
#[derive(Deserialize, Default)]
|
||||||
match actions::deny(&state.coord, id) {
|
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(),
|
Ok(()) => Redirect::to("/").into_response(),
|
||||||
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
|
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
HostRequest::Deny { id } => {
|
HostRequest::Deny { id } => {
|
||||||
actions::deny(&coord, *id)?;
|
actions::deny(&coord, *id, None)?;
|
||||||
HostResponse::success()
|
HostResponse::success()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue