Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4119b30
feat(console): add per-route in-flight tracking to ConsoleStore
been-there-done-that May 5, 2026
0ca2458
feat(console): expose idle_secs() on supervised engines
been-there-done-that May 5, 2026
ad83fa8
feat(console): extend MetricsSample with p50/p55/conv_rps/queue_wait …
been-there-done-that May 5, 2026
e55e6b9
feat(console): update all payload structs with new fields
been-there-done-that May 5, 2026
8fcce93
feat(console): fix *Payload constructor calls to match new struct fields
been-there-done-that May 5, 2026
78c4040
fix(console): per-route RPS, in-flight, and load_pct now computed cor…
been-there-done-that May 5, 2026
531322d
feat(console): compute p50/p55, per-engine conv RPS, queue wait p95 i…
been-there-done-that May 5, 2026
1e5d5dc
feat(console): assemble all new payload fields in build_console_payload
been-there-done-that May 5, 2026
d0758bd
feat(console): wire up build_batch_payloads with real BatchStateManag…
been-there-done-that May 5, 2026
ef50248
feat(console): update frontend types to match new payload structs
been-there-done-that May 5, 2026
51116df
feat(console): v4 layout — 2×2 chart grid, enhanced side-rail, Activi…
been-there-done-that May 5, 2026
e1f4f17
feat: console observability + real batch processing
been-there-done-that May 5, 2026
ce8eab8
fix(console): accurate memory/CPU metrics and better concurrency display
been-there-done-that May 5, 2026
e39b0bf
refactor(console): split CPU/Memory into own charts, engine conv/sec …
been-there-done-that May 5, 2026
99f9a60
fix(dev): build UI inside Docker so make dev always serves fresh UI
been-there-done-that May 5, 2026
a10bcd2
fix(console): clarify engine conv labels and queue wait p95 description
been-there-done-that May 5, 2026
7021649
fix(console): routes table shows real HTTP method instead of hardcode…
been-there-done-that May 5, 2026
4c9b165
fix(dev): restrict cargo-watch to crates/ to avoid UI build triggerin…
been-there-done-that May 5, 2026
2d16cf4
fix(console): full-height layout + accurate CPU % matching docker stats
been-there-done-that May 5, 2026
85cb706
feat(test): add docker-test-fast target for unit-only test runs
been-there-done-that May 6, 2026
7cb9eb6
chore(testdata): update BDD teststore zip fixtures
been-there-done-that May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ FROM rust:${RUST_VERSION} AS dev-base
# Install cargo-watch for hot reload
RUN cargo install cargo-watch --locked

# Install bun (used to build the operator console UI inside the container)
RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash

# Install system dependencies
RUN apt-get update -qq && apt-get upgrade -yqq && \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
Expand Down Expand Up @@ -128,15 +131,27 @@ RUN groupadd --gid 1001 pdfbro && \
# This layer will be cached; source code changes won't invalidate it
COPY Cargo.toml Cargo.lock ./
COPY crates/*/Cargo.toml ./crates/*/
RUN mkdir -p crates/engine/src crates/server/src crates/cli/src && \
COPY bench/Cargo.toml ./bench/
RUN mkdir -p crates/engine/src crates/server/src crates/cli/src bench/src && \
echo "fn main() {}" > crates/cli/src/main.rs && \
echo "pub fn dummy() {}" > crates/engine/src/lib.rs && \
echo "pub fn dummy() {}" > crates/server/src/lib.rs && \
echo "fn main() {}" > bench/src/main.rs && \
cargo build --features "chromium libreoffice" 2>/dev/null || true && \
rm -rf crates/
rm -rf crates/ bench/

# Stub UI build directory — gives rust-embed a valid folder at compile time.
# The docker-compose volume mount will shadow /app/ui with the host source tree,
# and the entrypoint script does a real bun build before starting the server.
RUN mkdir -p /app/ui/build && \
printf '<!doctype html><html><body>dev</body></html>' > /app/ui/build/index.html && \
mkdir -p /app/ui/node_modules

# Set up working directory with proper permissions
RUN chown -R pdfbro:pdfbro /app
# Pre-create /app/target so the cargo-target named volume is seeded with
# the right owner (Docker seeds an empty named volume from image content).
# Also chown the cargo registry for the same reason.
RUN mkdir -p /app/target && \
chown -R pdfbro:pdfbro /app /usr/local/cargo

# Switch to non-root user for Chrome
USER pdfbro
Expand All @@ -145,6 +160,15 @@ WORKDIR /app

EXPOSE 3000

