Skip to content

Commit da241c5

Browse files
authored
Merge pull request #70 from rgwood/better-no-logs-error
Diagnose missing logs better
2 parents aed5bc9 + db27004 commit da241c5

File tree

2 files changed

+150
-7
lines changed

2 files changed

+150
-7
lines changed

src/components/home.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use std::{
2727
use super::{logger::Logger, Component, Frame};
2828
use crate::{
2929
action::Action,
30-
systemd::{self, Scope, UnitFile, UnitId, UnitScope, UnitWithStatus},
30+
systemd::{self, diagnose_missing_logs, parse_journalctl_error, Scope, UnitFile, UnitId, UnitScope, UnitWithStatus},
3131
};
3232

3333
#[derive(Debug, Default, Copy, Clone, PartialEq)]
@@ -539,18 +539,28 @@ impl Component for Home {
539539
let mut logs = stdout.trim().split('\n').map(String::from).collect_vec();
540540

541541
if logs.is_empty() || logs[0].is_empty() {
542-
logs.push(String::from("No logs found/available. Maybe try relaunching with `sudo systemctl-tui`"));
542+
let diagnostic = diagnose_missing_logs(&unit);
543+
logs = vec![diagnostic.message()];
543544
}
544545
let _ = tx.send(Action::SetLogs { unit: unit.clone(), logs });
545546
let _ = tx.send(Action::Render);
546547
} else {
547548
warn!("Error parsing stdout for {}", unit.name);
548549
}
549550
} else {
550-
warn!("Error getting logs for {}: {}", unit.name, String::from_utf8_lossy(&output.stderr));
551+
let stderr = String::from_utf8_lossy(&output.stderr);
552+
warn!("Error getting logs for {}: {}", unit.name, stderr);
553+
let diagnostic = parse_journalctl_error(&stderr);
554+
let _ = tx.send(Action::SetLogs { unit: unit.clone(), logs: vec![diagnostic.message()] });
555+
let _ = tx.send(Action::Render);
551556
}
552557
},
553-
Err(e) => warn!("Error getting logs for {}: {}", unit.name, e),
558+
Err(e) => {
559+
warn!("Error getting logs for {}: {}", unit.name, e);
560+
let _ =
561+
tx.send(Action::SetLogs { unit: unit.clone(), logs: vec![format!("Failed to run journalctl: {}", e)] });
562+
let _ = tx.send(Action::Render);
563+
},
554564
}
555565

556566
// Then follow the logs

