1pub(crate) mod capabilities;
42pub(crate) mod claim;
43pub(crate) mod command;
44pub(crate) mod dashboard;
45pub(crate) mod delivery;
46pub(crate) mod deployments;
47pub(crate) mod docs;
48pub(crate) mod evidence;
49pub(crate) mod governance;
50pub(crate) mod icons;
51pub(crate) mod idd;
52pub(crate) mod intent;
53pub(crate) mod issues;
54pub(crate) mod knowledge;
55pub(crate) mod mesh;
56pub(crate) mod milestone;
57pub(crate) mod navbar;
58pub(crate) mod observatory;
59pub(crate) mod prose;
60pub(crate) mod prs;
61pub(crate) mod roadmap;
62pub(crate) mod rule;
63pub(crate) mod shell;
64pub(crate) mod skills;
65pub(crate) mod source;
66pub(crate) mod timings;
67pub(crate) mod view;
68pub(crate) mod work;
69
70use std::sync::Arc;
71
72use crate::cockpit::assets::Assets;
73use crate::http::router::{Request, Response};
74use crate::live::{IntoLive, Live};
75
76pub const PREFIX: &str = "/ui";
78
79pub const SURFACE: &str = "ui";
82
83pub const ASSETS_DIR: &str = "ui";
85
86pub const ASSETS_PREFIX: &str = "/ui/assets/";
88
89pub const FAVICON: &str = "share/ui/favicon.svg";
91
92pub const STYLESHEET: &str = "ui.css";
94
95pub const SCRIPT: &str = "ui.js";
97pub(crate) const BAR_SCRIPTS: &[&str] = &["nav.js", "theme.js"];
101
102pub const GRAPH_LIB: &str = "vendor/cytoscape.min.js";
104
105pub const FRAGMENT: &str = "fragment";
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub(crate) struct Screen {
113 pub route: &'static str,
115 pub title: &'static str,
117 pub icon: &'static str,
119 pub answers: &'static str,
121}
122
123pub(crate) const SCREENS: &[Screen] = &[
127 Screen {
128 route: PREFIX,
129 title: "Dashboard",
130 icon: "home",
131 answers: "How the delivery stands: health, release, plan, mesh and the queue.",
132 },
133 Screen {
134 route: command::ROUTE,
135 title: "Command",
136 icon: "terminal",
137 answers: "Everything that can be run, what it changes, and what is running.",
138 },
139 Screen {
140 route: idd::ROUTE,
141 title: "Intent workspace",
142 icon: "list-checks",
143 answers: "One intent and what serves it: milestones, issues, waves, dependencies and the next move.",
144 },
145 Screen {
146 route: roadmap::ROUTE,
147 title: "Roadmap",
148 icon: "target",
149 answers: "Which milestones are open, in what order, and which intents they serve.",
150 },
151 Screen {
152 route: work::ROUTE,
153 title: "Issues & PRs",
154 icon: "merge",
155 answers: "Which issues are ready or blocked, and what the pull-request queue would do.",
156 },
157 Screen {
158 route: issues::ROUTE,
159 title: "Issues",
160 icon: "list",
161 answers: "Every issue of the plan, by status, milestone, priority or text.",
162 },
163 Screen {
164 route: prs::ROUTE,
165 title: "PR Drain",
166 icon: "branch",
167 answers: "Every open pull request, what the lane would do with it, and why.",
168 },
169 Screen {
170 route: capabilities::ROUTE,
171 title: "Capabilities",
172 icon: "puzzle",
173 answers: "Everything the product can be asked, and on which surfaces.",
174 },
175 Screen {
176 route: skills::ROUTE,
177 title: "Skills",
178 icon: "sparkles",
179 answers: "What an agent can be asked by name, and where each name comes from.",
180 },
181 Screen {
182 route: mesh::ROUTE,
183 title: "Mesh",
184 icon: "nodes",
185 answers: "Which machines and runtimes are on the mesh, and what they recorded.",
186 },
187 Screen {
188 route: delivery::ROUTE,
189 title: "CI/CD & Releases",
190 icon: "layers",
191 answers: "What was released, what the tree declares, and what the CI model runs.",
192 },
193 Screen {
194 route: deployments::ROUTE,
195 title: "Deployments",
196 icon: "server",
197 answers: "What is live, and whether it is what was built.",
198 },
199 Screen {
200 route: evidence::ROUTE,
201 title: "Evidence",
202 icon: "file-check",
203 answers: "Which claims hold, which are stale or untested, and why.",
204 },
205 Screen {
206 route: knowledge::ROUTE,
207 title: "Knowledge",
208 icon: "sparkles",
209 answers: "What the work left for review, and whether the deriver is still writing.",
210 },
211 Screen {
212 route: docs::ROUTE,
213 title: "Documentation",
214 icon: "book",
215 answers: "What the product does, and where it is written down.",
216 },
217 Screen {
218 route: governance::ROUTE,
219 title: "Governance",
220 icon: "shield-check",
221 answers: "Which rules bind this repository and how each is proven.",
222 },
223 Screen {
224 route: observatory::ROUTE,
225 title: "Observatory",
226 icon: "eye",
227 answers: "What the repository and its mesh are doing, drawn as rotating scenes for a wall display.",
228 },
229];
230
231pub(crate) const PANELS: &[&str] = &[
234 dashboard::PULL_REQUESTS_PANEL,
235 deployments::DELIVERY_PANEL,
236 prs::PANEL,
237];
238
239pub(crate) struct Ui {
241 live: Arc<Live>,
242 version: &'static str,
243 assets: Assets,
244 warmed: std::sync::atomic::AtomicBool,
246}
247
248impl Ui {
249 pub(crate) fn new(
251 live: impl IntoLive,
252 version: &'static str,
253 share_dir: Option<&std::path::Path>,
254 ) -> Self {
255 let assets = match share_dir {
256 Some(dir) => Assets::under(dir, ASSETS_DIR, ASSETS_PREFIX),
257 None => Assets::none_under(ASSETS_DIR, ASSETS_PREFIX),
258 };
259 if !assets.present() {
260 tracing::warn!(
261 "the distribution has no share/ui/ directory: the UI will serve unstyled markup; run scripts/cockpit-assets to build it"
262 );
263 }
264 Ui {
265 live: live.into_live(),
266 version,
267 assets,
268 warmed: std::sync::atomic::AtomicBool::new(false),
269 }
270 }
271
272 pub(crate) fn warm_once(ui: &Arc<Ui>) {
282 use std::sync::atomic::Ordering;
283 if cfg!(test) || crate::capability::executor::bypassed() {
284 return;
285 }
286 if ui.warmed.swap(true, Ordering::SeqCst) {
287 return;
288 }
289 let ui = Arc::clone(ui);
290 let warm = move || {
291 let routes = SCREENS
292 .iter()
293 .map(|s| s.route)
294 .chain(PANELS.iter().copied());
295 for route in routes {
296 let _ = ui.handle(&Request::parse_target("GET", route, Vec::new()));
297 }
298 };
299 if let Err(refused) = std::thread::Builder::new()
300 .name("mj-ui-warm".into())
301 .spawn(warm)
302 {
303 tracing::warn!("no thread to warm the UI's screens: {refused}");
304 }
305 }
306
307 #[cfg(test)]
310 pub(crate) fn owns(path: &str) -> bool {
311 path == PREFIX || path.starts_with(&format!("{PREFIX}/"))
312 }
313
314 pub(crate) fn handle(&self, req: &Request) -> Response {
316 if req.method != "GET" {
317 return Response::error(
318 405,
319 "method_not_allowed",
320 "the UI's screens are read with GET; a capability that changes something is called on its own route",
321 );
322 }
323 if let Some(name) = req.path.strip_prefix(ASSETS_PREFIX) {
324 return self.assets.respond(name, req);
325 }
326 let (reading, behind) = self.live.view_without_waiting();
330 let ctx = reading.ctx;
331 let fragment = req.query.iter().any(|(k, _)| k == FRAGMENT);
332 let path = req.path.trim_end_matches('/');
333 if let Some((_, target)) = observatory::REDIRECTS.iter().find(|(old, _)| *old == path) {
335 let query = req
336 .query
337 .iter()
338 .map(|(k, v)| format!("{k}={v}"))
339 .collect::<Vec<_>>()
340 .join("&");
341 let to = match query.is_empty() {
342 true => target.to_string(),
343 false => format!("{target}?{query}"),
344 };
345 return Response::new(308, "text/plain; charset=utf-8", format!("{to}\n"))
346 .with_header("Location", to);
347 }
348 let kiosk = path == observatory::KIOSK;
350 let path = if kiosk { observatory::ROUTE } else { path };
351 let mut screen = match path {
352 "" | PREFIX => dashboard::screen(&ctx),
353 idd::ROUTE => idd::screen(&ctx, &req.query),
354 roadmap::ROUTE => roadmap::screen(&ctx, &req.query),
355 work::ROUTE => work::screen(&ctx, &req.query),
356 issues::ROUTE => issues::screen(&ctx, &req.query),
357 prs::ROUTE => prs::screen(&ctx, &req.query),
358 mesh::ROUTE => mesh::screen(&ctx, &req.query),
359 delivery::ROUTE => delivery::screen(&ctx),
360 deployments::ROUTE => deployments::screen(&ctx),
361 command::ROUTE => command::screen(&ctx, &req.query),
362 capabilities::ROUTE => capabilities::screen(&ctx, &req.query),
363 skills::ROUTE => skills::screen(&ctx),
364 docs::ROUTE => docs::screen(&ctx),
365 evidence::ROUTE => evidence::screen(&ctx, &req.query),
366 knowledge::ROUTE => knowledge::screen(&ctx),
367 governance::ROUTE => governance::screen(&ctx, &req.query),
368 observatory::ROUTE => {
369 let libs = observatory::Libs {
370 director: self.assets.url(observatory::LIB),
371 scenes: self.assets.url(observatory::SCENES_LIB),
372 p5: self.assets.url(observatory::P5_LIB),
373 };
374 observatory::screen(&ctx, &req.query, &libs, kiosk)
375 }
376 dashboard::PULL_REQUESTS_PANEL => dashboard::pull_requests_panel(&ctx, &req.query),
377 deployments::DELIVERY_PANEL => deployments::delivery_panel(&ctx),
378 prs::PANEL => prs::panel_view(&ctx, &req.query),
379 other => shell::not_found(other),
380 };
381 if behind {
382 screen.main = crate::cockpit::html::el("div")
383 .class("mj-ui-stack")
384 .child(
385 crate::cockpit::html::el("p")
386 .class("mj-ui-dim")
387 .attr(view::CATCHING_UP, "")
388 .text("The repository changed: this screen shows it as it was, and is being read again."),
389 )
390 .child(screen.main);
391 }
392 if !fragment && PANELS.contains(&path) {
395 screen.main = crate::cockpit::html::el("div")
396 .class("mj-ui-stack")
397 .child(
398 crate::cockpit::html::el("h1")
399 .class("mj-ui-title")
400 .text(&screen.title),
401 )
402 .child(screen.main);
403 }
404 let body = if fragment {
405 screen.main.render()
406 } else {
407 shell::page(
408 &ctx,
409 &shell::Frame {
410 current: path,
411 stylesheet: self.assets.url(STYLESHEET),
412 favicon: self.assets.url("favicon.svg"),
413 script: self.assets.url(SCRIPT),
414 bar_scripts: BAR_SCRIPTS.iter().map(|s| self.assets.url(s)).collect(),
415 graph_lib: self.assets.url(GRAPH_LIB),
416 assets_present: self.assets.present(),
417 version: self.version,
418 },
419 &screen,
420 )
421 };
422 let mut response = Response::new(screen.status, "text/html; charset=utf-8", body);
423 for (name, value) in [
424 ("Cache-Control", "no-cache"),
425 ("Content-Security-Policy", crate::cockpit::csp()),
426 ("X-Content-Type-Options", "nosniff"),
427 ("Referrer-Policy", "no-referrer"),
428 ] {
429 response.headers.push((name.into(), value.into()));
430 }
431 response
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438
439 #[test]
443 fn every_screen_is_answered_and_every_answered_route_is_a_screen_or_a_panel() {
444 let source = include_str!("mod.rs");
445 let dispatcher = source
446 .split("let mut screen = match path {")
447 .nth(1)
448 .and_then(|rest| rest.split("other =>").next())
449 .expect("the dispatcher's match");
450 let arms: Vec<&str> = dispatcher
451 .lines()
452 .filter_map(|l| l.trim().split_once(" =>").map(|(arm, _)| arm.trim()))
453 .collect();
454 assert_eq!(
455 arms.len(),
456 SCREENS.len() + PANELS.len(),
457 "an arm that is neither a screen nor a panel: {arms:?}"
458 );
459 for screen in SCREENS {
460 assert!(Ui::owns(screen.route), "{} is outside the UI", screen.route);
461 }
462 for panel in PANELS {
463 assert!(Ui::owns(panel), "{panel} is outside the UI");
464 }
465 assert!(Ui::owns("/ui") && Ui::owns("/ui/panels/pull-requests"));
467 assert!(!Ui::owns("/uix") && !Ui::owns("/cockpit"));
468 let mut routes: Vec<&str> = SCREENS.iter().map(|s| s.route).collect();
469 routes.extend(PANELS);
470 routes.sort_unstable();
471 routes.dedup();
472 assert_eq!(
473 routes.len(),
474 SCREENS.len() + PANELS.len(),
475 "a route is declared twice"
476 );
477 }
478
479 #[test]
483 fn the_observatory_is_one_screen_under_its_names() {
484 let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
485 .expect("a synthetic repository");
486 let ui = Ui::new(repo.context().expect("a context"), "0.0.0-test", None);
487 let get = |target: &str| ui.handle(&Request::parse_target("GET", target, Vec::new()));
488 let text = |r: &Response| r.body.text().into_owned();
489 let screen = get(observatory::ROUTE);
490 assert_eq!(screen.status, 200);
491 assert!(text(&screen).contains("data-observatory-mode=\"screen\""));
492 let kiosk = get(observatory::KIOSK);
493 assert_eq!(kiosk.status, 200);
494 assert!(text(&kiosk).contains("data-observatory-mode=\"kiosk\""));
495 for (old, target) in observatory::REDIRECTS {
496 let r = get(&format!("{old}?scene=delivery"));
497 assert_eq!(r.status, 308, "{old}");
498 let to = r
499 .headers
500 .iter()
501 .find(|(k, _)| k == "Location")
502 .map(|(_, v)| v.clone());
503 assert_eq!(to, Some(format!("{target}?scene=delivery")), "{old}");
504 }
505 assert_eq!(
506 observatory::REDIRECTS,
507 &[
508 ("/ui/screensaver", "/ui/kiosk"),
509 ("/ui/obsrvatory", "/ui/observatory")
510 ]
511 );
512 }
513
514 #[test]
518 fn the_ui_answers_every_route_in_process() {
519 use crate::http::router::Request;
520 let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
521 .expect("a synthetic repository");
522 let ctx = repo.context().expect("a context");
523 let share = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share");
524 let ui = Ui::new(std::sync::Arc::clone(&ctx), "0.0.0-test", Some(&share));
525 let get = |target: &str| ui.handle(&Request::parse_target("GET", target, Vec::new()));
526
527 for route in SCREENS
528 .iter()
529 .map(|s| s.route)
530 .chain(PANELS.iter().copied())
531 {
532 let r = get(route);
533 let html = r.body.text();
534 assert_eq!(r.status, 200, "{route}");
535 assert!(
536 html.to_ascii_lowercase().starts_with("<!doctype html>"),
537 "{route} is a page"
538 );
539 assert_eq!(html.matches("<h1").count(), 1, "{route} has one heading");
540 }
541 let fragment = get(&format!("{}?{FRAGMENT}", dashboard::PULL_REQUESTS_PANEL));
542 let html = fragment.body.text();
543 assert!(
544 !html.to_ascii_lowercase().contains("<!doctype") && !html.contains("<h1"),
545 "{html}"
546 );
547
548 let asset = get(&format!("{ASSETS_PREFIX}{SCRIPT}?v=anything"));
549 assert_eq!(asset.status, 200, "the UI's script is served");
550
551 let missing = get("/ui/nowhere");
552 assert_eq!(missing.status, 404);
553 assert!(missing
554 .body
555 .text()
556 .contains("serves no screen at /ui/nowhere"));
557
558 let post = ui.handle(&Request::parse_target("POST", PREFIX, Vec::new()));
559 assert_eq!(post.status, 405);
560
561 let bare = Ui::new(ctx, "0.0.0-test", None);
563 let r = bare.handle(&Request::parse_target("GET", PREFIX, Vec::new()));
564 assert!(r.body.text().contains("the UI is unstyled"));
565 }
566
567 #[test]
570 fn every_screen_renders_its_hero_and_panels() {
571 let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
572 .expect("a synthetic repository");
573 let ctx = repo.context().expect("a context");
574 for (route, view) in [
575 (PREFIX, dashboard::screen(&ctx)),
576 (
577 idd::ROUTE,
578 idd::screen(&ctx, &[("intent".into(), "no-such-intent".into())]),
579 ),
580 (
581 roadmap::ROUTE,
582 roadmap::screen(&ctx, &[("milestone".into(), "M001".into())]),
583 ),
584 (
585 work::ROUTE,
586 work::screen(&ctx, &[("pr".into(), "1".into())]),
587 ),
588 (
589 issues::ROUTE,
590 issues::screen(&ctx, &[("status".into(), "READY".into())]),
591 ),
592 (
593 prs::ROUTE,
594 prs::screen(&ctx, &[("issue".into(), "I0001".into())]),
595 ),
596 (
597 mesh::ROUTE,
598 mesh::screen(&ctx, &[("node".into(), "nowhere".into())]),
599 ),
600 (delivery::ROUTE, delivery::screen(&ctx)),
601 (
602 evidence::ROUTE,
603 evidence::screen(&ctx, &[("claim".into(), "no-such-claim".into())]),
604 ),
605 (
606 governance::ROUTE,
607 governance::screen(&ctx, &[("rule".into(), "no.such-rule".into())]),
608 ),
609 (command::ROUTE, command::screen(&ctx, &[])),
610 (
611 capabilities::ROUTE,
612 capabilities::screen(&ctx, &[("id".into(), "plan.status".into())]),
613 ),
614 (deployments::ROUTE, deployments::screen(&ctx)),
615 (skills::ROUTE, skills::screen(&ctx)),
616 (docs::ROUTE, docs::screen(&ctx)),
617 (knowledge::ROUTE, knowledge::screen(&ctx)),
618 ] {
619 let html = view.main.render();
620 assert_eq!(view.status, 200, "{route}");
621 assert!(html.contains("mj-ui-hero"), "{route} has no hero");
622 assert!(html.contains("mj-ui-panel"), "{route} has no panel");
623 assert!(html.contains("mj-ui-source"), "{route} names no source");
624 assert!(
625 !html.contains("NaN") && !html.contains("undefined"),
626 "{route}"
627 );
628 }
629 }
630}