From b4c5934d86faed37bce2d6bc11fface3c86f8460 Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 29 Jun 2026 09:38:08 +0200 Subject: [PATCH 1/2] Fix review logic and save performance --- app/src-tauri/src/commands.rs | 48 +++++++++---- app/src-tauri/src/export.rs | 67 ++++++++++++++++-- app/src-tauri/src/golden_tests.rs | 4 +- app/src-tauri/src/model.rs | 1 + app/src-tauri/src/store.rs | 4 +- app/src-tauri/src/store/assignments.rs | 50 +++++++++----- app/src-tauri/src/store/inventory.rs | 10 +-- app/src-tauri/src/store/io_tests.rs | 96 ++++++++++++++++++++++---- app/src-tauri/src/store/merge.rs | 73 ++++++++++++++++++-- app/src-tauri/src/store/overview.rs | 2 + app/src-tauri/src/upgrade.rs | 8 +-- app/src/app-panels.js | 11 ++- app/src/app.js | 2 +- app/src/mock.js | 9 +-- shared/test-vectors/upgrade-cases.json | 6 ++ 15 files changed, 312 insertions(+), 79 deletions(-) diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index ba20291..96a6941 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -2,6 +2,7 @@ use crate::ad; use crate::model::*; use crate::store; +use std::collections::BTreeSet; use std::sync::Mutex; use std::time::{Duration, Instant}; use tauri::State; @@ -177,23 +178,46 @@ pub fn set_assignment( user_dept: Option, note: String, ) -> Result { - let config = { - let inner = state.inner.lock().map_err(|e| e.to_string())?; - inner.config.clone() + let host_key = host.trim().to_uppercase(); + let (config, known_hosts) = { + let mut inner = state.inner.lock().map_err(|e| e.to_string())?; + let config = inner.config.clone(); + let known_hosts: BTreeSet = ensure_devices(&mut inner) + .iter() + .map(|d| d.host.clone()) + .collect(); + (config, known_hosts) }; let by = current_user_domain().0; - store::write_assignment( + store::write_assignment_for_known_hosts( &config, - &host, - &user, - &user_display, - user_dept.as_deref().unwrap_or(""), - ¬e, - &by, + &known_hosts, + store::AssignmentWrite { + host: &host, + user: &user, + user_display: &user_display, + user_dept: user_dept.as_deref().unwrap_or(""), + note: ¬e, + by: &by, + }, )?; let mut inner = state.inner.lock().map_err(|e| e.to_string())?; - inner.devices = None; // Cache invalidieren -> beim naechsten Lesen neu mergen - Ok(serde_json::json!({ "ok": true })) + let updated = inner.devices.as_mut().and_then(|devs| { + devs.iter_mut() + .find(|d| d.host.eq_ignore_ascii_case(&host_key)) + .map(|d| { + store::apply_manual_assignment( + d, + &user, + &user_display, + user_dept.as_deref().unwrap_or(""), + ¬e, + &by, + ); + d.clone() + }) + }); + Ok(serde_json::json!({ "ok": true, "device": updated })) } #[tauri::command] diff --git a/app/src-tauri/src/export.rs b/app/src-tauri/src/export.rs index 0636105..9596b33 100644 --- a/app/src-tauri/src/export.rs +++ b/app/src-tauri/src/export.rs @@ -2,26 +2,62 @@ //! und UTF-8 mit BOM (damit Excel Umlaute korrekt anzeigt). use crate::model::DeviceFull; use crate::upgrade::fmt_de; +use std::io::{ErrorKind, Write}; +use std::path::Path; use std::path::PathBuf; /// Serialisiert die Geraete als CSV, schreibt sie in den Documents-Ordner des /// Benutzers und liefert (Pfad, Zeilenzahl) zurueck. pub fn write_devices_csv(devs: &[DeviceFull]) -> Result<(PathBuf, usize), String> { - let csv = build_csv(devs); - let docs = std::env::var("USERPROFILE") .map(|p| std::path::Path::new(&p).join("Documents")) .unwrap_or_else(|_| std::env::temp_dir()); - let _ = std::fs::create_dir_all(&docs); - let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S"); - let file = docs.join(format!("HardView-Export-{}.csv", stamp)); + write_devices_csv_to_dir(devs, &docs) +} + +fn write_devices_csv_to_dir(devs: &[DeviceFull], docs: &Path) -> Result<(PathBuf, usize), String> { + let csv = build_csv(devs); + + std::fs::create_dir_all(docs) + .map_err(|e| format!("Export-Ordner konnte nicht erstellt werden: {}", e))?; + let now = chrono::Local::now(); + let stamp = format!( + "{}-{:03}", + now.format("%Y%m%d-%H%M%S"), + now.timestamp_subsec_millis() + ); let mut bytes = vec![0xEF, 0xBB, 0xBF]; bytes.extend_from_slice(csv.as_bytes()); - std::fs::write(&file, bytes).map_err(|e| format!("Export fehlgeschlagen: {}", e))?; + let file = write_unique_export(docs, &stamp, &bytes)?; Ok((file, devs.len())) } +fn write_unique_export(docs: &Path, stamp: &str, bytes: &[u8]) -> Result { + for suffix in 0..1000 { + let name = if suffix == 0 { + format!("HardView-Export-{}.csv", stamp) + } else { + format!("HardView-Export-{}-{}.csv", stamp, suffix) + }; + let file = docs.join(name); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&file) + { + Ok(mut out) => { + out.write_all(bytes) + .map_err(|e| format!("Export fehlgeschlagen: {}", e))?; + return Ok(file); + } + Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, + Err(e) => return Err(format!("Export fehlgeschlagen: {}", e)), + } + } + Err("Export fehlgeschlagen: kein freier Dateiname gefunden".into()) +} + fn build_csv(devs: &[DeviceFull]) -> String { let mut csv = String::from( "Hostname;Benutzer;Quelle;Abteilung;Status;Begruendungen;CPU;Kerne;RAM_GB;Datentraeger;Groesse_GB;Alter_Jahre;Betriebssystem;Letzte_Inventarisierung;Seriennummer;Modell\r\n", @@ -94,4 +130,23 @@ mod tests { assert_eq!(esc("a\nb"), "\"a b\""); assert!(!esc("a\r\nb").contains('\n') && !esc("a\r\nb").contains('\r')); } + + #[test] + fn export_creates_unique_files_without_overwrite() { + let dir = std::env::temp_dir().join(format!( + "hardview-export-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let (first, rows1) = super::write_devices_csv_to_dir(&[], &dir).unwrap(); + let (second, rows2) = super::write_devices_csv_to_dir(&[], &dir).unwrap(); + assert_eq!(rows1, 0); + assert_eq!(rows2, 0); + assert_ne!(first, second); + assert!(first.exists()); + assert!(second.exists()); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/app/src-tauri/src/golden_tests.rs b/app/src-tauri/src/golden_tests.rs index 965ee58..648629b 100644 --- a/app/src-tauri/src/golden_tests.rs +++ b/app/src-tauri/src/golden_tests.rs @@ -25,10 +25,10 @@ struct Facts { #[serde(rename = "ramGB")] ram_gb: i64, age_years: Option, - disk_is_ssd: bool, + disk_is_ssd: Option, cpu_cores: i64, cpu_clock_mhz: i64, - os_is_win11: bool, + os_is_win11: Option, last_seen_days: Option, } diff --git a/app/src-tauri/src/model.rs b/app/src-tauri/src/model.rs index 6e73687..0d9fdfa 100644 --- a/app/src-tauri/src/model.rs +++ b/app/src-tauri/src/model.rs @@ -271,6 +271,7 @@ pub struct Overview { pub current: i64, pub avg_age_years: f64, pub old5: i64, + pub old_age_label: String, pub dept_count: i64, pub by_dept: Vec, pub age_buckets: Vec, diff --git a/app/src-tauri/src/store.rs b/app/src-tauri/src/store.rs index e9a2b1c..b85f8b6 100644 --- a/app/src-tauri/src/store.rs +++ b/app/src-tauri/src/store.rs @@ -10,9 +10,9 @@ mod merge; mod overview; mod text; -pub use assignments::write_assignment; +pub use assignments::{write_assignment_for_known_hosts, AssignmentWrite}; pub use config::{load_config, save_config}; -pub use merge::build_devices; +pub use merge::{apply_manual_assignment, build_devices}; pub use overview::build_overview; #[cfg(test)] diff --git a/app/src-tauri/src/store/assignments.rs b/app/src-tauri/src/store/assignments.rs index ace6d5c..5b5d882 100644 --- a/app/src-tauri/src/store/assignments.rs +++ b/app/src-tauri/src/store/assignments.rs @@ -1,10 +1,10 @@ use super::atomic::{acquire_assignment_lock, atomic_write}; use super::common::now_iso; use super::config::{default_assignments_path, validate_config}; -use super::inventory::{is_valid_host_id, read_known_hosts}; +use super::inventory::is_valid_host_id; use super::text::read_text; use crate::model::{AssignmentEntry, AssignmentStore, Config}; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::fs; use std::path::Path; @@ -30,26 +30,42 @@ pub fn read_assignments(path: &str) -> AssignmentStore { store } -pub fn write_assignment( +pub struct AssignmentWrite<'a> { + pub host: &'a str, + pub user: &'a str, + pub user_display: &'a str, + pub user_dept: &'a str, + pub note: &'a str, + pub by: &'a str, +} + +pub fn write_assignment_for_known_hosts( cfg: &Config, - host: &str, - user: &str, - user_display: &str, - user_dept: &str, - note: &str, - by: &str, + known_hosts: &BTreeSet, + write: AssignmentWrite<'_>, ) -> Result<(), String> { + let checked_cfg = checked_assignment_config(cfg)?; + persist_assignment(&checked_cfg, known_hosts, write) +} + +fn checked_assignment_config(cfg: &Config) -> Result { let mut checked_cfg = cfg.clone(); if checked_cfg.assignments_path.is_none() { checked_cfg.assignments_path = Some(default_assignments_path(&checked_cfg.data_dir)); } validate_config(&checked_cfg)?; + Ok(checked_cfg) +} - let host_key = host.trim().to_uppercase(); +fn persist_assignment( + checked_cfg: &Config, + known_hosts: &BTreeSet, + write: AssignmentWrite<'_>, +) -> Result<(), String> { + let host_key = write.host.trim().to_uppercase(); if !is_valid_host_id(&host_key) { return Err("Ungueltiger Hostname".into()); } - let known_hosts = read_known_hosts(&checked_cfg); if !known_hosts.contains(&host_key) { return Err(format!( "Geraet '{}' ist nicht in Inventar oder Masterliste vorhanden", @@ -71,16 +87,16 @@ pub fn write_assignment( let now = now_iso(); store.version += 1; store.updated_at_utc = Some(now.clone()); - store.updated_by = Some(by.to_string()); + store.updated_by = Some(write.by.to_string()); store.assignments.insert( host_key, AssignmentEntry { - user: user.to_string(), - user_display: user_display.to_string(), - dept: user_dept.to_string(), - confirmed_by: Some(by.to_string()), + user: write.user.to_string(), + user_display: write.user_display.to_string(), + dept: write.user_dept.to_string(), + confirmed_by: Some(write.by.to_string()), confirmed_at_utc: Some(now), - note: note.to_string(), + note: write.note.to_string(), }, ); let txt = serde_json::to_string_pretty(&store).map_err(|e| e.to_string())?; diff --git a/app/src-tauri/src/store/inventory.rs b/app/src-tauri/src/store/inventory.rs index 3636412..d0277b7 100644 --- a/app/src-tauri/src/store/inventory.rs +++ b/app/src-tauri/src/store/inventory.rs @@ -1,6 +1,6 @@ -use super::master_csv::{read_master_csv, CsvRow}; +use super::master_csv::CsvRow; use super::text::read_text; -use crate::model::{Config, Inventory}; +use crate::model::Inventory; use std::collections::{BTreeSet, HashMap}; use std::fs; @@ -86,12 +86,6 @@ pub(super) fn is_valid_host_id(host: &str) -> bool { && host.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') } -pub(super) fn read_known_hosts(cfg: &Config) -> BTreeSet { - let csv = read_master_csv(&cfg.master_csv_path); - let inv = read_inventory_dir(&cfg.data_dir); - known_hosts_from(&csv, &inv) -} - pub(super) fn known_hosts_from( csv: &HashMap, inv: &HashMap, diff --git a/app/src-tauri/src/store/io_tests.rs b/app/src-tauri/src/store/io_tests.rs index 786ff51..b9902cb 100644 --- a/app/src-tauri/src/store/io_tests.rs +++ b/app/src-tauri/src/store/io_tests.rs @@ -1,4 +1,4 @@ -use super::assignments::{read_assignments, write_assignment}; +use super::assignments::{read_assignments, write_assignment_for_known_hosts, AssignmentWrite}; use super::atomic::atomic_write; use super::build_devices; use super::common::now_iso; @@ -127,26 +127,33 @@ fn write_assignment_validates_known_host_and_persists_dept() { let cfg = temp_config(&root); fs::write(&cfg.master_csv_path, "Computer;Benutzer\nWS-KNOWN-01;\n").unwrap(); - let err = write_assignment( + let known_hosts = ["WS-KNOWN-01".to_string()].into_iter().collect(); + let err = write_assignment_for_known_hosts( &cfg, - "WS-UNKNOWN-01", - "CORP\\ghost", - "Ghost User", - "IT", - "", - "tester", + &known_hosts, + AssignmentWrite { + host: "WS-UNKNOWN-01", + user: "CORP\\ghost", + user_display: "Ghost User", + user_dept: "IT", + note: "", + by: "tester", + }, ) .unwrap_err(); assert!(err.contains("nicht in Inventar")); - write_assignment( + write_assignment_for_known_hosts( &cfg, - "ws-known-01", - "CORP\\jsmith", - "Jane Smith", - "IT", - "confirmed", - "tester", + &known_hosts, + AssignmentWrite { + host: "ws-known-01", + user: "CORP\\jsmith", + user_display: "Jane Smith", + user_dept: "IT", + note: "confirmed", + by: "tester", + }, ) .unwrap(); @@ -161,6 +168,65 @@ fn write_assignment_validates_known_host_and_persists_dept() { let _ = fs::remove_dir_all(root); } +#[test] +fn write_assignment_can_reuse_cached_known_hosts() { + let root = unique_temp_dir("write-assignment-cached"); + let cfg = temp_config(&root); + fs::write(&cfg.master_csv_path, "Computer;Benutzer\n").unwrap(); + let known_hosts = ["WS-CACHED-01".to_string()].into_iter().collect(); + + write_assignment_for_known_hosts( + &cfg, + &known_hosts, + AssignmentWrite { + host: "ws-cached-01", + user: "CORP\\jdoe", + user_display: "Jane Doe", + user_dept: "IT", + note: "cached", + by: "tester", + }, + ) + .unwrap(); + + let store = read_assignments(cfg.assignments_path.as_deref().unwrap()); + assert!(store.assignments.contains_key("WS-CACHED-01")); + + let _ = fs::remove_dir_all(root); +} + +#[test] +fn unknown_disk_and_os_do_not_create_upgrade_reasons() { + let root = unique_temp_dir("unknown-disk-os"); + let cfg = temp_config(&root); + fs::write(&cfg.master_csv_path, "Computer;Benutzer\nWS-UNKNOWN-01;\n").unwrap(); + fs::write( + Path::new(&cfg.data_dir).join("WS-UNKNOWN-01.json"), + format!( + r#"{{ + "schemaVersion": 1, + "hostname": "WS-UNKNOWN-01", + "collectedAtUtc": "{}", + "ageYears": 1.0, + "cpu": {{"cores": 4, "logicalProcessors": 8, "maxClockMhz": 3000}}, + "ram": {{"totalGB": 16, "slotsUsed": 1, "slotsTotal": 2}}, + "disks": [{{"mediaType": "Unbekannt", "sizeGB": 512}}], + "os": {{"caption": "", "version": ""}} +}}"#, + now_iso() + ), + ) + .unwrap(); + + let devs = build_devices(&cfg); + let dev = devs.iter().find(|d| d.host == "WS-UNKNOWN-01").unwrap(); + assert_eq!(dev.status, "ok"); + assert!(!dev.upgrade_reasons.iter().any(|r| r.contains("HDD"))); + assert!(!dev.upgrade_reasons.iter().any(|r| r.contains("Win 10"))); + + let _ = fs::remove_dir_all(root); +} + #[test] fn mixed_ssd_hdd_is_not_treated_as_full_ssd() { let root = unique_temp_dir("mixed-disk"); diff --git a/app/src-tauri/src/store/merge.rs b/app/src-tauri/src/store/merge.rs index 5c7b20f..161f0a2 100644 --- a/app/src-tauri/src/store/merge.rs +++ b/app/src-tauri/src/store/merge.rs @@ -70,15 +70,12 @@ fn build_one( .collect(); let disks = iv.disks.clone().unwrap_or_default(); - let has_ssd = disks.iter().any(|d| eq_ci(&d.media_type, "SSD")); + let has_ssd = disks.iter().any(|d| is_solid_state_media(&d.media_type)); let has_hdd = disks.iter().any(|d| eq_ci(&d.media_type, "HDD")); let primary = disks .iter() .max_by_key(|d| d.size_gb.unwrap_or(0.0).round() as i64); - let primary_is_ssd = primary - .map(|d| eq_ci(&d.media_type, "SSD")) - .unwrap_or(false); - let disk_is_ssd = has_ssd && !has_hdd && primary_is_ssd; + let disk_is_ssd = classify_ssd_state(primary, has_hdd); let disk_type = if has_ssd && has_hdd { "Mixed SSD/HDD".to_string() } else { @@ -93,7 +90,7 @@ fn build_one( let os = iv.os.clone().unwrap_or_default(); let os_caption = opt_str(&os.caption, "—"); let os_build = os.version.clone().or(os.build.clone()).unwrap_or_default(); - let os_is_win11 = os_caption.contains("11"); + let os_is_win11 = classify_windows_11(&os_caption, &os_build); let age_years = iv.age_years; let last_seen_days = iv.collected_at_utc.as_deref().and_then(days_since); @@ -245,3 +242,67 @@ fn build_one( collected_at_utc: iv.collected_at_utc.clone(), } } + +pub fn apply_manual_assignment( + d: &mut DeviceFull, + user: &str, + user_display: &str, + user_dept: &str, + note: &str, + confirmed_by: &str, +) { + let display = if user_display.trim().is_empty() { + user.to_string() + } else { + user_display.to_string() + }; + d.user = display.clone(); + d.user_display = display.clone(); + d.user_sam = user.to_string(); + d.user_source = "manuell bestätigt".to_string(); + d.dept = if user_dept.trim().is_empty() { + dept_from_host(&d.host) + } else { + user_dept.to_string() + }; + d.initials = initials(&display); + d.note = note.to_string(); + d.confirmed_by = Some(confirmed_by.to_string()); +} + +fn is_solid_state_media(media_type: &Option) -> bool { + eq_ci(media_type, "SSD") || eq_ci(media_type, "SCM") +} + +fn classify_ssd_state(primary: Option<&DiskInv>, has_hdd: bool) -> Option { + if has_hdd { + return Some(false); + } + let media = primary.and_then(|d| d.media_type.as_ref())?; + if media.eq_ignore_ascii_case("HDD") { + Some(false) + } else if media.eq_ignore_ascii_case("SSD") || media.eq_ignore_ascii_case("SCM") { + Some(true) + } else { + None + } +} + +fn classify_windows_11(caption: &str, build: &str) -> Option { + let lower = caption.to_lowercase(); + if lower.contains("windows 11") { + return Some(true); + } + if lower.contains("windows 10") { + return Some(false); + } + if let Some(build_no) = build.rsplit('.').next().and_then(|b| b.parse::().ok()) { + if build_no >= 22_000 { + return Some(true); + } + if build_no >= 10_000 { + return Some(false); + } + } + None +} diff --git a/app/src-tauri/src/store/overview.rs b/app/src-tauri/src/store/overview.rs index 61a25d3..eae2239 100644 --- a/app/src-tauri/src/store/overview.rs +++ b/app/src-tauri/src/store/overview.rs @@ -1,4 +1,5 @@ use crate::model::{Bucket, DeptStat, DeviceFull, Overview, StatusCounts, Thresholds}; +use crate::upgrade::fmt_de; use std::collections::HashMap; // ------------------------------------------------------------------ Overview @@ -100,6 +101,7 @@ pub fn build_overview(devs: &[DeviceFull], th: &Thresholds) -> Overview { current: with_inv - stale, avg_age_years: (avg * 10.0).round() / 10.0, old5, + old_age_label: format!("> {} Jahre", fmt_de(th.max_age_years)), dept_count: by_dept.len() as i64, by_dept, age_buckets, diff --git a/app/src-tauri/src/upgrade.rs b/app/src-tauri/src/upgrade.rs index 070e46f..18bbb7d 100644 --- a/app/src-tauri/src/upgrade.rs +++ b/app/src-tauri/src/upgrade.rs @@ -14,10 +14,10 @@ pub struct DeviceFacts { pub has_inventory: bool, pub ram_gb: i64, pub age_years: Option, - pub disk_is_ssd: bool, + pub disk_is_ssd: Option, pub cpu_cores: i64, pub cpu_clock_mhz: i64, - pub os_is_win11: bool, + pub os_is_win11: Option, pub last_seen_days: Option, } @@ -40,7 +40,7 @@ pub fn evaluate(th: &Thresholds, f: &DeviceFacts) -> Eval { if f.ram_gb > 0 && f.ram_gb <= th.min_ram_gb { reasons.push(format!("RAM knapp ({} GB)", f.ram_gb)); } - if th.require_ssd && !f.disk_is_ssd { + if th.require_ssd && matches!(f.disk_is_ssd, Some(false)) { reasons.push("HDD statt SSD".into()); } if f.cpu_cores > 0 && f.cpu_cores < th.min_cpu_cores { @@ -49,7 +49,7 @@ pub fn evaluate(th: &Thresholds, f: &DeviceFacts) -> Eval { if th.min_cpu_clock_mhz > 0 && f.cpu_clock_mhz > 0 && f.cpu_clock_mhz < th.min_cpu_clock_mhz { reasons.push(format!("CPU-Takt niedrig ({} MHz)", f.cpu_clock_mhz)); } - if !f.os_is_win11 { + if matches!(f.os_is_win11, Some(false)) { reasons.push("Kein Windows 11 (Win 10 EOL)".into()); } diff --git a/app/src/app-panels.js b/app/src/app-panels.js index 489a28f..0f00024 100644 --- a/app/src/app-panels.js +++ b/app/src/app-panels.js @@ -56,10 +56,17 @@ el('div', { class: 'btn btn-primary', onclick: async () => { if (!selected) { toast('Bitte einen Benutzer auswählen.'); return; } try { - await invoke('set_assignment', { host, user: selected.sam, userDisplay: selected.display, userDept: selected.dept || '', note }); + const result = await invoke('set_assignment', { host, user: selected.sam, userDisplay: selected.display, userDept: selected.dept || '', note }); toast('Zugeordnet: ' + selected.display + ' → ' + host); close(); - await loadData(); + if (result && result.device) { + const i = state.devices.findIndex((d) => d.host === result.device.host); + if (i >= 0) state.devices[i] = result.device; else state.devices.push(result.device); + state.overview = await invoke('get_overview'); + renderKpis(); applyView(); + } else { + await loadData(); + } state.selected = host; renderDrawer(); } catch (e) { toast('Speichern fehlgeschlagen: ' + e); } } }, 'Speichern')))); diff --git a/app/src/app.js b/app/src/app.js index dc3eb2c..5e2d55f 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -123,7 +123,7 @@ host.appendChild(card('GERÄTE GESAMT', o.total, 'in ' + o.deptCount + ' Abteilungen')); host.appendChild(card('AKTUELL INVENTARISIERT', o.current, (o.missing + o.stale) + ' ohne aktuelle Meldung', 'green')); host.appendChild(card('UPGRADE NÖTIG', o.upgradeNeeded, 'RAM · Alter · SSD · Win 11', 'amber')); - host.appendChild(card('Ø ALTER', String(o.avgAgeYears).replace('.', ','), o.old5 + ' Geräte ≥ 5 Jahre', '', ' J.')); + host.appendChild(card('Ø ALTER', String(o.avgAgeYears).replace('.', ','), o.old5 + ' Geräte ' + (o.oldAgeLabel || '> 5 Jahre'), '', ' J.')); $('#navWarnBadge').textContent = o.upgradeNeeded; } diff --git a/app/src/mock.js b/app/src/mock.js index ef89b33..e3aa9c0 100644 --- a/app/src/mock.js +++ b/app/src/mock.js @@ -67,10 +67,10 @@ const reasons = []; if (f.ageYears != null && f.ageYears > th.maxAgeYears) reasons.push('Gerät alt (' + fmtDe(f.ageYears) + ' Jahre)'); if (f.ramGB > 0 && f.ramGB <= th.minRamGB) reasons.push('RAM knapp (' + f.ramGB + ' GB)'); - if (th.requireSsd && !f.diskIsSsd) reasons.push('HDD statt SSD'); + if (th.requireSsd && f.diskIsSsd === false) reasons.push('HDD statt SSD'); if (f.cpuCores > 0 && f.cpuCores < th.minCpuCores) reasons.push('CPU schwach (' + f.cpuCores + ' Kerne)'); if (th.minCpuClockMhz > 0 && f.cpuClockMhz > 0 && f.cpuClockMhz < th.minCpuClockMhz) reasons.push('CPU-Takt niedrig (' + f.cpuClockMhz + ' MHz)'); - if (!f.osIsWin11) reasons.push('Kein Windows 11 (Win 10 EOL)'); + if (f.osIsWin11 === false) reasons.push('Kein Windows 11 (Win 10 EOL)'); const futureTimestamp = f.lastSeenDays != null && f.lastSeenDays < -1; if (futureTimestamp || (f.lastSeenDays != null && f.lastSeenDays > th.staleDays)) { return { status: 'stale', statusLabel: futureTimestamp ? 'Unplausibel · Zeitstempel in Zukunft' : 'Veraltet · Agent meldet nicht', reasons }; @@ -85,10 +85,10 @@ hasInventory: hasInv, ramGB: pc.ram, ageYears: hasInv ? pc.age : null, - diskIsSsd: pc.disk === 'SSD', + diskIsSsd: pc.disk == null ? null : (pc.disk === 'SSD' || pc.disk === 'SCM'), cpuCores: pc.c, cpuClockMhz: pc.clock || 0, - osIsWin11: pc.os.includes('11'), + osIsWin11: pc.os ? pc.os.includes('11') : null, lastSeenDays: hasInv ? pc.stale : null }); const status = ev.status, statusLabel = ev.statusLabel, reasons = ev.reasons; @@ -146,6 +146,7 @@ return { total, withInventory: withInv, stale, missing, upgradeNeeded: upgrade, ok, current: withInv - stale, avgAgeYears: Math.round(avgAge * 10) / 10, old5, + oldAgeLabel: '> ' + fmtDe(THRESH.maxAgeYears) + ' Jahre', deptCount: Object.keys(depts).length, byDept: Object.values(depts).sort((a, b) => b.count - a.count), ageBuckets, ramBuckets, diff --git a/shared/test-vectors/upgrade-cases.json b/shared/test-vectors/upgrade-cases.json index c2cacc2..5c9ca18 100644 --- a/shared/test-vectors/upgrade-cases.json +++ b/shared/test-vectors/upgrade-cases.json @@ -52,6 +52,12 @@ "status": "ok", "reasons": [] }, + { + "name": "unbekannte-disk-und-os-ueberspringen-regeln", + "facts": { "hasInventory": true, "ramGB": 16, "ageYears": 1.0, "diskIsSsd": null, "cpuCores": 4, "cpuClockMhz": 2200, "osIsWin11": null, "lastSeenDays": 0 }, + "status": "ok", + "reasons": [] + }, { "name": "ssd-aber-altes-win10", "facts": { "hasInventory": true, "ramGB": 16, "ageYears": 5.5, "diskIsSsd": true, "cpuCores": 6, "cpuClockMhz": 2600, "osIsWin11": false, "lastSeenDays": 10 }, From 4e40619064348e74d65712d6617d188c6afe6c6f Mon Sep 17 00:00:00 2001 From: zerox80 Date: Mon, 29 Jun 2026 09:45:23 +0200 Subject: [PATCH 2/2] Keep refactored modules under line limit --- app/src-tauri/src/commands.rs | 20 +-------------- app/src-tauri/src/commands_tests.rs | 2 +- app/src-tauri/src/identity.rs | 18 +++++++++++++ app/src-tauri/src/lib.rs | 1 + app/src-tauri/src/store.rs | 1 + app/src-tauri/src/store/facts.rs | 39 +++++++++++++++++++++++++++++ app/src-tauri/src/store/merge.rs | 38 +--------------------------- 7 files changed, 62 insertions(+), 57 deletions(-) create mode 100644 app/src-tauri/src/identity.rs create mode 100644 app/src-tauri/src/store/facts.rs diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index 96a6941..e9b8d82 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -1,5 +1,6 @@ //! Tauri-Befehle (Bruecke Frontend <-> Backend). Halten Geraeteliste & AD-Cache im State. use crate::ad; +use crate::identity::synth_sam; use crate::model::*; use crate::store; use std::collections::BTreeSet; @@ -277,25 +278,6 @@ pub fn export_devices(state: State, format: String) -> Result String { - let mut sam = String::new(); - for ch in display.chars() { - match ch { - 'ä' | 'Ä' => sam.push_str("ae"), - 'ö' | 'Ö' => sam.push_str("oe"), - 'ü' | 'Ü' => sam.push_str("ue"), - 'ß' => sam.push_str("ss"), - ' ' => sam.push('.'), - c if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') => sam.push(c), - _ => {} - } - } - sam.to_lowercase() -} - fn current_user_domain() -> (String, String) { let user = std::env::var("USERNAME").unwrap_or_else(|_| "Unbekannt".into()); let domain = std::env::var("USERDNSDOMAIN") diff --git a/app/src-tauri/src/commands_tests.rs b/app/src-tauri/src/commands_tests.rs index f4ae0c7..c4d9356 100644 --- a/app/src-tauri/src/commands_tests.rs +++ b/app/src-tauri/src/commands_tests.rs @@ -1,4 +1,4 @@ -use crate::commands::synth_sam; +use crate::identity::synth_sam; #[test] fn synth_sam_transliterates_umlauts() { diff --git a/app/src-tauri/src/identity.rs b/app/src-tauri/src/identity.rs new file mode 100644 index 0000000..b4a2dab --- /dev/null +++ b/app/src-tauri/src/identity.rs @@ -0,0 +1,18 @@ +/// Leitet aus einem Anzeigenamen einen plausiblen SAM-Account ab — nur als +/// CSV-Fallback, wenn kein AD verfuegbar ist. Deutsche Umlaute werden +/// transliteriert, damit der Wert ASCII-stabil und deterministisch bleibt. +pub(crate) fn synth_sam(display: &str) -> String { + let mut sam = String::new(); + for ch in display.chars() { + match ch { + 'ä' | 'Ä' => sam.push_str("ae"), + 'ö' | 'Ö' => sam.push_str("oe"), + 'ü' | 'Ü' => sam.push_str("ue"), + 'ß' => sam.push_str("ss"), + ' ' => sam.push('.'), + c if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') => sam.push(c), + _ => {} + } + } + sam.to_lowercase() +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 25c60ca..64e2f12 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod ad; mod commands; mod export; +mod identity; mod model; mod store; mod upgrade; diff --git a/app/src-tauri/src/store.rs b/app/src-tauri/src/store.rs index b85f8b6..a00bb85 100644 --- a/app/src-tauri/src/store.rs +++ b/app/src-tauri/src/store.rs @@ -4,6 +4,7 @@ mod assignments; mod atomic; mod common; mod config; +mod facts; mod inventory; mod master_csv; mod merge; diff --git a/app/src-tauri/src/store/facts.rs b/app/src-tauri/src/store/facts.rs new file mode 100644 index 0000000..4dd1225 --- /dev/null +++ b/app/src-tauri/src/store/facts.rs @@ -0,0 +1,39 @@ +use super::common::eq_ci; +use crate::model::DiskInv; + +pub(super) fn is_solid_state_media(media_type: &Option) -> bool { + eq_ci(media_type, "SSD") || eq_ci(media_type, "SCM") +} + +pub(super) fn classify_ssd_state(primary: Option<&DiskInv>, has_hdd: bool) -> Option { + if has_hdd { + return Some(false); + } + let media = primary.and_then(|d| d.media_type.as_ref())?; + if media.eq_ignore_ascii_case("HDD") { + Some(false) + } else if media.eq_ignore_ascii_case("SSD") || media.eq_ignore_ascii_case("SCM") { + Some(true) + } else { + None + } +} + +pub(super) fn classify_windows_11(caption: &str, build: &str) -> Option { + let lower = caption.to_lowercase(); + if lower.contains("windows 11") { + return Some(true); + } + if lower.contains("windows 10") { + return Some(false); + } + if let Some(build_no) = build.rsplit('.').next().and_then(|b| b.parse::().ok()) { + if build_no >= 22_000 { + return Some(true); + } + if build_no >= 10_000 { + return Some(false); + } + } + None +} diff --git a/app/src-tauri/src/store/merge.rs b/app/src-tauri/src/store/merge.rs index 161f0a2..e806cdc 100644 --- a/app/src-tauri/src/store/merge.rs +++ b/app/src-tauri/src/store/merge.rs @@ -4,6 +4,7 @@ use super::common::{ os_short, strip_domain, }; use super::config::default_assignments_path; +use super::facts::{classify_ssd_state, classify_windows_11, is_solid_state_media}; use super::inventory::{known_hosts_from, read_inventory_dir}; use super::master_csv::{read_master_csv, CsvRow}; use crate::model::*; @@ -269,40 +270,3 @@ pub fn apply_manual_assignment( d.note = note.to_string(); d.confirmed_by = Some(confirmed_by.to_string()); } - -fn is_solid_state_media(media_type: &Option) -> bool { - eq_ci(media_type, "SSD") || eq_ci(media_type, "SCM") -} - -fn classify_ssd_state(primary: Option<&DiskInv>, has_hdd: bool) -> Option { - if has_hdd { - return Some(false); - } - let media = primary.and_then(|d| d.media_type.as_ref())?; - if media.eq_ignore_ascii_case("HDD") { - Some(false) - } else if media.eq_ignore_ascii_case("SSD") || media.eq_ignore_ascii_case("SCM") { - Some(true) - } else { - None - } -} - -fn classify_windows_11(caption: &str, build: &str) -> Option { - let lower = caption.to_lowercase(); - if lower.contains("windows 11") { - return Some(true); - } - if lower.contains("windows 10") { - return Some(false); - } - if let Some(build_no) = build.rsplit('.').next().and_then(|b| b.parse::().ok()) { - if build_no >= 22_000 { - return Some(true); - } - if build_no >= 10_000 { - return Some(false); - } - } - None -}