use axum::{
extract::Path,
response::{
Html,
sse::{Event, KeepAlive, Sse},
},
};
use std::convert::Infallible;
use crate::daemon::daemon_log_path;
use crate::daemon::is_valid_daemon_id;
use crate::daemon_id::DaemonId;
use crate::env;
use crate::pitchfork_toml::PitchforkToml;
use crate::settings::settings;
use crate::state_file::StateFile;
use crate::web::bp;
use crate::web::helpers::{html_escape, url_encode};
use console;
use std::io::{Read, Seek, SeekFrom};
fn base_html(title: &str, content: &str) -> String {
let bp = bp();
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} - pitchfork</title>
<link rel="icon" type="image/x-icon" href="{bp}/static/favicon.ico">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
<script src="https://unpkg.com/lucide@0.474.0"></script>
<script src="{bp}/static/logs.js"></script>
<link rel="stylesheet" href="{bp}/static/style.css">
</head>
<body>
<nav>
<a href="{bp}/" class="nav-brand"><img src="{bp}/static/logo.png" alt="pitchfork" class="logo-icon"> pitchfork</a>
<div class="nav-links">
<a href="{bp}/">Dashboard</a>
<a href="{bp}/logs" class="active">Logs</a>
<a href="{bp}/config">Config</a>
</div>
</nav>
<main>
{content}
</main>
<script>
// Initialize Lucide icons on page load
lucide.createIcons();
// Re-initialize Lucide icons after HTMX swaps content
document.body.addEventListener('htmx:afterSwap', function(evt) {{
lucide.createIcons();
}});
</script>
</body>
</html>"#
)
}
pub async fn index() -> Html<String> {
let bp = bp();
let state = match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
Ok(state) => state,
Err(e) => {
let content = format!(
r#"<h1>Error</h1><p class="error">Failed to read state file: {}</p><a href="{bp}/" class="btn">Back</a>"#,
html_escape(&e.to_string())
);
return Html(base_html("Error", &content));
}
};
let pt = match PitchforkToml::all_merged() {
Ok(pt) => pt,
Err(e) => {
let content = format!(
r#"<h1>Error</h1><p class="error">Failed to load configuration: {}</p><a href="{bp}/" class="btn">Back</a>"#,
html_escape(&e.to_string())
);
return Html(base_html("Error", &content));
}
};
let pitchfork_id = DaemonId::pitchfork();
let mut ids: Vec<String> = state
.daemons
.keys()
.filter(|id| **id != pitchfork_id)
.map(|id| id.to_string())
.collect();
for id in pt.daemons.keys() {
let id_str = id.to_string();
if !ids.contains(&id_str) {
ids.push(id_str);
}
}
ids.sort();
let content = if ids.is_empty() {
r#"
<h1>Logs</h1>
<div class="empty-state">
<h2>No daemons available</h2>
<p>Configure daemons in your pitchfork.toml to view their logs.</p>
</div>
"#
.to_string()
} else {
let mut tabs = String::new();
let mut tab_contents = String::new();
for (idx, id) in ids.iter().enumerate() {
let safe_id = html_escape(id);
let js_id = js_escape(id);
let url_id = url_encode(id);
let is_first = idx == 0;
let active_class = if is_first { " active" } else { "" };
tabs.push_str(&format!(
r#"<button class="tab{active_class}" onclick="switchTab('{js_id}', event)">{safe_id}</button>"#
));
let log_path = daemon_log_path(id);
let initial_logs = if log_path.exists() {
match std::fs::read(&log_path) {
Ok(bytes) => {
let content = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = content.lines().collect();
let log_lines = settings().web.log_lines.max(0) as usize;
let start = if log_lines > 0 && lines.len() > log_lines {
lines.len() - log_lines
} else {
0
};
let stripped = lines[start..].join("\n");
html_escape(&console::strip_ansi_codes(&stripped))
}
Err(_) => String::new(),
}
} else {
"No logs available yet.".to_string()
};
tab_contents.push_str(&format!(
r#"
<div id="tab-{safe_id}" class="tab-content{active_class}">
<div class="page-header">
<h2>DAEMON: {safe_id}</h2>
<div class="header-actions">
<button hx-post="{bp}/logs/{url_id}/clear" hx-swap="none" class="btn btn-sm"
hx-confirm="Are you sure you want to clear the logs for {safe_id}?"
onclick="clearTabLogs('{js_id}')"><i data-lucide="trash-2" class="icon"></i> Clear Logs</button>
</div>
</div>
<div class="log-viewer">
<pre id="log-output-{safe_id}" hx-ext="sse" sse-connect="{bp}/logs/{url_id}/stream" sse-swap="message" hx-swap="beforeend scroll:bottom">{initial_logs}</pre>
</div>
</div>
"#
));
}
format!(
r#"
<div class="page-header logs-header">
<h1><i data-lucide="file-text" class="icon" style="width: 28px; height: 28px; vertical-align: middle;"></i> Logs</h1>
</div>
<div class="tabs">
{}
</div>
{}
<script>
function switchTab(tabId, evt) {{
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
// Show selected tab
document.getElementById('tab-' + tabId).classList.add('active');
evt.currentTarget.classList.add('active');
// Scroll to bottom
const logOutput = document.getElementById('log-output-' + tabId);
if (logOutput) {{
logOutput.scrollTop = logOutput.scrollHeight;
}}
}}
function clearTabLogs(tabId) {{
const logOutput = document.getElementById('log-output-' + tabId);
if (logOutput) {{
setTimeout(() => {{
logOutput.textContent = '';
}}, 100);
}}
}}
// Auto-scroll first tab on load
window.addEventListener('load', function() {{
const firstLog = document.querySelector('.tab-content.active pre');
if (firstLog) {{
firstLog.scrollTop = firstLog.scrollHeight;
}}
}});
// Setup clear event listeners for all tabs
{}
</script>
"#,
tabs,
tab_contents,
ids.iter()
.enumerate()
.map(|(idx, id)| {
let js_id = js_escape(id);
let url_id = url_encode(id);
format!(
r#"
var clearSource_{idx} = window.pitchforkLogEventSource('{bp}/logs/{url_id}/stream');
clearSource_{idx}.addEventListener('clear', function(e) {{
document.getElementById('log-output-' + '{js_id}').textContent = '';
}});
"#
)
})
.collect::<Vec<_>>()
.join("\n")
)
};
Html(base_html("Logs", &content))
}
pub async fn show(Path(id): Path<String>) -> Html<String> {
let bp = bp();
let daemon_id = match DaemonId::parse(&id) {
Ok(d) => d,
Err(_) => {
let content = format!(
r#"<h1>Error</h1><p class="error">Invalid or unqualified daemon ID. Use the qualified form <code>namespace/name</code>.</p><a href="{bp}/logs" class="btn"><i data-lucide="arrow-left" class="icon"></i> Back</a>"#
);
return Html(base_html("Error", &content));
}
};
let safe_id = html_escape(&daemon_id.to_string());
let url_id = url_encode(&daemon_id.to_string());
let log_path = daemon_id.log_path();
let initial_logs = if log_path.exists() {
match std::fs::read(&log_path) {
Ok(bytes) => {
let content = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = content.lines().collect();
let log_lines = settings().web.log_lines.max(0) as usize;
let start = if log_lines > 0 && lines.len() > log_lines {
lines.len() - log_lines
} else {
0
};
html_escape(&lines[start..].join("\n"))
}
Err(_) => String::new(),
}
} else {
"No logs available yet.".to_string()
};
let content = format!(
r#"
<div class="page-header">
<h1>Logs: {safe_id}</h1>
<div class="header-actions">
<button hx-post="{bp}/logs/{url_id}/clear" hx-swap="none" class="btn btn-sm"
hx-confirm="Are you sure you want to clear the logs for {safe_id}?"><i data-lucide="trash-2" class="icon"></i> Clear Logs</button>
<a href="{bp}/logs" class="btn btn-sm"><i data-lucide="arrow-left" class="icon"></i> Back</a> </div>
</div>
<div class="log-viewer">
<pre id="log-output" hx-ext="sse" sse-connect="{bp}/logs/{url_id}/stream" sse-swap="message" hx-swap="beforeend scroll:bottom">{initial_logs}</pre>
</div>
<script>
// Auto-scroll to bottom on load
document.getElementById('log-output').scrollTop = document.getElementById('log-output').scrollHeight;
// Listen for clear event using native EventSource (htmx-ext-sse only handles 'message' events)
var clearSource = window.pitchforkLogEventSource('{bp}/logs/{url_id}/stream');
clearSource.addEventListener('clear', function(e) {{
document.getElementById('log-output').textContent = '';
}});
</script>
"#
);
Html(base_html(&format!("Logs: {safe_id}"), &content))
}
pub async fn lines_partial(Path(id): Path<String>) -> Html<String> {
let daemon_id = match DaemonId::parse(&id) {
Ok(d) => d,
Err(_) => return Html(String::new()),
};
let log_path = daemon_id.log_path();
let logs = if log_path.exists() {
match std::fs::read(&log_path) {
Ok(bytes) => {
let content = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = content.lines().collect();
let log_lines = settings().web.log_lines.max(0) as usize;
let start = if log_lines > 0 && lines.len() > log_lines {
lines.len() - log_lines
} else {
0
};
html_escape(&lines[start..].join("\n"))
}
Err(_) => String::new(),
}
} else {
String::new()
};
Html(logs)
}
pub async fn stream_sse(
Path(id): Path<String>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
let sse_poll_interval = settings().web_sse_poll_interval();
let stream = async_stream::stream! {
if !is_valid_daemon_id(&id) {
yield Ok(Event::default().event("error").data("invalid daemon id"));
return;
}
let daemon_id = match DaemonId::parse(&id) {
Ok(d) => d,
Err(_) => {
yield Ok(Event::default().event("error").data("invalid daemon id"));
return;
}
};
let log_path = daemon_id.log_path();
let (mut last_size, mut last_path_ino) = match tokio::task::spawn_blocking({
let path = log_path.clone();
move || {
match std::fs::metadata(&path) {
Ok(metadata) => {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
(metadata.len(), Some(metadata.ino()))
}
#[cfg(not(unix))]
{
(metadata.len(), None)
}
}
Err(_) => (0, None),
}
}
})
.await
{
Ok(state) => state,
Err(err) => {
warn!(
"SSE log stream: failed to read initial metadata for '{}': {err:?}",
log_path.display()
);
(0, None)
}
};
let mut file_handle: Option<std::fs::File> = None;
#[allow(dead_code)] enum FileOpResult {
Data(Vec<u8>),
Truncated,
FileRotated,
SeekFailed,
ReadFailed,
Eof,
}
struct FileOpOutput {
file: Option<std::fs::File>,
size: u64,
result: Option<FileOpResult>,
inode: Option<u64>,
}
let mut poll_count = 0u64;
loop {
tokio::time::sleep(sse_poll_interval).await;
poll_count = poll_count.wrapping_add(1);
let file_op_result = {
let path = log_path.clone();
let fh = file_handle.take();
let mut ls = last_size;
let prev_ino = last_path_ino;
#[cfg_attr(not(unix), allow(unused_variables))]
let poll_count = poll_count;
tokio::task::spawn_blocking(move || {
#[cfg_attr(not(unix), allow(unused_variables))]
let opened_fresh = fh.is_none();
let mut file = match fh {
Some(f) => f,
None => match std::fs::File::open(&path) {
Ok(f) => f,
Err(_) => {
return FileOpOutput {
file: None,
size: ls,
result: None,
inode: prev_ino,
};
}
},
};
#[cfg(unix)]
let (fresh_open_rotated, cached_metadata, fresh_ino) = if opened_fresh {
use std::os::unix::fs::MetadataExt;
if let Ok(meta) = file.metadata() {
let ino = meta.ino();
if let Some(prev_ino_val) = prev_ino {
if ino != prev_ino_val {
(true, Some(meta), Some(ino))
} else {
(false, Some(meta), Some(ino))
}
} else {
(false, Some(meta), Some(ino))
}
} else {
(false, None, None)
}
} else {
(false, None, None)
};
#[cfg(unix)]
if fresh_open_rotated {
return FileOpOutput {
file: None,
size: 0,
result: Some(FileOpResult::FileRotated),
inode: fresh_ino,
};
}
#[cfg(not(unix))]
let (cached_metadata, fresh_ino): (Option<std::fs::Metadata>, Option<u64>) = (None, None);
let metadata = match cached_metadata {
Some(m) => m,
None => match file.metadata() {
Ok(m) => m,
Err(_) => {
return FileOpOutput {
file: None,
size: ls,
result: None,
inode: fresh_ino,
};
}
},
};
let current_size = metadata.len();
#[cfg(unix)]
if current_size != ls || poll_count.is_multiple_of(10) {
use std::os::unix::fs::MetadataExt;
let path_ino = std::fs::metadata(&path).map(|m| m.ino()).ok();
let file_ino = metadata.ino();
if let Some(path_ino) = path_ino && path_ino != file_ino {
return FileOpOutput {
file: None,
size: 0,
result: Some(FileOpResult::FileRotated),
inode: Some(path_ino),
};
}
}
#[cfg(unix)]
let current_ino = fresh_ino.or_else(|| {
use std::os::unix::fs::MetadataExt;
Some(metadata.ino())
});
#[cfg(not(unix))]
let current_ino: Option<u64> = None;
if current_size > ls {
if file.seek(SeekFrom::Start(ls)).is_err() {
return FileOpOutput {
file: None,
size: ls,
result: Some(FileOpResult::SeekFailed),
inode: current_ino,
};
}
const MAX_READ_SIZE: u64 = 1024 * 1024;
let bytes_to_read = (current_size - ls).min(MAX_READ_SIZE) as usize;
let mut buffer = Vec::with_capacity(bytes_to_read);
match (&mut file).take(bytes_to_read as u64).read_to_end(&mut buffer) {
Ok(0) => {
return FileOpOutput {
file: Some(file),
size: ls,
result: Some(FileOpResult::Eof),
inode: current_ino,
};
}
Ok(n) => {
ls += n as u64;
return FileOpOutput {
file: Some(file),
size: ls,
result: Some(FileOpResult::Data(buffer)),
inode: current_ino,
};
}
Err(_) => {
return FileOpOutput {
file: None,
size: ls,
result: Some(FileOpResult::ReadFailed),
inode: current_ino,
};
}
}
} else if current_size < ls {
return FileOpOutput {
file: Some(file),
size: 0,
result: Some(FileOpResult::Truncated),
inode: current_ino,
};
}
FileOpOutput {
file: Some(file),
size: ls,
result: None,
inode: current_ino,
}
}).await
};
match file_op_result {
Ok(output) => {
file_handle = output.file;
last_size = output.size;
last_path_ino = output.inode;
match output.result {
Some(FileOpResult::Data(buffer)) => {
let new_content = String::from_utf8_lossy(&buffer);
let stripped = console::strip_ansi_codes(&new_content);
let escaped = html_escape(&stripped);
yield Ok(Event::default().event("message").data(escaped));
}
Some(FileOpResult::Truncated) => {
yield Ok(Event::default().event("clear").data(""));
}
Some(FileOpResult::FileRotated) => {
yield Ok(Event::default().event("clear").data(""));
}
Some(FileOpResult::SeekFailed) => {
debug!("SSE log stream: seek failed on '{}', will reopen", log_path.display());
}
Some(FileOpResult::ReadFailed) => {
debug!("SSE log stream: read failed on '{}', will reopen", log_path.display());
}
Some(FileOpResult::Eof) => {
debug!(
"SSE log stream: read 0 bytes from '{}' after size check; retrying",
log_path.display()
);
}
_ => {}
}
}
Err(err) => {
warn!(
"SSE log stream: spawn_blocking panicked for '{}': {err:?}",
log_path.display()
);
}
}
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
pub async fn clear(Path(id): Path<String>) -> Html<String> {
let daemon_id = match DaemonId::parse(&id) {
Ok(d) => d,
Err(_) => return Html("".to_string()),
};
let log_path = daemon_id.log_path();
if log_path.exists() {
let _ = std::fs::write(&log_path, "");
}
Html("".to_string())
}
fn js_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
}