Files
OpenPXE/crates/core/src/log_bus.rs
T
Miles Ward e3452fe976 v0.3.0 — rebrand: PXEForge → OpenPXE, Gated → Queued Deployment
Full rename to match the openpxe.com brand. The product now reads as a
polished open-source project rather than a personal-tool nickname:
the anvil/forge metaphor is gone, replaced with the rainbow-horizon
brand mark from the marketing site.

## Naming changes

**PXEForge → OpenPXE** everywhere it's user-visible or developer-
facing:
- All 8 crate package names (`pxeforge-*` → `openpxe-*`).
- The bin crate dir + binary (`crates/pxeforge` → `crates/openpxe`,
  `bin = "openpxe"`).
- Env vars: `PXEFORGE_*` → `OPENPXE_*` (no compat shim — pre-beta).
- Tracing targets: `pxeforge::*` → `openpxe::*`.
- Prometheus metrics: `pxeforge_*` → `openpxe_*` (pre-beta; nobody
  has dashboards on these yet).
- Container image: `gitea.milesward.dev/mward4/openpxe:0.3.0`.
- All in-tree paths: `/var/lib/openpxe/{isos,work,smb}`,
  `/usr/share/openpxe/ipxe`, `/etc/openpxe/...`.
- Unraid template renamed `pxeforge.xml` → `openpxe.xml`.
- README, NEXT_PHASE.md, architecture.md, comments, and the WebUI
  brand string.

**Gated Deployment → Queued Deployment** as the user-facing concept:
- `Settings::TimeoutAction::GatedDeployment` →
  `QueuedDeployment` (with `#[serde(alias = "gated_deployment")]`
  so v0.2.0 settings.json files keep deserializing).
- Rust types: `Gate` → `QueueEntry`, `GateQueue` → `DeploymentQueue`,
  `GateInner` → `QueueEntryInner`.
- File: `crates/core/src/gate.rs` → `crates/core/src/queue.rs`.
- HTTP routes: `/api/gate/*` → `/api/queue/*`. The JSON list key
  flipped from `"gates"` to `"entries"` to match.
- iPXE shortcut: `/boot/_gate.ipxe` → `/boot/_queue.ipxe`. The
  top-level menu's item id is now `queue` instead of `gate`.
- WebUI sidebar tab: "Forge Gate" → "Queue".
- Field on `AppState`: `gates` → `queue`.

## Brand assets

The anvil + forging-sparks logos are dropped:
- `logo.svg` is now a 24×24 medallion filled with the
  `rainbow-horizon` gradient from openpxe.com (sliding hue rotation
  via SMIL on the gradient stops, no JS needed).
- `anvil-forge.svg` renamed to `loader.svg` and rebuilt as a 64×64
  louder version of the same disc — used for page-load transitions
  and the imaging-progress widget. Adds a subtle scale pulse and a
  white inner-glow so it has dimensionality on either theme.

## CSS rename

- `.forge-progress` → `.queue-progress`
- `.forge-progress .anvil` → `.queue-progress .mark`
- `@keyframes forge-sheen` → `queue-sheen`
- `.loader .anvil` → `.loader .mark`
- "Heating the forge…" loader text → "Loading…"

The rest of the layout is untouched. Light/dark theme tokens and the
sidebar/topbar structure carry over from v0.2.0 unchanged — the
brief was "keeping the UI similar."

## Validation

- `cargo build --workspace` — clean.
- `cargo clippy --workspace --all-targets` — no warnings.
- `cargo test --workspace` — **66 tests passing**, same as v0.2.0.
- Local smoke run against the rebuilt release binary verifies:
  - `/boot.ipxe` emits `Queued Deployment` + `item queue` + chains
    `/boot/_queue.ipxe`
  - `/api/queue` returns `{count, entries}`
  - `/metrics` emits `openpxe_queue_count` (renamed)
  - `/assets/logo.svg` and `/assets/loader.svg` serve the new
    rainbow brand SVGs
  - `/api/status` reports version `0.3.0`

## Migration notes for operators on v0.2.0

- Container image path changed: pull
  `gitea.milesward.dev/mward4/openpxe:0.3.0` (not `pxeforge:`).
- Bind mounts: `/var/lib/openpxe/{isos,work,smb}` (not `pxeforge`).
  Move the host path or update the template.
- Env vars: replace `PXEFORGE_*` with `OPENPXE_*`. The Unraid
  template at `deploy/unraid/openpxe.xml` is already updated.
- `settings.json` carries over transparently — the
  `gated_deployment` value is accepted as an alias.
- HTTP API: any external scripts that hit `/api/gate/*` need to
  switch to `/api/queue/*`. The JSON envelope key is `entries`
  instead of `gates`.
2026-05-06 14:13:38 -04:00

201 lines
5.9 KiB
Rust

