redesign: light project site + live network status from a lusers probe
This commit is contained in:
parent
182f14a7f2
commit
d1ec6962dd
10 changed files with 473 additions and 282 deletions
38
src/main.rs
38
src/main.rs
|
|
@ -1,9 +1,11 @@
|
|||
//! The echoIRCd project website: a small Axum server that renders a handful of
|
||||
//! compile-time Askama templates. Templates and static assets are baked into the
|
||||
//! binary, so the deployed artifact is fully self-contained.
|
||||
//! The echoIRCd project website: an Axum server rendering compile-time Askama
|
||||
//! templates, with a small live "network status" pulled from the running ircd.
|
||||
|
||||
mod stats;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
|
|
@ -12,10 +14,27 @@ use axum::{
|
|||
use std::net::SocketAddr;
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
|
||||
/// Display-ready snapshot of the live network state for templates.
|
||||
struct Status {
|
||||
online: bool,
|
||||
users: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
fn view(shared: &stats::Shared) -> Status {
|
||||
let s = shared.read().ok().map(|g| g.clone()).unwrap_or_default();
|
||||
Status {
|
||||
online: s.online,
|
||||
users: s.users.map(|n| n.to_string()).unwrap_or_else(|| "—".into()),
|
||||
version: s.version.unwrap_or_else(|| "5.0.0".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
struct IndexTemplate {
|
||||
active: &'static str,
|
||||
status: Status,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
|
@ -30,7 +49,6 @@ struct ConnectTemplate {
|
|||
active: &'static str,
|
||||
}
|
||||
|
||||
/// Render a template to an HTML response, or a 500 if rendering fails.
|
||||
fn page<T: Template>(t: T) -> Response {
|
||||
match t.render() {
|
||||
Ok(body) => Html(body).into_response(),
|
||||
|
|
@ -41,8 +59,8 @@ fn page<T: Template>(t: T) -> Response {
|
|||
}
|
||||
}
|
||||
|
||||
async fn index() -> Response {
|
||||
page(IndexTemplate { active: "home" })
|
||||
async fn index(State(shared): State<stats::Shared>) -> Response {
|
||||
page(IndexTemplate { active: "home", status: view(&shared) })
|
||||
}
|
||||
async fn features() -> Response {
|
||||
page(FeaturesTemplate { active: "features" })
|
||||
|
|
@ -66,7 +84,6 @@ async fn favicon() -> impl IntoResponse {
|
|||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn not_found() -> Response {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
|
|
@ -84,6 +101,9 @@ async fn main() {
|
|||
)
|
||||
.init();
|
||||
|
||||
let shared: stats::Shared = Default::default();
|
||||
stats::spawn_updater(shared.clone());
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/features", get(features))
|
||||
|
|
@ -93,7 +113,8 @@ async fn main() {
|
|||
.route("/health", get(health))
|
||||
.fallback(not_found)
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http());
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(shared);
|
||||
|
||||
let addr: SocketAddr = std::env::var("ECHO_WEB_ADDR")
|
||||
.unwrap_or_else(|_| "127.0.0.1:8099".into())
|
||||
|
|
@ -111,7 +132,6 @@ async fn main() {
|
|||
.expect("server error");
|
||||
}
|
||||
|
||||
/// Resolve on SIGINT (Ctrl-C) or SIGTERM (systemd stop) for a clean shutdown.
|
||||
async fn shutdown() {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
||||
|
|
|
|||
101
src/stats.rs
Normal file
101
src/stats.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
//! Live network status, polled from the running ircd. A background task connects
|
||||
//! to the local daemon as an ordinary client every minute, runs LUSERS/VERSION,
|
||||
//! parses the reply, and stores it. If the daemon is unreachable the state simply
|
||||
//! reads "offline" — it never blocks or breaks page rendering.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{sleep, timeout};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct NetStats {
|
||||
pub online: bool,
|
||||
pub users: Option<u64>,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
pub type Shared = Arc<RwLock<NetStats>>;
|
||||
|
||||
/// Spawn the background poller. Refreshes every 60s.
|
||||
pub fn spawn_updater(shared: Shared) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let next = match timeout(Duration::from_secs(7), probe()).await {
|
||||
Ok(Some(s)) => s,
|
||||
_ => NetStats::default(),
|
||||
};
|
||||
if let Ok(mut g) = shared.write() {
|
||||
*g = next;
|
||||
}
|
||||
sleep(Duration::from_secs(60)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn probe() -> Option<NetStats> {
|
||||
let addr = std::env::var("ECHO_IRCD_ADDR").unwrap_or_else(|_| "127.0.0.1:6667".into());
|
||||
let stream = TcpStream::connect(&addr).await.ok()?;
|
||||
let (rd, mut wr) = stream.into_split();
|
||||
wr.write_all(b"NICK echostat\r\nUSER echostat 0 * :echo website status\r\n")
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let mut lines = BufReader::new(rd).lines();
|
||||
let (mut users, mut version, mut asked) = (None, None, false);
|
||||
|
||||
for _ in 0..100 {
|
||||
let line = match lines.next_line().await {
|
||||
Ok(Some(l)) => l,
|
||||
_ => break,
|
||||
};
|
||||
let line = line.trim_end_matches('\r');
|
||||
if let Some(rest) = line.strip_prefix("PING ") {
|
||||
let _ = wr.write_all(format!("PONG {rest}\r\n").as_bytes()).await;
|
||||
continue;
|
||||
}
|
||||
match numeric(line) {
|
||||
Some("001") if !asked => {
|
||||
let _ = wr.write_all(b"LUSERS\r\nVERSION\r\n").await;
|
||||
asked = true;
|
||||
}
|
||||
Some("251") => users = users.or_else(|| parse_users(line)),
|
||||
Some("351") => version = version.or_else(|| parse_version(line)),
|
||||
_ => {}
|
||||
}
|
||||
if users.is_some() && version.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = wr.write_all(b"QUIT :bye\r\n").await;
|
||||
Some(NetStats { online: true, users, version })
|
||||
}
|
||||
|
||||
/// The three-digit numeric of an IRC line (`:src 251 nick …`), if any.
|
||||
fn numeric(line: &str) -> Option<&str> {
|
||||
let mut it = line.split(' ');
|
||||
let first = it.next()?;
|
||||
let code = if first.starts_with(':') { it.next()? } else { first };
|
||||
(code.len() == 3 && code.bytes().all(|b| b.is_ascii_digit())).then_some(code)
|
||||
}
|
||||
|
||||
/// RPL_LUSERCLIENT trailing: "There are 5 users on 1 server" → first number.
|
||||
fn parse_users(line: &str) -> Option<u64> {
|
||||
line.splitn(2, " :")
|
||||
.nth(1)?
|
||||
.split_whitespace()
|
||||
.find_map(|w| w.parse::<u64>().ok())
|
||||
}
|
||||
|
||||
/// RPL_VERSION trailing: "echoircd 5.0.0 · …" → "5.0.0".
|
||||
fn parse_version(line: &str) -> Option<String> {
|
||||
let tail = line.splitn(2, " :").nth(1)?;
|
||||
let mut it = tail.split_whitespace();
|
||||
let a = it.next()?;
|
||||
if a.eq_ignore_ascii_case("echoircd") {
|
||||
it.next().map(|v| v.to_string())
|
||||
} else {
|
||||
Some(a.to_string())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue