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::{JobState, JobSummary, Status};
use crate::assets;
use crate::format;
pub(crate) mod caches;
pub(crate) mod config;
pub(crate) mod history;
pub(crate) mod job;
pub(crate) mod overview;
pub(crate) mod sizing;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Page {
Overview,
History,
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::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";
meta name="color-scheme" content="light 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="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" } @else { "Open the dashboard from your terminal" } }
p { "Run " code { "runnerctl web" } " on this machine. It opens a one-time link that signs this browser in." }
p.muted { "Each link works once. The daemon writes a fresh one to " code { "$XDG_RUNTIME_DIR/runnerd.web" } " after every use." }
}
};
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)
}
}
struct Outcome {
label: &'static str,
class: &'static str,
}
fn outcome(job: &JobSummary) -> Outcome {
let (label, class) = match (job.state, job.conclusion.as_deref()) {
(JobState::Queued, _) => ("queued", "queued"),
(JobState::Running, _) => ("running", "running"),
(JobState::Lost, _) => ("lost", "failure"),
(JobState::Elsewhere, _) => ("elsewhere", "neutral"),
(JobState::Completed, Some("success")) => ("success", "success"),
(JobState::Completed, Some("failure")) => ("failure", "failure"),
(JobState::Completed, Some("timed_out")) => ("timed out", "failure"),
(JobState::Completed, Some("cancelled")) => ("cancelled", "neutral"),
(JobState::Completed, Some("skipped")) => ("skipped", "neutral"),
(JobState::Completed, _) => ("completed", "neutral"),
};
Outcome { label, class }
}
pub(crate) fn badge(job: &JobSummary) -> Markup {
let Outcome { label, class } = outcome(job);
html! { span class=(format!("badge {class}")) { (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) struct Durations {
pub(crate) waited: Option<u64>,
pub(crate) ran: Option<u64>,
}
pub(crate) fn durations(job: &JobSummary, now: u64) -> Durations {
let timeline = &job.timeline;
let waited = timeline
.started_at
.map(|started| started.saturating_sub(timeline.queued_at));
let ran = timeline
.started_at
.map(|started| timeline.finished_at.unwrap_or(now).saturating_sub(started));
Durations { waited, ran }
}
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 repository_id(name: &str) -> String {
format!("repo-{}", hex(name))
}
#[cfg(test)]
mod tests {
use super::*;
#[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'");
}
}