# Default command: watch for changes and restart server
# Use --poll for Docker compatibility (file system events may not work in all setups)
CMD ["cargo", "watch", "--poll", "-x", "run -p server -- serve --port 3000 --host 0.0.0.0"]
# On startup:
# 1. Install/update UI deps inside the container (node_modules named volume keeps
# Linux binaries separate from the macOS host).
# 2. Do an initial production build so rust-embed serves the latest UI immediately.
# 3. Start vite build --watch in the background so UI changes rebuild automatically.
# 4. Start cargo watch in the foreground for Rust hot-reload.
CMD ["sh", "-c", "\
cd /app/ui && bun install && bun run build && \
bun run build:watch & \
cd /app && exec cargo watch --poll -w crates/ -w Cargo.toml -w Cargo.lock \
-x 'run -p server -- serve --port 3000 --host 0.0.0.0' \
"]
15 changes: 14 additions & 1 deletion Dockerfile.test
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,32 @@ COPY . .
# to the buildx invocation. The default keeps the layer cacheable so a
# repeat build with no source changes is a no-op.
ARG TEST_RUN_ID=0
# Set FAST=1 to run only unit tests (no BDD/integration, no Chrome/LO required, ~60s).
# Default is 0 (full suite).
ARG FAST=0

RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/app/target \
bash -c 'set -e -o pipefail; \
if [ "${FAST}" = "1" ]; then \
echo "::fast mode — unit tests only, no Chrome/LO (TEST_RUN_ID=${TEST_RUN_ID:-0})"; \
cargo test --lib 2>&1 | tee /tmp/cargo_test.log; \
if grep -qE "^test result: FAILED|^failures:|^error\[" /tmp/cargo_test.log; then \
echo "::test failures detected in libtest output"; \
exit 1; \
fi; \
echo "::unit tests ok"; \
exit 0; \
fi; \
echo "::compile pass — failing fast on any compile error (TEST_RUN_ID=${TEST_RUN_ID:-0})"; \
cargo test --no-fail-fast --no-run; \
echo "::run pass — ignoring LO atexit teardown noise on cargo exit"; \
set +e; \
cargo test --no-fail-fast -- --test-threads=1 2>&1 | tee /tmp/cargo_test.log; \
cargo_status=$?; \
set -e; \
if grep -qE "^test result: FAILED|^failures:$|^error\\[" /tmp/cargo_test.log; then \
if grep -qE "^test result: FAILED|^failures:$|^error\[" /tmp/cargo_test.log; then \
echo "::test failures detected in libtest output"; \
exit 1; \
fi; \
Expand Down
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ docker-test: ## Run tests inside Docker container (with Chrome + LibreOffice)
docker build -t pdfbro-test -f Dockerfile.test .
docker run --rm pdfbro-test

.PHONY: docker-test-fast
docker-test-fast: ## Run unit tests only inside Docker (no Chrome/LO required, ~60s)
docker build --build-arg FAST=1 -t pdfbro-test-fast -f Dockerfile.test .
docker run --rm pdfbro-test-fast

# IMAGE defaults to full image; override with: make test-image IMAGE=ghcr.io/inkkit/pdfbro:latest-chromium
IMAGE ?= $(DOCKER_REGISTRY):latest
.PHONY: test-image
Expand Down
2 changes: 1 addition & 1 deletion crates/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,13 @@ scalar_api_reference = { version = "0.1", features = ["axum"] }
# Operator console static asset embedding
rust-embed = { version = "8", features = ["mime-guess"] }
mime_guess = "2"
zip = { workspace = true }

[dev-dependencies]
tower = { workspace = true, features = ["util"] }
reqwest = { workspace = true }
static_assertions = { workspace = true }
lopdf = { workspace = true }
zip = { workspace = true }

