Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 14 additions & 1 deletion examples/leaderboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use job_watcher::{
AppBuilder, Heartbeat, TaskLabel, WatchedTask, WatchedTaskOutput, WatcherAppContext,
config::{Delay, TaskConfig, WatcherConfig},
};
use std::sync::Arc;
use std::{convert::Infallible, sync::Arc, time::Duration};
use tokio::net::TcpListener;

#[tokio::main]
Expand All @@ -29,6 +29,7 @@ impl DummyApp {

builder.watch_periodic(TaskLabel::new("leaderboard"), LeaderBoard)?;
builder.watch_periodic(TaskLabel::new("task_two"), TaskTwo)?;
builder.watch_background_with_status(TaskLabel::new("task_three"), run_task_three)?;

builder.wait(listener).await
}
Expand Down Expand Up @@ -125,3 +126,15 @@ async fn update_task_two() -> Result<WatchedTaskOutput> {
println!("Finished executing task two.");
Ok(WatchedTaskOutput::new("Finished executing task two"))
}

async fn run_task_three(_app: Arc<DummyApp>, heartbeat: Heartbeat) -> Result<Infallible> {
let mut i = 1;
loop {
println!("task three");
heartbeat
.set_status(format!("Status from task three {i}"))
.await;
i += 1;
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
75 changes: 75 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub enum TaskResultValue {
Ok(Cow<'static, str>),
Err(String),
NotYetRun,
Info(Cow<'static, str>),
}

const NOT_YET_RUN_MESSAGE: &str = "Task has not yet completed a single run";
Expand All @@ -90,8 +91,13 @@ impl TaskResultValue {
TaskResultValue::Ok(s) => s,
TaskResultValue::Err(s) => s,
TaskResultValue::NotYetRun => NOT_YET_RUN_MESSAGE,
TaskResultValue::Info(s) => s,
}
}

pub fn is_info(&self) -> bool {
matches!(self, TaskResultValue::Info(_))
}
}

#[derive(Clone, serde::Serialize, Debug)]
Expand Down Expand Up @@ -163,6 +169,7 @@ enum ShortStatus {
OutOfDateNoAlert,
Success,
NotYetRun,
Info,
}

impl ShortStatus {
Expand All @@ -175,6 +182,7 @@ impl ShortStatus {
ShortStatus::Error => "ERROR",
ShortStatus::ErrorNoAlert => "ERROR (no alert)",
ShortStatus::NotYetRun => "NOT YET RUN",
ShortStatus::Info => "RUNNING",
}
}

Expand All @@ -187,6 +195,7 @@ impl ShortStatus {
ShortStatus::OutOfDateNoAlert => false,
ShortStatus::Success => false,
ShortStatus::NotYetRun => false,
ShortStatus::Info => false,
}
}

Expand All @@ -199,6 +208,7 @@ impl ShortStatus {
ShortStatus::OutOfDateNoAlert => "text-red-300",
ShortStatus::Success => "link-success",
ShortStatus::NotYetRun => "link-primary",
ShortStatus::Info => "link-primary",
}
}
}
Expand Down Expand Up @@ -436,6 +446,7 @@ impl ResponseBuilder {
writeln!(&mut self.buffer, "{err}")?;
}
TaskResultValue::NotYetRun => writeln!(&mut self.buffer, "{}", NOT_YET_RUN_MESSAGE)?,
TaskResultValue::Info(cow) => writeln!(&mut self.buffer, "{cow}")?,
}
writeln!(&mut self.buffer)?;

