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
11 changes: 10 additions & 1 deletion agent/deploy/Install-InventoryTask.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ pub fn get_ad_users(state: State<AppState>, search: String) -> Result<Vec<AdUser

if !q.is_empty() {
users.retain(|u| {
format!("{} {} {}", u.display, u.sam, u.dept)
format!("{} {} {} {}", u.display, u.sam, u.dept, u.mail)
.to_lowercase()
.contains(&q)
});
Expand Down
4 changes: 3 additions & 1 deletion app/src-tauri/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,9 @@ pub struct Bucket {
pub struct DeptStat {
pub dept: String,
pub count: i64,
pub upgrade: i64,
/// Geraete, die Aufmerksamkeit brauchen: Upgrade-Kandidaten + Geraete ohne
/// Inventarmeldung ("missing") — bewusst weiter gefasst als nur "Upgrade noetig".
pub needs_action: i64,
}

#[derive(Serialize, Clone)]
Expand Down
2 changes: 2 additions & 0 deletions app/src-tauri/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub use overview::build_overview;
#[cfg(test)]
mod io_tests;
#[cfg(test)]
mod overview_tests;
#[cfg(test)]
mod test_support;
#[cfg(test)]
mod tests;
32 changes: 14 additions & 18 deletions app/src-tauri/src/store/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ fn build_one(
last_seen_text(last_seen_days)
};

let network = iv.network.clone().unwrap_or_default();
let bios = iv.bios.clone();
let win11 = iv.win11.clone();

DeviceFull {
host: host.to_string(),
has_inventory: has_inv,
Expand Down Expand Up @@ -221,29 +225,21 @@ fn build_one(
manufacturer: opt_str(&iv.manufacturer, "—"),
model: opt_str(&iv.model, ""),
serial_number: opt_str(&iv.serial_number, "—"),
bios_version: iv.bios.clone().and_then(|b| b.version).unwrap_or_default(),
bios_date: iv
.bios
.clone()
bios_version: bios.clone().and_then(|b| b.version).unwrap_or_default(),
bios_date: bios
.and_then(|b| b.release_date)
.map(|d| d.split('T').next().unwrap_or("").to_string()),
gpus: iv.gpus.clone().unwrap_or_default(),
ip: iv
.network
.clone()
.unwrap_or_default()
.into_iter()
.find_map(|n| n.ipv4)
ip: network
.iter()
.find_map(|n| n.ipv4.clone())
.unwrap_or_default(),
mac: iv
.network
.clone()
.unwrap_or_default()
.into_iter()
.find_map(|n| n.mac)
mac: network
.iter()
.find_map(|n| n.mac.clone())
.unwrap_or_default(),
tpm: iv.win11.clone().and_then(|w| w.tpm_present),
secure_boot: iv.win11.clone().and_then(|w| w.secure_boot),
tpm: win11.as_ref().and_then(|w| w.tpm_present),
secure_boot: win11.and_then(|w| w.secure_boot),
ram_sticks,
note,
confirmed_by,
Expand Down
64 changes: 39 additions & 25 deletions app/src-tauri/src/store/overview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,32 @@ use std::collections::HashMap;
pub fn build_overview(devs: &[DeviceFull], th: &Thresholds) -> 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<String, (i64, i64)> = 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<f64> = devs.iter().filter_map(|d| d.age_years).collect();
let avg = if aged.is_empty() {
Expand All @@ -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<String, (i64, i64)> = 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<DeptStat> = 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| {
Expand Down
63 changes: 63 additions & 0 deletions app/src-tauri/src/store/overview_tests.rs
Original file line number Diff line number Diff line change
@@ -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<i64> = 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);
}
7 changes: 6 additions & 1 deletion app/src-tauri/src/store/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand Down
7 changes: 6 additions & 1 deletion app/src-tauri/src/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
Expand Down
13 changes: 8 additions & 5 deletions app/src/app-panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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 ----------------
Expand Down
2 changes: 1 addition & 1 deletion app/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
25 changes: 17 additions & 8 deletions app/src/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading