kanidm/server/web_ui/admin/src/components/admin_objectgraph.rs
2024-03-04 13:14:51 +10:00

341 lines
11 KiB
Rust

use enum_iterator::{all, Sequence};
#[cfg(debug_assertions)]
use gloo::console;
use serde::Serialize;
use std::fmt::{Display, Formatter};
use yew::prelude::*;
use kanidm_proto::internal::Filter::{Eq, Or};
use kanidm_proto::internal::{SearchRequest, SearchResponse};
use kanidm_proto::v1::Entry;
use kanidmd_web_ui_shared::constants::CSS_PAGE_HEADER;
use kanidmd_web_ui_shared::{do_request, error::FetchError, RequestMethod};
use wasm_bindgen::prelude::*;
use web_sys::HtmlInputElement;
use yew_router::Routable;
use crate::router::AdminRoute;
use kanidmd_web_ui_shared::ui::{error_page, loading_spinner};
use kanidmd_web_ui_shared::utils::{init_graphviz, open_blank};
pub enum Msg {
NewFilters { filters: Vec<ObjectType> },
NewObjects { entries: Vec<Entry> },
Error { emsg: String, kopid: Option<String> },
}
impl From<FetchError> for Msg {
fn from(fe: FetchError) -> Self {
Msg::Error {
emsg: fe.as_string(),
kopid: None,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Sequence)]
pub enum ObjectType {
Group,
BuiltinGroup,
ServiceAccount,
Person,
}
impl Display for ObjectType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let str = match self {
ObjectType::Group => "Group",
ObjectType::BuiltinGroup => "Built In Group",
ObjectType::ServiceAccount => "Service Account",
ObjectType::Person => "Person",
};
write!(f, "{str}")
}
}
impl TryFrom<String> for ObjectType {
type Error = ();
fn try_from(value: String) -> Result<Self, Self::Error> {
all::<ObjectType>()
.find(|x| format!("{x}") == value)
.ok_or(())
}
}
pub enum State {
Waiting,
Ready { entries: Vec<Entry> },
Error { emsg: String, kopid: Option<String> },
}
pub struct AdminObjectGraph {
state: State,
filters: Vec<ObjectType>,
}
impl Component for AdminObjectGraph {
type Message = Msg;
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
#[cfg(debug_assertions)]
console::debug!("views::objectgraph::create");
ctx.link()
.send_future(async { Self::fetch_objects().await.unwrap_or_else(|v| v.into()) });
let state = State::Waiting;
AdminObjectGraph {
state,
filters: vec![],
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
#[cfg(debug_assertions)]
console::debug!("views::objectgraph::update");
match msg {
Msg::NewObjects { entries } => {
#[cfg(debug_assertions)]
console::debug!("Received new objects");
self.state = State::Ready { entries }
}
Msg::NewFilters { filters } => {
#[cfg(debug_assertions)]
console::debug!("Received new filters");
self.filters = filters;
}
Msg::Error { emsg, kopid } => self.state = State::Error { emsg, kopid },
}
true
}
fn changed(&mut self, _ctx: &Context<Self>, _props: &Self::Properties) -> bool {
#[cfg(debug_assertions)]
console::debug!("views::objectgraph::changed");
false
}
fn view(&self, ctx: &Context<Self>) -> Html {
match &self.state {
State::Waiting => self.view_waiting(),
State::Ready { entries } => self.view_ready(ctx, &self.filters, entries),
State::Error { emsg, kopid } => self.view_error(ctx, emsg, kopid.as_deref()),
}
}
fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
#[cfg(debug_assertions)]
console::debug!("views::objectgraph::rendered");
}
}
impl AdminObjectGraph {
fn view_waiting(&self) -> Html {
loading_spinner()
}
fn view_ready(&self, ctx: &Context<Self>, filters: &[ObjectType], entries: &[Entry]) -> Html {
let typed_entries = entries
.iter()
.filter_map(|entry| {
let classes = entry.attrs.get("class")?;
let uuid = entry.attrs.get("uuid")?.first()?;
// Logic to decide the type of each entry
let obj_type = if classes.contains(&"group".to_string()) {
if uuid.starts_with("00000000-0000-0000-0000-") {
ObjectType::BuiltinGroup
} else {
ObjectType::Group
}
} else if classes.contains(&"account".to_string()) {
if classes.contains(&"person".to_string()) {
ObjectType::Person
} else if classes.contains(&"service-account".to_string()) {
ObjectType::ServiceAccount
} else {
return None;
}
} else {
return None;
};
// Filter out the things we want to keep, if the filter is empty we assume we want all.
if !filters.contains(&obj_type) && !filters.is_empty() {
return None;
}
let spn = entry.attrs.get("spn")?.first()?;
Some((spn.clone(), uuid.clone(), obj_type))
})
.collect::<Vec<(String, String, ObjectType)>>();
// Vec<obj, uuid, obj's members>
let members_of = entries
.iter()
.filter_map(|entry| {
let spn = entry.attrs.get("spn")?.first()?.clone();
let uuid = entry.attrs.get("uuid")?.first()?.clone();
let keep = typed_entries
.iter()
.any(|(_, filtered_uuid, _)| &uuid == filtered_uuid);
if keep {
Some((spn, uuid, entry.attrs.get("member")?.clone()))
} else {
None
}
})
.collect::<Vec<_>>();
// Constructing graph source
let mut sb = String::new();
sb.push_str("digraph {\n rankdir=\"RL\"\n");
for (spn, _, members) in members_of {
members
.iter()
.filter(|member| typed_entries.iter().any(|(spn, _, _)| spn == *member))
.for_each(|member| {
sb.push_str(format!(r#" "{spn}" -> "{member}"{}"#, "\n").as_str());
});
}
for (spn, uuid, obj_type) in typed_entries {
let (color, shape, route) = match obj_type {
ObjectType::Group => ("#b86367", "box", AdminRoute::ViewGroup { id_or_name: uuid }),
ObjectType::BuiltinGroup => (
"#8bc1d6",
"component",
AdminRoute::ViewGroup { id_or_name: uuid },
),
ObjectType::ServiceAccount => (
"#77c98d",
"parallelogram",
AdminRoute::ViewServiceAccount { id_or_name: uuid },
),
ObjectType::Person => (
"#af8bd6",
"ellipse",
AdminRoute::ViewPerson { id_or_name: uuid },
),
};
let url = route.to_path();
sb.push_str(
format!(
r#" "{spn}" [color = "{color}", shape = {shape}, URL = "{url}"]{}"#,
"\n"
)
.as_str(),
);
}
sb.push('}');
init_graphviz(sb.as_str());
let node_refs = all::<ObjectType>()
.map(|object_type: ObjectType| (object_type, NodeRef::default()))
.collect::<Vec<_>>();
let on_checkbox_click = {
let scope = ctx.link().clone();
let node_refs = node_refs.clone();
move |_: Event| {
let mut filters = vec![];
for (obj_type, node_ref) in &node_refs {
if let Some(input_el) = node_ref.cast::<HtmlInputElement>() {
let checked = input_el.checked();
if checked {
filters.push(*obj_type);
}
}
}
scope.send_message(Msg::NewFilters { filters });
}
};
let view_graph_source = {
move |_: MouseEvent| {
open_blank(sb.as_str());
}
};
html! {
<>
<div class={CSS_PAGE_HEADER}>
<h2>{ "ObjectGraph view" }</h2>
</div>
if entries.is_empty() {
<div>
<h5>{ "No graph objects for the applied filters." }</h5>
</div>
} else {
<div class="column">
<div class="hstack gap-3">
{
node_refs.iter().map(|(ot, node_ref)| {
let ot_str = format!("{}", ot);
let selected = filters.contains(ot);
html! {
<>
<div class="form-check">
<input class="form-check-input obj-graph-filter-cb" type="checkbox" ref={ node_ref } id={ot_str.clone()} onchange={on_checkbox_click.clone()} checked={selected}/>
<label class="form-check-label" for={ot_str.clone()}>{ot_str.clone()}</label>
</div>
if *ot != ObjectType::last().unwrap() {
<div class="vr"></div>
}
</>
}
}).collect::<Html>()
}
</div>
<button class="btn btn-primary mt-2" onclick={view_graph_source}>{ "View graph source" }</button>
</div>
<div id="graph-container" class="mt-3"></div>
}
</>
}
}
fn view_error(&self, _ctx: &Context<Self>, msg: &str, kopid: Option<&str>) -> Html {
error_page(msg, kopid)
}
async fn fetch_objects() -> Result<Msg, FetchError> {
let req_body = SearchRequest {
filter: Or(vec![
Eq("class".to_string(), "person".to_string()),
Eq("class".to_string(), "service_account".to_string()),
Eq("class".to_string(), "group".to_string()),
]),
};
let req_jsvalue = req_body
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
.expect("Failed to serialise request");
let req_jsvalue = js_sys::JSON::stringify(&req_jsvalue).expect_throw("failed to stringify");
let (kopid, status, value, _) =
do_request("/v1/raw/search", RequestMethod::POST, Some(req_jsvalue)).await?;
if status == 200 {
let search_resp: SearchResponse = serde_wasm_bindgen::from_value(value)
.expect_throw("Invalid response type - objectgraph::SearchRequest");
Ok(Msg::NewObjects {
entries: search_resp.entries,
})
} else {
let emsg = value.as_string().unwrap_or_default();
Ok(Msg::Error { emsg, kopid })
}
}
}