diff --git a/agent/deploy/Install-InventoryTask.ps1 b/agent/deploy/Install-InventoryTask.ps1 index 9ab5d98..ef76a5a 100644 --- a/agent/deploy/Install-InventoryTask.ps1 +++ b/agent/deploy/Install-InventoryTask.ps1 @@ -67,6 +67,15 @@ function ConvertTo-SingleQuotedLiteral { return "'" + ($Value -replace "'", "''") + "'" } +# CommandLineToArgvW (vom Task-Scheduler-Host fuer den Prozessstart genutzt) interpretiert +# eine ungerade Anzahl Backslashes direkt vor einem schliessenden Anfuehrungszeichen als +# Escape fuer das Quote selbst -> das Argument wuerde nicht korrekt geschlossen. Ein +# trailing Backslash-Lauf muss deshalb verdoppelt werden, bevor er in "..." eingebettet wird. +function ConvertTo-SafeDoubleQuotedArg { + param([Parameter(Mandatory)] [string] $Value) + return ($Value -replace '(\\+)$', '$1$1') +} + if ($Uninstall) { Unregister-ScheduledTask -TaskName $TaskName -TaskPath $TaskPath -Confirm:$false -ErrorAction SilentlyContinue Write-Host "Aufgabe '$TaskPath$TaskName' entfernt." @@ -146,7 +155,7 @@ try { $encodedDebugCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($debugCommand)) $arg = '-NoProfile -NonInteractive -ExecutionPolicy {0} -WindowStyle Hidden -EncodedCommand {1}' -f $ExecutionPolicy, $encodedDebugCommand } else { - $arg = '-NoProfile -NonInteractive -ExecutionPolicy {0} -WindowStyle Hidden -File "{1}" -OutputDir "{2}"' -f $ExecutionPolicy, $ScriptPath, $OutputDir + $arg = '-NoProfile -NonInteractive -ExecutionPolicy {0} -WindowStyle Hidden -File "{1}" -OutputDir "{2}"' -f $ExecutionPolicy, (ConvertTo-SafeDoubleQuotedArg $ScriptPath), (ConvertTo-SafeDoubleQuotedArg $OutputDir) } $action = New-ScheduledTaskAction -Execute $powerShellPath -Argument $arg diff --git a/app/src-tauri/src/commands.rs b/app/src-tauri/src/commands.rs index dafb26d..14408fc 100644 --- a/app/src-tauri/src/commands.rs +++ b/app/src-tauri/src/commands.rs @@ -161,7 +161,7 @@ pub fn get_ad_users(state: State, search: String) -> Result Overview { let total = devs.len() as i64; let with_inv = devs.iter().filter(|d| d.has_inventory).count() as i64; - let count = |s: &str| devs.iter().filter(|d| d.status == s).count() as i64; let needs_upgrade = |d: &DeviceFull| { d.status == "upgrade" || (d.status == "stale" && !d.upgrade_reasons.is_empty()) }; let needs_action = |d: &DeviceFull| needs_upgrade(d) || d.status == "missing"; - let (ok, status_upgrade, stale, missing) = ( - count("ok"), - count("upgrade"), - count("stale"), - count("missing"), - ); + + // Ein einziger Durchlauf liefert sowohl die Status-Tallies als auch die + // Abteilungs-Aggregation (statt vier separater count()-Scans + Dept-Loop). + let mut ok = 0i64; + let mut status_upgrade = 0i64; + let mut stale = 0i64; + let mut missing = 0i64; + let mut dept_map: HashMap = HashMap::new(); + for d in devs { + match d.status.as_str() { + "ok" => ok += 1, + "upgrade" => status_upgrade += 1, + "stale" => stale += 1, + "missing" => missing += 1, + _ => {} + } + let e = dept_map.entry(d.dept.clone()).or_insert((0, 0)); + e.0 += 1; + if needs_action(d) { + e.1 += 1; + } + } let upgrade = devs.iter().filter(|d| needs_upgrade(d)).count() as i64; let aged: Vec = devs.iter().filter_map(|d| d.age_years).collect(); let avg = if aged.is_empty() { @@ -29,41 +44,40 @@ pub fn build_overview(devs: &[DeviceFull], th: &Thresholds) -> Overview { .filter(|d| d.age_years.map(|a| a > th.max_age_years).unwrap_or(false)) .count() as i64; - let mut dept_map: HashMap = HashMap::new(); - for d in devs { - let e = dept_map.entry(d.dept.clone()).or_insert((0, 0)); - e.0 += 1; - if needs_action(d) { - e.1 += 1; - } - } let mut by_dept: Vec = dept_map .into_iter() - .map(|(dept, (count, upgrade))| DeptStat { + .map(|(dept, (count, needs_action))| DeptStat { dept, count, - upgrade, + needs_action, }) .collect(); by_dept.sort_by(|a, b| b.count.cmp(&a.count).then(a.dept.cmp(&b.dept))); + // Bucket-Grenzen proportional zu max_age_years ableiten (statt fix 2/4/5), damit + // das Histogramm bei individuellen Schwellwerten zu old5/old_age_label passt. + // Beim Default (5,0 Jahre) reproduzieren die Faktoren exakt die fruehere feste + // Aufteilung 2,0/4,0/5,0 Jahre. + let b1 = th.max_age_years * (2.0 / 5.0); + let b2 = th.max_age_years * (4.0 / 5.0); + let b3 = th.max_age_years; let age_bucket = |lo: f64, hi: f64| aged.iter().filter(|&&a| a >= lo && a < hi).count() as i64; let age_buckets = vec![ Bucket { - label: "< 2 Jahre".into(), - count: age_bucket(0.0, 2.0), + label: format!("< {} Jahre", fmt_de(b1)), + count: age_bucket(0.0, b1), }, Bucket { - label: "2–4 Jahre".into(), - count: age_bucket(2.0, 4.0), + label: format!("{}–{} Jahre", fmt_de(b1), fmt_de(b2)), + count: age_bucket(b1, b2), }, Bucket { - label: "4–5 Jahre".into(), - count: aged.iter().filter(|&&a| (4.0..=5.0).contains(&a)).count() as i64, + label: format!("{}–{} Jahre", fmt_de(b2), fmt_de(b3)), + count: aged.iter().filter(|&&a| a >= b2 && a <= b3).count() as i64, }, Bucket { - label: "> 5 Jahre".into(), - count: aged.iter().filter(|&&a| a > 5.0).count() as i64, + label: format!("> {} Jahre", fmt_de(b3)), + count: aged.iter().filter(|&&a| a > b3).count() as i64, }, ]; let ram_count = |f: &dyn Fn(i64) -> bool| { diff --git a/app/src-tauri/src/store/overview_tests.rs b/app/src-tauri/src/store/overview_tests.rs new file mode 100644 index 0000000..12425cd --- /dev/null +++ b/app/src-tauri/src/store/overview_tests.rs @@ -0,0 +1,63 @@ +use super::common::now_iso; +use super::test_support::{temp_config, unique_temp_dir}; +use super::{build_devices, build_overview}; +use std::fs; +use std::path::Path; + +#[test] +fn age_buckets_scale_with_custom_max_age_threshold() { + let root = unique_temp_dir("age-buckets-custom-threshold"); + let mut cfg = temp_config(&root); + cfg.thresholds.max_age_years = 10.0; + fs::write( + &cfg.master_csv_path, + "Computer;Benutzer\nWS-AGE-01;\nWS-AGE-02;\nWS-AGE-03;\nWS-AGE-04;\n", + ) + .unwrap(); + for (host, age) in [ + ("WS-AGE-01", 2.0), + ("WS-AGE-02", 6.0), + ("WS-AGE-03", 9.0), + ("WS-AGE-04", 12.0), + ] { + fs::write( + Path::new(&cfg.data_dir).join(format!("{}.json", host)), + format!( + r#"{{ + "schemaVersion": 1, + "hostname": "{host}", + "collectedAtUtc": "{now}", + "ageYears": {age}, + "cpu": {{"cores": 4, "logicalProcessors": 8, "maxClockMhz": 3000}}, + "ram": {{"totalGB": 16, "slotsUsed": 1, "slotsTotal": 2}}, + "disks": [{{"mediaType": "SSD", "sizeGB": 512}}], + "os": {{"caption": "Microsoft Windows 11 Pro", "version": "10.0.22631"}} +}}"#, + host = host, + age = age, + now = now_iso() + ), + ) + .unwrap(); + } + + let devs = build_devices(&cfg); + let ov = build_overview(&devs, &cfg.thresholds); + + // Bei max_age_years = 10.0 liegen die Grenzen bei 4,0 / 8,0 / 10,0 Jahren statt + // der frueher fixen 2/4/5 -> die vier Testgeraete (2/6/9/12 Jahre) landen in vier + // unterschiedlichen Buckets. + let counts: Vec = ov.age_buckets.iter().map(|b| b.count).collect(); + assert_eq!(counts, vec![1, 1, 1, 1]); + assert_eq!(ov.age_buckets[0].label, "< 4,0 Jahre"); + assert_eq!(ov.age_buckets[3].label, "> 10,0 Jahre"); + + // Invariante: der letzte Age-Bucket ("> max_age_years") muss immer alters-konsistent + // mit old5 sein, da beide aus derselben age_years > th.max_age_years-Bedingung + // stammen — hier mit nicht-Default-Schwellwert geprueft. + assert_eq!(ov.age_buckets.last().unwrap().count, ov.old5); + assert_eq!(ov.old5, 1); + assert_eq!(ov.old_age_label, "> 10,0 Jahre"); + + let _ = fs::remove_dir_all(root); +} diff --git a/app/src-tauri/src/store/tests.rs b/app/src-tauri/src/store/tests.rs index e3bdc08..7c3929b 100644 --- a/app/src-tauri/src/store/tests.rs +++ b/app/src-tauri/src/store/tests.rs @@ -139,7 +139,7 @@ fn overview_aggregates() { .iter() .find(|d| d.dept == "Lager") .unwrap() - .upgrade, + .needs_action, 2 ); assert_eq!(ov.current, ov.with_inventory - ov.stale); @@ -152,6 +152,11 @@ fn overview_aggregates() { ram_sum, ov.with_inventory, "RAM-Buckets decken alle Geräte ab" ); + + // Invariante: der letzte Age-Bucket ("> max_age_years") muss immer alters-konsistent + // mit old5 sein, da beide aus derselben age_years > th.max_age_years-Bedingung + // stammen. + assert_eq!(ov.age_buckets.last().unwrap().count, ov.old5); } #[test] diff --git a/app/src-tauri/src/upgrade.rs b/app/src-tauri/src/upgrade.rs index 18bbb7d..73ef291 100644 --- a/app/src-tauri/src/upgrade.rs +++ b/app/src-tauri/src/upgrade.rs @@ -54,12 +54,17 @@ pub fn evaluate(th: &Thresholds, f: &DeviceFacts) -> Eval { } let future_timestamp = matches!(f.last_seen_days, Some(d) if d < -1); - let stale = matches!(f.last_seen_days, Some(d) if d > th.stale_days) || future_timestamp; + let timestamp_missing = f.last_seen_days.is_none(); + let stale = future_timestamp + || timestamp_missing + || matches!(f.last_seen_days, Some(d) if d > th.stale_days); if stale { Eval { status: "stale".into(), status_label: if future_timestamp { "Unplausibel · Zeitstempel in Zukunft".into() + } else if timestamp_missing { + "Unplausibel · Zeitstempel fehlt".into() } else { "Veraltet · Agent meldet nicht".into() }, diff --git a/app/src/app-panels.js b/app/src/app-panels.js index 1a9743f..071a9bb 100644 --- a/app/src/app-panels.js +++ b/app/src/app-panels.js @@ -107,18 +107,21 @@ if (state.view === 'gruppen') { host.appendChild(el('div', { class: 'panel' }, el('h3', {}, 'Geräte je Abteilung'), - distBars(o.byDept, (i) => i.upgrade > 0 ? 'var(--amber)' : 'var(--blue)'))); + distBars(o.byDept, (i) => i.needsAction > 0 ? 'var(--amber)' : 'var(--blue)'))); host.appendChild(el('div', { style: { height: '14px' } })); host.appendChild(el('div', { class: 'panel' }, el('h3', {}, 'Upgrade-Bedarf je Abteilung'), - distBars(o.byDept.map((d) => ({ label: d.dept, count: d.upgrade })), () => 'var(--amber)'))); + distBars(o.byDept.map((d) => ({ label: d.dept, count: d.needsAction })), () => 'var(--amber)'))); return; } // dashboard + // Farbe positional statt per Label-Regex bestimmen: der letzte Age-Bucket ist per + // Konstruktion (overview.rs/mock.js) immer der "ueber Schwellwert"-Bucket, der + // erste RAM-Bucket immer der "wenig RAM"-Bucket. const grid = el('div', { class: 'dash-grid' }, - el('div', { class: 'panel' }, el('h3', {}, 'Altersverteilung'), distBars(o.ageBuckets, (i) => /5/.test(i.label) && />/.test(i.label) ? 'var(--red)' : 'var(--blue)')), - el('div', { class: 'panel' }, el('h3', {}, 'Arbeitsspeicher'), distBars(o.ramBuckets, (i) => /≤/.test(i.label) ? 'var(--amber)' : 'var(--green)'))); + el('div', { class: 'panel' }, el('h3', {}, 'Altersverteilung'), distBars(o.ageBuckets, (i) => i === o.ageBuckets[o.ageBuckets.length - 1] ? 'var(--red)' : 'var(--blue)')), + el('div', { class: 'panel' }, el('h3', {}, 'Arbeitsspeicher'), distBars(o.ramBuckets, (i) => i === o.ramBuckets[0] ? 'var(--amber)' : 'var(--green)'))); host.appendChild(grid); host.appendChild(el('div', { style: { height: '14px' } })); host.appendChild(el('div', { class: 'panel' }, @@ -128,7 +131,7 @@ el('span', { class: 'tag upgrade' }, 'Upgrade ' + o.status.upgrade), el('span', { class: 'tag stale' }, 'Veraltet ' + o.status.stale), el('span', { class: 'tag missing' }, 'Kein Agent ' + o.status.missing)), - distBars(o.byDept, (i) => i.upgrade > 0 ? 'var(--amber)' : 'var(--blue)'))); + distBars(o.byDept, (i) => i.needsAction > 0 ? 'var(--amber)' : 'var(--blue)'))); } // ---------------- Einstellungen ---------------- diff --git a/app/src/app.js b/app/src/app.js index 92ccd3e..f9160cc 100644 --- a/app/src/app.js +++ b/app/src/app.js @@ -96,7 +96,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 ' + (o.oldAgeLabel || '> 5 Jahre'), '', ' J.')); + host.appendChild(card('Ø ALTER', ViewModel.fmtDe(o.avgAgeYears), 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 e3aa9c0..614a2a5 100644 --- a/app/src/mock.js +++ b/app/src/mock.js @@ -72,8 +72,12 @@ if (th.minCpuClockMhz > 0 && f.cpuClockMhz > 0 && f.cpuClockMhz < th.minCpuClockMhz) reasons.push('CPU-Takt niedrig (' + f.cpuClockMhz + ' MHz)'); 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 }; + const timestampMissing = f.lastSeenDays == null; + if (futureTimestamp || timestampMissing || (f.lastSeenDays != null && f.lastSeenDays > th.staleDays)) { + const statusLabel = futureTimestamp ? 'Unplausibel · Zeitstempel in Zukunft' + : timestampMissing ? 'Unplausibel · Zeitstempel fehlt' + : 'Veraltet · Agent meldet nicht'; + return { status: 'stale', statusLabel, reasons }; } if (reasons.length) return { status: 'upgrade', statusLabel: 'Upgrade empfohlen', reasons }; return { status: 'ok', statusLabel: 'Aktuell · OK', reasons }; @@ -129,12 +133,17 @@ const avgAge = aged.length ? (aged.reduce((a, d) => a + d.ageYears, 0) / aged.length) : 0; const old5 = devs.filter(d => d.ageYears != null && d.ageYears > THRESH.maxAgeYears).length; const depts = {}; - devs.forEach(d => { (depts[d.dept] = depts[d.dept] || { dept: d.dept, count: 0, upgrade: 0 }); depts[d.dept].count++; if (needsAction(d)) depts[d.dept].upgrade++; }); + devs.forEach(d => { (depts[d.dept] = depts[d.dept] || { dept: d.dept, count: 0, needsAction: 0 }); depts[d.dept].count++; if (needsAction(d)) depts[d.dept].needsAction++; }); + // Bucket-Grenzen proportional zu maxAgeYears ableiten (spiegelt overview.rs) - + // beim Default (5 Jahre) identisch zur frueheren fixen Aufteilung 2/4/5. + const b1 = THRESH.maxAgeYears * (2 / 5); + const b2 = THRESH.maxAgeYears * (4 / 5); + const b3 = THRESH.maxAgeYears; const ageBuckets = [ - { label: '< 2 Jahre', count: aged.filter(d => d.ageYears < 2).length }, - { label: '2–4 Jahre', count: aged.filter(d => d.ageYears >= 2 && d.ageYears < 4).length }, - { label: '4–5 Jahre', count: aged.filter(d => d.ageYears >= 4 && d.ageYears <= 5).length }, - { label: '> 5 Jahre', count: aged.filter(d => d.ageYears > 5).length } + { label: '< ' + fmtDe(b1) + ' Jahre', count: aged.filter(d => d.ageYears < b1).length }, + { label: fmtDe(b1) + '–' + fmtDe(b2) + ' Jahre', count: aged.filter(d => d.ageYears >= b1 && d.ageYears < b2).length }, + { label: fmtDe(b2) + '–' + fmtDe(b3) + ' Jahre', count: aged.filter(d => d.ageYears >= b2 && d.ageYears <= b3).length }, + { label: '> ' + fmtDe(b3) + ' Jahre', count: aged.filter(d => d.ageYears > b3).length } ]; const withInvDevs = devs.filter(d => d.hasInventory); const ramBuckets = [ @@ -198,7 +207,7 @@ case 'get_overview': return overview(DEVICES); case 'get_ad_users': { const q = (args.search || '').toLowerCase(); - return AD_USERS.filter(u => !q || (u.display + ' ' + u.sam + ' ' + u.dept).toLowerCase().includes(q)).slice(0, 50); + return AD_USERS.filter(u => !q || (u.display + ' ' + u.sam + ' ' + u.dept + ' ' + u.mail).toLowerCase().includes(q)).slice(0, 50); } case 'set_assignment': { const d = DEVICES.find(x => x.host === args.host); diff --git a/app/src/view-model.js b/app/src/view-model.js index e1ca374..cc0bd0f 100644 --- a/app/src/view-model.js +++ b/app/src/view-model.js @@ -13,6 +13,11 @@ 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); @@ -82,6 +87,7 @@ return { applyViewChange, + fmtDe, isUpgradeCandidate, visibleDevices }; diff --git a/shared/test-vectors/upgrade-cases.json b/shared/test-vectors/upgrade-cases.json index 5c9ca18..baa3dd7 100644 --- a/shared/test-vectors/upgrade-cases.json +++ b/shared/test-vectors/upgrade-cases.json @@ -96,6 +96,12 @@ "facts": { "hasInventory": true, "ramGB": 16, "ageYears": 1.0, "diskIsSsd": true, "cpuCores": 8, "cpuClockMhz": 3000, "osIsWin11": true, "lastSeenDays": -3 }, "status": "stale", "reasons": [] + }, + { + "name": "fehlender-zeitstempel-ist-unplausibel", + "facts": { "hasInventory": true, "ramGB": 16, "ageYears": 1.0, "diskIsSsd": true, "cpuCores": 8, "cpuClockMhz": 3000, "osIsWin11": true, "lastSeenDays": null }, + "status": "stale", + "reasons": [] } ] }