use std::cell::RefCell;
use std::fmt::Write as _;
use std::hash::{DefaultHasher, Hash as _, Hasher as _};
use maud::{DOCTYPE, Markup, PreEscaped, html};
use runnerd_core::api::{self, JobSummary, Status};
use crate::assets;
use crate::format;
pub(crate) mod caches;
pub(crate) mod config;
pub(crate) mod history;
pub(crate) mod insights;
pub(crate) mod job;
pub(crate) mod overview;
pub(crate) mod sizing;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Page {
Overview,
History,
Insights,
Caches,
Sizing,
Config,
}
const MARK: &str = include_str!("../assets/mark.svg");
#[derive(Debug, Default)]
pub(crate) struct Parts {
parts: RefCell<Vec<Part>>,
clocked: RefCell<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Part {
pub(crate) id: String,
pub(crate) group: Option<String>,
pub(crate) html: String,
}
impl Parts {
pub(crate) fn html(&self, id: &str, markup: Markup) -> Markup {
self.push(id, None, markup)
}
pub(crate) fn stretch(&self, id: &str, group: &str, markup: Markup) -> Markup {
self.push(id, Some(group), markup)
}
pub(crate) fn clocked(&self, value: String) -> String {
self.clocked.borrow_mut().push(value.clone());
value
}
fn push(&self, id: &str, group: Option<&str>, markup: Markup) -> Markup {
self.parts.borrow_mut().push(Part {
id: id.to_owned(),
group: group.map(str::to_owned),
html: markup.0.clone(),
});
markup
}
pub(crate) fn into_parts(self) -> (Vec<Part>, Vec<String>) {
(self.parts.into_inner(), self.clocked.into_inner())
}
}
pub(crate) fn digest(html: &str) -> String {
let mut hasher = DefaultHasher::new();
html.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub(crate) struct Live<'url> {
pub(crate) url: &'url str,
pub(crate) at: u64,
pub(crate) log: Option<u64>,
pub(crate) regions: Vec<(&'static str, String)>,
}
impl Live<'_> {
fn signals(&self) -> String {
let mut signals = format!("{{shown: {{at: {}", self.at);
if let Some(log) = self.log {
let _ = write!(signals, ", log: {log}");
}
for (id, digest) in &self.regions {
let _ = write!(signals, ", {}: {}", js(id), js(digest));
}
signals.push_str("}}");
signals
}
}
pub(crate) fn page(
title: &str,
active: Option<Page>,
live: Option<&Live<'_>>,
main: &Markup,
) -> Markup {
let nav = [
(Page::Overview, "/", "Overview"),
(Page::History, "/history", "History"),
(Page::Insights, "/insights", "Insights"),
(Page::Caches, "/caches", "Caches"),
(Page::Sizing, "/sizing", "Sizing"),
(Page::Config, "/config", "Config"),
];
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover";
meta name="color-scheme" content="light dark";
meta name="theme-color" content="#e9ece9" media="(prefers-color-scheme: light)";
meta name="theme-color" content="#070908" media="(prefers-color-scheme: dark)";
meta name="description" content="runnerd's dashboard: its slots, jobs, caches and config on this machine.";
title { (title) " · runnerd" }
link rel="icon" href=(assets::path("mark.svg")) type="image/svg+xml";
link rel="manifest" href=(assets::path("manifest.webmanifest"));
link rel="preload" href=(assets::path("libre-franklin.woff2")) as="font" type="font/woff2" crossorigin;
link rel="stylesheet" href=(assets::path("style.css"));
script src=(assets::path("theme.js")) {}
script type="module" src=(assets::path("datastar.js")) {}
}
body data-signals=[live.map(Live::signals)] {
a.skip href="#main" { "Skip to content" }
header.top {
a.wordmark href="/" aria-label="runnerd overview" {
span.mark aria-hidden="true" { (PreEscaped(MARK)) }
"runnerd"
}
nav aria-label="Dashboard" {
@for (page, href, label) in nav {
@if active == Some(page) {
a href=(href) aria-current="page" { (label) }
} @else {
a href=(href) { (label) }
}
}
}
@if let Some(live) = live {
span.connection data-indicator:_live=""
data-init=(format!("@get('{}', {{retryMaxCount: 1000000, retryMaxWait: 10000}})", live.url))
data-class:online="$_live"
data-attr:title="$_live ? 'Updates as it happens' : 'Not connected; retrying'" {
span.dot aria-hidden="true" {}
span data-text="$_live ? 'Live' : 'Offline'" { "Live" }
}
}
button.theme type="button" data-theme-toggle aria-label="Switch between light and dark" {
svg viewBox="0 0 20 20" aria-hidden="true" {
circle cx="10" cy="10" r="8" fill="none" stroke="currentColor" stroke-width="2" {}
path d="M10 2a8 8 0 0 1 0 16z" fill="currentColor" {}
}
}
}
main #main { (main) }
div #notice role="status" aria-live="polite" {}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SignedOut {
NoSession,
CodeUsed,
}
pub(crate) fn signed_out(reason: SignedOut) -> Markup {
let main = html! {
section.signed-out {
h1 { @if reason == SignedOut::CodeUsed { "That link was used already, or it expired" } @else { "Sign in to runnerd" } }
p { "On another device, such as a phone: open the dashboard on runnerd's computer, choose " strong { "Add a device" } " on the Config page, and scan the QR code. Or run " code { "runnerctl web --qr" } " on that computer." }
p { "On runnerd's computer: run " code { "runnerctl web" } ". It opens a link that signs this browser in." }
p.muted { "Each link works once. A device stays signed in for 30 days after its last visit." }
}
};
page("Sign in", None, None, &main)
}
pub(crate) fn error_page(title: &str, message: &str) -> Markup {
let main = html! {
section.signed-out {
h1 { (title) }
p { (message) }
p { a href="/" { "Back to the overview" } }
}
};
page(title, None, None, &main)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tone {
Done,
Failed,
}
pub(crate) fn notice(message: &str, tone: Tone) -> Markup {
html! {
div #notice role="status" aria-live="polite" {
p class=(if tone == Tone::Failed { "toast failed" } else { "toast" }) data-init="setTimeout(() => el.remove(), 8000)" { (message) }
}
}
}
pub(crate) fn question(message: &str, action: &str, label: &str, dismiss: &str) -> Markup {
html! {
div #notice role="status" aria-live="polite" {
div.toast.failed.question {
p { (message) }
div.toast-actions {
button.button-danger type="button" data-indicator:_answering=""
data-attr:aria-busy="$_answering && 'true'" data-attr:disabled="$_answering"
data-on:click=(action) { (label) }
button.button-quiet type="button" data-on:click="el.closest('.toast').remove()" { (dismiss) }
}
}
}
}
}
pub(crate) fn profile_class(status: &Status, profile: &str) -> String {
let index = status
.profiles
.iter()
.position(|candidate| candidate.name == profile)
.unwrap_or(usize::MAX);
if index == usize::MAX {
"p-none".to_owned()
} else {
format!("p{}", index % 6)
}
}
pub(crate) fn outcome_kind_class(kind: api::OutcomeKind) -> &'static str {
match kind {
api::OutcomeKind::Succeeded => "success",
api::OutcomeKind::Failed => "failure",
api::OutcomeKind::Running => "running",
api::OutcomeKind::Waiting => "queued",
api::OutcomeKind::Ended => "neutral",
}
}
pub(crate) fn outcome_class(job: &JobSummary) -> &'static str {
outcome_kind_class(job.outcome().kind)
}
pub(crate) fn badge(job: &JobSummary) -> Markup {
let outcome = job.outcome();
html! { span class=(format!("badge {}", outcome_kind_class(outcome.kind))) { (outcome.label) } }
}
pub(crate) fn time_ago(at: u64, now: u64) -> Markup {
html! { time datetime=(format::rfc3339(at)) title=(format::timestamp(at)) { (format::ago(at, now)) } }
}
pub(crate) fn js(text: &str) -> String {
let mut literal = String::with_capacity(text.len() + 2);
literal.push('\'');
for c in text.chars() {
match c {
'\\' | '\'' => {
literal.push('\\');
literal.push(c);
}
'\n' => literal.push_str("\\n"),
_ => literal.push(c),
}
}
literal.push('\'');
literal
}
fn hex(text: &str) -> String {
let mut hex = String::with_capacity(text.len() * 2);
for byte in text.bytes() {
let _ = write!(hex, "{byte:02x}");
}
hex
}
pub(crate) fn slot_id(profile: &str, slot: usize) -> String {
format!("slot-{}-{slot}", hex(profile))
}
pub(crate) fn block_size(memory: u64) -> Markup {
let full = format::bytes(memory);
let short = runnerd_core::config::ByteSize(memory).short();
html! {
span.size {
@if full.chars().count() > 5 {
(short)
} @else if short == full {
(full)
} @else {
span.full { (full) }
span.short { (short) }
}
}
}
}
pub(crate) fn repository_id(name: &str) -> String {
format!("repo-{}", hex(name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_block_reads_its_full_size_as_the_tui_does_and_the_short_one_only_when_narrow() {
let block = block_size(3328 << 20).into_string();
assert!(
block.contains(r#"<span class="full">3.25G</span>"#)
&& block.contains(r#"<span class="short">3.3G</span>"#),
"{block}"
);
assert_eq!(
block_size(8 << 30).into_string(),
r#"<span class="size">8G</span>"#
);
assert_eq!(
block_size(1535 << 19).into_string(),
r#"<span class="size">768M</span>"#,
"767.5M is too long for a block, so it reads short whatever the width"
);
}
#[test]
fn names_whose_slugs_match_get_their_own_ids() {
assert_ne!(slot_id("node-js", 1), slot_id("node_js", 1));
assert_ne!(slot_id("a", 11), slot_id("a1", 1));
assert_ne!(repository_id("halyard/b-c"), repository_id("halyard/b_c"));
}
#[test]
fn a_quote_in_a_job_name_stays_inside_its_expression_string() {
assert_eq!(js(r"it's a \ test"), r"'it\'s a \\ test'");
}
}