Expand Down Expand Up @@ -521,6 +532,17 @@ impl TaskStatus {
(OutOfDateType::Very, true) => ShortStatus::OutOfDateError,
}
}
TaskResultValue::Info(_) => {
match (
self.is_out_of_date(),
app.triggers_alert(label, selected_label),
) {
(OutOfDateType::Not, _) => ShortStatus::Info,
(_, false) => ShortStatus::OutOfDateNoAlert,
(OutOfDateType::Slightly, true) => ShortStatus::OutOfDate,
(OutOfDateType::Very, true) => ShortStatus::OutOfDateError,
}
}
TaskResultValue::Err(_) => {
if app.triggers_alert(label, selected_label) {
if self.is_expired() {
Expand Down Expand Up @@ -582,6 +604,16 @@ pub struct Heartbeat {
pub task_status: Arc<RwLock<TaskStatus>>,
}

impl Heartbeat {
pub async fn set_status(&self, message: impl Into<Cow<'static, str>>) {
let mut guard = self.task_status.write().await;
guard.last_result = TaskResult {
value: TaskResultValue::Info(message.into()).into(),
updated: Zoned::now(),
};
}
}

#[derive(Debug)]
pub struct WatchedTaskOutput {
/// Should we skip delay between tasks ? If yes, then we dont
Expand Down Expand Up @@ -679,6 +711,48 @@ impl<C: WatcherAppContext + Send + Sync + Clone + 'static> AppBuilder<C> {
self.watcher.set.spawn(task);
}

/// Watch a background job that runs continuously, with status reporting.
///
/// This is similar to `watch_background`, but it also registers the task
/// with the status monitoring page. The provided closure is given a
/// `Heartbeat` instance that can be used to update the task's status.
pub fn watch_background_with_status<F, Fut>(&mut self, label: TaskLabel, f: F) -> Result<()>
where
F: FnOnce(Arc<C>, Heartbeat) -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<Infallible>> + Send + 'static,
{
let task_status = Arc::new(RwLock::new(TaskStatus {
last_result: TaskResult {
value: TaskResultValue::NotYetRun.into(),
updated: Zoned::now(),
},
last_retry_error: None,
current_run_started: Some(Zoned::now()),
out_of_date: None,
counts: Default::default(),
expire_last_result: None,
last_run_seconds: None,
}));
{
let old = self
.watcher
.statuses
.insert(label.clone(), task_status.clone());
if old.is_some() {
anyhow::bail!("Two tasks with label {label:?}");
}
}
let heartbeat = Heartbeat { task_status };
let app = self.app.clone();
let future = f(app, heartbeat);
self.watcher.set.spawn(async move {
future
.await
.with_context(|| format!("Background task failed: {}", label))
});
Ok(())
}

pub fn watch_periodic<T>(&mut self, label: TaskLabel, mut task: T) -> Result<()>
where
T: WatchedTask<C>,
Expand Down Expand Up @@ -777,6 +851,7 @@ impl<C: WatcherAppContext + Send + Sync + Clone + 'static> AppBuilder<C> {
TaskResultValue::NotYetRun => {
// Catalog newly started
}
TaskResultValue::Info(_cow) => {}
}
}
let last_run_seconds = {
Expand Down
20 changes: 11 additions & 9 deletions templates/status.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
body {
font-family: 'Inter', sans-serif;
}
/*

/*
Compatibility Layer for Backend Classes
The Rust backend returns specific class names like 'link-danger', 'text-red-400', etc.
We map these to Tailwind values or custom styles here to ensure they look good.
Expand Down Expand Up @@ -47,8 +47,8 @@
color: #1d4ed8; /* blue-700 */
}

/*
Red Shade Mapping
/*
Red Shade Mapping
The backend uses text-red-100 to text-red-800.
We map them to standard Tailwind colors, ensuring visibility.
*/
Expand All @@ -73,7 +73,7 @@
</head>
<body class="h-full text-gray-900 antialiased">
<div class="min-h-full">

<!-- Header -->
<header class="bg-white shadow-sm sticky top-0 z-10">
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8 flex items-center justify-between">
Expand All @@ -83,7 +83,7 @@ <h1 class="text-xl font-bold leading-7 text-gray-900 sm:truncate sm:tracking-tig
</header>

<main class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">

<!-- Environment Stats -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3 lg:grid-cols-3 mb-8">
<div class="overflow-hidden rounded-lg bg-white shadow px-4 py-5 sm:p-6 border-l-4 border-blue-500">
Expand Down Expand Up @@ -138,7 +138,7 @@ <h3 class="text-base font-semibold leading-6 text-gray-900">Task Overview</h3>
{% for status in statuses %}
<div id="{{ status.label.ident() }}" class="overflow-hidden bg-white shadow-lg sm:rounded-lg border border-gray-100 scroll-mt-20">
<!-- Card Header -->
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 bg-gray-50/50 flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div class="px-4 py-5 sm:px-6 border-b border-gray-200 {% if status.status.last_result.value.is_info() %}bg-blue-50/50{% else %}bg-gray-50/50{% endif %} flex flex-col sm:flex-row justify-between sm:items-center gap-4">
<div>
<h3 class="text-lg font-semibold leading-6 text-gray-900 flex items-center gap-2">
{{ status.label }}
Expand All @@ -156,8 +156,9 @@ <h3 class="text-lg font-semibold leading-6 text-gray-900 flex items-center gap-2
</div>

<div class="px-4 py-5 sm:p-6">

<!-- Stats Row -->
{% if status.status.last_result.value.is_info() == false %}
<dl class="grid grid-cols-1 gap-5 sm:grid-cols-3 mb-6">
<div class="overflow-hidden rounded-lg bg-green-50 px-4 py-3 sm:p-4 text-center">
<dt class="truncate text-xs font-medium text-green-800 uppercase tracking-wider">Successes</dt>
Expand All @@ -172,6 +173,7 @@ <h3 class="text-lg font-semibold leading-6 text-gray-900 flex items-center gap-2
<dd class="mt-1 text-2xl font-semibold tracking-tight text-red-700">{{ status.status.counts.errors }}</dd>
</div>
</dl>
{% endif %}

<!-- Running State -->
{% if let Some(started) = status.status.current_run_started.as_ref() %}
Expand Down Expand Up @@ -229,7 +231,7 @@ <h3 class="text-sm font-medium text-red-800">Retry in progress</h3>
</div>
{% endfor %}
</div>

<footer class="mt-12 text-center text-sm text-gray-500 pb-8">
<p>&copy; <a href="https://github.com/fpco/job-watcher" class="hover:underline" target="_blank" rel="noopener noreferrer">Job Watcher</a> - {{ title }}</p>
</footer>
Expand Down