This commit is contained in:
3267
gui/Cargo.lock
generated
Normal file
3267
gui/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
gui/Cargo.toml
Normal file
9
gui/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "intake-gui"
|
||||
version = "2.6.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
39
gui/build.rs
Normal file
39
gui/build.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::{env, fs, path::Path};
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=CSR_BUILD_ID");
|
||||
println!("cargo:rerun-if-changed=src");
|
||||
println!("cargo:rerun-if-changed=../scripts/inventory-sync.py");
|
||||
println!("cargo:rerun-if-changed=Cargo.lock");
|
||||
fn hash_dir(path: &Path, hash: &mut u64) {
|
||||
let mut files: Vec<_> = fs::read_dir(path)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
files.sort();
|
||||
for p in files {
|
||||
if p.is_dir() {
|
||||
hash_dir(&p, hash);
|
||||
} else {
|
||||
for b in fs::read(p).unwrap() {
|
||||
*hash = (*hash ^ b as u64).wrapping_mul(1099511628211);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut hash = 14695981039346656037u64;
|
||||
hash_dir(Path::new("src"), &mut hash);
|
||||
for file in [
|
||||
"../scripts/inventory-sync.py",
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
"build.rs",
|
||||
] {
|
||||
for b in fs::read(file).unwrap() {
|
||||
hash = (hash ^ b as u64).wrapping_mul(1099511628211);
|
||||
}
|
||||
}
|
||||
let id = env::var("CSR_BUILD_ID")
|
||||
.unwrap_or_else(|_| format!("{}-{hash:016x}", env::var("CARGO_PKG_VERSION").unwrap()));
|
||||
println!("cargo:rustc-env=CSR_BUILD_ID={id}");
|
||||
}
|
||||
483
gui/src/diagnostics.rs
Normal file
483
gui/src/diagnostics.rs
Normal file
@@ -0,0 +1,483 @@
|
||||
//! Read-only or temporary hardware diagnostics. Never mark a technician check PASS automatically.
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn available(tool: &str) -> bool {
|
||||
Command::new("sh")
|
||||
.args(["-c", "command -v \"$1\" >/dev/null 2>&1", "sh", tool])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
fn exec(tool: &str, args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("timeout")
|
||||
.args(["--kill-after=3s", "150s", tool])
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| format!("{tool}: {e}"))?;
|
||||
let log = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
if out.status.success() {
|
||||
Ok(log)
|
||||
} else {
|
||||
Err(format!("{tool} ({})\n{log}", out.status))
|
||||
}
|
||||
}
|
||||
fn temps() -> Vec<(String, f64)> {
|
||||
let mut values = Vec::new();
|
||||
let Ok(chips) = fs::read_dir("/sys/class/hwmon") else {
|
||||
return values;
|
||||
};
|
||||
for entry in chips.flatten() {
|
||||
let base = entry.path();
|
||||
let chip = fs::read_to_string(base.join("name"))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_owned();
|
||||
let Ok(files) = fs::read_dir(&base) else {
|
||||
continue;
|
||||
};
|
||||
for file in files.flatten() {
|
||||
let name = file.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("temp") || !name.ends_with("_input") {
|
||||
continue;
|
||||
}
|
||||
let Ok(raw) = fs::read_to_string(file.path()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(milli) = raw.trim().parse::<f64>() else {
|
||||
continue;
|
||||
};
|
||||
let c = milli / 1000.0;
|
||||
if !(-20.0..=150.0).contains(&c) {
|
||||
continue;
|
||||
}
|
||||
values.push((format!("{chip}:{name}"), c));
|
||||
}
|
||||
}
|
||||
values
|
||||
}
|
||||
fn thermal(settings: &crate::workflow::Settings) -> (bool, String) {
|
||||
if !available("stress-ng") {
|
||||
return (false, "UNAVAILABLE: stress-ng fehlt im Live-System".into());
|
||||
}
|
||||
let baseline = temps();
|
||||
let start = Instant::now();
|
||||
let mut child = match Command::new("stress-ng")
|
||||
.args([
|
||||
"--cpu",
|
||||
"0",
|
||||
"--timeout",
|
||||
&format!("{}s", settings.stress_seconds),
|
||||
"--metrics-brief",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return (false, format!("stress-ng konnte nicht starten: {e}")),
|
||||
};
|
||||
let mut samples = 0usize;
|
||||
let mut peak: Option<f64> = None;
|
||||
loop {
|
||||
for (_, t) in temps() {
|
||||
peak = Some(peak.map_or(t, |p| p.max(t)));
|
||||
samples += 1;
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {}
|
||||
Err(e) => return (false, format!("Prozessprüfung fehlgeschlagen: {e}")),
|
||||
}
|
||||
if start.elapsed() > Duration::from_secs(settings.stress_seconds + 15) {
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
let output = match child.wait_with_output() {
|
||||
Ok(o) => o,
|
||||
Err(e) => return (false, format!("Ausgabe fehlt: {e}")),
|
||||
};
|
||||
let mut log = format!(
|
||||
"Dauer: {:.0}s\nSensorwerte zu Beginn: {:?}\nMesspunkte: {}\nMaximal beobachtet: {}\n{}",
|
||||
start.elapsed().as_secs_f64(),
|
||||
baseline,
|
||||
samples,
|
||||
peak.map(|p| format!("{p:.1} °C"))
|
||||
.unwrap_or("UNAVAILABLE".into()),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
if let Some(p) = peak {
|
||||
log.push_str(&format!("\nPEAK_TEMPERATURE_C={p}"));
|
||||
}
|
||||
if let Some(p) = peak {
|
||||
if p >= settings.max_temperature_c {
|
||||
log.push_str(
|
||||
"\nWARNUNG: Werkstatt-Temperaturgrenze überschritten; manuelle Prüfung nötig.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if samples == 0 {
|
||||
log.push_str("\nWARNUNG: Kein Temperatursensor auslesbar.");
|
||||
}
|
||||
(
|
||||
output.status.success()
|
||||
&& samples > 0
|
||||
&& peak
|
||||
.map(|p| p < settings.max_temperature_c)
|
||||
.unwrap_or(false)
|
||||
&& start.elapsed() >= Duration::from_secs(settings.stress_seconds.saturating_sub(2)),
|
||||
log,
|
||||
)
|
||||
}
|
||||
fn stability(settings: &crate::workflow::Settings) -> (bool, String) {
|
||||
if !available("stress-ng") {
|
||||
return (false, "UNAVAILABLE: stress-ng fehlt".into());
|
||||
}
|
||||
let mem_kib = fs::read_to_string("/proc/meminfo")
|
||||
.ok()
|
||||
.and_then(|s| {
|
||||
s.lines()
|
||||
.find(|l| l.starts_with("MemAvailable:"))
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
// Conservatively cap test allocation; never allocate all free RAM in a live USB GUI.
|
||||
if mem_kib < 256 * 1024 {
|
||||
return (
|
||||
false,
|
||||
"Insufficient available RAM for a bounded stress test / Zu wenig freier RAM".into(),
|
||||
);
|
||||
}
|
||||
let ram_mib = ((mem_kib / 1024) / 4).clamp(128, 2048);
|
||||
let mut child = match Command::new("stress-ng")
|
||||
.args([
|
||||
"--cpu",
|
||||
"0",
|
||||
"--vm",
|
||||
"1",
|
||||
"--vm-bytes",
|
||||
&format!("{ram_mib}M"),
|
||||
"--verify",
|
||||
"--timeout",
|
||||
&format!("{}s", settings.stress_seconds),
|
||||
"--metrics-brief",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => return (false, format!("Start fehlgeschlagen: {e}")),
|
||||
};
|
||||
let start = Instant::now();
|
||||
let mut peak: Option<f64> = None;
|
||||
loop {
|
||||
for (_, v) in temps() {
|
||||
peak = Some(peak.map_or(v, |p| p.max(v)));
|
||||
}
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {}
|
||||
Err(e) => return (false, format!("Prozessfehler: {e}")),
|
||||
}
|
||||
if start.elapsed() > Duration::from_secs(settings.stress_seconds + 15) {
|
||||
let _ = child.kill();
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
match child.wait_with_output(){Ok(o)=>(o.status.success() && peak.map(|p|p<settings.max_temperature_c).unwrap_or(true) && start.elapsed()>=Duration::from_secs(settings.stress_seconds.saturating_sub(2)),format!("CPU-/RAM-Test {:.0}s, RAM-Verbrauch ca. {} MiB, Temp-Spitze {}\nPEAK_TEMPERATURE_C={}\n{}",start.elapsed().as_secs_f64(),ram_mib,peak.map(|p|format!("{p:.1}°C")).unwrap_or("UNKNOWN".into()),peak.map(|p|p.to_string()).unwrap_or_default(),String::from_utf8_lossy(&o.stderr))),Err(e)=>(false,format!("Ausgabe fehlgeschlagen: {e}"))}
|
||||
}
|
||||
fn microphone(settings: &crate::workflow::Settings) -> (bool, String) {
|
||||
if !available("arecord") || !available("aplay") {
|
||||
return (
|
||||
false,
|
||||
"UNAVAILABLE: alsa-utils / arecord / aplay fehlen".into(),
|
||||
);
|
||||
}
|
||||
let path = format!(
|
||||
"/tmp/csr-audio-{}-{}.wav",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let record = exec(
|
||||
"arecord",
|
||||
&[
|
||||
"-q",
|
||||
"-d",
|
||||
&settings.recording_seconds.to_string(),
|
||||
"-f",
|
||||
"S16_LE",
|
||||
"-r",
|
||||
"16000",
|
||||
"-c",
|
||||
"1",
|
||||
&path,
|
||||
],
|
||||
);
|
||||
let result = match record {
|
||||
Err(e) => (false, format!("Mikrofonaufnahme fehlgeschlagen:\n{e}")),
|
||||
Ok(_) => match exec("aplay", &["-q", &path]) {
|
||||
Ok(_) => (
|
||||
true,
|
||||
format!(
|
||||
"{} Sekunden aufgenommen und abgespielt. Bitte Tonqualität bestätigen.",
|
||||
settings.recording_seconds
|
||||
),
|
||||
),
|
||||
Err(e) => (false, format!("Wiedergabe fehlgeschlagen: {e}")),
|
||||
},
|
||||
};
|
||||
if Path::new(&path).exists() {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
result
|
||||
}
|
||||
fn camera(settings: &crate::workflow::Settings) -> (bool, String) {
|
||||
if !Path::new("/dev/video0").exists() {
|
||||
return (false, "UNAVAILABLE: keine Kamera unter /dev/video0".into());
|
||||
}
|
||||
if !available("ffplay") {
|
||||
return (false, "UNAVAILABLE: ffplay fehlt".into());
|
||||
}
|
||||
let out = exec(
|
||||
"timeout",
|
||||
&[
|
||||
&format!("{}s", settings.camera_seconds),
|
||||
"ffplay",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"video4linux2",
|
||||
"-i",
|
||||
"/dev/video0",
|
||||
],
|
||||
);
|
||||
match out {
|
||||
Ok(s) => (
|
||||
true,
|
||||
format!("Vorschau beendet. Sichtprüfung erforderlich.\n{s}"),
|
||||
),
|
||||
Err(s) => {
|
||||
// timeout exits 124 after a normal 25s bounded preview.
|
||||
if s.contains("124") {
|
||||
(
|
||||
true,
|
||||
format!(
|
||||
"{} Sekunden Vorschau beendet. Sichtprüfung erforderlich.",
|
||||
settings.camera_seconds
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(false, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn run_legacy(tool: &str, settings: &crate::workflow::Settings) -> (bool, String) {
|
||||
match tool {
|
||||
"audio" => match exec("speaker-test", &["-c", "2", "-t", "wav", "-l", "1"]) {
|
||||
Ok(s) => (
|
||||
true,
|
||||
format!("Linker/rechter Testkanal ausgegeben. Bitte Hörtest bestätigen.\n{s}"),
|
||||
),
|
||||
Err(s) => (false, s),
|
||||
},
|
||||
"microphone" => microphone(settings),
|
||||
"camera" => camera(settings),
|
||||
"thermals" => thermal(settings),
|
||||
"stability" => stability(settings),
|
||||
"wifi" => {
|
||||
let service = exec("nmcli", &["general", "status"]);
|
||||
match service {
|
||||
Err(s) => (false, format!("NetworkManager nicht bereit: {s}")),
|
||||
Ok(status) => {
|
||||
let dev = exec("nmcli", &["device", "status"]).unwrap_or_else(|e| e);
|
||||
let scan = exec(
|
||||
"nmcli",
|
||||
&["-f", "IN-USE,SSID,SIGNAL", "device", "wifi", "list"],
|
||||
)
|
||||
.unwrap_or_else(|e| e);
|
||||
(true,format!("Status:\n{status}\nGeräte:\n{dev}\nNetze:\n{scan}\nLAN trennen und WLAN-Verbindung aktiv testen; Sichtbarkeit ist noch kein PASS."))
|
||||
}
|
||||
}
|
||||
}
|
||||
"video" => (
|
||||
true,
|
||||
"Kameratest bitte über Kameravorschau durchführen.".into(),
|
||||
),
|
||||
_ => (false, "Unbekannter Diagnosetest".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Diagnostic {
|
||||
pub outcome: crate::workflow::Outcome,
|
||||
pub text: String,
|
||||
}
|
||||
pub fn run(tool: &str, settings: &crate::workflow::Settings) -> Diagnostic {
|
||||
use crate::workflow::Outcome;
|
||||
let required: &[&str] = match tool {
|
||||
"audio" => &["speaker-test"],
|
||||
"microphone" => &["arecord", "aplay"],
|
||||
"webcam" => &["ffplay", "timeout"],
|
||||
"thermals" | "stability" => &["stress-ng"],
|
||||
"usb_ports" => &["lsusb"],
|
||||
_ => &[],
|
||||
};
|
||||
if !available("timeout") {
|
||||
return Diagnostic {
|
||||
outcome: Outcome::Unavailable,
|
||||
text: "timeout tool missing / fehlt".into(),
|
||||
};
|
||||
}
|
||||
let missing: Vec<_> = required.iter().filter(|p| !available(p)).copied().collect();
|
||||
if !missing.is_empty() {
|
||||
return Diagnostic {
|
||||
outcome: Outcome::Unavailable,
|
||||
text: format!("Missing tools / Fehlende Werkzeuge: {}", missing.join(", ")),
|
||||
};
|
||||
}
|
||||
if let Some(disk) = tool.strip_prefix("disk:") {
|
||||
if !disk
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
return Diagnostic {
|
||||
outcome: Outcome::SetupProblem,
|
||||
text: "Invalid disk name".into(),
|
||||
};
|
||||
}
|
||||
if !available("smartctl") {
|
||||
return Diagnostic {
|
||||
outcome: Outcome::Unavailable,
|
||||
text: "smartctl missing / fehlt".into(),
|
||||
};
|
||||
}
|
||||
return match Command::new("timeout")
|
||||
.args(["--kill-after=3s", "30s", "smartctl"])
|
||||
.args(["-j", "-H", "-A", &format!("/dev/{disk}")])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => {
|
||||
let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_default();
|
||||
let outcome = match v
|
||||
.pointer("/smart_status/passed")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
{
|
||||
Some(false) => Outcome::HardwareFailure,
|
||||
Some(true) => Outcome::NeedsReview,
|
||||
None => Outcome::SetupProblem,
|
||||
};
|
||||
Diagnostic {
|
||||
outcome,
|
||||
text: format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&o.stdout),
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(e) => Diagnostic {
|
||||
outcome: Outcome::SetupProblem,
|
||||
text: e.to_string(),
|
||||
},
|
||||
};
|
||||
}
|
||||
if tool == "charging" {
|
||||
let mut lines = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir("/sys/class/power_supply") {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
let read = |k: &str| {
|
||||
fs::read_to_string(p.join(k))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_owned()
|
||||
};
|
||||
lines.push(format!("{}: type={} status={} charge={}% online={} energy_full={} energy_full_design={} charge_full={} charge_full_design={}",e.file_name().to_string_lossy(),read("type"),read("status"),read("capacity"),read("online"),read("energy_full"),read("energy_full_design"),read("charge_full"),read("charge_full_design")));
|
||||
}
|
||||
}
|
||||
return Diagnostic {
|
||||
outcome: if lines.is_empty() {
|
||||
Outcome::Unavailable
|
||||
} else {
|
||||
Outcome::NeedsReview
|
||||
},
|
||||
text: if lines.is_empty() {
|
||||
"No power supply data / Keine Akkudaten".into()
|
||||
} else {
|
||||
lines.join("\n")
|
||||
},
|
||||
};
|
||||
}
|
||||
if tool == "usb_ports" {
|
||||
return match exec("lsusb", &[]) {
|
||||
Ok(text) => Diagnostic {
|
||||
outcome: Outcome::NeedsReview,
|
||||
text,
|
||||
},
|
||||
Err(text) => Diagnostic {
|
||||
outcome: Outcome::SetupProblem,
|
||||
text,
|
||||
},
|
||||
};
|
||||
}
|
||||
let (ok, text) = run_legacy(if tool == "webcam" { "camera" } else { tool }, settings);
|
||||
// Command failures are setup problems, never proof of a broken component.
|
||||
Diagnostic {
|
||||
outcome: if text.starts_with("UNAVAILABLE")
|
||||
|| (tool == "thermals" && text.contains("Messpunkte: 0"))
|
||||
{
|
||||
Outcome::Unavailable
|
||||
} else if peak_temperature(&text)
|
||||
.map(|v| v >= settings.max_temperature_c)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
Outcome::NeedsReview
|
||||
} else if ok {
|
||||
Outcome::NeedsReview
|
||||
} else {
|
||||
Outcome::SetupProblem
|
||||
},
|
||||
text,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn peak_temperature(text: &str) -> Option<f64> {
|
||||
text.lines().find_map(|l| {
|
||||
l.strip_prefix("PEAK_TEMPERATURE_C=")
|
||||
.and_then(|s| s.parse::<f64>().ok())
|
||||
.filter(|v| v.is_finite())
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn peak_is_machine_readable_and_finite() {
|
||||
assert_eq!(
|
||||
peak_temperature("log\nPEAK_TEMPERATURE_C=94.5\nend"),
|
||||
Some(94.5)
|
||||
);
|
||||
assert_eq!(peak_temperature("PEAK_TEMPERATURE_C=NaN"), None);
|
||||
}
|
||||
}
|
||||
72
gui/src/inventory.rs
Normal file
72
gui/src/inventory.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use serde::Deserialize;
|
||||
use std::{path::Path, process::Command};
|
||||
const BRIDGE: &str = include_str!("../../scripts/inventory-sync.py");
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct QueueEntry {
|
||||
pub asset_id: String,
|
||||
pub inventory_number: Option<u64>,
|
||||
pub status: String,
|
||||
pub attempts: u64,
|
||||
pub next_attempt: u64,
|
||||
pub error: String,
|
||||
pub report_path: String,
|
||||
}
|
||||
fn call(data: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let out = Command::new("python3")
|
||||
.args(["-c", BRIDGE, "--data"])
|
||||
.arg(data)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let log = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
if out.status.success() {
|
||||
Ok(log)
|
||||
} else {
|
||||
Err(log)
|
||||
}
|
||||
}
|
||||
pub fn queue(data: &Path) -> Result<Vec<QueueEntry>, String> {
|
||||
serde_json::from_str(&call(data, &["--list"])?).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn sync(data: &Path, retry: bool) -> Result<String, String> {
|
||||
call(data, if retry { &["--retry"] } else { &[] })
|
||||
}
|
||||
|
||||
pub fn check(data: &Path) -> Result<String, String> {
|
||||
call(data, &["--check"])
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HardwareIdentity {
|
||||
pub hwid: String,
|
||||
pub local_id: String,
|
||||
}
|
||||
pub fn identify(identity: &crate::workflow::Identity) -> Result<HardwareIdentity, String> {
|
||||
let report = serde_json::json!({"system_uuid": identity.uuid, "serial_number": identity.serial,
|
||||
"manufacturer": identity.manufacturer});
|
||||
let output = Command::new("python3")
|
||||
.args(["-c", BRIDGE, "--identity-json", &report.to_string()])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned());
|
||||
}
|
||||
serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn station(
|
||||
data: &Path,
|
||||
action: &str,
|
||||
report: Option<&Path>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut args = vec!["--station", action];
|
||||
let path = report.map(|p| p.to_string_lossy().into_owned());
|
||||
if let Some(p) = &path {
|
||||
args.extend(["--report", p]);
|
||||
}
|
||||
serde_json::from_str(&call(data, &args)?).map_err(|e| e.to_string())
|
||||
}
|
||||
1936
gui/src/main.rs
Normal file
1936
gui/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
187
gui/src/report.rs
Normal file
187
gui/src/report.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use crate::workflow::{self, Inspection};
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
fn escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
fn value(v: &Value, k: &str) -> String {
|
||||
escape(&match &v[k] {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => "Unknown / Unbekannt".into(),
|
||||
v => v.to_string(),
|
||||
})
|
||||
}
|
||||
pub fn html(s: &Inspection) -> String {
|
||||
let en = s.settings.language == "en";
|
||||
let title = if en {
|
||||
"Inspection report"
|
||||
} else {
|
||||
"Prüfbericht"
|
||||
};
|
||||
let mut h=format!("<!doctype html><html lang='{}'><head><meta charset='utf-8'><title>{} {}</title><style>body{{font:14px system-ui;margin:32px;color:#182330}}h1,h2{{color:#153f46}}table{{border-collapse:collapse;width:100%;margin-bottom:20px}}th,td{{text-align:left;vertical-align:top;border-bottom:1px solid #ccd;padding:8px;overflow-wrap:anywhere}}pre{{white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px}}.banner{{padding:12px;background:#eff5f5}}@media print{{body{{margin:0}}button{{display:none}}thead{{display:table-header-group}}tr{{break-inside:avoid}}h2{{break-after:avoid}}}}@page{{size:A4;margin:16mm}}</style></head><body><button onclick='window.print()'>Print / Drucken</button><h1>{title}: {}</h1><p>Build: {} · Revision: {} · UTC Unix: {}</p>",if en{"en"}else{"de"},title,escape(&s.asset),escape(&s.asset),escape(&s.build),s.revision,s.updated);
|
||||
h.push_str(&format!(
|
||||
"<p>Report generator / Berichterstellung: {}</p>",
|
||||
escape(env!("CSR_BUILD_ID"))
|
||||
));
|
||||
let approved = s
|
||||
.approval
|
||||
.as_ref()
|
||||
.filter(|a| a.revision == s.revision && s.blockers().is_empty());
|
||||
h.push_str(&format!(
|
||||
"<div class='banner'><strong>{}</strong></div>",
|
||||
if approved.is_some() {
|
||||
"APPROVED / FREIGEGEBEN"
|
||||
} else {
|
||||
"NOT APPROVED / NICHT FREIGEGEBEN"
|
||||
}
|
||||
));
|
||||
if let Some(a) = approved {
|
||||
h.push_str(&format!(
|
||||
"<p>Technician / Techniker: {} · UTC Unix: {}</p>",
|
||||
escape(&a.technician),
|
||||
a.timestamp
|
||||
));
|
||||
}
|
||||
h.push_str(&format!(
|
||||
"<h2>Device / Gerät</h2><p>{} {}<br>Serial: {}<br>UUID: {}</p>",
|
||||
escape(&s.identity.manufacturer),
|
||||
escape(&s.identity.model),
|
||||
escape(&s.identity.serial),
|
||||
escape(&s.identity.uuid)
|
||||
));
|
||||
if let Some(r) = &s.report {
|
||||
h.push_str("<table><tr><th>Specification / Spezifikation</th><th>Value / Wert</th></tr>");
|
||||
for k in [
|
||||
"cpu",
|
||||
"logical_threads",
|
||||
"installed_ram_gib",
|
||||
"ram_gib",
|
||||
"gpus",
|
||||
"screen_resolution",
|
||||
"screen_inches",
|
||||
"tool_version",
|
||||
] {
|
||||
h.push_str(&format!("<tr><td>{k}</td><td>{}</td></tr>", value(r, k)));
|
||||
}
|
||||
h.push_str("</table><h2>Storage health / Laufwerkszustand</h2><table><tr><th>Disk</th><th>Model / GiB</th><th>SMART</th></tr>");
|
||||
for d in r["disks"].as_array().into_iter().flatten() {
|
||||
h.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{} / {}</td><td>{}<pre>{}</pre></td></tr>",
|
||||
value(d, "name"),
|
||||
value(d, "model"),
|
||||
value(d, "size_gib"),
|
||||
value(d, "smart_health"),
|
||||
value(d, "smart_details")
|
||||
));
|
||||
}
|
||||
h.push_str("</table><h2>Battery health / Akkuzustand</h2><table><tr><th>Battery</th><th>Charge %</th><th>Health %</th><th>Status</th></tr>");
|
||||
for b in r["batteries"].as_array().into_iter().flatten() {
|
||||
h.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||
value(b, "name"),
|
||||
value(b, "charge_percent"),
|
||||
value(b, "health_percent"),
|
||||
value(b, "status")
|
||||
));
|
||||
}
|
||||
h.push_str("</table><h2>Measurements / Messwerte</h2>");
|
||||
for k in [
|
||||
"temperatures_c",
|
||||
"benchmarks",
|
||||
"memory_benchmark",
|
||||
"compilation_benchmark",
|
||||
"concerns",
|
||||
] {
|
||||
if !r[k].is_null() {
|
||||
h.push_str(&format!(
|
||||
"<h3>{k}</h3><pre>{}</pre>",
|
||||
escape(&serde_json::to_string_pretty(&r[k]).unwrap_or_default())
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
h.push_str("<h2>Inspection results / Prüfergebnisse</h2><table><thead><tr><th>Test</th><th>Result / Ergebnis</th><th>Notes / Notizen</th></tr></thead><tbody>");
|
||||
for k in &s.order {
|
||||
let c = &s.checks[k];
|
||||
h.push_str(&format!(
|
||||
"<tr><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||
escape(k),
|
||||
c.outcome.label(en),
|
||||
escape(&c.notes)
|
||||
));
|
||||
}
|
||||
h.push_str("</tbody></table>");
|
||||
for k in &s.order {
|
||||
let c = &s.checks[k];
|
||||
h.push_str(&format!(
|
||||
"<h2>{}</h2><pre>{}</pre>",
|
||||
escape(k),
|
||||
escape(&c.evidence)
|
||||
));
|
||||
for e in &c.history {
|
||||
h.push_str(&format!(
|
||||
"<p>{} · {} · {}</p><pre>{}</pre>",
|
||||
e.timestamp,
|
||||
escape(&e.kind),
|
||||
e.outcome.label(en),
|
||||
escape(&e.text)
|
||||
));
|
||||
}
|
||||
}
|
||||
h.push_str(&format!("<h2>Notes / Notizen</h2><pre>{}</pre><h2>Acceptance settings / Werkstattgrenzen</h2><pre>{}</pre>",escape(&s.notes),escape(&serde_json::to_string_pretty(&s.settings).unwrap_or_default())));
|
||||
for b in s.blockers() {
|
||||
h.push_str(&format!("<p>{}</p>", escape(&b)));
|
||||
}
|
||||
if !s.approvals.is_empty() {
|
||||
h.push_str("<h2>Approval history / Freigabehistorie</h2>");
|
||||
for a in &s.approvals {
|
||||
h.push_str(&format!(
|
||||
"<p>Revision {} · {} · UTC Unix {}</p>",
|
||||
a.revision,
|
||||
escape(&a.technician),
|
||||
a.timestamp
|
||||
));
|
||||
}
|
||||
}
|
||||
h.push_str("</body></html>");
|
||||
h
|
||||
}
|
||||
pub fn export(data: &Path, s: &Inspection) -> Result<PathBuf, String> {
|
||||
if !workflow::valid_id(&s.asset) {
|
||||
return Err("Invalid asset ID".into());
|
||||
}
|
||||
let path = data.join("final-reports").join(&s.asset).join(format!(
|
||||
"inspection-{}-{}.html",
|
||||
s.revision,
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
workflow::atomic_write(&path, html(s).as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::workflow::*;
|
||||
#[test]
|
||||
fn escapes_notes_and_does_not_print_stale_approval() {
|
||||
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
|
||||
s.notes = "<script>alert('x')</script>".into();
|
||||
s.approval = Some(Approval {
|
||||
technician: "T".into(),
|
||||
timestamp: 0,
|
||||
revision: 0,
|
||||
});
|
||||
let h = html(&s);
|
||||
assert!(!h.contains("<script>"));
|
||||
assert!(h.contains("<script>"));
|
||||
assert!(h.contains("NOT APPROVED"));
|
||||
assert!(h.contains("@media print"));
|
||||
}
|
||||
}
|
||||
825
gui/src/wifi.rs
Normal file
825
gui/src/wifi.rs
Normal file
@@ -0,0 +1,825 @@
|
||||
//! Wi-Fi workflow shared by the walkthrough and inventory page.
|
||||
use eframe::egui;
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::Write,
|
||||
os::unix::fs::OpenOptionsExt,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, Stdio},
|
||||
sync::mpsc::{self, Receiver},
|
||||
thread,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Network {
|
||||
ssid: String,
|
||||
bssid: String,
|
||||
device: String,
|
||||
security: String,
|
||||
signal: u8,
|
||||
active: bool,
|
||||
}
|
||||
impl Network {
|
||||
fn enterprise(&self) -> bool {
|
||||
self.security.contains("802.1X") || self.security.contains("EAP")
|
||||
}
|
||||
fn secured(&self) -> bool {
|
||||
!self.security.is_empty() && self.security != "--"
|
||||
}
|
||||
}
|
||||
fn fields(line: &str) -> Vec<String> {
|
||||
let mut result = vec![String::new()];
|
||||
let mut escaped = false;
|
||||
for c in line.chars() {
|
||||
if escaped {
|
||||
result.last_mut().unwrap().push(c);
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == ':' {
|
||||
result.push(String::new());
|
||||
} else {
|
||||
result.last_mut().unwrap().push(c);
|
||||
}
|
||||
}
|
||||
if escaped {
|
||||
result.last_mut().unwrap().push('\\');
|
||||
}
|
||||
result
|
||||
}
|
||||
fn parse_networks(raw: &str) -> Vec<Network> {
|
||||
let mut networks = Vec::new();
|
||||
for line in raw.lines() {
|
||||
let f = fields(line);
|
||||
if f.len() != 6 || f[1].is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(signal) = f[3].parse() else {
|
||||
continue;
|
||||
};
|
||||
networks.push(Network {
|
||||
active: f[0] == "*",
|
||||
ssid: f[1].clone(),
|
||||
bssid: f[2].clone(),
|
||||
signal,
|
||||
security: f[4].clone(),
|
||||
device: f[5].clone(),
|
||||
});
|
||||
}
|
||||
networks.sort_by_key(|n| std::cmp::Reverse(n.signal));
|
||||
networks.dedup_by(|a, b| a.bssid == b.bssid && a.device == b.device);
|
||||
networks
|
||||
}
|
||||
fn nmcli(args: &[&str]) -> Command {
|
||||
let mut c = Command::new("nmcli");
|
||||
c.env("LC_ALL", "C").args(args).stdin(Stdio::null());
|
||||
c
|
||||
}
|
||||
fn output(mut command: Command) -> Result<String, String> {
|
||||
let out = command
|
||||
.output()
|
||||
.map_err(|e| format!("Werkzeug konnte nicht starten: {e}"))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).trim().to_owned())
|
||||
}
|
||||
}
|
||||
fn scan(rescan: &str) -> Result<Vec<Network>, String> {
|
||||
output(nmcli(&[
|
||||
"--wait",
|
||||
"20",
|
||||
"-t",
|
||||
"--escape",
|
||||
"yes",
|
||||
"-f",
|
||||
"IN-USE,SSID,BSSID,SIGNAL,SECURITY,DEVICE",
|
||||
"device",
|
||||
"wifi",
|
||||
"list",
|
||||
"--rescan",
|
||||
rescan,
|
||||
]))
|
||||
.map(|s| parse_networks(&s))
|
||||
}
|
||||
// Use a user-owned, memory-only profile: the live account has no admin password
|
||||
// or desktop authentication agent. Passwords travel through stdin, never argv.
|
||||
fn connect_with(
|
||||
n: &Network,
|
||||
password: &str,
|
||||
user: &str,
|
||||
uuid: &str,
|
||||
mut run: impl FnMut(&[&str], Option<&str>) -> Result<String, String>,
|
||||
) -> Result<(), String> {
|
||||
if password.contains(['\n', '\r', '\0']) {
|
||||
return Err("Das Passwort darf keinen Zeilenumbruch oder NUL enthalten.".into());
|
||||
}
|
||||
if n.enterprise() {
|
||||
return Err("Enterprise Wi-Fi needs a configured NetworkManager profile / Unternehmens-WLAN benötigt ein konfiguriertes Profil.".into());
|
||||
}
|
||||
// Reuse existing credentials without an interactive prompt if the field is blank.
|
||||
if n.secured() && !n.security.contains("OWE") && password.is_empty() {
|
||||
return run(&["--wait", "25", "device", "wifi", "connect", &n.bssid, "ifname", &n.device], None)
|
||||
.map(|_|()).map_err(|e|format!("Kein gespeichertes Passwort verwendbar. WLAN-Passwort eingeben und erneut versuchen.\n{e}"));
|
||||
}
|
||||
let name = format!("CS-R Wi-Fi {uuid}");
|
||||
let owner = format!("user:{user}");
|
||||
let mut args = vec![
|
||||
"--wait",
|
||||
"10",
|
||||
"connection",
|
||||
"add",
|
||||
"save",
|
||||
"no",
|
||||
"type",
|
||||
"wifi",
|
||||
"ifname",
|
||||
&n.device,
|
||||
"con-name",
|
||||
&name,
|
||||
"ssid",
|
||||
&n.ssid,
|
||||
"connection.uuid",
|
||||
uuid,
|
||||
"connection.permissions",
|
||||
&owner,
|
||||
"connection.autoconnect",
|
||||
"no",
|
||||
];
|
||||
let secret_property = if n.security.contains("WEP") {
|
||||
args.extend([
|
||||
"wifi-sec.key-mgmt",
|
||||
"none",
|
||||
"wifi-sec.wep-key-type",
|
||||
"key",
|
||||
"wifi-sec.wep-key-flags",
|
||||
"2",
|
||||
]);
|
||||
Some("802-11-wireless-security.wep-key0")
|
||||
} else if n.security.contains("OWE") {
|
||||
args.extend(["wifi-sec.key-mgmt", "owe"]);
|
||||
None
|
||||
} else if n.secured() {
|
||||
let key_mgmt = if n.security.contains("WPA3") && !n.security.contains("WPA2") {
|
||||
"sae"
|
||||
} else {
|
||||
"wpa-psk"
|
||||
};
|
||||
args.extend(["wifi-sec.key-mgmt", key_mgmt, "wifi-sec.psk-flags", "2"]);
|
||||
Some("802-11-wireless-security.psk")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
run(&args, None)?;
|
||||
let payload = secret_property.map(|property| format!("{property}:{password}\n"));
|
||||
let mut up = vec![
|
||||
"--wait",
|
||||
"25",
|
||||
"connection",
|
||||
"up",
|
||||
"uuid",
|
||||
uuid,
|
||||
"ifname",
|
||||
&n.device,
|
||||
"ap",
|
||||
&n.bssid,
|
||||
];
|
||||
if payload.is_some() {
|
||||
up.extend(["passwd-file", "/dev/stdin"]);
|
||||
}
|
||||
match run(&up, payload.as_deref()) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) => {
|
||||
// Delete only the temporary profile created by this attempt.
|
||||
let _ = run(
|
||||
&["--wait", "10", "connection", "delete", "uuid", uuid],
|
||||
None,
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn run_connect_command(
|
||||
mut command: Command,
|
||||
input: Option<&str>,
|
||||
password: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut child = command
|
||||
.stdin(if input.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
Stdio::null()
|
||||
})
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("NetworkManager konnte nicht starten: {e}"))?;
|
||||
let write_error = if let Some(input) = input {
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Password input unavailable".to_owned())
|
||||
.and_then(|mut pipe| pipe.write_all(input.as_bytes()).map_err(|e| e.to_string()))
|
||||
.err()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let out = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Verbindung fehlgeschlagen: {e}"))?;
|
||||
let redact = |text: &str| {
|
||||
if password.is_empty() {
|
||||
text.to_owned()
|
||||
} else {
|
||||
text.replace(password, "[hidden]")
|
||||
}
|
||||
};
|
||||
if !out.status.success() {
|
||||
let detail = redact(String::from_utf8_lossy(&out.stderr).trim());
|
||||
return Err(format!(
|
||||
"NetworkManager ({}): {}",
|
||||
out.status,
|
||||
if detail.is_empty() {
|
||||
"Connection failed / Verbindung fehlgeschlagen"
|
||||
} else {
|
||||
&detail
|
||||
}
|
||||
));
|
||||
}
|
||||
if let Some(error) = write_error {
|
||||
return Err(format!(
|
||||
"Password delivery failed / Passwortübergabe fehlgeschlagen: {error}"
|
||||
));
|
||||
}
|
||||
Ok(redact(&String::from_utf8_lossy(&out.stdout)))
|
||||
}
|
||||
fn connect(n: &Network, password: &str) -> Result<(), String> {
|
||||
let mut id = Command::new("id");
|
||||
id.arg("-un");
|
||||
let user = output(id)?;
|
||||
let uuid =
|
||||
std::fs::read_to_string("/proc/sys/kernel/random/uuid").map_err(|e| e.to_string())?;
|
||||
connect_with(n, password, user.trim(), uuid.trim(), |args, input| {
|
||||
let mut command = Command::new("timeout");
|
||||
command
|
||||
.env("LC_ALL", "C")
|
||||
.args(["--kill-after=3s", "40s", "nmcli"])
|
||||
.args(args);
|
||||
run_connect_command(command, input, password)
|
||||
})
|
||||
}
|
||||
fn test_connection(n: &Network, url: &str) -> Result<String, String> {
|
||||
if !(url.starts_with("https://") || url.starts_with("http://")) {
|
||||
return Err("Bitte eine HTTP- oder HTTPS-Testadresse eingeben.".into());
|
||||
}
|
||||
let active = scan("no")?;
|
||||
if !active
|
||||
.iter()
|
||||
.any(|a| a.active && a.ssid == n.ssid && a.device == n.device)
|
||||
{
|
||||
return Err(
|
||||
"Das ausgewählte WLAN ist nicht verbunden. Zuerst verbinden, dann erneut testen."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let ip = output(nmcli(&[
|
||||
"-g",
|
||||
"IP4.ADDRESS,IP6.ADDRESS",
|
||||
"device",
|
||||
"show",
|
||||
&n.device,
|
||||
]))?;
|
||||
if ip.trim().is_empty() {
|
||||
return Err("Verbunden, aber noch keine IP-Adresse erhalten. Bitte erneut testen.".into());
|
||||
}
|
||||
let mut curl = Command::new("curl");
|
||||
curl.args([
|
||||
"--disable",
|
||||
"--noproxy",
|
||||
"*",
|
||||
"--interface",
|
||||
&format!("if!{}", n.device),
|
||||
"--connect-timeout",
|
||||
"8",
|
||||
"--max-time",
|
||||
"20",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--output",
|
||||
"/dev/null",
|
||||
"--write-out",
|
||||
"%{http_code}",
|
||||
"--proto",
|
||||
"=http,https",
|
||||
"--url",
|
||||
url,
|
||||
]);
|
||||
let code =
|
||||
output(curl).map_err(|e| format!("WLAN verbunden; Datentransfer fehlgeschlagen: {e}"))?;
|
||||
if !code.starts_with('2') {
|
||||
return Err(format!("WLAN verbunden; Testziel meldet HTTP {code}. Adresse oder Anmeldeseite des WLANs prüfen."));
|
||||
}
|
||||
Ok(format!("Datentransfer erfolgreich · {} über {} · HTTP {}\nIP: {}\nTestziel: {}\nJetzt das Prüfergebnis bestätigen.", n.ssid, n.device, code, ip.trim(), url))
|
||||
}
|
||||
fn save_network(path: &Path, ssid: &str, password: &str) -> Result<(), String> {
|
||||
let tmp = path.with_extension(format!(
|
||||
"{}.{}.new",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&tmp)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let result = (|| {
|
||||
serde_json::to_writer(
|
||||
&mut file,
|
||||
&serde_json::json!({"ssid":ssid,"password":password}),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
file.sync_all().map_err(|e| e.to_string())?;
|
||||
fs::rename(&tmp, path).map_err(|e| e.to_string())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Scanned(Result<Vec<Network>, String>),
|
||||
Connected(Result<(), String>),
|
||||
Tested(Result<String, String>),
|
||||
}
|
||||
pub struct Wifi {
|
||||
en: bool,
|
||||
saved_path: Option<PathBuf>,
|
||||
remember: bool,
|
||||
networks: Vec<Network>,
|
||||
selected: Option<Network>,
|
||||
password: String,
|
||||
show_password: bool,
|
||||
filter: String,
|
||||
url: String,
|
||||
message: String,
|
||||
completed_test: Option<String>,
|
||||
receiver: Option<Receiver<Event>>,
|
||||
}
|
||||
impl Default for Wifi {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
en: false,
|
||||
saved_path: None,
|
||||
remember: true,
|
||||
networks: Vec::new(),
|
||||
selected: None,
|
||||
password: String::new(),
|
||||
show_password: false,
|
||||
filter: String::new(),
|
||||
url: "https://example.com".into(),
|
||||
message: "Starte die Suche nach WLAN-Netzen.".into(),
|
||||
completed_test: None,
|
||||
receiver: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Wifi {
|
||||
pub fn configure_saved(&mut self, data: &Path) {
|
||||
self.saved_path = Some(data.join("config/workshop-wifi.json"));
|
||||
}
|
||||
pub fn reconnect_saved(&mut self) {
|
||||
let Some(path) = self.saved_path.clone().filter(|p| p.exists()) else {
|
||||
return;
|
||||
};
|
||||
if self.busy() {
|
||||
return;
|
||||
}
|
||||
let (tx, rx) = mpsc::channel();
|
||||
self.receiver = Some(rx);
|
||||
self.message = "Connecting to saved workshop Wi-Fi / Verbinde Werkstatt-WLAN …".into();
|
||||
thread::spawn(move || {
|
||||
let result = (|| {
|
||||
let saved: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(path).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let ssid = saved["ssid"].as_str().ok_or("Saved SSID missing")?;
|
||||
let password = saved["password"].as_str().unwrap_or("");
|
||||
for attempt in 0..3 {
|
||||
if let Ok(networks) = scan("yes") {
|
||||
if let Some(network) = networks.iter().find(|n| n.ssid == ssid) {
|
||||
if network.active {
|
||||
return Ok(());
|
||||
}
|
||||
return connect(network, password);
|
||||
}
|
||||
}
|
||||
if attempt < 2 {
|
||||
thread::sleep(std::time::Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
Err("Saved Wi-Fi unavailable. Check network in Settings / Werkstatt-WLAN nicht gefunden.".into())
|
||||
})();
|
||||
let _ = tx.send(Event::Connected(result));
|
||||
});
|
||||
}
|
||||
pub fn set_language(&mut self, en: bool) {
|
||||
self.en = en;
|
||||
}
|
||||
pub fn set_test_url(&mut self, url: &str) {
|
||||
self.url = url.to_owned();
|
||||
}
|
||||
pub fn take_test_log(&mut self) -> Option<String> {
|
||||
self.completed_test.take()
|
||||
}
|
||||
pub fn busy(&self) -> bool {
|
||||
self.receiver.is_some()
|
||||
}
|
||||
pub fn poll(&mut self) {
|
||||
let event = self.receiver.as_ref().map(|r| r.try_recv());
|
||||
match event {
|
||||
Some(Ok(event)) => {
|
||||
self.receiver = None;
|
||||
match event {
|
||||
Event::Scanned(Ok(networks)) => {
|
||||
self.message = if networks.is_empty() {
|
||||
"Keine WLAN-Netze gefunden. WLAN-Adapter und Funkschalter prüfen, dann erneut suchen.".into()
|
||||
} else {
|
||||
format!(
|
||||
"{} WLAN-Netze gefunden. Wähle ein Netzwerk aus.",
|
||||
networks.len()
|
||||
)
|
||||
};
|
||||
self.networks = networks;
|
||||
self.selected = None;
|
||||
self.password.clear();
|
||||
}
|
||||
Event::Connected(Ok(())) => {
|
||||
self.password.clear();
|
||||
if let Some(n) = &mut self.selected {
|
||||
n.active = true;
|
||||
}
|
||||
self.message = "WLAN verbunden. Starte jetzt den Verbindungstest.".into()
|
||||
}
|
||||
Event::Tested(result) => {
|
||||
let log = match result {
|
||||
Ok(message) => {
|
||||
format!("TEST: wifi\nSTATUS: DATENTRANSFER ERFOLGREICH\n{message}")
|
||||
}
|
||||
Err(e) => format!("TEST: wifi\nSTATUS: FEHLER\n{e}"),
|
||||
};
|
||||
self.message = log.clone();
|
||||
self.completed_test = Some(log);
|
||||
}
|
||||
Event::Scanned(Err(e)) => self.message = format!("Fehler: {e}"),
|
||||
Event::Connected(Err(e)) => {
|
||||
self.message = format!(
|
||||
"Fehler: {e}\nPasswort bleibt für einen erneuten Versuch erhalten."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(mpsc::TryRecvError::Disconnected)) => {
|
||||
self.receiver = None;
|
||||
self.message = "WLAN-Aktion unerwartet beendet. Bitte erneut versuchen.".into();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, other_busy: bool) {
|
||||
let en = self.en;
|
||||
if let Some(path) = self.saved_path.clone().filter(|p| p.exists()) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tr(
|
||||
en,
|
||||
"Werkstatt-WLAN gespeichert",
|
||||
"Workshop Wi-Fi saved",
|
||||
));
|
||||
if ui
|
||||
.add_enabled(
|
||||
!self.busy(),
|
||||
egui::Button::new(crate::tr(en, "Vergessen", "Forget")),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => {
|
||||
self.message =
|
||||
"Saved Wi-Fi removed / Gespeichertes WLAN gelöscht".into()
|
||||
}
|
||||
Err(e) => self.message = e.to_string(),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_enabled_ui(!self.busy() && !other_busy, |ui| {
|
||||
ui.label(egui::RichText::new(crate::tr(en, "1. WLAN suchen und auswählen", "1. Find and select Wi-Fi")).strong());
|
||||
if ui.button(crate::tr(en, "WLAN-Netze suchen / aktualisieren", "Search / refresh Wi-Fi networks")).clicked() {
|
||||
self.networks.clear(); self.selected = None; self.password.clear();
|
||||
let (tx, rx) = mpsc::channel(); self.receiver = Some(rx);
|
||||
self.message = crate::tr(en, "Suche läuft …", "Scanning …").into();
|
||||
thread::spawn(move || { let _ = tx.send(Event::Scanned(scan("yes"))); });
|
||||
}
|
||||
ui.add(egui::TextEdit::singleline(&mut self.filter).hint_text(crate::tr(en, "Netzwerkname suchen …", "Filter network name …")));
|
||||
egui::ScrollArea::vertical().id_salt("wifi-networks").max_height(135.0).show(ui, |ui| {
|
||||
for network in &self.networks {
|
||||
if !network.ssid.to_lowercase().contains(&self.filter.to_lowercase()) { continue; }
|
||||
let selected = self.selected.as_ref() == Some(network);
|
||||
let label = format!("{} · {}% · {} · {}{}", network.ssid, network.signal, if network.secured() { &network.security } else { crate::tr(en, "Offen", "Open") }, network.device, if network.active { crate::tr(en, " · Verbunden", " · Connected") } else { "" });
|
||||
if ui.selectable_label(selected, label).clicked() {
|
||||
self.selected = Some(network.clone()); self.password.clear(); self.show_password = false;
|
||||
self.message = format!("{}: {}", crate::tr(en,"Ausgewählt","Selected"), network.ssid);
|
||||
}
|
||||
}
|
||||
});
|
||||
if !self.networks.is_empty() && !self.networks.iter().any(|n| n.ssid.to_lowercase().contains(&self.filter.to_lowercase())) { ui.label(crate::tr(en, "Kein Netzwerk passt zur Suche.", "No network matches the filter.")); }
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(crate::tr(en, "2. Passwort eingeben und verbinden", "2. Enter password and connect")).strong());
|
||||
if let Some(n) = self.selected.clone() {
|
||||
ui.label(format!("{}: {}", crate::tr(en,"Ausgewähltes WLAN","Selected Wi-Fi"), n.ssid));
|
||||
if n.enterprise() { ui.label(crate::tr(en, "Dieses WLAN benötigt eine Unternehmensanmeldung. Bitte über NetworkManager konfigurieren.", "This network needs enterprise authentication. Configure it through NetworkManager.")); }
|
||||
else {
|
||||
if n.secured() && !n.security.contains("OWE") {
|
||||
ui.add(egui::TextEdit::singleline(&mut self.password).password(!self.show_password).hint_text(crate::tr(en, "WLAN-Passwort", "Wi-Fi password")));
|
||||
ui.checkbox(&mut self.show_password, crate::tr(en, "Passwort anzeigen", "Show password"));
|
||||
ui.small(crate::tr(en, "Leer lassen, wenn das Passwort bereits in NetworkManager gespeichert ist.", "Leave blank to use a password already saved in NetworkManager."));
|
||||
} else { ui.label(crate::tr(en, "Offenes Netzwerk – kein Passwort nötig.", "Open network — no password needed.")); }
|
||||
if self.saved_path.is_some() {
|
||||
ui.checkbox(&mut self.remember, crate::tr(en, "Werkstatt-WLAN auf USB merken", "Remember workshop Wi-Fi on USB"));
|
||||
}
|
||||
if ui.button(crate::tr(en, "Mit WLAN verbinden", "Connect to Wi-Fi")).clicked() {
|
||||
let password = self.password.clone(); self.show_password = false;
|
||||
let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.message = crate::tr(en, "Verbindung wird hergestellt …", "Connecting …").into();
|
||||
let save = if self.remember { self.saved_path.clone() } else { None };
|
||||
thread::spawn(move || {
|
||||
let result = connect(&n, &password).and_then(|_| {
|
||||
if let Some(path) = save {
|
||||
if n.secured() && !n.security.contains("OWE") && password.is_empty() { return Err("Connected, but enter password once to save it on USB / Verbunden; Passwort zum Speichern einmal eingeben.".into()); }
|
||||
save_network(&path, &n.ssid, &password)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let _ = tx.send(Event::Connected(result));
|
||||
});
|
||||
}
|
||||
}
|
||||
} else { ui.label(crate::tr(en, "Wähle zuerst ein Netzwerk aus der Liste.", "Select a network from the list first.")); }
|
||||
ui.label(if en { english_message(&self.message) } else { self.message.clone() });
|
||||
if self.saved_path.is_none() {
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(crate::tr(en, "3. Verbindung testen", "3. Test connection")).strong());
|
||||
ui.label(crate::tr(en, "Testadresse (Internet oder lokaler Prüfserver)", "Test URL (internet or local test server)"));
|
||||
ui.add(egui::TextEdit::singleline(&mut self.url).desired_width(ui.available_width()));
|
||||
if ui.add_enabled(self.selected.is_some() && !self.busy(), egui::Button::new(crate::tr(en, "Verbindung testen", "Test connection"))).clicked() {
|
||||
let n = self.selected.clone().unwrap(); let url = self.url.trim().to_owned();
|
||||
let (tx, rx) = mpsc::channel(); self.receiver = Some(rx); self.message = crate::tr(en, "Prüfe WLAN, IP-Adresse und Datentransfer …", "Checking Wi-Fi, IP address and data transfer …").into();
|
||||
thread::spawn(move || { let _ = tx.send(Event::Tested(test_connection(&n, &url))); });
|
||||
}
|
||||
}
|
||||
});
|
||||
if self.busy() {
|
||||
ui.spinner();
|
||||
}
|
||||
ui.label(if en {
|
||||
english_message(&self.message)
|
||||
} else {
|
||||
self.message.clone()
|
||||
});
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn parses_escaped_networks_and_sorts_signal() {
|
||||
let n = parse_networks("*:Office\\: West\\\\Lab:AA\\:BB\\:CC\\:DD\\:EE\\:FF:82:WPA2:wlan0\n:Cafe:11\\:22:40:--:wlan1\n::33\\:44:90:WPA2:wlan0\nmalformed");
|
||||
assert_eq!(n.len(), 2);
|
||||
assert_eq!(n[0].ssid, "Office: West\\Lab");
|
||||
assert_eq!(n[0].bssid, "AA:BB:CC:DD:EE:FF");
|
||||
assert!(n[0].active && n[0].secured());
|
||||
assert!(!n[1].secured());
|
||||
}
|
||||
#[test]
|
||||
fn connection_is_user_owned_and_password_is_only_in_stdin() {
|
||||
let n = parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0);
|
||||
let mut calls = Vec::new();
|
||||
connect_with(
|
||||
&n,
|
||||
" secret: with spaces ",
|
||||
"intake",
|
||||
"test-uuid",
|
||||
|args, input| {
|
||||
calls.push((
|
||||
args.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||
input.map(str::to_owned),
|
||||
));
|
||||
Ok(String::new())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let add = &calls[0].0;
|
||||
assert!(add.windows(2).any(|w| w == ["save", "no"]));
|
||||
assert!(add
|
||||
.windows(2)
|
||||
.any(|w| w == ["connection.permissions", "user:intake"]));
|
||||
assert!(add.windows(2).any(|w| w == ["wifi-sec.psk-flags", "2"]));
|
||||
let up = &calls[1].0;
|
||||
assert!(up.windows(2).any(|w| w == ["ifname", "wlan7"]));
|
||||
assert!(up.windows(2).any(|w| w == ["ap", "AA:BB"]));
|
||||
assert!(up.windows(2).any(|w| w == ["passwd-file", "/dev/stdin"]));
|
||||
assert_eq!(
|
||||
calls[1].1.as_deref(),
|
||||
Some("802-11-wireless-security.psk: secret: with spaces \n")
|
||||
);
|
||||
assert!(calls
|
||||
.iter()
|
||||
.all(|(args, _)| args.iter().all(|a| !a.contains("secret:") && a != "--ask")));
|
||||
}
|
||||
#[test]
|
||||
fn failed_activation_cleans_only_its_temporary_profile() {
|
||||
let n = parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0);
|
||||
let mut calls = Vec::new();
|
||||
let error = connect_with(&n, "password", "intake", "unique-uuid", |args, _| {
|
||||
calls.push(args.join(" "));
|
||||
if args.contains(&"up") {
|
||||
Err("Error: Secrets were required, but not provided.".into())
|
||||
} else {
|
||||
Ok(String::new())
|
||||
}
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.contains("Secrets were required"));
|
||||
assert_eq!(
|
||||
calls.last().unwrap(),
|
||||
"--wait 10 connection delete uuid unique-uuid"
|
||||
);
|
||||
assert!(
|
||||
connect_with(&n, "bad\npassword", "intake", "id", |_, _| panic!(
|
||||
"must not run"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
assert!(test_connection(&n, "file:///etc/passwd").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn process_errors_are_visible_but_secrets_are_redacted() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args(["-c", "cat >&2; printf 'Authentication failed' >&2; exit 4"]);
|
||||
let error = run_connect_command(command, Some("wifi-sec.psk:secret123\n"), "secret123")
|
||||
.unwrap_err();
|
||||
assert!(error.contains("Authentication failed"));
|
||||
assert!(!error.contains("secret123"));
|
||||
assert!(error.contains("[hidden]"));
|
||||
}
|
||||
#[test]
|
||||
fn open_wpa3_and_saved_connections_use_correct_modes() {
|
||||
for (security, password, expected) in [
|
||||
("--", "", None),
|
||||
("WPA3", "password", Some("sae")),
|
||||
("OWE", "", Some("owe")),
|
||||
] {
|
||||
let mut n = parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0);
|
||||
n.security = security.into();
|
||||
let mut calls = Vec::new();
|
||||
connect_with(&n, password, "intake", "uuid", |args, input| {
|
||||
calls.push((args.join(" "), input.map(str::to_owned)));
|
||||
Ok(String::new())
|
||||
})
|
||||
.unwrap();
|
||||
if let Some(key) = expected {
|
||||
assert!(calls[0].0.contains(&format!("wifi-sec.key-mgmt {key}")));
|
||||
}
|
||||
if password.is_empty() {
|
||||
assert!(calls.iter().all(|(_, input)| input.is_none()));
|
||||
}
|
||||
}
|
||||
let n = parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0);
|
||||
connect_with(&n, "", "intake", "uuid", |args, input| {
|
||||
assert!(args.contains(&"connect"));
|
||||
assert!(!args.contains(&"--ask"));
|
||||
assert!(input.is_none());
|
||||
Ok(String::new())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod workflow_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn renders_network_password_and_result_states() {
|
||||
let mut wifi = Wifi::default();
|
||||
wifi.networks =
|
||||
parse_networks(":Workshop:AA\\:BB:80:WPA2:wlan0\n*:Guest:CC\\:DD:30:--:wlan0");
|
||||
let ctx = egui::Context::default();
|
||||
for i in 0..4 {
|
||||
wifi.selected = wifi.networks.get(i % 2).cloned();
|
||||
wifi.message = if i > 1 {
|
||||
"Fehler: Falsches Passwort. Erneut versuchen.".into()
|
||||
} else {
|
||||
"WLAN verbunden.".into()
|
||||
};
|
||||
let _ = ctx.run(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(
|
||||
egui::Pos2::ZERO,
|
||||
egui::vec2(580.0, 600.0),
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
|ctx| {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
wifi.ui(ui, false);
|
||||
assert!(ui.min_rect().is_finite());
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn completion_and_worker_failure_release_busy_state() {
|
||||
let mut wifi = Wifi::default();
|
||||
wifi.password = "keep-for-retry".into();
|
||||
wifi.selected = Some(parse_networks(":Office:AA\\:BB:80:WPA2:wlan7").remove(0));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
wifi.receiver = Some(rx);
|
||||
tx.send(Event::Connected(Err("Passwort prüfen".into())))
|
||||
.unwrap();
|
||||
wifi.poll();
|
||||
assert!(!wifi.busy());
|
||||
assert!(wifi.message.contains("Passwort prüfen"));
|
||||
assert_eq!(wifi.password, "keep-for-retry");
|
||||
assert_eq!(wifi.selected.as_ref().unwrap().ssid, "Office");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
wifi.receiver = Some(rx);
|
||||
tx.send(Event::Connected(Ok(()))).unwrap();
|
||||
wifi.poll();
|
||||
assert!(wifi.password.is_empty());
|
||||
assert!(wifi.selected.as_ref().unwrap().active);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
wifi.receiver = Some(rx);
|
||||
tx.send(Event::Tested(Ok("HTTP 200".into()))).unwrap();
|
||||
wifi.poll();
|
||||
assert!(wifi.take_test_log().unwrap().contains("HTTP 200"));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
wifi.receiver = Some(rx);
|
||||
drop(tx);
|
||||
wifi.poll();
|
||||
assert!(!wifi.busy());
|
||||
assert!(wifi.message.contains("erneut"));
|
||||
}
|
||||
}
|
||||
|
||||
fn english_message(message: &str) -> String {
|
||||
let mut s = message.to_owned();
|
||||
for (de,en) in [
|
||||
("Starte die Suche nach WLAN-Netzen.","Start searching for Wi-Fi networks."),
|
||||
("Keine WLAN-Netze gefunden. WLAN-Adapter und Funkschalter prüfen, dann erneut suchen.","No Wi-Fi networks found. Check the adapter and radio switch, then retry."),
|
||||
("WLAN-Netze gefunden. Wähle ein Netzwerk aus.","Wi-Fi networks found. Select a network."),
|
||||
("WLAN verbunden. Starte jetzt den Verbindungstest.","Wi-Fi connected. Run the connection test now."),
|
||||
("WLAN-Aktion unerwartet beendet. Bitte erneut versuchen.","Wi-Fi action interrupted. Please retry."),
|
||||
("Verbindung fehlgeschlagen. Passwort, WLAN-Funkschalter und NetworkManager-Berechtigungen prüfen; danach erneut verbinden.","Connection failed. Check the password, radio switch and NetworkManager permissions, then retry."),
|
||||
("Das ausgewählte WLAN ist nicht verbunden. Zuerst verbinden, dann erneut testen.","The selected Wi-Fi network is not connected. Connect first, then retry."),
|
||||
("Verbunden, aber noch keine IP-Adresse erhalten. Bitte erneut testen.","Connected, but no IP address received yet. Please retry."),
|
||||
("Bitte eine HTTP- oder HTTPS-Testadresse eingeben.","Enter an HTTP or HTTPS test URL."),
|
||||
("WLAN verbunden; Datentransfer fehlgeschlagen:","Wi-Fi connected; data transfer failed:"),
|
||||
("WLAN verbunden; Testziel meldet HTTP", "Wi-Fi connected; test server returned HTTP"),
|
||||
("Adresse oder Anmeldeseite des WLANs prüfen.","Check the test URL or captive portal."),
|
||||
("Datentransfer erfolgreich", "Data transfer successful"),
|
||||
("über", "via"), ("Testziel:", "Test URL:"),
|
||||
("Jetzt das Prüfergebnis bestätigen.", "Confirm the inspection result now."),
|
||||
("DATENTRANSFER ERFOLGREICH", "DATA TRANSFER SUCCEEDED"), ("FEHLER", "ERROR"), ("Fehler:", "Error:"),
|
||||
("Passwort bleibt für einen erneuten Versuch erhalten.","Password kept for retry."),
|
||||
("Kein gespeichertes Passwort verwendbar. WLAN-Passwort eingeben und erneut versuchen.","No usable saved password. Enter the Wi-Fi password and retry."),
|
||||
] {s=s.replace(de,en);}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_network_tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
#[test]
|
||||
fn saved_wifi_replaces_one_network_and_restricts_credentials() {
|
||||
let root = std::env::temp_dir().join(format!("csr-wifi-save-{}", std::process::id()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let path = root.join("workshop-wifi.json");
|
||||
save_network(&path, "Workshop", " secret:pass ").unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let data: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(data["password"], " secret:pass ");
|
||||
save_network(&path, "New workshop", "new pass").unwrap();
|
||||
let data: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(data["ssid"], "New workshop");
|
||||
assert_eq!(fs::read_dir(&root).unwrap().count(), 1);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
893
gui/src/workflow.rs
Normal file
893
gui/src/workflow.rs
Normal file
@@ -0,0 +1,893 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs::{self, File, OpenOptions},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
pub fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
pub fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> {
|
||||
let parent = path.parent().ok_or("Missing parent directory")?;
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
let temp = parent.join(format!(
|
||||
".write-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
let result = (|| {
|
||||
let mut opts = OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(&temp).map_err(|e| e.to_string())?;
|
||||
f.write_all(bytes)
|
||||
.and_then(|_| f.sync_all())
|
||||
.map_err(|e| e.to_string())?;
|
||||
fs::rename(&temp, path).map_err(|e| e.to_string())?;
|
||||
File::open(parent)
|
||||
.and_then(|f| f.sync_all())
|
||||
.map_err(|e| e.to_string())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(temp);
|
||||
}
|
||||
result
|
||||
}
|
||||
pub fn save_json<T: Serialize>(path: &Path, data: &T) -> Result<(), String> {
|
||||
atomic_write(
|
||||
path,
|
||||
&serde_json::to_vec_pretty(data).map_err(|e| e.to_string())?,
|
||||
)
|
||||
}
|
||||
|
||||
fn sqlquote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
pub fn sql(data: &Path, query: &str) -> Result<(), String> {
|
||||
fs::create_dir_all(data.join("database")).map_err(|e| e.to_string())?;
|
||||
let out = std::process::Command::new("sqlite3")
|
||||
.args(["-cmd", ".timeout 10000"])
|
||||
.arg(data.join("database/intake.sqlite3"))
|
||||
.arg(query)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&out.stderr).into_owned())
|
||||
}
|
||||
}
|
||||
pub fn init_index(data: &Path) -> Result<(), String> {
|
||||
sql(data,"CREATE TABLE IF NOT EXISTS scans (id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id TEXT NOT NULL, timestamp INTEGER NOT NULL, report_path TEXT NOT NULL UNIQUE, inspection_path TEXT, inspection_status TEXT NOT NULL DEFAULT 'NOT_TESTED', sync_status TEXT NOT NULL DEFAULT 'PENDING', synced_inventory_id TEXT);")
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Settings {
|
||||
pub language: String,
|
||||
pub test_url: String,
|
||||
pub stress_seconds: u64,
|
||||
pub recording_seconds: u64,
|
||||
pub camera_seconds: u64,
|
||||
pub max_temperature_c: f64,
|
||||
pub min_battery_health_pct: u64,
|
||||
pub retry_seconds: u64,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
language: "de".into(),
|
||||
test_url: "https://example.com".into(),
|
||||
stress_seconds: 120,
|
||||
recording_seconds: 5,
|
||||
camera_seconds: 25,
|
||||
max_temperature_c: 90.0,
|
||||
min_battery_health_pct: 70,
|
||||
retry_seconds: 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Settings {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if !["de", "en"].contains(&self.language.as_str()) {
|
||||
return Err("Language must be de or en".into());
|
||||
}
|
||||
if !(self.test_url.starts_with("https://") || self.test_url.starts_with("http://"))
|
||||
|| self.test_url.contains(['\n', '\r'])
|
||||
{
|
||||
return Err("Test URL must use HTTP(S)".into());
|
||||
}
|
||||
if !(10..=600).contains(&self.stress_seconds)
|
||||
|| !(1..=30).contains(&self.recording_seconds)
|
||||
|| !(5..=120).contains(&self.camera_seconds)
|
||||
{
|
||||
return Err("Test durations out of range".into());
|
||||
}
|
||||
if !self.max_temperature_c.is_finite()
|
||||
|| !(40.0..=110.0).contains(&self.max_temperature_c)
|
||||
|| self.min_battery_health_pct > 100
|
||||
|| !(10..=3600).contains(&self.retry_seconds)
|
||||
{
|
||||
return Err("Threshold or retry interval out of range".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn load(data: &Path) -> Result<Self, String> {
|
||||
let path = data.join("config/workshop.json");
|
||||
let s: Self = if path.exists() {
|
||||
serde_json::from_slice(&fs::read(path).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
Self::default()
|
||||
};
|
||||
s.validate()?;
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct Identity {
|
||||
pub manufacturer: String,
|
||||
pub model: String,
|
||||
pub serial: String,
|
||||
pub uuid: String,
|
||||
}
|
||||
fn identity_value(s: &str) -> String {
|
||||
let s = s.trim();
|
||||
let l = s.to_lowercase();
|
||||
if s.is_empty()
|
||||
|| [
|
||||
"unknown",
|
||||
"none",
|
||||
"default string",
|
||||
"to be filled by o.e.m.",
|
||||
"not specified",
|
||||
"system serial number",
|
||||
]
|
||||
.contains(&l.as_str())
|
||||
|| s.chars().all(|c| matches!(c, '0' | 'f' | 'F' | '-'))
|
||||
{
|
||||
String::new()
|
||||
} else {
|
||||
s.into()
|
||||
}
|
||||
}
|
||||
impl Identity {
|
||||
pub fn detect() -> Self {
|
||||
let read = |key: &str| {
|
||||
identity_value(
|
||||
&fs::read_to_string(format!("/sys/class/dmi/id/{key}"))
|
||||
.or_else(|_| fs::read_to_string(format!("/run/csr-intake/{key}")))
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
Self {
|
||||
manufacturer: read("sys_vendor"),
|
||||
model: read("product_name"),
|
||||
serial: read("product_serial"),
|
||||
uuid: read("product_uuid"),
|
||||
}
|
||||
}
|
||||
pub fn from_report(v: &Value) -> Self {
|
||||
let text = |k| identity_value(v.get(k).and_then(Value::as_str).unwrap_or(""));
|
||||
Self {
|
||||
manufacturer: text("manufacturer"),
|
||||
model: text("model"),
|
||||
serial: text("serial_number"),
|
||||
uuid: text("system_uuid"),
|
||||
}
|
||||
}
|
||||
pub fn matches(&self, other: &Self) -> bool {
|
||||
(!self.uuid.is_empty() && self.uuid.eq_ignore_ascii_case(&other.uuid))
|
||||
|| (!self.serial.is_empty()
|
||||
&& self.serial.eq_ignore_ascii_case(&other.serial)
|
||||
&& self.manufacturer.eq_ignore_ascii_case(&other.manufacturer))
|
||||
}
|
||||
pub fn conflicts(&self, other: &Self) -> bool {
|
||||
(!self.uuid.is_empty()
|
||||
&& !other.uuid.is_empty()
|
||||
&& !self.uuid.eq_ignore_ascii_case(&other.uuid))
|
||||
|| (!self.serial.is_empty()
|
||||
&& !other.serial.is_empty()
|
||||
&& !self.serial.eq_ignore_ascii_case(&other.serial))
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Outcome {
|
||||
NotTested,
|
||||
Pass,
|
||||
HardwareFailure,
|
||||
NotApplicable,
|
||||
Unavailable,
|
||||
SetupProblem,
|
||||
NeedsReview,
|
||||
}
|
||||
impl Outcome {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::NotTested,
|
||||
Self::Pass,
|
||||
Self::HardwareFailure,
|
||||
Self::NotApplicable,
|
||||
Self::Unavailable,
|
||||
Self::SetupProblem,
|
||||
Self::NeedsReview,
|
||||
];
|
||||
pub fn label(self, en: bool) -> &'static str {
|
||||
match (self, en) {
|
||||
(Self::NotTested, true) => "Not tested",
|
||||
(Self::NotTested, false) => "Nicht getestet",
|
||||
(Self::Pass, true) => "Passed",
|
||||
(Self::Pass, false) => "Bestanden",
|
||||
(Self::HardwareFailure, true) => "Hardware failure",
|
||||
(Self::HardwareFailure, false) => "Hardwaredefekt",
|
||||
(Self::NotApplicable, true) => "Not present / N/A",
|
||||
(Self::NotApplicable, false) => "Nicht vorhanden / N/A",
|
||||
(Self::Unavailable, true) => "Test unavailable",
|
||||
(Self::Unavailable, false) => "Test nicht verfügbar",
|
||||
(Self::SetupProblem, true) => "Setup problem",
|
||||
(Self::SetupProblem, false) => "Einrichtungsproblem",
|
||||
(Self::NeedsReview, true) => "Needs confirmation",
|
||||
(Self::NeedsReview, false) => "Bestätigung nötig",
|
||||
}
|
||||
}
|
||||
pub fn complete(self) -> bool {
|
||||
matches!(self, Self::Pass | Self::NotApplicable)
|
||||
}
|
||||
pub fn legacy(self) -> &'static str {
|
||||
match self {
|
||||
Self::Pass => "PASS",
|
||||
Self::HardwareFailure => "FAIL",
|
||||
Self::NotApplicable => "NOT_APPLICABLE",
|
||||
Self::Unavailable => "UNAVAILABLE",
|
||||
Self::SetupProblem => "SETUP_PROBLEM",
|
||||
_ => "NOT_TESTED",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Event {
|
||||
pub timestamp: u64,
|
||||
pub kind: String,
|
||||
pub outcome: Outcome,
|
||||
pub text: String,
|
||||
pub settings: Settings,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Check {
|
||||
pub outcome: Outcome,
|
||||
pub notes: String,
|
||||
pub evidence: String,
|
||||
pub history: Vec<Event>,
|
||||
pub suggestion: String,
|
||||
}
|
||||
impl Default for Check {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
outcome: Outcome::NotTested,
|
||||
notes: String::new(),
|
||||
evidence: String::new(),
|
||||
history: Vec::new(),
|
||||
suggestion: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Approval {
|
||||
pub technician: String,
|
||||
pub timestamp: u64,
|
||||
pub revision: u64,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Inspection {
|
||||
pub schema_version: u32,
|
||||
pub asset: String,
|
||||
#[serde(default)]
|
||||
pub sync_phase: String,
|
||||
pub identity: Identity,
|
||||
pub step: usize,
|
||||
pub updated: u64,
|
||||
pub revision: u64,
|
||||
pub checks: BTreeMap<String, Check>,
|
||||
pub order: Vec<String>,
|
||||
pub report: Option<Value>,
|
||||
pub report_path: Option<PathBuf>,
|
||||
pub notes: String,
|
||||
pub technician: String,
|
||||
pub approval: Option<Approval>,
|
||||
#[serde(default)]
|
||||
pub approvals: Vec<Approval>,
|
||||
pub settings: Settings,
|
||||
pub build: String,
|
||||
}
|
||||
pub const BASE: &[&str] = &[
|
||||
"display",
|
||||
"keyboard",
|
||||
"touchpad",
|
||||
"wifi",
|
||||
"audio",
|
||||
"microphone",
|
||||
"webcam",
|
||||
"usb_ports",
|
||||
"charging",
|
||||
"thermals",
|
||||
"stability",
|
||||
];
|
||||
impl Inspection {
|
||||
pub fn new(asset: &str, identity: Identity, settings: Settings) -> Self {
|
||||
Self {
|
||||
schema_version: 1,
|
||||
asset: asset.into(),
|
||||
sync_phase: String::new(),
|
||||
identity,
|
||||
step: 0,
|
||||
updated: now(),
|
||||
revision: 0,
|
||||
checks: BASE
|
||||
.iter()
|
||||
.map(|k| (k.to_string(), Check::default()))
|
||||
.collect(),
|
||||
order: BASE.iter().map(|k| k.to_string()).collect(),
|
||||
report: None,
|
||||
report_path: None,
|
||||
notes: String::new(),
|
||||
technician: String::new(),
|
||||
approval: None,
|
||||
approvals: Vec::new(),
|
||||
settings,
|
||||
build: env!("CSR_BUILD_ID").into(),
|
||||
}
|
||||
}
|
||||
pub fn changed(&mut self) {
|
||||
self.revision += 1;
|
||||
self.updated = now();
|
||||
self.approval = None;
|
||||
}
|
||||
pub fn record(&mut self, key: &str, kind: &str, outcome: Outcome, text: String) {
|
||||
if !self.sync_phase.is_empty() {
|
||||
self.sync_phase = "IN_PROGRESS".into();
|
||||
}
|
||||
self.changed();
|
||||
let c = self.checks.entry(key.into()).or_default();
|
||||
c.history.push(Event {
|
||||
timestamp: now(),
|
||||
kind: kind.into(),
|
||||
outcome,
|
||||
text: text.clone(),
|
||||
settings: self.settings.clone(),
|
||||
});
|
||||
c.outcome = outcome;
|
||||
if kind == "diagnostic" {
|
||||
c.evidence = text;
|
||||
}
|
||||
}
|
||||
pub fn set_report(&mut self, path: PathBuf, report: Value) {
|
||||
self.changed();
|
||||
self.identity = Identity::from_report(&report);
|
||||
for key in ["charging", "touchpad", "webcam"] {
|
||||
let detected = match key {
|
||||
"charging" => report
|
||||
.get("batteries")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| !a.is_empty()),
|
||||
_ => report
|
||||
.get("capabilities")
|
||||
.and_then(|c| c.get(key))
|
||||
.and_then(Value::as_bool),
|
||||
};
|
||||
self.checks.entry(key.into()).or_default().suggestion = match detected {
|
||||
Some(false) => {
|
||||
"Not detected / Nicht erkannt — confirm absence before choosing N/A".into()
|
||||
}
|
||||
Some(true) => "Detected / Erkannt".into(),
|
||||
None => "Detection unavailable / Erkennung nicht verfügbar".into(),
|
||||
};
|
||||
}
|
||||
// Detected components first; optional, undetected hardware stays explicit until confirmed absent.
|
||||
if self.report.is_none() {
|
||||
self.order.sort_by_key(|key| {
|
||||
self.checks
|
||||
.get(key)
|
||||
.map(|c| c.suggestion.starts_with("Not detected"))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
}
|
||||
// Keep previous disks in the audit trail, but require explicit disposition when removed.
|
||||
let disk_keys: Vec<String> = report
|
||||
.get("disks")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|d| d.get("removable") == Some(&Value::Bool(false)))
|
||||
.filter_map(|d| d.get("name").and_then(Value::as_str))
|
||||
.map(|n| format!("disk:{n}"))
|
||||
.collect();
|
||||
for key in &disk_keys {
|
||||
self.checks.entry(key.clone()).or_default();
|
||||
if !self.order.contains(key) {
|
||||
self.order.push(key.clone());
|
||||
}
|
||||
}
|
||||
for key in self
|
||||
.order
|
||||
.clone()
|
||||
.into_iter()
|
||||
.filter(|k| k.starts_with("disk:") && !disk_keys.contains(k))
|
||||
{
|
||||
self.record(
|
||||
&key,
|
||||
"hardware_change",
|
||||
Outcome::NeedsReview,
|
||||
"Drive no longer detected; confirm replacement/removal.".into(),
|
||||
);
|
||||
}
|
||||
self.report = Some(report);
|
||||
self.report_path = Some(path);
|
||||
}
|
||||
pub fn blockers(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if self.report.is_none() {
|
||||
out.push("No hardware scan / Kein Hardware-Scan".into());
|
||||
}
|
||||
for k in &self.order {
|
||||
if !self
|
||||
.checks
|
||||
.get(k)
|
||||
.map(|c| c.outcome.complete())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
out.push(format!("{k}: incomplete / offen"));
|
||||
}
|
||||
}
|
||||
for k in ["thermals", "stability"] {
|
||||
if let Some(c) = self.checks.get(k) {
|
||||
if crate::diagnostics::peak_temperature(&c.evidence)
|
||||
.map(|v| v >= self.settings.max_temperature_c)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
out.push(format!("{k}: measured peak exceeds workshop threshold / gemessene Spitze überschreitet Grenzwert"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = &self.report {
|
||||
if !r
|
||||
.get("disks")
|
||||
.and_then(Value::as_array)
|
||||
.map(|ds| {
|
||||
ds.iter()
|
||||
.any(|d| d.get("removable") == Some(&Value::Bool(false)))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
out.push("No internal storage verified / Kein internes Laufwerk geprüft".into());
|
||||
}
|
||||
for d in r
|
||||
.get("disks")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|d| d.get("removable") == Some(&Value::Bool(false)))
|
||||
{
|
||||
if d.get("smart_health").and_then(Value::as_str) != Some("PASS") {
|
||||
out.push(format!("{}: SMART requires review / prüfen", d["name"]));
|
||||
}
|
||||
}
|
||||
for b in r
|
||||
.get("batteries")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if b.get("health_percent")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v < self.settings.min_battery_health_pct)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
out.push(format!(
|
||||
"{}: battery health below threshold or unknown / Akkuzustand prüfen",
|
||||
b["name"]
|
||||
));
|
||||
}
|
||||
}
|
||||
for t in r
|
||||
.get("temperatures_c")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if t.get("celsius")
|
||||
.and_then(Value::as_f64)
|
||||
.map(|v| v >= self.settings.max_temperature_c)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
out.push("Temperature above workshop threshold / Temperatur zu hoch".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn approve(&mut self) -> Result<(), String> {
|
||||
if self.technician.trim().is_empty() {
|
||||
return Err("Technician name required / Technikername erforderlich".into());
|
||||
}
|
||||
let reasons = self.blockers();
|
||||
if !reasons.is_empty() {
|
||||
return Err(reasons.join("\n"));
|
||||
}
|
||||
self.approval = Some(Approval {
|
||||
technician: self.technician.trim().into(),
|
||||
timestamp: now(),
|
||||
revision: self.revision,
|
||||
});
|
||||
self.approvals.push(self.approval.clone().unwrap());
|
||||
Ok(())
|
||||
}
|
||||
pub fn save(&self, data: &Path) -> Result<(), String> {
|
||||
if !valid_id(&self.asset) {
|
||||
return Err("Invalid asset ID".into());
|
||||
}
|
||||
save_json(
|
||||
&data
|
||||
.join("inspections")
|
||||
.join(format!("{}.json", self.asset)),
|
||||
self,
|
||||
)?;
|
||||
let legacy = BASE
|
||||
.iter()
|
||||
.map(|k| {
|
||||
format!(
|
||||
"{}={}\n",
|
||||
k,
|
||||
self.checks
|
||||
.get(*k)
|
||||
.map(|c| c.outcome)
|
||||
.unwrap_or(Outcome::NotTested)
|
||||
.legacy()
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
atomic_write(
|
||||
&data.join("checklists").join(format!("{}.txt", self.asset)),
|
||||
legacy.as_bytes(),
|
||||
)?;
|
||||
if let Some(path) = &self.report_path {
|
||||
init_index(data)?;
|
||||
let state = if self.approval.is_some() {
|
||||
"APPROVED"
|
||||
} else if self
|
||||
.checks
|
||||
.values()
|
||||
.any(|c| c.outcome == Outcome::HardwareFailure)
|
||||
{
|
||||
"FAIL"
|
||||
} else if self.checks.values().all(|c| c.outcome.complete()) {
|
||||
"COMPLETED"
|
||||
} else {
|
||||
"INCOMPLETE"
|
||||
};
|
||||
let timestamp = self
|
||||
.report
|
||||
.as_ref()
|
||||
.and_then(|r| r.get("timestamp_unix"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(self.updated);
|
||||
sql(data,&format!("BEGIN; INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{}); UPDATE scans SET inspection_path={},inspection_status={} WHERE report_path={}; COMMIT;",sqlquote(&self.asset),timestamp,sqlquote(&path.to_string_lossy()),sqlquote(&data.join("inspections").join(format!("{}.json",self.asset)).to_string_lossy()),sqlquote(state),sqlquote(&path.to_string_lossy())))?;
|
||||
}
|
||||
atomic_write(&data.join("active-inspection.txt"), self.asset.as_bytes())
|
||||
}
|
||||
pub fn load(data: &Path, asset: &str) -> Result<Self, String> {
|
||||
if !valid_id(asset) {
|
||||
return Err("Invalid asset ID".into());
|
||||
}
|
||||
let mut s: Self = serde_json::from_slice(
|
||||
&fs::read(data.join("inspections").join(format!("{asset}.json")))
|
||||
.map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| format!("Inspection unreadable; original file preserved: {e}"))?;
|
||||
if s.schema_version != 1
|
||||
|| s.asset != asset
|
||||
|| s.order.iter().any(|k| !s.checks.contains_key(k))
|
||||
|| BASE
|
||||
.iter()
|
||||
.any(|k| !s.order.iter().any(|present| present == k))
|
||||
|| s.order
|
||||
.iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len()
|
||||
!= s.order.len()
|
||||
|| s.settings.validate().is_err()
|
||||
{
|
||||
return Err("Unsupported or inconsistent inspection; file preserved".into());
|
||||
}
|
||||
s.step = s.step.min(s.order.len());
|
||||
if s.approval
|
||||
.as_ref()
|
||||
.map(|a| a.revision != s.revision)
|
||||
.unwrap_or(false)
|
||||
|| !s.blockers().is_empty()
|
||||
{
|
||||
s.approval = None;
|
||||
}
|
||||
if s.checks
|
||||
.values()
|
||||
.any(|c| c.history.last().map(|e| e.kind.as_str()) == Some("started"))
|
||||
{
|
||||
s.approval = None;
|
||||
}
|
||||
for c in s.checks.values_mut() {
|
||||
if c.history.last().map(|e| e.kind.as_str()) == Some("started") {
|
||||
c.outcome = Outcome::NotTested;
|
||||
c.evidence = "Interrupted test — rerun / Unterbrochen — erneut testen".into();
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
pub fn latest_report(data: &Path, asset: &str) -> Option<(PathBuf, Value)> {
|
||||
if !valid_id(asset) {
|
||||
return None;
|
||||
}
|
||||
let mut files: Vec<_> = fs::read_dir(data.join("reports").join(asset))
|
||||
.ok()?
|
||||
.flatten()
|
||||
.map(|e| e.path().join("report.json"))
|
||||
.filter_map(|p| {
|
||||
p.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.map(|t| (t, p))
|
||||
})
|
||||
.collect();
|
||||
files.sort_by_key(|x| x.0);
|
||||
files.into_iter().rev().find_map(|(_, p)| {
|
||||
fs::read(&p)
|
||||
.ok()
|
||||
.and_then(|v| serde_json::from_slice::<Value>(&v).ok())
|
||||
.filter(|v| v.get("asset_id").and_then(Value::as_str) == Some(asset))
|
||||
.map(|v| (p, v))
|
||||
})
|
||||
}
|
||||
pub fn known_assets(data: &Path) -> Vec<String> {
|
||||
let mut ids = Vec::new();
|
||||
for folder in ["inspections", "reports"] {
|
||||
if let Ok(entries) = fs::read_dir(data.join(folder)) {
|
||||
for e in entries.flatten() {
|
||||
let name = if folder == "inspections" {
|
||||
e.path()
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
} else {
|
||||
e.file_name().to_string_lossy().into_owned()
|
||||
};
|
||||
if valid_id(&name) {
|
||||
ids.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
pub fn duplicates(data: &Path, asset: &str, identity: &Identity) -> Vec<String> {
|
||||
known_assets(data)
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
id == asset
|
||||
|| Inspection::load(data, id)
|
||||
.map(|s| s.identity.matches(identity))
|
||||
.unwrap_or_else(|_| {
|
||||
latest_report(data, id)
|
||||
.map(|(_, v)| Identity::from_report(&v).matches(identity))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn migrate(data: &Path, asset: &str, settings: Settings) -> Inspection {
|
||||
let mut s = Inspection::new(asset, Identity::default(), settings);
|
||||
if let Some((p, v)) = latest_report(data, asset) {
|
||||
s.set_report(p, v);
|
||||
}
|
||||
if let Ok(raw) = fs::read_to_string(data.join("checklists").join(format!("{asset}.txt"))) {
|
||||
for line in raw.lines() {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
if s.checks.contains_key(key) {
|
||||
let outcome = match value.trim() {
|
||||
"PASS" => Outcome::Pass,
|
||||
"FAIL" => Outcome::HardwareFailure,
|
||||
"NOT_APPLICABLE" => Outcome::NotApplicable,
|
||||
"UNAVAILABLE" => Outcome::Unavailable,
|
||||
"SETUP_PROBLEM" => Outcome::SetupProblem,
|
||||
_ => Outcome::NotTested,
|
||||
};
|
||||
s.record(
|
||||
key,
|
||||
"legacy_import",
|
||||
outcome,
|
||||
"Imported checklist / Importierte Prüfliste".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn temp() -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"csr-state-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
#[test]
|
||||
fn roundtrip_repair_and_interruption() {
|
||||
let p = temp();
|
||||
let mut s = Inspection::new("PC-42", Identity::default(), Settings::default());
|
||||
s.notes = "Notes before reboot".into();
|
||||
s.step = 4;
|
||||
s.record("wifi", "result", Outcome::HardwareFailure, "broken".into());
|
||||
s.record(
|
||||
"wifi",
|
||||
"repair",
|
||||
Outcome::NotTested,
|
||||
"replaced adapter".into(),
|
||||
);
|
||||
s.record("wifi", "started", Outcome::NotTested, "running".into());
|
||||
s.save(&p).unwrap();
|
||||
let r = Inspection::load(&p, "PC-42").unwrap();
|
||||
assert_eq!(r.step, 4);
|
||||
assert_eq!(r.notes, s.notes);
|
||||
assert_eq!(r.checks["wifi"].history.len(), 3);
|
||||
assert!(r.checks["wifi"].evidence.contains("Interrupted"));
|
||||
fs::remove_dir_all(p).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_and_traversal_never_overwritten() {
|
||||
let p = temp();
|
||||
fs::create_dir_all(p.join("inspections")).unwrap();
|
||||
fs::write(p.join("inspections/PC-1.json"), "broken").unwrap();
|
||||
assert!(Inspection::load(&p, "PC-1").is_err());
|
||||
assert!(Inspection::load(&p, "../x").is_err());
|
||||
assert_eq!(
|
||||
fs::read_to_string(p.join("inspections/PC-1.json")).unwrap(),
|
||||
"broken"
|
||||
);
|
||||
fs::remove_dir_all(p).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn only_completed_checks_can_be_approved_and_changes_revoke() {
|
||||
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
|
||||
s.technician = "T".into();
|
||||
s.report = Some(
|
||||
serde_json::json!({"disks":[{"name":"sda","removable":false,"smart_health":"PASS"}]}),
|
||||
);
|
||||
for c in s.checks.values_mut() {
|
||||
c.outcome = Outcome::Pass;
|
||||
}
|
||||
s.approve().unwrap();
|
||||
s.record("wifi", "repair", Outcome::NotTested, "repaired".into());
|
||||
assert!(s.approval.is_none());
|
||||
for outcome in [
|
||||
Outcome::NotTested,
|
||||
Outcome::Unavailable,
|
||||
Outcome::SetupProblem,
|
||||
Outcome::HardwareFailure,
|
||||
Outcome::NeedsReview,
|
||||
] {
|
||||
s.checks.get_mut("wifi").unwrap().outcome = outcome;
|
||||
assert!(s.approve().is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn thresholds_block_approval() {
|
||||
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
|
||||
s.report = Some(
|
||||
serde_json::json!({"batteries":[{"health_percent":60}],"disks":[{"removable":false,"smart_health":"UNKNOWN"}]}),
|
||||
);
|
||||
assert!(s.blockers().iter().any(|s| s.contains("battery")));
|
||||
assert!(s.blockers().iter().any(|s| s.contains("SMART")));
|
||||
}
|
||||
#[test]
|
||||
fn placeholder_identity_never_matches() {
|
||||
let a = Identity::from_report(
|
||||
&serde_json::json!({"serial_number":"To Be Filled By O.E.M.","system_uuid":"00000000-0000-0000-0000-000000000000"}),
|
||||
);
|
||||
assert!(!a.matches(&a));
|
||||
}
|
||||
#[test]
|
||||
fn per_disk_checklist_retains_removed_drive_history() {
|
||||
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
|
||||
s.set_report(PathBuf::new(),serde_json::json!({"disks":[{"name":"sda","removable":false},{"name":"sdb","removable":false},{"name":"sdc","removable":true}]}));
|
||||
assert!(s.checks.contains_key("disk:sda"));
|
||||
assert!(s.checks.contains_key("disk:sdb"));
|
||||
assert!(!s.checks.contains_key("disk:sdc"));
|
||||
s.set_report(PathBuf::new(), serde_json::json!({"disks":[]}));
|
||||
assert_eq!(s.checks["disk:sda"].outcome, Outcome::NeedsReview);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod regression_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn legacy_checklist_migrates_and_duplicates_match_uuid() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"csr-migration-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(root.join("reports/PC-1/100-1")).unwrap();
|
||||
fs::create_dir_all(root.join("checklists")).unwrap();
|
||||
let report = serde_json::json!({"asset_id":"PC-1","system_uuid":"12345678-1234-1234-1234-123456789012","serial_number":"ABC123","manufacturer":"Maker","disks":[]});
|
||||
fs::write(
|
||||
root.join("reports/PC-1/100-1/report.json"),
|
||||
report.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("checklists/PC-1.txt"),
|
||||
"display=PASS\nwifi=SETUP_PROBLEM\nwebcam=NOT_APPLICABLE\n",
|
||||
)
|
||||
.unwrap();
|
||||
let s = migrate(&root, "PC-1", Settings::default());
|
||||
assert_eq!(s.checks["display"].outcome, Outcome::Pass);
|
||||
assert_eq!(s.checks["wifi"].outcome, Outcome::SetupProblem);
|
||||
s.save(&root).unwrap();
|
||||
assert_eq!(
|
||||
duplicates(&root, "PC-NEW", &Identity::from_report(&report)),
|
||||
vec!["PC-1"]
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn changed_thresholds_recheck_measured_peaks() {
|
||||
let mut s = Inspection::new("PC-1", Identity::default(), Settings::default());
|
||||
s.checks.get_mut("thermals").unwrap().evidence = "PEAK_TEMPERATURE_C=88.5".into();
|
||||
assert!(!s.blockers().iter().any(|b| b.contains("measured peak")));
|
||||
s.settings.max_temperature_c = 85.0;
|
||||
assert!(s.blockers().iter().any(|b| b.contains("measured peak")));
|
||||
}
|
||||
#[test]
|
||||
fn invalid_settings_are_rejected() {
|
||||
let mut s = Settings::default();
|
||||
s.stress_seconds = 0;
|
||||
assert!(s.validate().is_err());
|
||||
s.stress_seconds = 120;
|
||||
s.max_temperature_c = f64::NAN;
|
||||
assert!(s.validate().is_err());
|
||||
s.max_temperature_c = 90.0;
|
||||
s.test_url = "file:///tmp/private".into();
|
||||
assert!(s.validate().is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user