use crate::{presence, projection, roster};
use anyhow::Result;
use chrono::Utc;
use std::net::{IpAddr, UdpSocket};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::Duration;
const BG: &str = "#1e2127";
const PANEL: &str = "#22262e";
const FG: &str = "#abb2bf";
const DIM: &str = "#5c6370";
const ACCENT: &str = "#61afef";
fn status_color(status: &str) -> &'static str {
match status {
"OPEN" => "#e5c07b",
"CLAIMED" => "#56b6c2",
"BLOCKED" => "#c678dd",
"DONE" => "#98c379",
"ERROR" => "#e06c75",
_ => "#5c6370",
}
}
fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&"),
'<' => o.push_str("<"),
'>' => o.push_str(">"),
'"' => o.push_str("""),
'\'' => o.push_str("'"),
_ => o.push(c),
}
}
o
}
fn fmt_age(secs: i64) -> String {
if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86400)
}
}
fn lan_ip() -> Option<IpAddr> {
let s = UdpSocket::bind("0.0.0.0:0").ok()?;
s.connect("8.8.8.8:80").ok()?;
s.local_addr().ok().map(|a| a.ip())
}
fn health_html(s: &projection::Snapshot) -> String {
let now = Utc::now();
let live = s
.agents
.iter()
.filter(|a| a.presence.as_ref().map(|p| presence::liveness(p, now) == presence::Live::Up).unwrap_or(false))
.count();
let mut parts = Vec::new();
if !s.health.role.is_empty() {
parts.push(format!("<span style='color:{DIM}'>as {}</span>", esc(&s.health.role)));
}
parts.push(format!("<span style='color:#98c379'>● {live} live</span>"));
parts.push(match s.health.reachable {
Some(true) => "<span style='color:#98c379'>hub reachable</span>".to_string(),
Some(false) => "<span style='color:#e06c75'>hub UNREACHABLE</span>".to_string(),
None => format!("<span style='color:{DIM}'>hub …</span>"),
});
if let Some(p) = s.health.pending.filter(|n| *n > 0) {
parts.push(format!("<span style='color:#e5c07b'>↑{p}</span>"));
}
if let Some(b) = s.health.behind.filter(|n| *n > 0) {
parts.push(format!("<span style='color:#e5c07b'>↓{b}</span>"));
}
if let Some(w) = &s.health.watch {
let (t, c) = match w {
crate::watchlock::WatchState::Healthy => ("watch ok", "#98c379"),
_ => ("watch !", "#e06c75"),
};
parts.push(format!("<span style='color:{c}'>{t}</span>"));
}
if let Some(g) = s.health.disk_gb {
let c = if g < 1.0 { "#e06c75" } else { DIM };
parts.push(format!("<span style='color:{c}'>{g:.0}GB free</span>"));
}
parts.join(&format!(" <span style='color:{DIM}'>·</span> "))
}
fn agents_html(s: &projection::Snapshot) -> String {
let now = Utc::now();
if s.agents.is_empty() {
return format!("<div style='color:{DIM}'>no agents</div>");
}
let mut out = String::new();
for a in &s.agents {
let (glyph, col) = match &a.presence {
Some(p) => match presence::liveness(p, now) {
presence::Live::Up => ("●", "#98c379"),
presence::Live::Stale => ("○", "#e5c07b"),
presence::Live::Down => ("✕", "#e06c75"),
},
None => ("·", DIM),
};
let desc = a.desc.as_deref().map(|d| format!(" <span style='color:{DIM}'>— {}</span>", esc(d))).unwrap_or_default();
let xh = if a.xhub.is_empty() {
String::new()
} else {
format!(
" <span style='color:#56b6c2'>≡ {}</span>",
esc(&a.xhub.iter().map(|(l, r)| format!("{l}:{r}")).collect::<Vec<_>>().join(", "))
)
};
out.push_str(&format!(
"<div class='row'><span style='color:{col}'>{glyph}</span> <b>{}</b>{desc}{xh}</div>",
esc(&a.display)
));
}
out
}
fn board_html(s: &projection::Snapshot, show_closed: bool) -> (String, String) {
let b = &s.board;
let title = format!(
"board — {} open · {} claimed · {} blocked · {} backlog · {} closed",
b.open, b.claimed, b.blocked, b.backlog, b.closed
);
let mut out = String::new();
for row in &b.rows {
let closed = !row.is_open() && row.status != "BLOCKED";
if closed && !show_closed {
continue;
}
let label = match &row.resolution {
Some(x) => format!("{}·{x}", row.status),
None => row.status.to_string(),
};
let owner = match row.claimants.as_slice() {
[] => String::new(),
[one] => format!(" <span style='color:{DIM}'>[by {}]</span>", esc(one)),
[first, rest @ ..] => format!(" <span style='color:{DIM}'>[by {}; ⚠ {}]</span>", esc(first), esc(&rest.join(","))),
};
let tag = if row.deferred { " <span style='color:#e5c07b'>⏳</span>" } else { "" };
let bang = if row.stale { "<span style='color:#e06c75'>! </span>" } else { "" };
out.push_str(&format!(
"<div class='row'>{bang}<span style='color:{};font-weight:600'>{:<12}</span>\
<span style='color:{DIM}'>{:>4}</span> {} <span style='color:{DIM}'>→</span> {}{tag} \
<span style='color:{DIM}'>—</span> {}{owner}</div>",
status_color(row.status),
esc(&label),
fmt_age(row.age_secs),
esc(roster::display(&s.roster, &row.from)),
esc(&projection::render_targets(&s.roster, &row.to)),
esc(&crate::truncate(&row.summary, 72)),
));
}
if out.is_empty() {
out = format!("<div style='color:{DIM}'>no open requests</div>");
}
(title, out)
}
fn tail_html(s: &projection::Snapshot) -> String {
if s.tail.is_empty() {
return format!("<div style='color:{DIM}'>no activity</div>");
}
let mut out = String::new();
for t in s.tail.iter().rev().take(25).rev() {
out.push_str(&format!(
"<div class='row'><span style='color:{DIM}'>{}</span> \
<span style='color:{}'>{:<9}</span>{} <span style='color:{DIM}'>→</span> {} \
<span style='color:{DIM}'>—</span> {}</div>",
esc(&t.time),
status_color(&t.kind.to_uppercase()),
esc(&t.kind),
esc(roster::display(&s.roster, &t.from)),
esc(&projection::render_targets(&s.roster, &t.to)),
esc(&crate::truncate(&t.summary, 80)),
));
}
out
}
fn page(snaps: &[projection::Snapshot], sel: usize, show_closed: bool) -> String {
let closed_q = if show_closed { "&closed=1" } else { "" };
let tabs: String = snaps
.iter()
.enumerate()
.map(|(i, s)| {
let active = i == sel;
let style = if active {
format!("background:#56b6c2;color:#1e2127;font-weight:700;border-radius:4px")
} else {
format!("color:{ACCENT}")
};
format!("<a href='/?hub={i}{closed_q}' style='{style};padding:2px 8px;margin-right:4px;text-decoration:none'>{}</a>", esc(&s.label))
})
.collect();
let s = &snaps[sel];
if let Some(err) = &s.error {
return shell(&s.label, &tabs, &format!("<div class='panel' style='color:#e06c75'>⚠ {}</div>", esc(err)));
}
let (btitle, board) = board_html(s, show_closed);
let toggle = if show_closed {
format!("<a href='/?hub={sel}' style='color:{DIM}'>hide closed</a>")
} else {
format!("<a href='/?hub={sel}&closed=1' style='color:{DIM}'>show closed</a>")
};
let body = format!(
"<div class='bar'>{tabs}<br><span style='margin-left:2px'>{}</span></div>\
<div class='wrap'>\
<div class='panel agents'><div class='pt'>agents ({})</div>{}</div>\
<div class='panel board'><div class='pt'>{}</div>{}</div>\
<div class='panel tail'><div class='pt'>activity</div>{}</div>\
</div>\
<div class='foot'>confer web view · read-only · auto-refresh 5s · {toggle} · the live TUI is <code>confer dashboard</code></div>",
health_html(s),
s.agents.len(),
agents_html(s),
esc(&btitle),
board,
tail_html(s),
);
shell(&s.label, &tabs, &body)
}
fn shell(title: &str, _tabs: &str, body: &str) -> String {
format!(
"<!doctype html><html><head><meta charset='utf-8'>\
<meta name='viewport' content='width=device-width, initial-scale=1'>\
<meta http-equiv='refresh' content='5'>\
<title>confer · {}</title><style>\
*{{box-sizing:border-box}}\
body{{margin:0;background:{BG};color:{FG};font-family:ui-monospace,'SF Mono',Menlo,Consolas,monospace;font-size:13px;line-height:1.5}}\
.bar{{background:{PANEL};padding:8px 12px;border-bottom:1px solid #333;position:sticky;top:0}}\
.wrap{{display:flex;flex-wrap:wrap;gap:10px;padding:10px}}\
.panel{{background:{PANEL};border:1px solid #333;border-radius:6px;padding:8px 10px;overflow:auto}}\
.agents{{flex:1 1 320px}} .board{{flex:2 1 460px}} .tail{{flex:1 1 100%}}\
.pt{{color:{ACCENT};font-weight:600;margin-bottom:6px;border-bottom:1px solid #333;padding-bottom:4px}}\
.row{{white-space:pre-wrap;word-break:break-word}}\
.foot{{padding:6px 12px;color:{DIM}}} a{{text-decoration:none}}\
</style></head><body>{}</body></html>",
esc(title),
body
)
}
fn parse_query(url: &str) -> (usize, bool) {
let q = url.splitn(2, '?').nth(1).unwrap_or("");
let mut hub = 0;
let mut closed = false;
for kv in q.split('&') {
match kv.split_once('=') {
Some(("hub", v)) => hub = v.parse().unwrap_or(0),
Some(("closed", v)) => closed = v == "1" || v == "true",
_ => {}
}
}
(hub, closed)
}
pub fn run(dirs: Vec<PathBuf>, bind: &str) -> Result<()> {
let snaps: Vec<projection::Snapshot> = dirs.iter().map(|d| projection::Snapshot::load(d.clone(), true)).collect();
let cache = Arc::new(Mutex::new(snaps));
{
let cache = Arc::clone(&cache);
let dirs = dirs.clone();
std::thread::spawn(move || {
const STAGGER: Duration = Duration::from_secs(3);
const SWEEP_REST: Duration = Duration::from_secs(12);
loop {
for (i, dir) in dirs.iter().enumerate() {
let reachable = crate::gitcmd::integrate(dir).is_ok();
let _ = presence::load_all(dir, true);
let mut snap = projection::Snapshot::load(dir.clone(), false);
snap.health.reachable = Some(reachable);
if let Ok(mut c) = cache.lock() {
if i < c.len() {
c[i] = snap;
}
}
std::thread::sleep(STAGGER);
}
std::thread::sleep(SWEEP_REST);
}
});
}
let server = tiny_http::Server::http(bind).map_err(|e| anyhow::anyhow!("bind {bind}: {e}"))?;
let n = cache.lock().map(|c| c.len()).unwrap_or(0);
let port = bind.rsplit(':').next().unwrap_or("8787");
eprintln!("confer serve: {n} hub(s), read-only, on:");
eprintln!(" http://localhost:{port}");
if let Some(ip) = lan_ip() {
eprintln!(" http://{ip}:{port} ← open this on your phone (same wifi)");
}
eprintln!("(Ctrl-C to stop)");
for req in server.incoming_requests() {
let (mut sel, closed) = parse_query(req.url());
let html = {
let snaps = cache.lock().unwrap();
if snaps.is_empty() {
"<h1>no hubs</h1>".to_string()
} else {
if sel >= snaps.len() {
sel = 0;
}
page(&snaps, sel, closed)
}
};
let header = tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap();
let _ = req.respond(tiny_http::Response::from_string(html).with_header(header));
}
Ok(())
}