//! Defines the HTTP-based UI. These endpoints generally return HTML and SVG. use std::{borrow::Cow, path::Path, sync::Arc, time::Duration}; use futures::{future, Future}; use hyper::{header, service::Service, Body, Method, Request, Response, StatusCode}; use maplit::hashmap; use rbx_dom_weak::{RbxId, RbxValue}; use ritz::{html, Fragment, HtmlContent, HtmlSelfClosingTag}; use crate::{ serve_session::ServeSession, snapshot::RojoTree, vfs::{Vfs, VfsDebug, VfsFetcher}, web::{ assets, interface::{ErrorResponse, SERVER_VERSION}, util::json, }, }; pub struct UiService { serve_session: Arc>, } impl Service for UiService { type ReqBody = Body; type ResBody = Body; type Error = hyper::Error; type Future = Box, Error = Self::Error> + Send>; fn call(&mut self, request: Request) -> Self::Future { let response = match (request.method(), request.uri().path()) { (&Method::GET, "/") => self.handle_home(), (&Method::GET, "/logo.png") => self.handle_logo(), (&Method::GET, "/icon.png") => self.handle_icon(), (&Method::GET, "/show-instances") => self.handle_show_instances(), (&Method::GET, "/show-vfs") => self.handle_show_vfs(), (_method, path) => { return json( ErrorResponse::not_found(format!("Route not found: {}", path)), StatusCode::NOT_FOUND, ) } }; Box::new(future::ok(response)) } } impl UiService { pub fn new(serve_session: Arc>) -> Self { UiService { serve_session } } fn handle_logo(&self) -> Response { Response::builder() .header(header::CONTENT_TYPE, "image/png") .body(Body::from(assets::logo())) .unwrap() } fn handle_icon(&self) -> Response { Response::builder() .header(header::CONTENT_TYPE, "image/png") .body(Body::from(assets::icon())) .unwrap() } fn handle_home(&self) -> Response { let page = self.normal_page(html! {
{ Self::button("Rojo Documentation", "https://rojo.space/docs") } { Self::button("View in-memory filesystem state", "/show-vfs") } { Self::button("View instance tree state", "/show-instances") }
}); Response::builder() .header(header::CONTENT_TYPE, "text/html") .body(Body::from(format!("{}", page))) .unwrap() } fn handle_show_instances(&self) -> Response { let tree = self.serve_session.tree(); let root_id = tree.get_root_id(); let page = self.normal_page(html! { { Self::instance(&tree, root_id) } }); Response::builder() .header(header::CONTENT_TYPE, "text/html") .body(Body::from(format!("{}", page))) .unwrap() } fn handle_show_vfs(&self) -> Response { let vfs = self.serve_session.vfs(); let orphans: Vec<_> = vfs .debug_orphans() .into_iter() .map(|path| Self::render_vfs_path(&vfs, path, true)) .collect(); let watched_list: Vec<_> = vfs .debug_watched_paths() .into_iter() .map(|path| { html! {
  • { format!("{}", path.display()) }
  • } }) .collect(); let page = self.normal_page(html! { <>

    "Known FS Items"

    { Fragment::new(orphans) }

    "Watched Paths"

      { Fragment::new(watched_list) }
    }); Response::builder() .header(header::CONTENT_TYPE, "text/html") .body(Body::from(format!("{}", page))) .unwrap() } fn render_vfs_path(vfs: &Vfs, path: &Path, is_root: bool) -> HtmlContent<'static> { let is_file = vfs.debug_is_file(path); let (note, children) = if is_file { (HtmlContent::None, Vec::new()) } else { let (is_exhaustive, mut children) = vfs.debug_children(path).unwrap(); // Sort files above directories, then sort how Path does after that. children.sort_unstable_by(|a, b| { let a_is_file = vfs.debug_is_file(a); let b_is_file = vfs.debug_is_file(b); b_is_file.cmp(&a_is_file).then_with(|| a.cmp(b)) }); let children: Vec<_> = children .into_iter() .map(|child| Self::render_vfs_path(vfs, child, false)) .collect(); let note = if is_exhaustive { HtmlContent::None } else { html!({ " (not enumerated)" }) }; (note, children) }; // For root entries, we want the full path to contextualize the path. let mut name = if is_root { path.to_str().unwrap().to_owned() } else { path.file_name().unwrap().to_str().unwrap().to_owned() }; // Directories should end with `/` in the UI to mark them. if !is_file && !name.ends_with('/') && !name.ends_with('\\') { name.push('/'); } html! {
    { name } { note }
    { Fragment::new(children) }
    } } fn instance(tree: &RojoTree, id: RbxId) -> HtmlContent<'_> { let instance = tree.get_instance(id).unwrap(); let children_list: Vec<_> = instance .children() .iter() .copied() .map(|id| Self::instance(tree, id)) .collect(); let children_container = if children_list.is_empty() { HtmlContent::None } else { let section = ExpandableSection { title: "Children", class_name: "instance-children", id, expanded: true, content: html! { { Fragment::new(children_list) } }, }; section.render() }; let mut properties: Vec<_> = instance.properties().iter().collect(); properties.sort_by_key(|pair| pair.0); let property_list: Vec<_> = properties .into_iter() .map(|(key, value)| { html! {
    { key.clone() } ": " { format!("{:?}", value.get_type()) }
    } }) .collect(); let property_container = if property_list.is_empty() { HtmlContent::None } else { let section = ExpandableSection { title: "Properties", class_name: "instance-properties", id, expanded: false, content: html! { { Fragment::new(property_list) } }, }; section.render() }; let metadata_container = { let metadata = instance.metadata(); let relevant_paths = if metadata.relevant_paths.is_empty() { HtmlContent::None } else { let list = metadata .relevant_paths .iter() .map(|path| html! {
  • { format!("{}", path.display()) }
  • }); html! {
    "relevant_paths: "
      { Fragment::new(list) }
    } }; let content = html! { <>
    "ignore_unknown_instances: " { metadata.ignore_unknown_instances.to_string() }
    "instigating source: " { format!("{:?}", metadata.instigating_source) }
    { relevant_paths } }; let section = ExpandableSection { title: "Metadata", class_name: "instance-metadata", id, expanded: false, content, }; section.render() }; let class_name_specifier = if instance.name() == instance.class_name() { HtmlContent::None } else { html! { " (" { instance.class_name().to_owned() } ")" } }; html! {
    { metadata_container } { property_container } { children_container }
    } } fn display_value(value: &RbxValue) -> String { match value { RbxValue::String { value } => value.clone(), RbxValue::Bool { value } => value.to_string(), _ => format!("{:?}", value), } } fn stat_item>(name: &str, value: S) -> HtmlContent<'_> { html! { { name } ": " { value.into() } } } fn button<'a>(text: &'a str, href: &'a str) -> HtmlContent<'a> { html! { { text } } } fn normal_page<'a>(&'a self, body: HtmlContent<'a>) -> HtmlContent<'a> { let project_name = self.serve_session.project_name().unwrap_or(""); let uptime = { let elapsed = self.serve_session.start_time().elapsed(); // Round off all of our sub-second precision to make timestamps // nicer. let just_nanos = Duration::from_nanos(elapsed.subsec_nanos() as u64); let elapsed = elapsed - just_nanos; humantime::format_duration(elapsed).to_string() }; Self::page(html! {
    { Self::stat_item("Server Version", SERVER_VERSION) } { Self::stat_item("Project", project_name) } { Self::stat_item("Server Uptime", uptime) }
    { body }
    }) } fn page(body: HtmlContent<'_>) -> HtmlContent<'_> { html! { "Rojo Live Server" { body } } } } struct ExpandableSection<'a> { title: &'a str, class_name: &'a str, id: RbxId, expanded: bool, content: HtmlContent<'a>, } impl<'a> ExpandableSection<'a> { fn render(self) -> HtmlContent<'a> { let input_id = format!("{}-{}", self.class_name, self.id); // We need to specify this input manually because Ritz doesn't have // support for conditional attributes like `checked`. let mut input = HtmlSelfClosingTag { name: Cow::Borrowed("input"), attributes: hashmap! { Cow::Borrowed("class") => Cow::Borrowed("expandable-input"), Cow::Borrowed("id") => Cow::Owned(input_id.clone()), Cow::Borrowed("type") => Cow::Borrowed("checkbox"), }, }; if self.expanded { input .attributes .insert(Cow::Borrowed("checked"), Cow::Borrowed("checked")); } html! {
    { input }

    { self.content }
    } } }