//! In-process log bus.
//!
//! The web UI's Terminal tab streams live server logs over SSE. To feed it
//! we install a `tracing_subscriber::Layer` that captures formatted lines
//! and pushes them onto:
//!
//! 1. A bounded `tokio::sync::broadcast` channel for live subscribers.
//! 2. A small in-memory ring buffer (default 500 lines) so a UI that
//! connects mid-session sees recent context, not a blank pane.
//!
//! No file logging happens here — Docker/OpenShift already capture stdout.
//! This is purely an extra fan-out for the UI.
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::sync::Arc;
use time::OffsetDateTime;
use tokio::sync::broadcast;
use tracing::field::{Field, Visit};
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::layer::Context;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::Layer;
/// One captured log line. Cheap to clone (small struct, short strings).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogLine {
#[serde(with = "time::serde::rfc3339")]
pub timestamp: OffsetDateTime,
/// Lowercase: `error`, `warn`, `info`, `debug`, `trace`.
pub level: String,
pub target: String,
pub message: String,
}
impl LogLine {
/// Compact one-line "tail -f"-style render.
#[must_use]
pub fn render(&self) -> String {
// 2026-04-29T12:34:56Z [info] openpxe::http: HTTP listening on 0.0.0.0:80
let ts = self
.timestamp
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "?".into());
format!(
"{ts} [{:>5}] {}: {}",
self.level, self.target, self.message
)
}
}
#[derive(Debug)]
pub struct LogBus {
tx: broadcast::Sender<LogLine>,
buf: Mutex<VecDeque<LogLine>>,
cap: usize,
}
impl LogBus {
#[must_use]
pub fn new(capacity: usize) -> Arc<Self> {
// 256 = upper bound on concurrent live subscribers' lag tolerance.
// If a slow client falls behind it'll get a Lagged error and skip
// ahead — which is what we want for a live tail.
let (tx, _) = broadcast::channel(256);
Arc::new(Self {
tx,
buf: Mutex::new(VecDeque::with_capacity(capacity)),
cap: capacity,
})
}
/// Subscribe to new log lines as they're emitted.
#[must_use]
pub fn subscribe(&self) -> broadcast::Receiver<LogLine> {
self.tx.subscribe()
}
/// Snapshot of the recent ring buffer (oldest → newest).
#[must_use]
pub fn recent(&self) -> Vec<LogLine> {
self.buf.lock().iter().cloned().collect()
}
/// Drop everything in the recent ring buffer.
pub fn clear(&self) {
self.buf.lock().clear();
}
/// Manually push a synthetic log line (used by the terminal-command
/// handler so operator commands appear inline in the live tail).
pub fn push(&self, level: &str, target: &str, message: impl Into<String>) {
let line = LogLine {
timestamp: OffsetDateTime::now_utc(),
level: level.to_string(),
target: target.to_string(),
message: message.into(),
};
self.record(line);
}
fn record(&self, line: LogLine) {
{
let mut g = self.buf.lock();
if g.len() == self.cap {
g.pop_front();
}
g.push_back(line.clone());
}
// Send errors are fine — just means no live subscribers right now.
let _ = self.tx.send(line);
}
}
/// `tracing_subscriber::Layer` that funnels every event into the LogBus.
///
/// Install once in `main` alongside the existing `fmt::layer()` so console
/// output and the UI tail see the same stream.
pub struct LogBusLayer {
bus: Arc<LogBus>,
}
impl LogBusLayer {
#[must_use]
pub fn new(bus: Arc<LogBus>) -> Self {
Self { bus }
}
}
impl<S> Layer<S> for LogBusLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let meta = event.metadata();
let level = match *meta.level() {
Level::ERROR => "error",
Level::WARN => "warn",
Level::INFO => "info",
Level::DEBUG => "debug",
Level::TRACE => "trace",
};
let mut visitor = MessageVisitor::default();
event.record(&mut visitor);
let line = LogLine {
timestamp: OffsetDateTime::now_utc(),
level: level.to_string(),
target: meta.target().to_string(),
message: visitor.message,
};
self.bus.record(line);
}
}
#[derive(Default)]
struct MessageVisitor {
message: String,
}
impl Visit for MessageVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.message = value.to_string();
}
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{value:?}").trim_matches('"').to_string();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn push_and_recent() {
let bus = LogBus::new(3);
bus.push("info", "test", "first");
bus.push("info", "test", "second");
bus.push("info", "test", "third");
bus.push("info", "test", "fourth");
let r = bus.recent();
assert_eq!(r.len(), 3);
assert_eq!(r[0].message, "second");
assert_eq!(r[2].message, "fourth");
}
#[tokio::test(flavor = "current_thread")]
async fn subscribe_sees_new_lines() {
let bus = LogBus::new(8);
let mut rx = bus.subscribe();
bus.push("info", "test", "live");
let l = rx.recv().await.unwrap();
assert_eq!(l.message, "live");
assert_eq!(l.level, "info");
}
}