# BDD testing
cucumber = "0.21"
Expand Down
10 changes: 10 additions & 0 deletions crates/server/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,19 @@ async fn console_log_middleware(

use std::sync::atomic::Ordering;
state.console.active_requests.fetch_add(1, Ordering::SeqCst);
{
let mut map = state.console.active_per_route.lock().await;
*map.entry(path.clone()).or_insert(0) += 1;
}
let start = Instant::now();
let response = next.run(req).await;
state.console.active_requests.fetch_sub(1, Ordering::SeqCst);
{
let mut map = state.console.active_per_route.lock().await;
if let Some(c) = map.get_mut(&path) {
*c = c.saturating_sub(1);
}
}
let elapsed = start.elapsed();
let duration_ms = elapsed.as_millis() as u64;
let status = response.status().as_u16();
Expand Down
15 changes: 15 additions & 0 deletions crates/server/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ pub trait PdfBackend: Send + Sync + 'static {
/// Liveness probe.
async fn healthy(&self) -> bool;

/// Non-blocking liveness check based on an atomic flag — safe to call from
/// the console sampler without competing for the engine's internal mutex.
fn is_alive(&self) -> bool { true }

/// Seconds since the last conversion handled by this engine. Returns 0 if never used.
fn idle_secs(&self) -> u64 { 0 }

/// Render HTML to screenshot image.
#[cfg(feature = "chromium")]
async fn html_to_screenshot(&self, html: &str, opts: &ScreenshotOptions) -> EngineResult<Vec<u8>>;
Expand Down Expand Up @@ -117,6 +124,14 @@ impl PdfBackend for ChromiumBackend {
self.inner.healthy().await
}

fn is_alive(&self) -> bool {
self.inner.is_running()
}

fn idle_secs(&self) -> u64 {
self.inner.idle_secs()
}

async fn html_to_screenshot(&self, html: &str, opts: &ScreenshotOptions) -> EngineResult<Vec<u8>> {
self.inner.html_to_screenshot(html, opts).await
}
Expand Down
76 changes: 53 additions & 23 deletions crates/server/src/banner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ use std::io::IsTerminal;
use crate::config::LogFormat;
use crate::ServerConfig;

/// Runtime status of a supervised engine at banner-print time.
#[derive(Clone, Copy)]
pub enum EngineStatus {
/// Started and healthy.
Ready,
/// Configured for lazy start — will spin up on first request.
Lazy,
/// Eager start attempted but the engine failed to come up.
Unavailable,
/// Feature not compiled in; row is omitted from the banner.
Disabled,
}

/// A single label / value pair rendered as one banner line.
struct Row<'a> {
label: &'a str,
Expand All @@ -26,13 +39,13 @@ struct Row<'a> {
/// In text mode the banner always prints regardless of TTY, so `cargo run` and
/// Docker (tty: true) both show it. Color is automatically disabled when
/// stdout is not a TTY.
pub fn print(config: &ServerConfig, chromium_ready: bool, libreoffice_ready: bool) {
pub fn print(config: &ServerConfig, chromium: EngineStatus, libreoffice: EngineStatus) {
if matches!(config.log_format, LogFormat::Json) {
let version = env!("CARGO_PKG_VERSION");
tracing::info!(
version,
chromium = if chromium_ready { "ready" } else { "unavailable" },
libreoffice = if libreoffice_ready { "ready" } else { "unavailable" },
chromium = engine_log_str(chromium),
libreoffice = engine_log_str(libreoffice),
engines = "merge,split,flatten,metadata,convert,bookmarks,watermark,stamp,encrypt,decrypt,rotate",
"pdfbro server ready",
);
Expand All @@ -43,16 +56,13 @@ pub fn print(config: &ServerConfig, chromium_ready: bool, libreoffice_ready: boo
let version = env!("CARGO_PKG_VERSION");

// ── Services section ─────────────────────────────────────────────
let services = vec![
Row {
label: "Chromium",
value: status(chromium_ready, c),
},
Row {
label: "LibreOffice",
value: status(libreoffice_ready, c),
},
];
let mut services: Vec<Row<'_>> = Vec::new();
if !matches!(chromium, EngineStatus::Disabled) {
services.push(Row { label: "Chromium", value: engine_status(chromium, c) });
}
if !matches!(libreoffice, EngineStatus::Disabled) {
services.push(Row { label: "LibreOffice", value: engine_status(libreoffice, c) });
}
let service_width = compute_width(&services);

// ── PDF Engines section ──────────────────────────────────────────
Expand Down Expand Up @@ -137,14 +147,24 @@ fn color(s: &str, code: &str, enabled: bool) -> String {
}

/// Colored status tag with a fixed visible width so columns stay aligned
/// when the state flips between ready / unavailable.
fn status(ready: bool, c: bool) -> String {
let plain = if ready {
format!("{:<20}", "[OK] ready")
} else {
format!("{:<20}", "[FAIL] unavailable")
/// across all possible states.
fn engine_status(status: EngineStatus, c: bool) -> String {
let (plain, code) = match status {
EngineStatus::Ready => (format!("{:<20}", "[OK] ready"), "32"),
EngineStatus::Lazy => (format!("{:<20}", "[--] lazy"), "2"),
EngineStatus::Unavailable => (format!("{:<20}", "[FAIL] unavailable"), "31"),
EngineStatus::Disabled => unreachable!("disabled rows are skipped"),
};
color(&plain, if ready { "32" } else { "31" }, c)
color(&plain, code, c)
}

fn engine_log_str(status: EngineStatus) -> &'static str {
match status {
EngineStatus::Ready => "ready",
EngineStatus::Lazy => "lazy",
EngineStatus::Unavailable => "unavailable",
EngineStatus::Disabled => "disabled",
}
}

/// Simple OK tag for capability rows.
Expand Down Expand Up @@ -226,9 +246,19 @@ mod tests {

#[test]
fn print_does_not_panic() {
// We can't assert stdout in a unit test easily, but we can at
// least exercise the formatting code path.
let config = dummy_config();
print(&config, true, true);
print(&config, EngineStatus::Ready, EngineStatus::Ready);
}

#[test]
fn print_lazy_statuses_do_not_panic() {
let config = dummy_config();
print(&config, EngineStatus::Lazy, EngineStatus::Lazy);
}

#[test]
fn print_disabled_statuses_do_not_panic() {
let config = dummy_config();
print(&config, EngineStatus::Disabled, EngineStatus::Disabled);
}
}
Loading