Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 37 additions & 31 deletions app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! 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;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::State;
Expand Down Expand Up @@ -177,23 +179,46 @@ pub fn set_assignment(
user_dept: Option<String>,
note: String,
) -> Result<serde_json::Value, String> {
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<String> = 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(""),
&note,
&by,
&known_hosts,
store::AssignmentWrite {
host: &host,
user: &user,
user_display: &user_display,
user_dept: user_dept.as_deref().unwrap_or(""),
note: &note,
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(""),
&note,
&by,
);
d.clone()
})
});
Ok(serde_json::json!({ "ok": true, "device": updated }))
}

#[tauri::command]
Expand Down Expand Up @@ -253,25 +278,6 @@ pub fn export_devices(state: State<AppState>, format: String) -> Result<serde_js
Ok(serde_json::json!({ "ok": true, "path": file.to_string_lossy(), "rows": rows }))
}

/// 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()
}

fn current_user_domain() -> (String, String) {
let user = std::env::var("USERNAME").unwrap_or_else(|_| "Unbekannt".into());
let domain = std::env::var("USERDNSDOMAIN")
Expand Down
2 changes: 1 addition & 1 deletion app/src-tauri/src/commands_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::commands::synth_sam;
use crate::identity::synth_sam;

#[test]
fn synth_sam_transliterates_umlauts() {
Expand Down
67 changes: 61 additions & 6 deletions app/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf, String> {
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",
Expand Down Expand Up @@ -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);
}
}
4 changes: 2 additions & 2 deletions app/src-tauri/src/golden_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ struct Facts {
#[serde(rename = "ramGB")]
ram_gb: i64,
age_years: Option<f64>,
disk_is_ssd: bool,
disk_is_ssd: Option<bool>,
cpu_cores: i64,
cpu_clock_mhz: i64,
os_is_win11: bool,
os_is_win11: Option<bool>,
last_seen_days: Option<i64>,
}

Expand Down
18 changes: 18 additions & 0 deletions app/src-tauri/src/identity.rs
Original file line number Diff line number Diff line change
@@ -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()
}
1 change: 1 addition & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod ad;
mod commands;
mod export;
mod identity;
mod model;
mod store;
mod upgrade;
Expand Down
1 change: 1 addition & 0 deletions app/src-tauri/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeptStat>,
pub age_buckets: Vec<Bucket>,
Expand Down
5 changes: 3 additions & 2 deletions app/src-tauri/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ mod assignments;
mod atomic;
mod common;
mod config;
mod facts;
mod inventory;
mod master_csv;
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)]
Expand Down
50 changes: 33 additions & 17 deletions app/src-tauri/src/store/assignments.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<String>,
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<Config, String> {
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<String>,
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",
Expand All @@ -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())?;
Expand Down
39 changes: 39 additions & 0 deletions app/src-tauri/src/store/facts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use super::common::eq_ci;
use crate::model::DiskInv;

pub(super) fn is_solid_state_media(media_type: &Option<String>) -> bool {
eq_ci(media_type, "SSD") || eq_ci(media_type, "SCM")
}

pub(super) fn classify_ssd_state(primary: Option<&DiskInv>, has_hdd: bool) -> Option<bool> {
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<bool> {
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::<i64>().ok()) {
if build_no >= 22_000 {
return Some(true);
}
if build_no >= 10_000 {
return Some(false);
}
}
None
}
Loading
Loading