This commit is contained in:
@@ -1,20 +1,220 @@
|
||||
mod extended;
|
||||
use std::{env, fs, io::{self, Write}, path::{Path, PathBuf}, process::Command, time::{SystemTime, UNIX_EPOCH}};
|
||||
use std::{
|
||||
env, fs,
|
||||
io::{self, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
const DATA: &str = "/mnt/intake-data";
|
||||
fn prompt(q: &str) -> String { print!("{}",q); let _=io::stdout().flush(); let mut s=String::new(); let _=io::stdin().read_line(&mut s); s.trim().to_string() }
|
||||
fn run(program:&str,args:&[&str])->Result<(),String>{let st=Command::new(program).args(args).status().map_err(|e|format!("{}: {}",program,e))?;if st.success(){Ok(())}else{Err(format!("{} exited with {}",program,st))}}
|
||||
fn capture(program:&str,args:&[&str])->Result<String,String>{let o=Command::new(program).args(args).output().map_err(|e|e.to_string())?;if !o.status.success(){return Err(String::from_utf8_lossy(&o.stderr).to_string())}Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())}
|
||||
fn sqlquote(s:&str)->String{format!("'{}'",s.replace('\'',"''"))}
|
||||
fn db_path(data:&Path)->PathBuf{data.join("database/intake.sqlite3")}
|
||||
fn sql(data:&Path,statement:&str)->Result<String,String>{capture("sqlite3",&[db_path(data).to_str().ok_or("Invalid DB path")?,statement])}
|
||||
fn init(data:&Path)->Result<(),String>{for part in ["database","reports","checklists","config"]{fs::create_dir_all(data.join(part)).map_err(|e|e.to_string())?;}let probe=data.join(".write-test");fs::write(&probe,b"ok").map_err(|e|format!("Persistent partition not writable: {}",e))?;let _=fs::remove_file(probe);sql(data,"PRAGMA journal_mode=DELETE; 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);")?;Ok(())}
|
||||
fn valid_id(s:&str)->bool{!s.is_empty()&&s.len()<=64&&s.bytes().all(|b|b.is_ascii_alphanumeric()||b==b'-'||b==b'_')}
|
||||
fn latest(data:&Path,id:&str)->Option<PathBuf>{let root=data.join("reports").join(id);let mut matches=Vec::new();for d in fs::read_dir(root).ok()?.flatten(){let p=d.path().join("report.json");if p.is_file(){let mt=p.metadata().ok()?.modified().ok()?;matches.push((mt,p));}}matches.sort_by_key(|(m,_)|*m);matches.pop().map(|(_,p)|p)}
|
||||
fn get_time()->u64{SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()}
|
||||
fn scan(data:&Path,quick:bool){let id=prompt("Asset ID: ");if !valid_id(&id){println!("Invalid ID (letters, digits, underscore and hyphen only)");return;}let out=data.join("reports");let outstr=out.to_string_lossy().to_string();let mut cmd=Command::new("/usr/local/bin/pc-intake");cmd.args(["--asset-id",&id,"--output",&outstr]);if quick{cmd.arg("--quick");}else{cmd.arg("--compile");}let checklist=data.join("checklists").join(format!("{}.txt",id));if checklist.exists(){cmd.args(["--checklist",&checklist.to_string_lossy()]);}println!("Running {} scan...",if quick{"quick"}else{"full"});match cmd.status(){Ok(status) if status.success()=>{if let Some(report)=latest(data,&id){let result=sql(data,&format!("INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{});",sqlquote(&id),get_time(),sqlquote(&report.to_string_lossy())));match result{Ok(_)=>{println!("Saved: {}",report.display());println!("Inventory sync: PENDING (not uploaded)");},Err(e)=>println!("Database write failed: {}. JSON report remains saved.",e)}}else{println!("Scanner exited successfully, but no report found");}},Ok(status)=>println!("Scanner failed: {}",status),Err(e)=>println!("Could not launch scanner: {}",e)}}
|
||||
const CHECKS:&[(&str,&str)]=&[("display","Show colors/brightness and inspect for dead pixels"),("keyboard","Check every key, including modifiers and function keys"),("touchpad","Verify movement, taps, buttons and scrolling; N/A for desktop"),("wifi","Connect to a network and open a local or remote test service"),("audio","Play left/right sound and check for distortion"),("microphone","Record voice and play it back"),("webcam","Preview image and verify focus and quality; N/A if absent"),("usb_ports","Insert a known-good test device in each physical port"),("charging","Verify AC adapter, charging status, battery runtime; N/A for desktop"),("thermals","Verify fans, heat and sustained load"),("stability","Check for crashes, unexpected resets or kernel errors")];
|
||||
fn technician(data:&Path){let id=prompt("Asset ID for inspection: ");if !valid_id(&id){println!("Invalid ID");return;}let mut rows=Vec::new();for (key,desc) in CHECKS{println!("\n{}: {}",key,desc);if *key=="audio"{println!("Optional speaker test (if installed): speaker-test -c 2 -t wav");}if *key=="webcam"{println!("Optional camera detection: ls /dev/video*; preview requires graphical session");}loop{let ans=prompt("[p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase();if let Some(st)=match ans.as_str(){"p"=>Some("PASS"),"f"=>Some("FAIL"),"u"|""=>Some("NOT_TESTED"),"n"=>Some("NOT_APPLICABLE"),_=>None}{rows.push((key.to_string(),st.to_string()));break;}println!("Enter p, f, u or n");}}
|
||||
let target=data.join("checklists").join(format!("{}.txt",id));let mut raw=String::new();for (k,v) in &rows{raw.push_str(&format!("{}={}\n",k,v));}if let Err(e)=fs::write(&target,raw){println!("Could not save checklist: {}",e);return;}let fail=rows.iter().any(|(_,s)|s=="FAIL");let unknown=rows.iter().any(|(_,s)|s=="NOT_TESTED");let verdict=if fail{"FAIL"}else if unknown{"INCOMPLETE"}else{"COMPLETED"};let _=sql(data,&format!("UPDATE scans SET inspection_path={},inspection_status={} WHERE id=(SELECT MAX(id) FROM scans WHERE asset_id={});",sqlquote(&target.to_string_lossy()),sqlquote(verdict),sqlquote(&id)));println!("Inspection {}. Saved {}; re-scan to embed checklist in hardware report.",verdict,target.display());}
|
||||
fn prompt(q: &str) -> String {
|
||||
print!("{}", q);
|
||||
let _ = io::stdout().flush();
|
||||
let mut s = String::new();
|
||||
let _ = io::stdin().read_line(&mut s);
|
||||
s.trim().to_string()
|
||||
}
|
||||
fn run(program: &str, args: &[&str]) -> Result<(), String> {
|
||||
let st = Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.map_err(|e| format!("{}: {}", program, e))?;
|
||||
if st.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{} exited with {}", program, st))
|
||||
}
|
||||
}
|
||||
fn capture(program: &str, args: &[&str]) -> Result<String, String> {
|
||||
let o = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !o.status.success() {
|
||||
return Err(String::from_utf8_lossy(&o.stderr).to_string());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
}
|
||||
fn sqlquote(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', "''"))
|
||||
}
|
||||
fn db_path(data: &Path) -> PathBuf {
|
||||
data.join("database/intake.sqlite3")
|
||||
}
|
||||
fn sql(data: &Path, statement: &str) -> Result<String, String> {
|
||||
capture(
|
||||
"sqlite3",
|
||||
&[db_path(data).to_str().ok_or("Invalid DB path")?, statement],
|
||||
)
|
||||
}
|
||||
fn init(data: &Path) -> Result<(), String> {
|
||||
for part in ["database", "reports", "checklists", "config"] {
|
||||
fs::create_dir_all(data.join(part)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let probe = data.join(".write-test");
|
||||
fs::write(&probe, b"ok").map_err(|e| format!("Persistent partition not writable: {}", e))?;
|
||||
let _ = fs::remove_file(probe);
|
||||
sql(data,"PRAGMA journal_mode=DELETE; 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);")?;
|
||||
Ok(())
|
||||
}
|
||||
fn valid_id(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= 64
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
fn latest(data: &Path, id: &str) -> Option<PathBuf> {
|
||||
let root = data.join("reports").join(id);
|
||||
let mut matches = Vec::new();
|
||||
for d in fs::read_dir(root).ok()?.flatten() {
|
||||
let p = d.path().join("report.json");
|
||||
if p.is_file() {
|
||||
let mt = p.metadata().ok()?.modified().ok()?;
|
||||
matches.push((mt, p));
|
||||
}
|
||||
}
|
||||
matches.sort_by_key(|(m, _)| *m);
|
||||
matches.pop().map(|(_, p)| p)
|
||||
}
|
||||
fn get_time() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
fn scan(data: &Path, quick: bool) {
|
||||
let id = prompt("Asset ID: ");
|
||||
if !valid_id(&id) {
|
||||
println!("Invalid ID (letters, digits, underscore and hyphen only)");
|
||||
return;
|
||||
}
|
||||
let out = data.join("reports");
|
||||
let outstr = out.to_string_lossy().to_string();
|
||||
let mut cmd = Command::new("/usr/local/bin/pc-intake");
|
||||
cmd.args(["--asset-id", &id, "--output", &outstr]);
|
||||
if quick {
|
||||
cmd.arg("--quick");
|
||||
} else {
|
||||
cmd.arg("--compile");
|
||||
}
|
||||
let checklist = data.join("checklists").join(format!("{}.txt", id));
|
||||
if checklist.exists() {
|
||||
cmd.args(["--checklist", &checklist.to_string_lossy()]);
|
||||
}
|
||||
println!("Running {} scan...", if quick { "quick" } else { "full" });
|
||||
match cmd.status() {
|
||||
Ok(status) if status.success() => {
|
||||
if let Some(report) = latest(data, &id) {
|
||||
let result=sql(data,&format!("INSERT OR IGNORE INTO scans(asset_id,timestamp,report_path) VALUES({},{},{});",sqlquote(&id),get_time(),sqlquote(&report.to_string_lossy())));
|
||||
match result {
|
||||
Ok(_) => {
|
||||
println!("Saved: {}", report.display());
|
||||
println!("Inventory sync: PENDING (not uploaded)");
|
||||
}
|
||||
Err(e) => println!("Database write failed: {}. JSON report remains saved.", e),
|
||||
}
|
||||
} else {
|
||||
println!("Scanner exited successfully, but no report found");
|
||||
}
|
||||
}
|
||||
Ok(status) => println!("Scanner failed: {}", status),
|
||||
Err(e) => println!("Could not launch scanner: {}", e),
|
||||
}
|
||||
}
|
||||
const CHECKS: &[(&str, &str)] = &[
|
||||
(
|
||||
"display",
|
||||
"Show colors/brightness and inspect for dead pixels",
|
||||
),
|
||||
(
|
||||
"keyboard",
|
||||
"Check every key, including modifiers and function keys",
|
||||
),
|
||||
(
|
||||
"touchpad",
|
||||
"Verify movement, taps, buttons and scrolling; N/A for desktop",
|
||||
),
|
||||
(
|
||||
"wifi",
|
||||
"Connect to a network and open a local or remote test service",
|
||||
),
|
||||
("audio", "Play left/right sound and check for distortion"),
|
||||
("microphone", "Record voice and play it back"),
|
||||
(
|
||||
"webcam",
|
||||
"Preview image and verify focus and quality; N/A if absent",
|
||||
),
|
||||
(
|
||||
"usb_ports",
|
||||
"Insert a known-good test device in each physical port",
|
||||
),
|
||||
(
|
||||
"charging",
|
||||
"Verify AC adapter, charging status, battery runtime; N/A for desktop",
|
||||
),
|
||||
("thermals", "Verify fans, heat and sustained load"),
|
||||
(
|
||||
"stability",
|
||||
"Check for crashes, unexpected resets or kernel errors",
|
||||
),
|
||||
];
|
||||
fn technician(data: &Path) {
|
||||
let id = prompt("Asset ID for inspection: ");
|
||||
if !valid_id(&id) {
|
||||
println!("Invalid ID");
|
||||
return;
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
for (key, desc) in CHECKS {
|
||||
println!("\n{}: {}", key, desc);
|
||||
if *key == "audio" {
|
||||
println!("Optional speaker test (if installed): speaker-test -c 2 -t wav");
|
||||
}
|
||||
if *key == "webcam" {
|
||||
println!(
|
||||
"Optional camera detection: ls /dev/video*; preview requires graphical session"
|
||||
);
|
||||
}
|
||||
loop {
|
||||
let ans = prompt("[p]ass / [f]ail / [u]ntested / [n]ot applicable: ").to_lowercase();
|
||||
if let Some(st) = match ans.as_str() {
|
||||
"p" => Some("PASS"),
|
||||
"f" => Some("FAIL"),
|
||||
"u" | "" => Some("NOT_TESTED"),
|
||||
"n" => Some("NOT_APPLICABLE"),
|
||||
_ => None,
|
||||
} {
|
||||
rows.push((key.to_string(), st.to_string()));
|
||||
break;
|
||||
}
|
||||
println!("Enter p, f, u or n");
|
||||
}
|
||||
}
|
||||
let target = data.join("checklists").join(format!("{}.txt", id));
|
||||
let mut raw = String::new();
|
||||
for (k, v) in &rows {
|
||||
raw.push_str(&format!("{}={}\n", k, v));
|
||||
}
|
||||
if let Err(e) = fs::write(&target, raw) {
|
||||
println!("Could not save checklist: {}", e);
|
||||
return;
|
||||
}
|
||||
let fail = rows.iter().any(|(_, s)| s == "FAIL");
|
||||
let unknown = rows.iter().any(|(_, s)| s == "NOT_TESTED");
|
||||
let verdict = if fail {
|
||||
"FAIL"
|
||||
} else if unknown {
|
||||
"INCOMPLETE"
|
||||
} else {
|
||||
"COMPLETED"
|
||||
};
|
||||
let _=sql(data,&format!("UPDATE scans SET inspection_path={},inspection_status={} WHERE id=(SELECT MAX(id) FROM scans WHERE asset_id={});",sqlquote(&target.to_string_lossy()),sqlquote(verdict),sqlquote(&id)));
|
||||
println!(
|
||||
"Inspection {}. Saved {}; re-scan to embed checklist in hardware report.",
|
||||
verdict,
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
fn recent(data: &Path) {
|
||||
let query = "SELECT id || ' | ' || asset_id || ' | ' || inspection_status || ' | ' || sync_status || ' | ' || report_path FROM scans ORDER BY id DESC LIMIT 20;";
|
||||
match sql(data, query) {
|
||||
@@ -23,6 +223,90 @@ fn recent(data: &Path) {
|
||||
Err(err) => println!("Database error: {}", err),
|
||||
}
|
||||
}
|
||||
fn help(){println!("PC Intake AIO v1.7.0 — offline-first technician controller");println!("Usage: intake-controller [--data PATH] [--self-check]");}
|
||||
fn main(){let args:Vec<String>=env::args().collect();if args.iter().any(|x|x=="--help"){help();return;}if args.iter().any(|x|x=="--self-check"){for tool in ["sqlite3","smartctl","ffmpeg","mpv","glmark2","stress-ng","memtester","speaker-test"]{println!("{}: {}",tool,if Command::new("sh").args(["-c",&format!("command -v {} >/dev/null",tool)]).status().map(|s|s.success()).unwrap_or(false){"FOUND"}else{"MISSING"});}return;}let data=if let Some(p)=args.iter().position(|x|x=="--data"){PathBuf::from(args.get(p+1).unwrap_or(&DATA.to_owned()))}else{PathBuf::from(DATA)};if let Err(e)=init(&data){eprintln!("FATAL: {} — refusing to scan without persistent storage",e);std::process::exit(1)}loop{println!("\n==== PC INTAKE AIO v1.7.0 ====\nStorage: {}\n1 Full scan and local queue\n2 Quick inventory\n3 Guided technician checklist\n4 Recent scans and sync states\n5 Check diagnostic tools\n6 Safely power off\n7 Hardware details (UUID / MAC / RAM slots / screen)\n8 Guided audio / webcam / Wi-Fi test\n9 Installed-system maintenance instructions\n10 CPU and RAM stress test (opt-in)\n",data.display());match prompt("Select: ").as_str(){"1"=>scan(&data,false),"2"=>scan(&data,true),"3"=>technician(&data),"4"=>recent(&data),"5"=>{let _=Command::new(env::current_exe().unwrap_or_else(|_|PathBuf::from("intake-controller"))).arg("--self-check").status();},"6"=>{let _=run("sync",&[]);let _=run("systemctl",&["poweroff"]);break;},"7"=>extended::device_details(&data),"8"=>extended::guided_tests(&data),"9"=>extended::maintenance_guidance(),"10"=>extended::stress_test(&data),_=>println!("Unknown selection")}}}
|
||||
#[cfg(test)]mod tests{use super::*;#[test]fn id_validation(){assert!(valid_id("PC-0123"));assert!(!valid_id("../../evil"));}#[test]fn sql_escape(){assert_eq!(sqlquote("O'Reilly"),"'O''Reilly'");}}
|
||||
fn help() {
|
||||
println!("PC Intake AIO v1.7.0 — offline-first technician controller");
|
||||
println!("Usage: intake-controller [--data PATH] [--self-check]");
|
||||
}
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.iter().any(|x| x == "--help") {
|
||||
help();
|
||||
return;
|
||||
}
|
||||
if args.iter().any(|x| x == "--self-check") {
|
||||
for tool in [
|
||||
"sqlite3",
|
||||
"smartctl",
|
||||
"ffmpeg",
|
||||
"mpv",
|
||||
"glmark2",
|
||||
"stress-ng",
|
||||
"memtester",
|
||||
"speaker-test",
|
||||
] {
|
||||
println!(
|
||||
"{}: {}",
|
||||
tool,
|
||||
if Command::new("sh")
|
||||
.args(["-c", &format!("command -v {} >/dev/null", tool)])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
"FOUND"
|
||||
} else {
|
||||
"MISSING"
|
||||
}
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let data = if let Some(p) = args.iter().position(|x| x == "--data") {
|
||||
PathBuf::from(args.get(p + 1).unwrap_or(&DATA.to_owned()))
|
||||
} else {
|
||||
PathBuf::from(DATA)
|
||||
};
|
||||
if let Err(e) = init(&data) {
|
||||
eprintln!("FATAL: {} — refusing to scan without persistent storage", e);
|
||||
std::process::exit(1)
|
||||
}
|
||||
loop {
|
||||
println!("\n==== PC INTAKE AIO v1.7.0 ====\nStorage: {}\n1 Full scan and local queue\n2 Quick inventory\n3 Guided technician checklist\n4 Recent scans and sync states\n5 Check diagnostic tools\n6 Safely power off\n7 Hardware details (UUID / MAC / RAM slots / screen)\n8 Guided audio / webcam / Wi-Fi test\n9 Installed-system maintenance instructions\n10 CPU and RAM stress test (opt-in)\n",data.display());
|
||||
match prompt("Select: ").as_str() {
|
||||
"1" => scan(&data, false),
|
||||
"2" => scan(&data, true),
|
||||
"3" => technician(&data),
|
||||
"4" => recent(&data),
|
||||
"5" => {
|
||||
let _ = Command::new(
|
||||
env::current_exe().unwrap_or_else(|_| PathBuf::from("intake-controller")),
|
||||
)
|
||||
.arg("--self-check")
|
||||
.status();
|
||||
}
|
||||
"6" => {
|
||||
let _ = run("sync", &[]);
|
||||
let _ = run("systemctl", &["poweroff"]);
|
||||
break;
|
||||
}
|
||||
"7" => extended::device_details(&data),
|
||||
"8" => extended::guided_tests(&data),
|
||||
"9" => extended::maintenance_guidance(),
|
||||
"10" => extended::stress_test(&data),
|
||||
_ => println!("Unknown selection"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn id_validation() {
|
||||
assert!(valid_id("PC-0123"));
|
||||
assert!(!valid_id("../../evil"));
|
||||
}
|
||||
#[test]
|
||||
fn sql_escape() {
|
||||
assert_eq!(sqlquote("O'Reilly"), "'O''Reilly'");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user