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
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
15 changes: 11 additions & 4 deletions app/src-tauri/src/ad_users.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,25 @@ 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<AdUser> {
let mut seen = HashSet::new();
let mut users: Vec<AdUser> = Vec::new();
for d in devs {
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 {
Expand Down
29 changes: 23 additions & 6 deletions app/src-tauri/src/ad_users_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}

Expand Down
12 changes: 8 additions & 4 deletions app/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ pub fn get_ad_users(state: State<AppState>, search: String) -> Result<Vec<AdUser
let _fetch_guard = state.ad_fetch.lock().map_err(|e| e.to_string())?;
match ad::fetch_ad_users(&query) {
Ok(list) => users = list,
Err(_) => {
Err(e) => {
eprintln!("[hardview] AD-Suche fehlgeschlagen ('{query}'): {e}");
if let Some(list) = cached_full {
users = list;
}
Expand All @@ -121,15 +122,18 @@ pub fn get_ad_users(state: State<AppState>, search: String) -> Result<Vec<AdUser
};
match refreshed {
Some(list) => 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}");
}
},
}
}
}
Expand Down
11 changes: 7 additions & 4 deletions app/src-tauri/src/store/assignments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<AssignmentStore>(&t).ok())
.unwrap_or_default();
let mut store = match read_text(path) {
Ok(txt) => serde_json::from_str::<AssignmentStore>(&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<String, AssignmentEntry> = store
.assignments
Expand Down
10 changes: 6 additions & 4 deletions app/src-tauri/src/store/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Config>(&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::<Config>(&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") {
Expand Down
3 changes: 1 addition & 2 deletions app/src/app-panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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));
Expand Down
1 change: 1 addition & 0 deletions app/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ <h1 id="viewTitle">Geräte-Inventar</h1>
<div id="modalMount"></div>
<div id="toastMount"></div>

<script src="shared.js"></script>
<script src="mock.js"></script>
<script src="view-model.js"></script>
<script src="app.js"></script>
Expand Down
7 changes: 3 additions & 4 deletions app/src/mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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;
Expand Down
24 changes: 24 additions & 0 deletions app/src/shared.js
Original file line number Diff line number Diff line change
@@ -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; }
})();
9 changes: 4 additions & 5 deletions app/src/view-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading