Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-ui-server/src/narrate.rs

//! **"Logs" on a narrated site** (lane N2, 2026-10-20): the DEV-NARRATE boot logs the ONE
//! box's narrate job keeps for a test site that says `[estates.<label>] narrate = true` —
//! `EstateOrder.NarrateLog`, through the operator credential. ADMIN ONLY, reads included
//! ([`crate::auth::role_floor`]): a box's boot log is the operator's, never a viewer's.
//!
//! * `GET /api/narrate/{estate}` — every machine's tail, as JSON (the page).
//! * `GET /api/narrate/{estate}/{vm}` — one machine's tail as plain text (to read or save).

use axum::extract::{Path, State};
use axum::http::header;
use axum::response::IntoResponse;
use axum::Json;
use monetize_proto as pb;
use serde::{Deserialize, Serialize};

use crate::api::ApiError;
use crate::app::AppState;
use crate::grpc::Fail;

/// One machine's tail.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct VmJson {
    pub vm: String,
    pub text: String,
    pub bytes: u64,
    pub updated_unix_ms: i64,
}

/// A site's narration.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct NarrateJson {
    pub estate: String,
    pub narrated: bool,
    pub words: String,
    pub vms: Vec<VmJson>,
}

/// Pure: the answer as the page reads it.
pub fn narrate_json(r: pb::EstateNarrateLogResponse) -> NarrateJson {
    NarrateJson { estate: r.estate, narrated: r.narrated, words: r.words, vms: r.vms.into_iter().map(|v| VmJson { vm: v.vm, text: v.text, bytes: v.bytes, updated_unix_ms: v.updated_unix_ms }).collect() }
}

fn operator(state: &AppState) -> Result<&crate::grpc::Monetize, ApiError> {
    state.operator.as_ref().ok_or_else(|| ApiError(Fail { code: "unavailable".into(), message: "this console holds no operator credential, so it cannot read a site's logs".into() }))
}

/// `GET /api/narrate/{estate}`.
pub async fn logs(State(state): State<AppState>, Path(estate): Path<String>) -> Result<Json<NarrateJson>, ApiError> {
    let r = operator(&state)?.estate_orders().narrate_log(pb::EstateNarrateLogRequest { estate: estate.trim().to_owned(), vm: String::new(), max_bytes: 0 }).await?.into_inner();
    Ok(Json(narrate_json(r)))
}

/// `GET /api/narrate/{estate}/{vm}` — the text alone.
pub async fn vm_text(State(state): State<AppState>, Path((estate, vm)): Path<(String, String)>) -> Result<impl IntoResponse, ApiError> {
    let r = operator(&state)?.estate_orders().narrate_log(pb::EstateNarrateLogRequest { estate: estate.trim().to_owned(), vm: vm.trim().to_owned(), max_bytes: 0 }).await?.into_inner();
    let text = r.vms.into_iter().map(|v| v.text).collect::<Vec<_>>().join("");
    Ok(([(header::CONTENT_TYPE, "text/plain; charset=utf-8")], text))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_answer_reads_as_the_page_needs_it() {
        let j = narrate_json(pb::EstateNarrateLogResponse {
            estate: "test3-holger-rs".into(),
            narrated: true,
            words: "heard".into(),
            vms: vec![pb::EstateNarrateVm { vm: "main".into(), text: "a\nb\n".into(), bytes: 4, updated_unix_ms: 9 }],
        });
        assert_eq!((j.estate.as_str(), j.narrated, j.vms.len(), j.vms[0].vm.as_str(), j.vms[0].text.as_str()), ("test3-holger-rs", true, 1, "main", "a\nb\n"));
    }
}