refactor(api): embed runtime in unit list/get/detail responses

Remove the standalone GET /api/unit/runtimes endpoint in favour of
embedding runtime directly in existing responses:
- GET /api/unit          → each item now includes `runtime` field
- GET /api/unit/:id      → returns UnitWithRuntime
- GET /api/unit/:id/detail → UnitDetail now includes `runtime`

runtime is null when the engine has not yet initialised the unit.
Frontend loadUnits() reads the embedded runtime field to populate
state.runtimes — one request instead of two.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-03-26 09:18:14 +08:00
parent 42cdbbc0cc
commit 08add0d087
3 changed files with 29 additions and 23 deletions

View File

@ -26,6 +26,13 @@ pub struct GetUnitListQuery {
pub pagination: PaginationParams, pub pagination: PaginationParams,
} }
#[derive(serde::Serialize)]
pub struct UnitWithRuntime {
#[serde(flatten)]
pub unit: crate::model::ControlUnit,
pub runtime: Option<crate::control::runtime::UnitRuntime>,
}
pub async fn get_unit_list( pub async fn get_unit_list(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<GetUnitListQuery>, Query(query): Query<GetUnitListQuery>,
@ -41,6 +48,15 @@ pub async fn get_unit_list(
) )
.await?; .await?;
let all_runtimes = state.control_runtime.get_all().await;
let data = data
.into_iter()
.map(|unit| {
let runtime = all_runtimes.get(&unit.id).cloned();
UnitWithRuntime { unit, runtime }
})
.collect::<Vec<_>>();
Ok(Json(PaginatedResponse::new( Ok(Json(PaginatedResponse::new(
data, data,
total, total,
@ -118,10 +134,11 @@ pub async fn get_unit(
State(state): State<AppState>, State(state): State<AppState>,
Path(unit_id): Path<Uuid>, Path(unit_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiErr> { ) -> Result<impl IntoResponse, ApiErr> {
match crate::service::get_unit_by_id(&state.pool, unit_id).await? { let unit = crate::service::get_unit_by_id(&state.pool, unit_id)
Some(unit) => Ok(Json(unit)), .await?
None => Err(ApiErr::NotFound("Unit not found".to_string(), None)), .ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
} let runtime = state.control_runtime.get(unit_id).await;
Ok(Json(UnitWithRuntime { unit, runtime }))
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@ -142,6 +159,7 @@ pub struct EquipmentDetail {
pub struct UnitDetail { pub struct UnitDetail {
#[serde(flatten)] #[serde(flatten)]
pub unit: crate::model::ControlUnit, pub unit: crate::model::ControlUnit,
pub runtime: Option<crate::control::runtime::UnitRuntime>,
pub equipments: Vec<EquipmentDetail>, pub equipments: Vec<EquipmentDetail>,
} }
@ -153,6 +171,8 @@ pub async fn get_unit_detail(
.await? .await?
.ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?; .ok_or_else(|| ApiErr::NotFound("Unit not found".to_string(), None))?;
let runtime = state.control_runtime.get(unit_id).await;
let equipments = crate::service::get_equipment_by_unit_id(&state.pool, unit_id).await?; let equipments = crate::service::get_equipment_by_unit_id(&state.pool, unit_id).await?;
let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect(); let equipment_ids: Vec<Uuid> = equipments.iter().map(|e| e.id).collect();
let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?; let all_points = crate::service::get_points_by_equipment_ids(&state.pool, &equipment_ids).await?;
@ -177,7 +197,7 @@ pub async fn get_unit_detail(
}) })
.collect(); .collect();
Ok(Json(UnitDetail { unit, equipments })) Ok(Json(UnitDetail { unit, runtime, equipments }))
} }
#[derive(Debug, Deserialize, Validate)] #[derive(Debug, Deserialize, Validate)]
@ -510,10 +530,3 @@ pub async fn get_unit_runtime(
Ok(Json(runtime)) Ok(Json(runtime))
} }
/// Returns all known runtimes as { unit_id: UnitRuntime }.
/// Used by the frontend on page load to populate initial state.
pub async fn get_all_unit_runtimes(
State(state): State<AppState>,
) -> impl IntoResponse {
Json(state.control_runtime.get_all().await)
}

View File

@ -209,10 +209,6 @@ fn build_router(state: AppState) -> Router {
"/api/unit", "/api/unit",
get(handler::control::get_unit_list).post(handler::control::create_unit), get(handler::control::get_unit_list).post(handler::control::create_unit),
) )
.route(
"/api/unit/runtimes",
get(handler::control::get_all_unit_runtimes),
)
.route( .route(
"/api/unit/{unit_id}", "/api/unit/{unit_id}",
get(handler::control::get_unit) get(handler::control::get_unit)

View File

@ -180,10 +180,7 @@ export function renderUnits() {
} }
export async function loadUnits() { export async function loadUnits() {
const [response, runtimes] = await Promise.all([ const response = await apiFetch("/api/unit?page=1&page_size=-1");
apiFetch("/api/unit?page=1&page_size=-1"),
apiFetch("/api/unit/runtimes").catch(() => ({})),
]);
state.units = response.data || []; state.units = response.data || [];
state.unitMap = new Map(state.units.map((unit) => [unit.id, unit])); state.unitMap = new Map(state.units.map((unit) => [unit.id, unit]));
@ -191,9 +188,9 @@ export async function loadUnits() {
state.selectedUnitId = null; state.selectedUnitId = null;
} }
for (const [unitId, runtime] of Object.entries(runtimes)) { state.units.forEach((unit) => {
state.runtimes.set(unitId, runtime); if (unit.runtime) state.runtimes.set(unit.id, unit.runtime);
} });
renderUnits(); renderUnits();
renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId); renderUnitOptions(dom.equipmentUnitId?.value || "", dom.equipmentUnitId);