src/systemd.rs

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,31 @@ pub async fn get_unit_files(scope: Scope) -> Result<Vec<UnitFile>> {
147147

148148
let mut ret = vec![];
149149
let is_root = nix::unistd::geteuid().is_root();
150+
info!("get_unit_files: is_root={}, scope={:?}", is_root, scope);
150151

151152
for unit_scope in unit_scopes {
152-
let connection = get_connection(unit_scope).await?;
153+
info!("get_unit_files: fetching {:?} unit files", unit_scope);
154+
let connection = match get_connection(unit_scope).await {
155+
Ok(conn) => conn,
156+
Err(e) => {
157+
error!("get_unit_files: failed to get {:?} connection: {:?}", unit_scope, e);
158+
if is_root && unit_scope == UnitScope::User {
159+
info!("get_unit_files: skipping user scope because we're root");
160+
continue;
161+
}
162+
return Err(e);
163+
},
164+
};
153165
let manager_proxy = ManagerProxy::new(&connection).await?;
154166
let unit_files = match manager_proxy.list_unit_files_by_patterns(vec![], vec!["*.service".into()]).await {
155-
Ok(files) => files,
167+
Ok(files) => {
168+
info!("get_unit_files: got {} {:?} unit files", files.len(), unit_scope);
169+
files
170+
},
156171
Err(e) => {
172+
error!("get_unit_files: list_unit_files_by_patterns failed for {:?}: {:?}", unit_scope, e);
157173
if is_root && unit_scope == UnitScope::User {
158-
error!("Failed to get user unit files, ignoring because we're running as root");
174+
info!("get_unit_files: ignoring user scope error because we're root");
159175
vec![]
160176
} else {
161177
return Err(e.into());
@@ -638,6 +654,105 @@ pub fn get_unit_path(full_service_name: &str) -> String {
638654
format!("/org/freedesktop/systemd1/unit/{}", encode_as_dbus_object_path(full_service_name))
639655
}
640656

657+
/// Diagnostic result explaining why logs might be missing
658+
#[derive(Debug, Clone)]
659+
pub enum LogDiagnostic {
660+
/// Unit has never been activated (ActiveEnterTimestamp is 0)
661+
NeverRun { unit_name: String },
662+
/// Journal is not accessible (likely permissions)
663+
JournalInaccessible { error: String },
664+
/// Unit-specific permission issue
665+
PermissionDenied { error: String },
666+
/// Journal is available but no logs exist for this unit
667+
NoLogsRecorded { unit_name: String },
668+
/// journalctl command failed with an error
669+
JournalctlError { stderr: String },
670+
}
671+
672+
impl LogDiagnostic {
673+
/// Returns a human-readable message for display
674+
pub fn message(&self) -> String {
675+
match self {
676+
Self::NeverRun { unit_name } => format!("No logs: {} has never been started", unit_name),
677+
Self::JournalInaccessible { error } => {
678+
format!("Cannot access journal: {}\n\nCheck that systemd-journald is running", error)
679+
},
680+
Self::PermissionDenied { error } => format!("Permission denied: {}\n\nTry: sudo systemctl-tui", error),
681+
Self::NoLogsRecorded { unit_name } => {
682+
format!("No logs recorded for {} (unit has run but produced no journal output)", unit_name)
683+
},
684+
Self::JournalctlError { stderr } => format!("journalctl error: {}", stderr),
685+
}
686+
}
687+
}
688+
689+
/// Check if a unit has ever been activated using systemctl show
690+
pub fn check_unit_has_run(unit: &UnitId) -> bool {
691+
let mut args = vec!["show", "-P", "ActiveEnterTimestampMonotonic"];
692+
if unit.scope == UnitScope::User {
693+
args.insert(0, "--user");
694+
}
695+
args.push(&unit.name);
696+
697+
Command::new("systemctl")
698+
.args(&args)
699+
.output()
700+
.ok()
701+
.and_then(
702+
|output| if output.status.success() { std::str::from_utf8(&output.stdout).ok().map(String::from) } else { None },
703+
)
704+
.map(|s| s.trim().parse::<u64>().unwrap_or(0) > 0)
705+
.unwrap_or(false)
706+
}
707+
708+
/// Check if the journal is accessible at all (tests general read access)
709+
fn can_access_journal(scope: UnitScope) -> Result<(), String> {
710+
let mut args = vec!["--lines=1", "--quiet"];
711+
if scope == UnitScope::User {
712+
args.push("--user");
713+
}
714+
715+
match Command::new("journalctl").args(&args).output() {
716+
Ok(output) => {
717+
if output.status.success() {
718+
Ok(())
719+
} else {
720+
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
721+
}
722+
},
723+
Err(e) => Err(e.to_string()),
724+
}
725+
}
726+
727+
/// Parse journalctl stderr to determine the specific error type
728+
pub fn parse_journalctl_error(stderr: &str) -> LogDiagnostic {
729+
let stderr_lower = stderr.to_lowercase();
730+
731+
if stderr_lower.contains("permission denied") || stderr_lower.contains("access denied") {
732+
LogDiagnostic::PermissionDenied { error: stderr.trim().to_string() }
733+
} else if stderr_lower.contains("no such file") || stderr_lower.contains("failed to open") {
734+
LogDiagnostic::JournalInaccessible { error: stderr.trim().to_string() }
735+
} else {
736+
LogDiagnostic::JournalctlError { stderr: stderr.trim().to_string() }
737+
}
738+
}
739+
740+
/// Diagnose why logs are missing for a unit
741+
pub fn diagnose_missing_logs(unit: &UnitId) -> LogDiagnostic {
742+
// Check 1: Has unit ever run?
743+
if !check_unit_has_run(unit) {
744+
return LogDiagnostic::NeverRun { unit_name: unit.name.clone() };
745+
}
746+
747+
// Check 2: Can we access the journal at all?
748+
if let Err(error) = can_access_journal(unit.scope) {
749+
return parse_journalctl_error(&error);
750+
}
751+
752+
// If we get here, journal is accessible but no logs for this specific unit
753+
LogDiagnostic::NoLogsRecorded { unit_name: unit.name.clone() }
754+
}
755+
641756
#[cfg(test)]
642757
mod tests {
643758
use super::*;
@@ -652,4 +767,22 @@ mod tests {
652767
assert_eq!(encode_as_dbus_object_path("test.service"), "test_2eservice");
653768
assert_eq!(encode_as_dbus_object_path("test-with-hyphen.service"), "test_2dwith_2dhyphen_2eservice");
654769
}
770+
771+
#[test]
772+
fn test_parse_journalctl_error_permission() {
773+
let diagnostic = parse_journalctl_error("Failed to get journal access: Permission denied");
774+
assert!(matches!(diagnostic, LogDiagnostic::PermissionDenied { .. }));
775+
}
776+
777+
#[test]
778+
fn test_parse_journalctl_error_no_file() {
779+
let diagnostic = parse_journalctl_error("No such file or directory");
780+
assert!(matches!(diagnostic, LogDiagnostic::JournalInaccessible { .. }));
781+
}
782+
783+
#[test]
784+
fn test_parse_journalctl_error_generic() {
785+
let diagnostic = parse_journalctl_error("Something unexpected happened");
786+
assert!(matches!(diagnostic, LogDiagnostic::JournalctlError { .. }));
787+
}
655788
}

0 commit comments

Comments
 (0)