1use axum::extract::{Path, Query, State};
17use axum::http::{HeaderValue, StatusCode, header};
18use axum::response::{IntoResponse, Response};
19use axum::routing::get;
20use axum::{Json, Router};
21use bhtune_core::{
22 ControllerDirection, ControllerPidValues, ControllerType, DcsTemplate, LoopConfig, PidRounding,
23 ProcessType, ResponseLevel, Tick, TuningResultInvalidReason, TuningResultStatus,
24};
25use bhtune_db::models::{
26 MvActuationKind, MvActuationStatus, Pagination, RestoreStatus, RollbackState, SampleQuality,
27 TemplateOrigin, TimingMetrics, TuneDriver, TuneMvActuationRow, TuneOutcome, TuneRecoveryState,
28 TuneResultRow, TuneRunFilter, TuneRunRow, TuneSampleRow, TuneWriteRow, WriteKind,
29};
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32use std::future::Future;
33use utoipa::{IntoParams, ToSchema};
34
35use crate::error::{ApiError, ErrorBody};
36use crate::routes::runs::StartRunRequest;
37use crate::state::AppState;
38
39#[derive(Debug, Deserialize, IntoParams)]
43#[into_params(parameter_in = Query)]
44pub struct RunListQuery {
45 pub loop_id: Option<i64>,
46 pub process_type: Option<ProcessType>,
47 pub controller_type: Option<ControllerType>,
48 pub outcome: Option<TuneOutcome>,
49 pub driver: Option<TuneDriver>,
50 pub started_after: Option<DateTime<Utc>>,
51 pub started_before: Option<DateTime<Utc>>,
52 pub template_name: Option<String>,
53 pub template_origin: Option<TemplateOrigin>,
54 pub opc_server: Option<String>,
57 pub bridge_host: Option<String>,
59 pub limit: Option<i64>,
60 pub offset: Option<i64>,
61}
62
63pub(crate) fn filter_from_query(query: &RunListQuery) -> TuneRunFilter {
64 let mut filter = TuneRunFilter::default();
65 if let Some(v) = query.loop_id {
66 filter = filter.with_loop_id(v);
67 }
68 if let Some(v) = query.process_type {
69 filter = filter.with_process_type(v);
70 }
71 if let Some(v) = query.controller_type {
72 filter = filter.with_controller_type(v);
73 }
74 if let Some(v) = query.outcome {
75 filter = filter.with_outcome(v);
76 }
77 if let Some(v) = query.driver {
78 filter = filter.with_driver(v);
79 }
80 if let Some(v) = query.started_after {
81 filter = filter.with_started_after(v);
82 }
83 if let Some(v) = query.started_before {
84 filter = filter.with_started_before(v);
85 }
86 if let Some(v) = &query.template_name {
87 filter = filter.with_template_name(v.clone());
88 }
89 if let Some(v) = query.template_origin {
90 filter = filter.with_template_origin(v);
91 }
92 if let Some(v) = &query.opc_server {
93 filter = filter.with_opc_server(v.clone());
94 }
95 if let Some(v) = &query.bridge_host {
96 filter = filter.with_bridge_host(v.clone());
97 }
98 filter
99}
100
101#[derive(Debug, Serialize, ToSchema)]
105pub struct RunSummaryResponse {
106 pub id: i64,
107 pub tag_name: String,
108 pub notes: Option<String>,
109 pub driver: TuneDriver,
110 pub outcome: TuneOutcome,
111 pub process_type: ProcessType,
112 pub started_at: DateTime<Utc>,
113}
114
115impl From<&TuneRunRow> for RunSummaryResponse {
116 fn from(run: &TuneRunRow) -> Self {
117 RunSummaryResponse {
118 id: run.id,
119 tag_name: run.loop_name.clone(),
120 notes: run.notes.clone(),
121 driver: run.driver,
122 outcome: run.outcome,
123 process_type: run.config.process_type,
124 started_at: run.started_at,
125 }
126 }
127}
128
129#[derive(Debug, Serialize, ToSchema)]
130pub struct RunListResponse {
131 pub runs: Vec<RunSummaryResponse>,
132 pub returned: usize,
135 pub total: i64,
136}
137
138#[utoipa::path(
143 get,
144 path = "/api/runs",
145 tag = "runs",
146 params(RunListQuery),
147 responses(
148 (status = 200, description = "A page of runs matching the filter.", body = RunListResponse),
149 ),
150)]
151pub(crate) async fn list_runs(
152 State(state): State<AppState>,
153 Query(query): Query<RunListQuery>,
154) -> Result<Json<RunListResponse>, ApiError> {
155 let filter = filter_from_query(&query);
156 let pagination = Pagination::new(
157 query.limit.unwrap_or_else(|| Pagination::default().limit),
158 query.offset.unwrap_or(0),
159 );
160 let runs = TuneRunRow::list(&state.pool, &filter, pagination).await?;
161 let total = TuneRunRow::count(&state.pool, &filter).await?;
162 Ok(Json(RunListResponse {
163 returned: runs.len(),
164 runs: runs.iter().map(RunSummaryResponse::from).collect(),
165 total,
166 }))
167}
168
169#[utoipa::path(
180 get,
181 path = "/api/runs/last-request",
182 tag = "runs",
183 responses(
184 (status = 200, description = "The newest run's original request, or `null` if no runs exist yet or its request isn't usable.", body = Option<StartRunRequest>),
185 ),
186)]
187pub(crate) async fn last_request(
188 State(state): State<AppState>,
189) -> Result<Json<Option<StartRunRequest>>, ApiError> {
190 let newest =
191 TuneRunRow::list(&state.pool, &TuneRunFilter::default(), Pagination::first(1)).await?;
192 let Some(run) = newest.into_iter().next() else {
193 return Ok(Json(None));
194 };
195 Ok(Json(parse_stored_request(run.id, &run.request_json)))
196}
197
198pub(crate) fn parse_stored_request(run_id: i64, request_json: &str) -> Option<StartRunRequest> {
208 match serde_json::from_str(request_json) {
209 Ok(request) => Some(request),
210 Err(e) => {
211 tracing::warn!(
212 run_id,
213 error = %e,
214 "run's stored request_json did not parse as StartRunRequest; treating as unavailable"
215 );
216 None
217 }
218 }
219}
220
221#[derive(Debug, Serialize, ToSchema)]
225pub struct InitialReadingsResponse {
226 pub pv_ini: f32,
227 pub mv_ini: f32,
228 pub mv_range_low: f32,
229 pub mv_range_high: f32,
230 pub pv_range_high: f32,
231 pub pv_range_low: f32,
232 pub controller_direction: ControllerDirection,
233 pub mode_raw: Option<String>,
234 pub mode_attribute_raw: Option<String>,
235 pub setpoint_ini: Option<f32>,
236}
237
238impl From<bhtune_db::models::TuneRunInitialReadings> for InitialReadingsResponse {
239 fn from(r: bhtune_db::models::TuneRunInitialReadings) -> Self {
240 InitialReadingsResponse {
241 pv_ini: r.pv_ini,
242 mv_ini: r.mv_ini,
243 mv_range_low: r.mv_range_low,
244 mv_range_high: r.mv_range_high,
245 pv_range_high: r.pv_range_high,
246 pv_range_low: r.pv_range_low,
247 controller_direction: r.controller_direction,
248 mode_raw: r.mode_raw,
249 mode_attribute_raw: r.mode_attribute_raw,
250 setpoint_ini: r.setpoint_ini,
251 }
252 }
253}
254
255#[derive(Debug, Serialize, ToSchema)]
260pub struct SampleResponse {
261 pub tick_index: i64,
262 pub sample: Tick,
263 pub state: bhtune_core::MrftState,
264 pub pv_quality: SampleQuality,
265}
266
267impl From<&TuneSampleRow> for SampleResponse {
268 fn from(row: &TuneSampleRow) -> Self {
269 SampleResponse {
270 tick_index: row.tick_index,
271 sample: row.sample,
272 state: row.state,
273 pv_quality: row.pv_quality,
274 }
275 }
276}
277
278#[derive(Debug, Serialize, ToSchema)]
280pub struct ResultResponse {
281 pub response_level: ResponseLevel,
282 pub kp: Option<f32>,
283 pub ti_minutes: Option<f32>,
284 pub td_minutes: Option<f32>,
285 pub proportional: Option<f32>,
286 pub integral: Option<f32>,
287 pub derivative: Option<f32>,
288 pub status: TuningResultStatus,
289 pub invalid_reason: Option<TuningResultInvalidReason>,
290 pub controller_values: Option<ControllerPidValues>,
292 pub controller_target_error: Option<String>,
294}
295
296impl ResultResponse {
297 pub(super) fn from_recorded(r: &TuneResultRow, run: &TuneRunRow) -> Self {
298 let preview =
299 bhtune_runtime::tune::pid_write_preview(r, run.config.controller_type, &run.template);
300 ResultResponse {
301 response_level: r.response_level,
302 kp: r.kp,
303 ti_minutes: r.ti_minutes,
304 td_minutes: r.td_minutes,
305 proportional: r.proportional,
306 integral: r.integral,
307 derivative: r.derivative,
308 status: r.status,
309 invalid_reason: r.invalid_reason,
310 controller_values: preview.values,
311 controller_target_error: preview.unavailable_reason,
312 }
313 }
314}
315
316#[derive(Debug, Serialize, ToSchema)]
318pub struct WriteResponse {
319 pub kind: WriteKind,
320 pub response_level: ResponseLevel,
321 pub allow_uncertain_quality: bool,
322 pub written_at: DateTime<Utc>,
323 pub proportional_previous: Option<f32>,
324 pub integral_previous: Option<f32>,
325 pub derivative_previous: Option<f32>,
326 pub proportional_written: Option<f32>,
327 pub integral_written: Option<f32>,
328 pub derivative_written: Option<f32>,
329 pub proportional_readback: Option<f32>,
330 pub integral_readback: Option<f32>,
331 pub derivative_readback: Option<f32>,
332 pub success: bool,
333 pub error_message: Option<String>,
334 pub rollback_state: Option<RollbackState>,
335 pub rollback_error: Option<String>,
336}
337
338impl From<&TuneWriteRow> for WriteResponse {
339 fn from(w: &TuneWriteRow) -> Self {
340 WriteResponse {
341 kind: w.kind,
342 response_level: w.response_level,
343 allow_uncertain_quality: w.allow_uncertain_quality,
344 written_at: w.written_at,
345 proportional_previous: w.previous.map(|p| p.proportional),
346 integral_previous: w.previous.map(|p| p.integral),
347 derivative_previous: w.previous.map(|p| p.derivative),
348 proportional_written: w.proportional_written,
349 integral_written: w.integral_written,
350 derivative_written: w.derivative_written,
351 proportional_readback: w.proportional_readback,
352 integral_readback: w.integral_readback,
353 derivative_readback: w.derivative_readback,
354 success: w.success,
355 error_message: w.error_message.clone(),
356 rollback_state: w.rollback_state,
357 rollback_error: w.rollback_error.clone(),
358 }
359 }
360}
361
362#[derive(Debug, Serialize, ToSchema)]
366pub struct MvActuationResponse {
367 pub id: i64,
368 pub sequence: i64,
369 pub kind: MvActuationKind,
370 pub commanded_at: DateTime<Utc>,
371 pub target_mv: f32,
372 pub previous_commanded_mv: Option<f32>,
373 pub tolerance: f32,
374 pub confirmation_due_at: DateTime<Utc>,
375 pub last_checked_at: Option<DateTime<Utc>>,
376 pub readback_mv: Option<f32>,
377 pub readback_quality: Option<SampleQuality>,
378 pub attempt_count: i64,
379 pub status: MvActuationStatus,
380 pub detail: Option<String>,
381}
382
383impl From<&TuneMvActuationRow> for MvActuationResponse {
384 fn from(row: &TuneMvActuationRow) -> Self {
385 Self {
386 id: row.id,
387 sequence: row.sequence,
388 kind: row.kind,
389 commanded_at: row.commanded_at,
390 target_mv: row.target_mv,
391 previous_commanded_mv: row.previous_commanded_mv,
392 tolerance: row.tolerance,
393 confirmation_due_at: row.confirmation_due_at,
394 last_checked_at: row.last_checked_at,
395 readback_mv: row.readback_mv,
396 readback_quality: row.readback_quality,
397 attempt_count: row.attempt_count,
398 status: row.status,
399 detail: row.detail.clone(),
400 }
401 }
402}
403
404#[derive(Debug, Serialize, ToSchema)]
409pub struct PidConstantTagsResponse {
410 pub proportional: String,
411 pub integral: String,
412 pub derivative: String,
413}
414
415#[derive(Debug, Serialize, ToSchema)]
418pub struct PidParameterLabelsResponse {
419 pub proportional: String,
420 pub integral: String,
421 pub derivative: String,
422}
423
424impl From<&DcsTemplate> for PidParameterLabelsResponse {
425 fn from(template: &DcsTemplate) -> Self {
426 Self {
427 proportional: pid_parameter_label(&template.proportional_constant_suffix, "P"),
428 integral: pid_parameter_label(&template.integral_constant_suffix, "I"),
429 derivative: pid_parameter_label(&template.derivative_constant_suffix, "D"),
430 }
431 }
432}
433
434fn pid_parameter_label(suffix: &str, fallback: &str) -> String {
435 if suffix.is_empty() {
436 fallback.to_owned()
437 } else {
438 suffix.to_owned()
439 }
440}
441
442#[derive(Debug, Serialize, ToSchema)]
443pub struct RunDetailResponse {
444 pub id: i64,
445 pub tag_name: String,
446 pub notes: Option<String>,
447 pub driver: TuneDriver,
448 pub outcome: TuneOutcome,
449 pub failure_reason: Option<String>,
450 pub started_at: DateTime<Utc>,
451 pub completed_at: Option<DateTime<Utc>>,
452 pub template_name: String,
455 pub template_origin: TemplateOrigin,
456 pub allow_uncertain_quality: bool,
458 pub config: LoopConfig,
459 pub pid_rounding: PidRounding,
461 pub effective_tuning: Option<bhtune_db::models::EffectiveTuning>,
464 pub opc_server: Option<String>,
468 pub bridge_host: Option<String>,
470 pub pid_constant_tags: Option<PidConstantTagsResponse>,
477 pub pid_parameter_labels: PidParameterLabelsResponse,
480 pub initial_readings: Option<InitialReadingsResponse>,
481 pub timing_metrics: Option<TimingMetrics>,
482 pub samples: Vec<SampleResponse>,
483 pub results: Vec<ResultResponse>,
484 pub writes: Vec<WriteResponse>,
485 pub mv_actuations: Vec<MvActuationResponse>,
486 pub restore_status: Option<RestoreStatus>,
487 pub restore_detail: Option<String>,
488 pub original_request: Option<StartRunRequest>,
495 pub gateway_compatibility: Option<crate::routes::opc::GatewayCompatibilityResponse>,
498}
499
500pub(crate) async fn build_run_detail(
506 pool: &bhtune_db::SqlitePool,
507 run_id: i64,
508) -> Result<Option<RunDetailResponse>, ApiError> {
509 let Some(run) = TuneRunRow::get(pool, run_id).await? else {
510 return Ok(None);
511 };
512 let samples = TuneSampleRow::list_for_run(pool, run_id).await?;
513 let results = TuneResultRow::list_for_run(pool, run_id).await?;
514 let writes = TuneWriteRow::list_for_run(pool, run_id).await?;
515 let mv_actuations = TuneMvActuationRow::list_for_run(pool, run_id).await?;
516 let pid_constant_tags = match (
517 &run.tags.proportional_constant,
518 &run.tags.integral_constant,
519 &run.tags.derivative_constant,
520 ) {
521 (Some(proportional), Some(integral), Some(derivative)) => Some(PidConstantTagsResponse {
522 proportional: proportional.clone(),
523 integral: integral.clone(),
524 derivative: derivative.clone(),
525 }),
526 _ => None,
527 };
528 let pid_parameter_labels = PidParameterLabelsResponse::from(&run.template);
529 let results = results
530 .iter()
531 .map(|result| ResultResponse::from_recorded(result, &run))
532 .collect();
533
534 Ok(Some(RunDetailResponse {
535 id: run.id,
536 tag_name: run.loop_name,
537 notes: run.notes,
538 driver: run.driver,
539 outcome: run.outcome,
540 failure_reason: run.failure_reason,
541 started_at: run.started_at,
542 completed_at: run.completed_at,
543 template_name: run.template.name,
544 template_origin: run.template_origin,
545 allow_uncertain_quality: run.allow_uncertain_quality,
546 config: run.config,
547 pid_rounding: run.template.pid_rounding,
548 effective_tuning: run.effective_tuning,
549 opc_server: run.opc_server,
550 bridge_host: run.bridge_host,
551 pid_constant_tags,
552 pid_parameter_labels,
553 initial_readings: run.initial_readings.map(InitialReadingsResponse::from),
554 timing_metrics: run.timing_metrics,
555 samples: samples.iter().map(SampleResponse::from).collect(),
556 results,
557 writes: writes.iter().map(WriteResponse::from).collect(),
558 mv_actuations: mv_actuations
559 .iter()
560 .map(MvActuationResponse::from)
561 .collect(),
562 restore_status: run.restore_status,
563 restore_detail: run.restore_detail,
564 original_request: parse_stored_request(run.id, &run.request_json),
565 gateway_compatibility: run
566 .gateway_compatibility_json
567 .as_deref()
568 .map(bhtune_runtime::gateway::parse_stored_compatibility)
569 .transpose()?
570 .map(crate::routes::opc::GatewayCompatibilityResponse::from),
571 }))
572}
573
574#[utoipa::path(
578 get,
579 path = "/api/runs/{id}",
580 tag = "runs",
581 params(
582 ("id" = i64, Path, description = "Run id"),
583 ),
584 responses(
585 (status = 200, description = "The full recorded detail for one run.", body = RunDetailResponse),
586 (status = 404, description = "No run with that id.", body = ErrorBody),
587 ),
588)]
589pub(crate) async fn show_run(
590 State(state): State<AppState>,
591 Path(run_id): Path<i64>,
592) -> Result<Json<RunDetailResponse>, ApiError> {
593 build_run_detail(&state.pool, run_id)
594 .await?
595 .map(Json)
596 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))
597}
598
599#[derive(Debug, Clone, Copy, Deserialize, ToSchema)]
606#[serde(rename_all = "snake_case")]
607pub enum RunExportFormat {
608 Csv,
609 Json,
610}
611
612impl From<RunExportFormat> for bhtune_runtime::export::SampleExportFormat {
613 fn from(format: RunExportFormat) -> Self {
614 match format {
615 RunExportFormat::Csv => bhtune_runtime::export::SampleExportFormat::Csv,
616 RunExportFormat::Json => bhtune_runtime::export::SampleExportFormat::Json,
617 }
618 }
619}
620
621#[derive(Debug, Deserialize, IntoParams)]
622#[into_params(parameter_in = Query)]
623pub struct RunExportQuery {
624 pub format: Option<RunExportFormat>,
626}
627
628#[utoipa::path(
634 get,
635 path = "/api/runs/{id}/export",
636 tag = "runs",
637 params(
638 ("id" = i64, Path, description = "Run id"),
639 RunExportQuery,
640 ),
641 responses(
642 (status = 200, description = "The run's recorded samples, as CSV (default) or JSON.", content_type = "text/csv"),
643 (status = 404, description = "No run with that id, or it has no recorded samples.", body = ErrorBody),
644 ),
645)]
646pub(crate) async fn export_run(
647 State(state): State<AppState>,
648 Path(run_id): Path<i64>,
649 Query(query): Query<RunExportQuery>,
650) -> Result<Response, ApiError> {
651 let format = query.format.unwrap_or(RunExportFormat::Csv);
652 let samples = TuneSampleRow::list_for_run(&state.pool, run_id).await?;
653 if samples.is_empty() {
654 return Err(ApiError::NotFound(format!(
655 "run {run_id} has no recorded samples (unknown run id, or it never started)"
656 )));
657 }
658 let bytes = bhtune_runtime::export::samples_to_bytes(&samples, format.into())?;
659 let (content_type, extension) = match format {
660 RunExportFormat::Csv => ("text/csv", "csv"),
661 RunExportFormat::Json => ("application/json", "json"),
662 };
663 let mut response = bytes.into_response();
664 let headers = response.headers_mut();
665 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
666 headers.insert(
667 header::CONTENT_DISPOSITION,
668 HeaderValue::from_str(&format!(
669 "attachment; filename=\"run-{run_id}.{extension}\""
670 ))
671 .map_err(|e| ApiError::Internal(e.into()))?,
672 );
673 Ok(response)
674}
675
676#[utoipa::path(
692 delete,
693 path = "/api/runs/{id}",
694 tag = "runs",
695 params(
696 ("id" = i64, Path, description = "Run id"),
697 ),
698 responses(
699 (status = 204, description = "Run deleted."),
700 (status = 404, description = "No run with that id.", body = ErrorBody),
701 (status = 409, description = "The run is active or has unresolved live recovery.", body = ErrorBody),
702 ),
703)]
704pub(crate) async fn delete_run(
705 State(state): State<AppState>,
706 Path(run_id): Path<i64>,
707) -> Result<StatusCode, ApiError> {
708 delete_run_with_hook(state, run_id, |_| async {}).await
709}
710
711async fn delete_run_with_hook<F, Fut>(
712 state: AppState,
713 run_id: i64,
714 after_lookup: F,
715) -> Result<StatusCode, ApiError>
716where
717 F: FnOnce(&AppState) -> Fut,
718 Fut: Future<Output = ()>,
719{
720 let run = TuneRunRow::get(&state.pool, run_id)
721 .await?
722 .ok_or_else(|| ApiError::NotFound(format!("no run with id {run_id}")))?;
723 if run.outcome == TuneOutcome::Running {
724 return Err(ApiError::Conflict(format!(
725 "run {run_id} has not finished yet; cancel it before deleting"
726 )));
727 }
728 if recovery_blocks_deletion(run.recovery_state) {
729 return Err(recovery_delete_conflict(run_id));
730 }
731 after_lookup(&state).await;
732 if TuneRunRow::delete(&state.pool, run_id).await? {
733 Ok(StatusCode::NO_CONTENT)
734 } else {
735 if TuneRunRow::get(&state.pool, run_id)
736 .await?
737 .is_some_and(|run| recovery_blocks_deletion(run.recovery_state))
738 {
739 return Err(recovery_delete_conflict(run_id));
740 }
741 Err(ApiError::NotFound(format!("no run with id {run_id}")))
745 }
746}
747
748fn recovery_blocks_deletion(recovery_state: Option<TuneRecoveryState>) -> bool {
749 matches!(
750 recovery_state,
751 Some(
752 TuneRecoveryState::Eligible
753 | TuneRecoveryState::Running
754 | TuneRecoveryState::Incomplete
755 )
756 )
757}
758
759fn recovery_delete_conflict(run_id: i64) -> ApiError {
760 ApiError::Conflict(format!(
761 "run {run_id} has an unresolved live-loop recovery; use `bhtune restore-loop {run_id} --yes` or follow the manual recovery procedure before deleting it"
762 ))
763}
764
765pub fn router() -> Router<AppState> {
766 Router::new()
767 .route("/api/runs", get(list_runs))
768 .route("/api/runs/last-request", get(last_request))
769 .route("/api/runs/{id}", get(show_run).delete(delete_run))
770 .route("/api/runs/{id}/export", get(export_run))
771}
772
773#[cfg(test)]
774mod tests;