From 6d5bb291b168b6bc01948fd8de824521158f1eda Mon Sep 17 00:00:00 2001 From: zerox80 Date: Wed, 1 Jul 2026 21:17:58 +0200 Subject: [PATCH] Fix AD-fallback name collisions, silence-swallowed errors, dedupe frontend helpers - ad_users.rs: disambiguate synthesized SAMs on collision (host suffix) instead of silently dropping a second user with the same display name; update the existing test and add coverage for real-SAM dedup vs. synthesized collision. - commands.rs/config.rs/assignments.rs: log (eprintln!) previously silent AD-fetch, config.json and assignments.json failures so operators can diagnose stale/empty data instead of it failing invisibly. - Frontend: extract PALETTE/hashColor/fmtDe (triplicated across mock.js, view-model.js, app-panels.js) into a single shared.js, loaded first and consumed via window.HVShared (browser) / require (Node tests). - CI: gate the release build on the dependency-audit job passing, and add npm audit alongside the existing cargo audit. --- .github/workflows/ci.yml | 6 +++++- app/package.json | 2 +- app/src-tauri/src/ad_users.rs | 15 +++++++++---- app/src-tauri/src/ad_users_tests.rs | 29 ++++++++++++++++++++------ app/src-tauri/src/commands.rs | 12 +++++++---- app/src-tauri/src/store/assignments.rs | 11 ++++++---- app/src-tauri/src/store/config.rs | 10 +++++---- app/src/app-panels.js | 3 +-- app/src/index.html | 1 + app/src/mock.js | 7 +++---- app/src/shared.js | 24 +++++++++++++++++++++ app/src/view-model.js | 9 ++++---- 12 files changed, 94 insertions(+), 35 deletions(-) create mode 100644 app/src/shared.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81ad511..fdcd733 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,10 @@ jobs: working-directory: app run: npm test + - name: npm audit + working-directory: app + run: npm audit --audit-level=high + source-limits: name: Source-Line-Limits runs-on: ubuntu-latest @@ -115,7 +119,7 @@ jobs: build: name: Tauri-Build (Windows) runs-on: windows-latest - needs: [backend, frontend, source-limits] + needs: [backend, frontend, source-limits, audit] steps: - uses: actions/checkout@v5 diff --git a/app/package.json b/app/package.json index 91afbce..1ce001f 100644 --- a/app/package.json +++ b/app/package.json @@ -10,7 +10,7 @@ "icons": "tauri icon src-tauri/icon-source.png", "preview": "node dev-server.js", "check": "npm run check:js", - "check:js": "node --check src/view-model.js && node --check src/app.js && node --check src/app-panels.js && node --check src/mock.js && node --check dev-server.js", + "check:js": "node --check src/shared.js && node --check src/view-model.js && node --check src/app.js && node --check src/app-panels.js && node --check src/mock.js && node --check dev-server.js", "test": "node --test tests/upgrade-parity.test.js tests/view-model.test.js" }, "devDependencies": { diff --git a/app/src-tauri/src/ad_users.rs b/app/src-tauri/src/ad_users.rs index a706275..92206db 100644 --- a/app/src-tauri/src/ad_users.rs +++ b/app/src-tauri/src/ad_users.rs @@ -7,7 +7,9 @@ use std::collections::HashSet; /// Baut die Fallback-Benutzerliste aus Geraetedaten (CSV/Inventar), wenn AD deaktiviert /// oder keine AD-Antwort verfuegbar ist. Dedupliziert nach (synthetisiertem oder echtem) -/// SAM und sortiert nach Anzeigename. +/// SAM und sortiert nach Anzeigename. Kollidieren zwei synthetisierte SAMs (z.B. zwei +/// Personen mit gleichem Anzeigenamen in unterschiedlichen Abteilungen), wird der Host +/// als Disambiguator angehaengt statt den zweiten Eintrag stillschweigend zu verwerfen. pub(crate) fn fallback_users_from_devices(devs: &[DeviceFull]) -> Vec { let mut seen = HashSet::new(); let mut users: Vec = Vec::new(); @@ -15,10 +17,15 @@ pub(crate) fn fallback_users_from_devices(devs: &[DeviceFull]) -> Vec { if d.user_display.is_empty() || d.user_display == "Unbekannt" { continue; } - let sam = if d.user_sam.is_empty() { - synth_sam(&d.user_display) - } else { + let sam = if !d.user_sam.is_empty() { d.user_sam.clone() + } else { + let base = synth_sam(&d.user_display); + if seen.contains(&base) { + format!("{base}.{}", d.host.to_lowercase()) + } else { + base + } }; if seen.insert(sam.clone()) { users.push(AdUser { diff --git a/app/src-tauri/src/ad_users_tests.rs b/app/src-tauri/src/ad_users_tests.rs index ec84408..a9ec737 100644 --- a/app/src-tauri/src/ad_users_tests.rs +++ b/app/src-tauri/src/ad_users_tests.rs @@ -33,20 +33,37 @@ fn fallback_users_from_devices_skips_empty_and_unbekannt() { } #[test] -fn fallback_users_from_devices_dedupes_by_sam_and_synthesizes_when_missing() { +fn fallback_users_from_devices_dedupes_by_real_sam() { + let devs = vec![ + // Gleicher echter SAM (z.B. dieselbe Person auf zwei Geraeten) -> dedupliziert. + device("WS-A", "Anna Berger", "a.berger", "IT"), + device("WS-B", "Anna Berger", "a.berger", "IT"), + ]; + let users = fallback_users_from_devices(&devs); + assert_eq!(users.len(), 1); + assert_eq!(users[0].sam, "a.berger"); +} + +#[test] +fn fallback_users_from_devices_disambiguates_synthesized_sam_collision() { let devs = vec![ // Kein user_sam -> wird aus dem Anzeigenamen synthetisiert. device("WS-A", "Jürgen Müller", "", "IT"), - // Zweites Geraet mit gleichem synthetisiertem SAM -> dedupliziert (erstes gewinnt). + // Zweites Geraet mit gleichem synthetisiertem SAM (andere Person, anderer Host) + // -> darf NICHT verworfen werden, sondern wird per Host disambiguiert. device("WS-B", "Jürgen Müller", "", "Marketing"), // Eigener echter SAM -> eigener Eintrag. device("WS-C", "Anna Berger", "a.berger", "IT"), ]; let users = fallback_users_from_devices(&devs); - assert_eq!(users.len(), 2); - let juergen = users.iter().find(|u| u.sam == "juergen.mueller").unwrap(); - assert_eq!(juergen.display, "Jürgen Müller"); - assert_eq!(juergen.dept, "IT", "erstes Geraet (IT) gewinnt beim Dedup"); + assert_eq!(users.len(), 3); + let first = users.iter().find(|u| u.sam == "juergen.mueller").unwrap(); + assert_eq!(first.dept, "IT"); + let second = users + .iter() + .find(|u| u.sam == "juergen.mueller.ws-b") + .unwrap(); + assert_eq!(second.dept, "Marketing"); assert!(users.iter().any(|u| u.sam == "a.berger")); } diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index 55b2d10..08191df 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -95,7 +95,8 @@ pub fn get_ad_users(state: State, search: String) -> Result users = list, - Err(_) => { + Err(e) => { + eprintln!("[hardview] AD-Suche fehlgeschlagen ('{query}'): {e}"); if let Some(list) = cached_full { users = list; } @@ -121,15 +122,18 @@ pub fn get_ad_users(state: State, search: String) -> Result users = list, - None => { - if let Ok(list) = ad::fetch_ad_users("") { + None => match ad::fetch_ad_users("") { + Ok(list) => { let mut inner = state.inner.lock().map_err(|e| e.to_string())?; if inner.config.ad_enabled { inner.ad = Some((Instant::now(), list.clone())); users = list; } } - } + Err(e) => { + eprintln!("[hardview] AD-Vollabruf fehlgeschlagen: {e}"); + } + }, } } } diff --git a/app/src-tauri/src/store/assignments.rs b/app/src-tauri/src/store/assignments.rs index 5b5d882..581ccb8 100644 --- a/app/src-tauri/src/store/assignments.rs +++ b/app/src-tauri/src/store/assignments.rs @@ -16,10 +16,13 @@ pub fn read_assignments(path: &str) -> AssignmentStore { return AssignmentStore::default(); } } - let mut store = read_text(path) - .ok() - .and_then(|t| serde_json::from_str::(&t).ok()) - .unwrap_or_default(); + let mut store = match read_text(path) { + Ok(txt) => serde_json::from_str::(&txt).unwrap_or_else(|e| { + eprintln!("[hardview] assignments.json konnte nicht geparst werden ({path}): {e}"); + AssignmentStore::default() + }), + Err(_) => AssignmentStore::default(), + }; // Schluessel auf Grossschreibung normalisieren (Host-Matching) let upper: HashMap = store .assignments diff --git a/app/src-tauri/src/store/config.rs b/app/src-tauri/src/store/config.rs index a3f5c61..af961ea 100644 --- a/app/src-tauri/src/store/config.rs +++ b/app/src-tauri/src/store/config.rs @@ -37,10 +37,12 @@ pub fn default_config() -> Config { } pub fn load_config() -> Config { - let mut cfg = if let Ok(txt) = fs::read_to_string(config_path()) { - serde_json::from_str::(&txt).unwrap_or_else(|_| default_config()) - } else { - default_config() + let mut cfg = match fs::read_to_string(config_path()) { + Ok(txt) => serde_json::from_str::(&txt).unwrap_or_else(|e| { + eprintln!("[hardview] config.json konnte nicht geparst werden: {e}"); + default_config() + }), + Err(_) => default_config(), }; // Dev-/Override per Umgebungsvariablen (erleichtert Tests gegen sample-data) if let Ok(v) = std::env::var("HARDVIEW_DATA_DIR") { diff --git a/app/src/app-panels.js b/app/src/app-panels.js index 071a9bb..7d0dc1d 100644 --- a/app/src/app-panels.js +++ b/app/src/app-panels.js @@ -4,6 +4,7 @@ const H = window.HardView; const { $, DEFAULT_THRESHOLDS, TAURI, VIEWS, el, invoke, loadData, renderDrawer, renderKpis, renderList, renderRows, state, svg, toast, ViewModel } = H; + const { hashColor: colorFor } = window.HVShared; // ---------------- Zuordnungs-Modal ---------------- async function openAssign(host) { @@ -77,8 +78,6 @@ const p = (name || '').trim().split(/\s+/).filter(Boolean); return (((p[0] || '?')[0] || '?') + ((p[1] || '')[0] || '')).toUpperCase(); } - function colorFor(s) { const PAL = ['#4f8cff', '#2fd6a6', '#b98cff', '#ff8a4f', '#ffb454', '#5fc9ff', '#ff7a9c', '#7ee081']; let n = 0; for (let i = 0; i < s.length; i++) n = (n * 31 + s.charCodeAt(i)) >>> 0; return PAL[n % PAL.length]; } - // ---------------- Dashboard / Abteilungen / Berichte ---------------- function distBars(items, colorFn) { const max = Math.max(1, ...items.map((i) => i.count)); diff --git a/app/src/index.html b/app/src/index.html index b3cd4bb..4b25b92 100644 --- a/app/src/index.html +++ b/app/src/index.html @@ -138,6 +138,7 @@

Geräte-Inventar

+ diff --git a/app/src/mock.js b/app/src/mock.js index 614a2a5..cd2ef3c 100644 --- a/app/src/mock.js +++ b/app/src/mock.js @@ -4,9 +4,11 @@ * In der echten Tauri-App ist window.__TAURI__ vorhanden und dieser Code wird NICHT genutzt. * Die Berechnungslogik hier spiegelt store.rs/upgrade.rs wider (Quelle der Wahrheit = Rust). */ (function () { + const { hashColor, fmtDe } = (typeof window !== 'undefined' && window.HVShared) + ? window.HVShared + : require('./shared.js'); const DEFAULT_THRESHOLDS = { minRamGB: 8, maxAgeYears: 5, staleDays: 30, requireSsd: true, minCpuCores: 4, minCpuClockMhz: 0, targetRamGB: 16 }; let THRESH = Object.assign({}, DEFAULT_THRESHOLDS); - const PALETTE = ['#4f8cff', '#2fd6a6', '#b98cff', '#ff8a4f', '#ffb454', '#5fc9ff', '#ff7a9c', '#7ee081']; // Roh-PCs (entspricht sample-data). inv=false -> in CSV aber ohne Agent-JSON. const PCS = [ @@ -30,12 +32,9 @@ { h:'WS-LAGER-04', f:'Michael', l:'Scholz', d:'Lager', cpu:'Intel Celeron G4900', c:2, t:2, ram:4, su:1, st:2, disk:'HDD', dgb:500, os:'Windows 10 Pro', b:'19044', age:6.9, stale:0, inv:false, mfg:'Fujitsu', mdl:'Esprimo D538' } ]; - function hashColor(s) { let n = 0; for (let i = 0; i < s.length; i++) n = (n * 31 + s.charCodeAt(i)) >>> 0; return PALETTE[n % PALETTE.length]; } function initials(f, l) { return ((f || '?')[0] + (l || '?')[0]).toUpperCase(); } function osShort(os, b) { const w = os.includes('11') ? 'Win 11' : os.includes('10') ? 'Win 10' : os; const map = { '22631':'23H2','22621':'22H2','19045':'22H2','19044':'21H2','26100':'24H2','26200':'25H2' }; return w + (map[b] ? ' ' + map[b] : ''); } function lastSeen(days) { if (days == null) return '—'; if (days < -1) return 'Zeitstempel in Zukunft'; if (days < 1) return 'gerade eben'; if (days === 1) return 'vor 1 Tag'; return 'vor ' + days + ' Tagen'; } - // Deutsche Dezimaldarstellung, 1 Nachkommastelle — spiegelt fmt_de() aus upgrade.rs. - function fmtDe(v) { return Number(v).toFixed(1).replace('.', ','); } function intAtLeast(value, fallback, min) { const parsed = parseInt(value, 10); return Number.isFinite(parsed) ? Math.max(min, parsed) : fallback; diff --git a/app/src/shared.js b/app/src/shared.js new file mode 100644 index 0000000..f4e5815 --- /dev/null +++ b/app/src/shared.js @@ -0,0 +1,24 @@ +/* shared.js — kleine Helfer, die von mehreren Frontend-Dateien genutzt werden + * (Avatar-Farbe, deutsche Zahlenformatierung). Browser: an window.HVShared gehaengt + * (muss vor mock.js/view-model.js/app-panels.js geladen werden). Node: als + * CommonJS-Modul exportiert, damit Tests dieselbe Implementierung nutzen. */ +(function () { + 'use strict'; + + const PALETTE = ['#4f8cff', '#2fd6a6', '#b98cff', '#ff8a4f', '#ffb454', '#5fc9ff', '#ff7a9c', '#7ee081']; + + function hashColor(s) { + let n = 0; + for (let i = 0; i < s.length; i++) n = (n * 31 + s.charCodeAt(i)) >>> 0; + return PALETTE[n % PALETTE.length]; + } + + // Deutsche Dezimaldarstellung, 1 Nachkommastelle — spiegelt fmt_de() aus upgrade.rs. + function fmtDe(v) { + return Number(v).toFixed(1).replace('.', ','); + } + + const api = { PALETTE, hashColor, fmtDe }; + if (typeof window !== 'undefined') { window.HVShared = api; } + if (typeof module !== 'undefined' && module.exports) { module.exports = api; } +})(); diff --git a/app/src/view-model.js b/app/src/view-model.js index cc0bd0f..29e829c 100644 --- a/app/src/view-model.js +++ b/app/src/view-model.js @@ -7,17 +7,16 @@ })(typeof globalThis !== 'undefined' ? globalThis : this, function () { 'use strict'; + const { fmtDe } = (typeof window !== 'undefined' && window.HVShared) + ? window.HVShared + : require('./shared.js'); + const STATUS_RANK = { ok: 0, upgrade: 1, stale: 2, missing: 3 }; function lower(value) { return value == null ? '' : String(value).toLowerCase(); } - // Deutsche Dezimaldarstellung, 1 Nachkommastelle - spiegelt fmt_de() aus upgrade.rs. - function fmtDe(value) { - return Number(value).toFixed(1).replace('.', ','); - } - function isUpgradeCandidate(device) { const reasons = device.upgradeReasons || device.upgrade_reasons || []; return device.status === 'upgrade' || (device.status === 'stale' && reasons.length > 0);