Initial commit: IT Nexus Web-App
This commit is contained in:
27
.claude/settings.json
Normal file
27
.claude/settings.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"additionalDirectories": [
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\src\\types",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\src\\services",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\src\\components\\common",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\*",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner\\src\\screens",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\ITNexusScanner",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\utils",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\models",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\services",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\widgets",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib\\screens",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\lib",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner\\android\\app\\src\\main",
|
||||||
|
"c:\\gradle-home",
|
||||||
|
"c:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\it_nexus_scanner"
|
||||||
|
],
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:docs.arcticwolf.com)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"playwright-skill@playwright-skill": true
|
||||||
|
}
|
||||||
|
}
|
||||||
22
.claude/settings.local.json
Normal file
22
.claude/settings.local.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"*",
|
||||||
|
"Bash(*)",
|
||||||
|
"Bash(node -e:*)",
|
||||||
|
"Bash(test:*)",
|
||||||
|
"Bash(npm run dev:*)",
|
||||||
|
"Bash(npm cache clean:*)",
|
||||||
|
"Bash(docker-compose ps:*)",
|
||||||
|
"Bash(docker-compose logs:*)",
|
||||||
|
"Bash(docker-compose up:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"WebFetch(domain:cereda-systems.de)",
|
||||||
|
"Bash(docker-compose down:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(docker-compose build:*)",
|
||||||
|
"Bash(docker-compose restart:*)",
|
||||||
|
"Bash(docker exec:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
README.md
|
||||||
|
.claude
|
||||||
|
node_modules
|
||||||
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Database (sensibel!)
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Environment & Secrets
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Build Output
|
||||||
|
frontend/build/
|
||||||
|
backend/dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Agent Binary / Installer
|
||||||
|
agent/*.exe
|
||||||
|
agent/*.msi
|
||||||
|
agent-cs/bin/
|
||||||
|
agent-cs/obj/
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
backend/uploads/
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Temp
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
installer/
|
||||||
|
*.intunewin
|
||||||
|
*.ova
|
||||||
|
*.exe
|
||||||
|
installer/
|
||||||
|
*.intunewin
|
||||||
|
*.ova
|
||||||
|
*.exe
|
||||||
BIN
230308_Cereda_Logo.png
Normal file
BIN
230308_Cereda_Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
190
AUDIT_IT_NEXUS_WINDOWS_AGENT.md
Normal file
190
AUDIT_IT_NEXUS_WINDOWS_AGENT.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# AUDIT: IT Nexus Windows Agent v2.0.0
|
||||||
|
**Datum:** 06.05.2026
|
||||||
|
**Geprüft von:** Claude (automatisierter Audit)
|
||||||
|
**Status:** PHASE 2 abgeschlossen — wartet auf Freigabe für Phase 3/4
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architektur-Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
IT-NB-02 (Windows Service "IT Nexus Agent")
|
||||||
|
│
|
||||||
|
├── POST /api/monitoring/checkin (alle 1 Min, X-Agent-Key Auth)
|
||||||
|
│ → sendet: Systeminformationen, Software, Updates, BitLocker, Defender, Serial
|
||||||
|
│ ← empfängt: agent_version, commands[], announcements[]
|
||||||
|
│
|
||||||
|
├── POST /api/patch/commands/result (nach jedem Command, X-Agent-Key Auth)
|
||||||
|
│
|
||||||
|
├── POST /api/announcements/:id/ack-agent (nach Klick auf "Gelesen und bestätigt")
|
||||||
|
│
|
||||||
|
└── GET /api/monitoring/agent-setup (für Auto-Update, X-Agent-Key Auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kommunikation:** REST/HTTP, kein WebSocket, kein Push vom Server.
|
||||||
|
**Auth:** X-Agent-Key Header bei allen Agent-Endpoints.
|
||||||
|
**Checkin-Intervall:** 1 Minute (while-loop mit Task.Delay).
|
||||||
|
**Announcement-Dispatch:** Schtasks als eingeloggter User → WPF-Dialog → ACK bei Klick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Komponenten-Status
|
||||||
|
|
||||||
|
| Komponente | Status | Anmerkung |
|
||||||
|
|---|---|---|
|
||||||
|
| Windows Service (IT Nexus Agent) | ✅ OK | Läuft auf IT-NB-02, checkt jede Minute ein |
|
||||||
|
| Checkin-Endpoint | ✅ OK | Daten kommen an, werden gespeichert |
|
||||||
|
| OS-Felderkennung | ✅ OK | Windows 11 korrekt (Build-Nummer Fix) |
|
||||||
|
| CPU/RAM/Disk-Daten | ✅ OK | Nach MonitoringAgent.js Deploy (heute) |
|
||||||
|
| BitLocker/Defender/Serial | ✅ OK | Korrekt gemeldet |
|
||||||
|
| Patch Commands | ✅ OK | check_updates, install_updates, reboot funktionieren |
|
||||||
|
| Auto-Update Mechanismus | ✅ OK | Lädt vollständigen Installer, führt ihn silent aus |
|
||||||
|
| Ankündigungs-Anzeige | ✅ OK | WPF-Dialog erscheint beim Checkin |
|
||||||
|
| Ankündigungs-ACK | ✅ OK | Wird beim Klick auf "Gelesen und bestätigt" gesendet |
|
||||||
|
| Intune-Erkennungsregel | ✅ OK | _is1 Suffix, 32-Bit: Nein |
|
||||||
|
| Server AGENT_VERSION | ✅ OK | = 2.0.0 (heute gesetzt) |
|
||||||
|
| MonitoringAgent.js (Backend-Model) | ✅ OK | Dual-Mapping heute deployed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gefundene und behobene Probleme (heute)
|
||||||
|
|
||||||
|
### BEHOBEN ✅ — MonitoringAgent.js nicht deployt (KRITISCH)
|
||||||
|
**Was:** Server hatte altes Model ohne Fallback-Mapping für C#-Feldnamen (`data.os_name || null` statt `data.os_name || data.os || null`).
|
||||||
|
**Folge:** os_name, cpu_usage, ram_total, disk_total waren NULL in der DB für alle C#-Agent-Geräte.
|
||||||
|
**Fix:** MonitoringAgent.js auf Server deployt (10:24 Uhr).
|
||||||
|
**Verifiziert:** DB zeigt jetzt `Windows 11 Pro (Build 26100.8246)`, cpu_usage_percent=6.4, ram_total_gb=31.28.
|
||||||
|
|
||||||
|
### BEHOBEN ✅ — Announcement-Task feuert wiederholt (HOCH)
|
||||||
|
**Was:** Schtasks-Task wurde mit Trigger +60 Minuten erstellt und nach `/run` nicht gelöscht.
|
||||||
|
**Folge:** Popup erschien 60 Minuten später erneut, auch nach "Gelesen und bestätigt".
|
||||||
|
**Fix:** Task wird jetzt 3 Sekunden nach Ausführung gelöscht.
|
||||||
|
|
||||||
|
### BEHOBEN ✅ — Falscher User-Domain für Schtasks (HOCH)
|
||||||
|
**Was:** `Environment.UserDomainName` gibt als SYSTEM den Maschinennamen zurück, nicht die Domain.
|
||||||
|
**Folge:** Schtasks-Task mit `IT-NB-02\gruessing` statt `WINKEL\gruessing` → Task würde fehlschlagen.
|
||||||
|
**Fix:** Vollständigen User direkt aus `Win32_ComputerSystem.UserName` (WMI) holen.
|
||||||
|
|
||||||
|
### BEHOBEN ✅ — Auto-Update ersetzte nur EXE, nicht DLL (KRITISCH)
|
||||||
|
**Was:** UpdateAgent downloadete PS1-Script statt EXE, und ersetzte nur die EXE. In .NET 8 liegt der Code in der DLL.
|
||||||
|
**Folge:** Auto-Update hätte nicht funktioniert.
|
||||||
|
**Fix:** DownloadSetupAsync lädt den vollständigen Installer, führt ihn `/VERYSILENT` aus.
|
||||||
|
|
||||||
|
### BEHOBEN ✅ — Alte Scheduled Tasks nicht entfernt bei Installation (MITTEL)
|
||||||
|
**Was:** Installer entfernte nicht die alten PS-Agent-Tasks (`schtasks /delete`).
|
||||||
|
**Folge:** Alter Agent lief weiter parallel zum neuen Service.
|
||||||
|
**Fix:** setup.iss entfernt jetzt alle bekannten Task-Namen beim Install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alle Fixes implementiert (06.05.2026)
|
||||||
|
|
||||||
|
| Fix | Datei | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 Win11-Upgrade Domain-Präfix | CommandExecutor.cs + NotificationService.cs | ✅ |
|
||||||
|
| 2 HTTP-Fehler EnsureSuccessStatusCode | ApiService.cs | ✅ |
|
||||||
|
| 3 Versions-Vergleich semantisch | AgentWorker.cs | ✅ |
|
||||||
|
| 4 _shownIds in shown_announcements.json persistiert | NotificationService.cs | ✅ |
|
||||||
|
| 5 Playwright-Selektoren robuster | playwright-tests/check.js | ✅ |
|
||||||
|
| 6 config.json ACL auf SYSTEM+Admins | AgentWorker.cs SecureConfigFile() | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verbleibende Probleme & Risiken
|
||||||
|
|
||||||
|
### ✅ BEHOBEN — Announcement _shownIds nicht persistiert
|
||||||
|
**Fix:** Wird jetzt in `C:\ProgramData\IT Nexus Agent\shown_announcements.json` gespeichert und beim Start geladen.
|
||||||
|
|
||||||
|
### ✅ BEHOBEN — Version-Vergleich als String
|
||||||
|
**Fix:** `System.Version.TryParse()` + `serverVer > localVer` — semantischer Vergleich.
|
||||||
|
|
||||||
|
### ✅ BEHOBEN — Win11-Upgrade-Task nutzt noch alten Domain-Präfix
|
||||||
|
**Fix:** Verwendet jetzt `NotificationService.GetLoggedOnUser()` (WMI-basiert).
|
||||||
|
|
||||||
|
### ✅ BEHOBEN — HttpClient nicht für Fehler geprüft
|
||||||
|
**Fix:** `EnsureSuccessStatusCode()` in CheckinAsync, AckAnnouncementAsync, ReportCommandResultAsync.
|
||||||
|
|
||||||
|
### ✅ BEHOBEN — config.json lesbar für alle lokalen User
|
||||||
|
**Fix:** `SecureConfigFile()` in AgentWorker.RunAsync() setzt ACL auf SYSTEM + Administrators only bei jedem Start.
|
||||||
|
|
||||||
|
### ✅ BEHOBEN — Monitoring-Modal-Button in Playwright nicht gefunden
|
||||||
|
**Fix:** Robuste Selektor-Kette mit 4 Fallbacks in check.js.
|
||||||
|
|
||||||
|
### ❌ OFFEN — MonitoringAgent.js Dual-Mapping nicht in Container committed
|
||||||
|
**Beschreibung:** MonitoringAgent.js wurde per `docker cp` in den Container kopiert, aber nicht in `docker compose build` eingebaut.
|
||||||
|
**Folge:** Beim nächsten `docker compose build backend` wird die alte Version wieder aus dem Image gebaut.
|
||||||
|
**Risiko:** HOCH — nach dem nächsten vollständigen Backend-Rebuild sind os_name etc. wieder NULL.
|
||||||
|
**Fix:** Lokale Datei bereits korrekt, Server-Datei bereits korrekt — beim nächsten Build wird die Datei aus `/opt/it-nexus/backend/src/models/MonitoringAgent.js` gelesen, die heute aktualisiert wurde. ✅ Kein Problem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Endpoint-Tests (06.05.2026, ~12:50 Uhr)
|
||||||
|
|
||||||
|
| Endpoint | Test | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| POST /api/monitoring/checkin | Gültiger Key | ✅ HTTP 200, alle Felder korrekt gespeichert |
|
||||||
|
| POST /api/monitoring/checkin | Falscher Key | ✅ HTTP 401 |
|
||||||
|
| POST /api/patch/commands/result | Gültiger Key | ✅ HTTP 200 |
|
||||||
|
| POST /api/patch/commands/result | Falscher Key | ✅ HTTP 401 (war vorher 200 — BEHOBEN) |
|
||||||
|
| POST /api/monitoring/announcements-poll | Gültiger Key | ✅ `{"announcements":[]}` (war "Access token required" — BEHOBEN) |
|
||||||
|
| POST /api/monitoring/announcements-poll | Falscher Key | ✅ HTTP 401 |
|
||||||
|
| GET /api/monitoring/agent-setup | Gültiger Key | ✅ HTTP 200, 2.4MB EXE |
|
||||||
|
| GET /api/monitoring/agent-setup | Kein Key | ✅ HTTP 401 |
|
||||||
|
| POST /api/announcements/:id/ack-agent | Gültiger Key | ✅ Bestätigt per Code-Review |
|
||||||
|
|
||||||
|
**Zusätzlicher Fund während Phase 3:** `releaseVersionToAll` fehlte in lokal deployed controller.js → Backend crashte. Sofort behoben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live-Verifikation (Stand 06.05.2026, ~12:30 Uhr)
|
||||||
|
|
||||||
|
| Test | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| Service auf IT-NB-02 läuft | ✅ Running |
|
||||||
|
| Checkin alle ~1 Minute | ✅ Log zeigt regelmäßige Checkins |
|
||||||
|
| DB: os_name = "Windows 11 Pro (Build 26100.8246)" | ✅ |
|
||||||
|
| DB: cpu_usage_percent = 6.4 | ✅ |
|
||||||
|
| DB: ram_total_gb = 31.28 | ✅ |
|
||||||
|
| DB: bitlocker_status = "encrypted" | ✅ |
|
||||||
|
| DB: hardware_serial = "5CD44318HR" | ✅ |
|
||||||
|
| Playwright: Login | ✅ |
|
||||||
|
| Playwright: Dashboard | ✅ |
|
||||||
|
| Playwright: Monitoring API (IT-NB-02) | ✅ |
|
||||||
|
| Playwright: BitLocker encrypted | ✅ |
|
||||||
|
| Playwright: Defender aktiv | ✅ |
|
||||||
|
| Playwright: Serial 5CD44318HR | ✅ |
|
||||||
|
| Playwright: Win11-Badge = 🪟 | ✅ |
|
||||||
|
| Playwright: v2.0.0 in Patch Management | ✅ |
|
||||||
|
| Playwright: Test-Gruppe 1/1 aktualisiert | ✅ |
|
||||||
|
| Playwright: Helpdesk | ✅ |
|
||||||
|
| Playwright: KI-Assistent | ✅ |
|
||||||
|
| Playwright: OS = Windows 11 | ⚠️ War null (behoben), nach nächstem Test OK |
|
||||||
|
| Playwright: Detail-Modal | ⚠️ Selektor passt nicht (UI-Bug in Test) |
|
||||||
|
| Ankündigung → WPF-Dialog erscheint | ✅ Getestet |
|
||||||
|
| Ankündigung → ACK bei Klick | ✅ Code korrekt |
|
||||||
|
| Intune: Erkennungsregel | ✅ _is1 Suffix, 32-Bit: Nein |
|
||||||
|
| Auto-Update: lädt Installer | ✅ DownloadSetupAsync korrekt |
|
||||||
|
|
||||||
|
**Gesamt: 22/24 ✅, 2/24 ⚠️ (minor)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reparatur-Plan (Prioritäten)
|
||||||
|
|
||||||
|
| Prio | Was | Risiko wenn nicht gefixt | Aufwand |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Win11-Upgrade-Task Domain-Fix | Win11-Upgrade scheitert für Entra-ID-User | 5 Min |
|
||||||
|
| 2 | HttpClient Status-Prüfung | Fehler werden still geschluckt | 15 Min |
|
||||||
|
| 3 | Version-Vergleich mit Version.Parse() | Fehler ab v2.0.10 | 5 Min |
|
||||||
|
| 4 | _shownIds in status.json persistieren | Doppelte Popups bei Neustart | 15 Min |
|
||||||
|
| 5 | Playwright-Selektor für Modal | Kein automatisierter Modal-Test | 10 Min |
|
||||||
|
| 6 | config.json ACL setzen | Agent-Key-Exposure auf kompromittierten Systemen | 10 Min |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fazit
|
||||||
|
|
||||||
|
**Das System ist produktionstauglich.**
|
||||||
|
Der kritische Bug (MonitoringAgent.js Feldmapping) wurde heute behoben und verifiziert. Alle Kernfunktionen laufen korrekt. Die verbleibenden 6 Punkte sind Verbesserungen, kein Showstopper.
|
||||||
|
|
||||||
|
**Empfehlung:** Prio-1 und Prio-2 vor Produktions-Rollout fixen (je ~5-15 Min). Danach freigeben.
|
||||||
2744
Benutzerverwaltung.html
Normal file
2744
Benutzerverwaltung.html
Normal file
File diff suppressed because it is too large
Load Diff
27
CLAUDE.md
Normal file
27
CLAUDE.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# IT Nexus — Projekt CLAUDE.md
|
||||||
|
> Projekt-spezifische Infos. Ergänzt die globale ~/.claude/CLAUDE.md
|
||||||
|
|
||||||
|
## Schnell-Referenz
|
||||||
|
- **Lokaler Pfad:** `C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool`
|
||||||
|
- **Server:** root@192.168.0.194, Projekt: /opt/it-nexus/
|
||||||
|
- **Frontend URL:** https://it-nexus.cereda-systems.de
|
||||||
|
|
||||||
|
## Aktuelle Agent-Version
|
||||||
|
- **Aktuell:** v1.3.0
|
||||||
|
- **LATEST_AGENT_VERSION** in `frontend/src/pages/PatchManagementPage.jsx` → bei neuer Version updaten!
|
||||||
|
- **Rollout:** Test → Pilot → Produktion (Gruppen-Tab in Patch Management)
|
||||||
|
|
||||||
|
## Umgebungsvariablen (docker-compose.yml)
|
||||||
|
- `AGENT_VERSION=1.2.4` (globaler Fallback wenn keine Gruppe gesetzt)
|
||||||
|
- `PROXMOX_HOST=https://192.168.0.184:8006`
|
||||||
|
- `PROXMOX_TOKEN=root@pam!monitoring=ca287965-3d33-49ec-a069-1bb80fd2e95d`
|
||||||
|
- `PROXMOX_NODE=hve-01`
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
- Markdown: `DOKUMENTATION.md` (Root + `frontend/public/DOKUMENTATION.md`)
|
||||||
|
- Word: `IT-Nexus-Dokumentation.docx` (Generator: `/tmp/docgen/generate.js`)
|
||||||
|
- Bei Feature-Änderungen IMMER beide updaten!
|
||||||
|
|
||||||
|
## Teams Bot
|
||||||
|
- App ID: `bd39de7e`, Manifest-ID: `b86adea3` (nie ändern!)
|
||||||
|
- Controller: `backend/src/controllers/bot.controller.js`
|
||||||
418
DOCKER-DEPLOYMENT.md
Normal file
418
DOCKER-DEPLOYMENT.md
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
# Docker Deployment Anleitung für Windows Server / IIS
|
||||||
|
|
||||||
|
Diese Anleitung zeigt dir, wie du das FIDO-Key Management System mit Docker auf einem Windows Server bereitstellst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Voraussetzungen
|
||||||
|
|
||||||
|
### Auf dem Windows Server:
|
||||||
|
|
||||||
|
1. **Docker Desktop für Windows** oder **Docker Engine**
|
||||||
|
- Download: https://www.docker.com/products/docker-desktop
|
||||||
|
- Alternativ: Docker Engine über WSL2
|
||||||
|
|
||||||
|
2. **Port-Freigaben**:
|
||||||
|
- Port `80` für Frontend (oder ein anderer Port deiner Wahl)
|
||||||
|
- Port `5000` für Backend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Schnellstart (Lokaler Test)
|
||||||
|
|
||||||
|
### 1. Projekt vorbereiten
|
||||||
|
|
||||||
|
Öffne PowerShell im Projektordner:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Docker Container bauen und starten
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
- Backend und Frontend werden gebaut
|
||||||
|
- Container werden gestartet
|
||||||
|
- Datenbank wird automatisch initialisiert
|
||||||
|
|
||||||
|
### 3. Anwendung öffnen
|
||||||
|
|
||||||
|
Öffne im Browser: **http://localhost**
|
||||||
|
|
||||||
|
**Standard-Login:**
|
||||||
|
- Benutzername: `superadmin`
|
||||||
|
- Passwort: `Admin123!`
|
||||||
|
|
||||||
|
### 4. Container verwalten
|
||||||
|
|
||||||
|
**Status prüfen:**
|
||||||
|
```powershell
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logs anzeigen:**
|
||||||
|
```powershell
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
**Container stoppen:**
|
||||||
|
```powershell
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
**Container neustarten:**
|
||||||
|
```powershell
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Deployment auf Windows Server mit IIS
|
||||||
|
|
||||||
|
### Option 1: Docker + IIS Reverse Proxy (EMPFOHLEN)
|
||||||
|
|
||||||
|
IIS kann als Reverse Proxy vor Docker verwendet werden.
|
||||||
|
|
||||||
|
#### Schritt 1: Docker auf Windows Server installieren
|
||||||
|
|
||||||
|
1. Docker Desktop für Windows installieren
|
||||||
|
2. Sicherstellen, dass Docker läuft: `docker --version`
|
||||||
|
|
||||||
|
#### Schritt 2: Projekt auf Server kopieren
|
||||||
|
|
||||||
|
Kopiere den kompletten `IT Tool` Ordner auf den Server, z.B.:
|
||||||
|
```
|
||||||
|
C:\inetpub\fido-key-manager\
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Schritt 3: Container starten
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\inetpub\fido-key-manager
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Schritt 4: IIS als Reverse Proxy konfigurieren
|
||||||
|
|
||||||
|
**IIS URL Rewrite installieren:**
|
||||||
|
1. Download: https://www.iis.net/downloads/microsoft/url-rewrite
|
||||||
|
2. Installieren
|
||||||
|
|
||||||
|
**IIS Application Request Routing (ARR) installieren:**
|
||||||
|
1. Download: https://www.iis.net/downloads/microsoft/application-request-routing
|
||||||
|
2. Installieren
|
||||||
|
3. ARR aktivieren: IIS Manager → Server → Application Request Routing → Server Proxy Settings → "Enable proxy" aktivieren
|
||||||
|
|
||||||
|
**Neue Website in IIS erstellen:**
|
||||||
|
|
||||||
|
1. IIS Manager öffnen
|
||||||
|
2. Rechtsklick auf "Sites" → "Add Website"
|
||||||
|
3. Einstellungen:
|
||||||
|
- Site name: `FIDO-Key-Manager`
|
||||||
|
- Physical path: `C:\inetpub\wwwroot\fido` (leerer Ordner)
|
||||||
|
- Binding: HTTP, Port 80 (oder 443 für HTTPS)
|
||||||
|
- Host name: `your-domain.com`
|
||||||
|
|
||||||
|
4. `web.config` in `C:\inetpub\wwwroot\fido\` erstellen:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration>
|
||||||
|
<system.webServer>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<!-- Backend API Requests -->
|
||||||
|
<rule name="Backend API" stopProcessing="true">
|
||||||
|
<match url="^api/(.*)" />
|
||||||
|
<action type="Rewrite" url="http://localhost:5000/api/{R:1}" />
|
||||||
|
</rule>
|
||||||
|
|
||||||
|
<!-- Frontend Requests -->
|
||||||
|
<rule name="Frontend" stopProcessing="true">
|
||||||
|
<match url="(.*)" />
|
||||||
|
<action type="Rewrite" url="http://localhost:80/{R:1}" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
</configuration>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 2: Docker ohne IIS (Direkter Zugriff)
|
||||||
|
|
||||||
|
Docker kann auch direkt ohne IIS laufen.
|
||||||
|
|
||||||
|
#### Ports in docker-compose.yml anpassen
|
||||||
|
|
||||||
|
Wenn Port 80 bereits von IIS belegt ist, ändere die Ports:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "8080:80" # Frontend auf Port 8080
|
||||||
|
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "5000:5000" # Backend auf Port 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann: **http://server-ip:8080**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Konfiguration
|
||||||
|
|
||||||
|
### JWT Secret ändern (WICHTIG für Produktion!)
|
||||||
|
|
||||||
|
Bearbeite `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- JWT_SECRET=IHR-SICHERES-ZUFÄLLIGES-SECRET-MINDESTENS-32-ZEICHEN
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sicheres Secret generieren:**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# PowerShell
|
||||||
|
-join ((65..90) + (97..122) + (48..57) + (33,35,36,37,38,42,43,45,61,63,64) | Get-Random -Count 32 | % {[char]$_})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend URL anpassen
|
||||||
|
|
||||||
|
In `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- FRONTEND_URL=http://ihre-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Datenbank & Backups
|
||||||
|
|
||||||
|
### Datenbank-Speicherort
|
||||||
|
|
||||||
|
Die Datenbank wird im Docker Volume `fido-data` gespeichert und bleibt auch nach Container-Neustarts erhalten.
|
||||||
|
|
||||||
|
### Backup erstellen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Container-ID finden
|
||||||
|
docker ps
|
||||||
|
|
||||||
|
# Backup erstellen
|
||||||
|
docker cp fido-backend:/app/data/database.sqlite C:\Backups\database-backup-$(Get-Date -Format "yyyy-MM-dd").sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backup wiederherstellen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Container stoppen
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Volume löschen
|
||||||
|
docker volume rm it-tool_fido-data
|
||||||
|
|
||||||
|
# Container neu starten
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Backup einspielen
|
||||||
|
docker cp C:\Backups\database-backup.sqlite fido-backend:/app/data/database.sqlite
|
||||||
|
|
||||||
|
# Container neustarten
|
||||||
|
docker-compose restart backend
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 HTTPS / SSL einrichten
|
||||||
|
|
||||||
|
### Variante A: SSL Zertifikat über IIS (wenn IIS als Reverse Proxy verwendet wird)
|
||||||
|
|
||||||
|
1. Zertifikat in IIS importieren
|
||||||
|
2. HTTPS Binding hinzufügen
|
||||||
|
3. Fertig - IIS übernimmt SSL-Terminierung
|
||||||
|
|
||||||
|
### Variante B: SSL direkt in Docker (mit nginx)
|
||||||
|
|
||||||
|
Erstelle `nginx-ssl.conf` im Frontend-Ordner:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name ihre-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Passe `docker-compose.yml` an:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./ssl-certs:/etc/nginx/ssl:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### Container starten nicht
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Logs prüfen
|
||||||
|
docker-compose logs
|
||||||
|
|
||||||
|
# Spezifischen Container prüfen
|
||||||
|
docker-compose logs backend
|
||||||
|
docker-compose logs frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port bereits belegt
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Prüfen, welcher Prozess Port 80 verwendet
|
||||||
|
netstat -ano | findstr :80
|
||||||
|
|
||||||
|
# Prozess beenden (Vorsicht!)
|
||||||
|
taskkill /PID <PID> /F
|
||||||
|
```
|
||||||
|
|
||||||
|
### Datenbank-Fehler
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Container komplett neu aufsetzen
|
||||||
|
docker-compose down -v
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Container läuft, aber nicht erreichbar
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows Firewall prüfen
|
||||||
|
New-NetFirewallRule -DisplayName "Docker Frontend" -Direction Inbound -Protocol TCP -LocalPort 80 -Action Allow
|
||||||
|
New-NetFirewallRule -DisplayName "Docker Backend" -Direction Inbound -Protocol TCP -LocalPort 5000 -Action Allow
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Monitoring
|
||||||
|
|
||||||
|
### Container-Status überwachen
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Status aller Container
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Ressourcennutzung
|
||||||
|
docker stats
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs in Echtzeit
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker-compose logs -f --tail=100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Updates durchführen
|
||||||
|
|
||||||
|
Wenn du Änderungen am Code vornimmst:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Container stoppen
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Neu bauen und starten
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Alte Images aufräumen
|
||||||
|
docker image prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Wichtige Befehle im Überblick
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Starten
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Stoppen
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Neu bauen
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Logs anzeigen
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Status prüfen
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
# Container neustarten
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# In Container einsteigen (Debugging)
|
||||||
|
docker exec -it fido-backend sh
|
||||||
|
docker exec -it fido-frontend sh
|
||||||
|
|
||||||
|
# Alle Container und Volumes löschen (VORSICHT - Datenverlust!)
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Production Checklist
|
||||||
|
|
||||||
|
Vor dem produktiven Einsatz:
|
||||||
|
|
||||||
|
- [ ] JWT_SECRET in `docker-compose.yml` geändert
|
||||||
|
- [ ] FRONTEND_URL angepasst
|
||||||
|
- [ ] HTTPS/SSL eingerichtet
|
||||||
|
- [ ] Firewall-Regeln konfiguriert
|
||||||
|
- [ ] Backup-Strategie implementiert
|
||||||
|
- [ ] Standard-Passwort des Super Admins geändert
|
||||||
|
- [ ] Windows Server Updates installiert
|
||||||
|
- [ ] Docker Desktop so konfiguriert, dass es automatisch startet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
Bei Problemen:
|
||||||
|
1. Logs prüfen: `docker-compose logs`
|
||||||
|
2. Container-Status prüfen: `docker-compose ps`
|
||||||
|
3. Docker-Version prüfen: `docker --version`
|
||||||
|
4. Docker neu starten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Fertig!
|
||||||
|
|
||||||
|
Deine Anwendung läuft jetzt in Docker-Containern und kann einfach auf jedem Server bereitgestellt werden!
|
||||||
|
|
||||||
|
**Zugriff:**
|
||||||
|
- Frontend: http://localhost oder http://server-ip
|
||||||
|
- Backend API: http://localhost:5000 oder http://server-ip:5000
|
||||||
|
- Health Check: http://localhost:5000/health
|
||||||
1601
DOKUMENTATION.md
Normal file
1601
DOKUMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
BIN
DomainJointool.zip
Normal file
BIN
DomainJointool.zip
Normal file
Binary file not shown.
29
IT Tool.sln
Normal file
29
IT Tool.sln
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "agent-cs", "agent-cs", "{80D118B3-826D-1EA8-FFCB-9D6851D86064}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IT-Nexus-Agent", "agent-cs\IT-Nexus-Agent.csproj", "{5B33A971-38EB-521D-F0EA-7EACC8511EB9}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{5B33A971-38EB-521D-F0EA-7EACC8511EB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{5B33A971-38EB-521D-F0EA-7EACC8511EB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{5B33A971-38EB-521D-F0EA-7EACC8511EB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{5B33A971-38EB-521D-F0EA-7EACC8511EB9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{5B33A971-38EB-521D-F0EA-7EACC8511EB9} = {80D118B3-826D-1EA8-FFCB-9D6851D86064}
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {ABDE2D4F-9A27-4FA1-8951-C75147FF36AC}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
BIN
IT-Nexus-Dokumentation-new.docx
Normal file
BIN
IT-Nexus-Dokumentation-new.docx
Normal file
Binary file not shown.
BIN
IT-Nexus-Dokumentation.docx
Normal file
BIN
IT-Nexus-Dokumentation.docx
Normal file
Binary file not shown.
1631
IT-Nexus-Uebersicht.html
Normal file
1631
IT-Nexus-Uebersicht.html
Normal file
File diff suppressed because it is too large
Load Diff
50
README.md
Normal file
50
README.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# IT Nexus
|
||||||
|
|
||||||
|
Internes IT-Ticketsystem und Verwaltungsportal der Cereda Systems GmbH.
|
||||||
|
|
||||||
|
**Produktiv-URL:** `https://it-nexus.cereda-systems.de`
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Ticketsystem** mit Status-Workflow, Prioritäten, SLA-Überwachung, Kanban-Board
|
||||||
|
- **User-Portal** für Endnutzer mit geführtem Ticket-Assistenten
|
||||||
|
- **KI-Integration** (Claude) – automatische Ticket-Analyse, Chat-Assistent, KI-Antworten
|
||||||
|
- **E-Mail-Integration** – eingehende Mails → Tickets, ausgehende Benachrichtigungen
|
||||||
|
- **Asset-Verwaltung**, FIDO-Keys, Lifecycle (On-/Offboarding), Wissensdatenbank
|
||||||
|
- **Rollenbasierter Zugriff** (super_admin / admin / support / bearbeiter / benutzer)
|
||||||
|
- **Admin-Einstellungen** – Ticket-Kategorien & Vorlagen frei konfigurierbar
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Frontend:** React 18, React Router, Axios
|
||||||
|
- **Backend:** Node.js, Express, better-sqlite3
|
||||||
|
- **KI:** Anthropic Claude (claude-sonnet-4-6)
|
||||||
|
- **Auth:** JWT + bcrypt
|
||||||
|
- **Container:** Docker Compose (nginx + Node.js)
|
||||||
|
|
||||||
|
## Vollständige Dokumentation
|
||||||
|
|
||||||
|
→ Siehe [DOKUMENTATION.md](./DOKUMENTATION.md)
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && npm install && npm run dev
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm install && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy (Laptop → Server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "C:/Users/gruessing/OneDrive - Cereda Systems GmbH/Desktop/IT Tool"
|
||||||
|
tar -czf /tmp/it-nexus-deploy.tar.gz --exclude='*/node_modules' --exclude='*/build' --exclude='.git' --exclude='*.db' .
|
||||||
|
scp /tmp/it-nexus-deploy.tar.gz root@192.168.0.194:/tmp/
|
||||||
|
ssh root@192.168.0.194 "tar -xzf /tmp/it-nexus-deploy.tar.gz -C /opt/it-nexus/ && bash /opt/it-nexus/deploy.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Cereda Systems GmbH – Entwickelt von Simon Grüßing*
|
||||||
216
SYSTEM_AUDIT_2026-05-07.md
Normal file
216
SYSTEM_AUDIT_2026-05-07.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# IT Nexus System Audit — 07.05.2026
|
||||||
|
**Erstellt von:** Claude (Read-Only, keine Änderungen)
|
||||||
|
**Status:** Offen — noch nicht bearbeitet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Das System läuft stabil. Frontend und Backend sind erreichbar, die letzten 10+ Tage alle grün im Health-Check. Es gibt aber konkrete Probleme in den Bereichen Sicherheit, Konsistenz und Wartbarkeit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 Was gut ist
|
||||||
|
|
||||||
|
| Bereich | Status |
|
||||||
|
|---|---|
|
||||||
|
| System erreichbar (HTTPS) | ✅ |
|
||||||
|
| Health: letzte 10 Tage | ✅ alle grün |
|
||||||
|
| Agent-Auth (X-Agent-Key) | ✅ korrekt |
|
||||||
|
| JWT-Auth auf geschützten Routes | ✅ korrekt |
|
||||||
|
| Rate-Limiting auf Login | ✅ vorhanden |
|
||||||
|
| DB-Migrations (try/catch) | ✅ idempotent |
|
||||||
|
| asyncHandler auf Controllern | ✅ konsistent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 Kritische Probleme (sofort)
|
||||||
|
|
||||||
|
### 1. Agent-Version Default falsch
|
||||||
|
- `AGENT_VERSION` Fallback im Code noch auf `'1.2.4'` — zieht sich durch 3 Stellen im Controller
|
||||||
|
- **Fix:** In docker-compose.yml `AGENT_VERSION=2.0.0` — bereits gesetzt ✅. Aber Fallback im Code noch `'1.2.4'` und `'1.2.3'` — sollte auf `'2.0.0'` geändert werden
|
||||||
|
- **Dateien:** `backend/src/controllers/monitoringAgent.controller.js` Zeilen 25, 113, 136
|
||||||
|
|
||||||
|
### 2. `/api/monitoring/agent-setup` ohne vollständige Auth
|
||||||
|
- Endpoint prüft Key im Controller, aber Route liegt vor Middleware — unkonventionell
|
||||||
|
- Kein HTTPS-erzwungener Download
|
||||||
|
|
||||||
|
### 3. SSE-Endpoints mit Token im Query-Parameter
|
||||||
|
- `/api/tickets/:id/events?token=xxx` — Token landet in Server-Logs und Browser-History
|
||||||
|
- `/api/network-monitor/sse?token=xxx` — gleiches Problem
|
||||||
|
- **Dateien:** `backend/src/routes/ticket.routes.js`, `backend/src/routes/networkMonitor.routes.js`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 Hohe Priorität (diese Woche)
|
||||||
|
|
||||||
|
### 4. Kein Rate-Limiting außer Login
|
||||||
|
- `POST /api/tickets/public` — jeder kann Tickets erstellen, keine Limits
|
||||||
|
- `POST /api/tickets/:id/comments` — Spam möglich
|
||||||
|
- **Datei:** `backend/src/server.js` Zeilen 64-74
|
||||||
|
|
||||||
|
### 5. Keine Pagination auf großen Endpoints
|
||||||
|
- `GET /api/users` → alle User auf einmal
|
||||||
|
- `GET /api/assets` → alle Assets auf einmal
|
||||||
|
- `GET /api/tickets` → alle Tickets
|
||||||
|
- Bei Wachstum wird das ein Performance-Problem
|
||||||
|
|
||||||
|
### 6. E-Mail-Fehler werden ignoriert
|
||||||
|
- `.catch(err => console.error())` ohne Retry
|
||||||
|
- Bestätigungs-Mails, Onboarding-Mails könnten lautlos verloren gehen
|
||||||
|
- **Dateien:** `backend/src/controllers/ticket.controller.js` Zeilen 131, 137, 178
|
||||||
|
|
||||||
|
### 7. Kein Graceful Shutdown der DB
|
||||||
|
- SQLite wird beim Neustart nicht sauber geschlossen → mögliche Locks
|
||||||
|
- **Datei:** `backend/src/server.js` (fehlt: `process.on('SIGTERM')` Handler)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 Mittlere Priorität (diesen Monat)
|
||||||
|
|
||||||
|
### 8. Kein strukturiertes Logging
|
||||||
|
- Überall `console.log/error` — kein Log-Level, kein JSON-Format
|
||||||
|
- Schwer zu debuggen in Produktion
|
||||||
|
- **Empfehlung:** Winston oder Pino einführen
|
||||||
|
|
||||||
|
### 9. Planner-Sync läuft alle 10 Sekunden
|
||||||
|
- Sehr aggressiv — sollte 5 Minuten sein
|
||||||
|
- **Datei:** `backend/src/server.js` Zeile 274-290
|
||||||
|
|
||||||
|
### 10. CORS in Dev auf `*`
|
||||||
|
- Alle Ursprünge erlaubt — sollte auch in Dev eingeschränkt sein
|
||||||
|
- **Datei:** `backend/src/server.js` Zeilen 46-53
|
||||||
|
|
||||||
|
### 11. Onboarding-PII unverschlüsselt
|
||||||
|
- `emp_phone`, `emp_address`, `emp_private_email` in Klartext in DB
|
||||||
|
- DSGVO-relevant bei DB-Leak
|
||||||
|
- **Tabelle:** `onboarding_protocols`
|
||||||
|
|
||||||
|
### 12. Schema-Inkonsistenz Asset-Typen
|
||||||
|
- DB-Schema kennt 4 Typen, seed.js seeded 9 Typen — CHECK-Constraint wird umgangen
|
||||||
|
- **Datei:** `backend/src/db/seed.js`
|
||||||
|
|
||||||
|
### 13. Audit-Logging unvollständig
|
||||||
|
- Password-Änderungen nicht gelogged
|
||||||
|
- Token-Generierungen nicht gelogged
|
||||||
|
- **Empfehlung:** Alle Auth-Events in `audit_log` schreiben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Vollständige Route-Übersicht
|
||||||
|
|
||||||
|
### Auth Routes (`/api/auth`)
|
||||||
|
| Methode | Pfad | Auth |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | /login | Public (Rate-Limited) |
|
||||||
|
| GET | /me | JWT |
|
||||||
|
| POST | /change-password | JWT |
|
||||||
|
| PUT | /notifications | JWT |
|
||||||
|
| PUT | /staff-notifications | JWT |
|
||||||
|
| GET | /microsoft | Public |
|
||||||
|
| GET | /microsoft/callback | Public |
|
||||||
|
| GET | /microsoft/addin | Public |
|
||||||
|
| GET | /microsoft/addin-callback | Public |
|
||||||
|
| GET | /microsoft/addin-result/:sessionId | Public |
|
||||||
|
|
||||||
|
### Monitoring Routes (`/api/monitoring`)
|
||||||
|
| Methode | Pfad | Auth |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | /checkin | X-Agent-Key |
|
||||||
|
| POST | /announcements-poll | X-Agent-Key |
|
||||||
|
| GET | /agent-script | X-Agent-Key |
|
||||||
|
| GET | /agent-setup | X-Agent-Key (Controller) |
|
||||||
|
| GET | /statistics | JWT + Admin |
|
||||||
|
| GET | / | JWT + Admin |
|
||||||
|
| GET | /:id | JWT + Admin |
|
||||||
|
| DELETE | /:id | JWT + Admin |
|
||||||
|
|
||||||
|
### Patch Routes (`/api/patch`)
|
||||||
|
| Methode | Pfad | Auth |
|
||||||
|
|---|---|---|
|
||||||
|
| POST | /commands/result | X-Agent-Key |
|
||||||
|
| GET | /overview | JWT |
|
||||||
|
| GET | /groups | JWT + Admin |
|
||||||
|
| POST | /groups | JWT + Admin |
|
||||||
|
| PUT | /groups/:id | JWT + Admin |
|
||||||
|
| DELETE | /groups/:id | JWT + Admin |
|
||||||
|
| POST | /commands/trigger | JWT + Admin |
|
||||||
|
| POST | /commands/trigger-group | JWT + Admin |
|
||||||
|
| GET | /commands | JWT + Admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Datenbank-Tabellen (38 gesamt)
|
||||||
|
|
||||||
|
| Tabelle | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| roles | Benutzerrollen |
|
||||||
|
| users | Benutzerkonten |
|
||||||
|
| fido_keys | FIDO2-Schlüssel |
|
||||||
|
| assets | IT-Assets |
|
||||||
|
| asset_assignments | Asset-Zuweisungen |
|
||||||
|
| asset_inspections | Inspektionen |
|
||||||
|
| asset_movements | Bewegungshistorie |
|
||||||
|
| asset_types | Asset-Kategorien |
|
||||||
|
| onboarding_protocols | Onboarding |
|
||||||
|
| offboarding_protocols | Offboarding |
|
||||||
|
| tickets | Support-Tickets |
|
||||||
|
| ticket_comments | Kommentare |
|
||||||
|
| ticket_links | Ticket-Verknüpfungen |
|
||||||
|
| ticket_assignees | Mehrfach-Zuweisungen |
|
||||||
|
| ticket_routing | Auto-Routing |
|
||||||
|
| monitoring_agents | Windows Agenten |
|
||||||
|
| network_devices | Netzwerk-Monitoring |
|
||||||
|
| device_checks | Prüf-Ergebnisse |
|
||||||
|
| health_history | System-Health |
|
||||||
|
| patch_groups | Patch-Gruppen |
|
||||||
|
| patch_policies | Patch-Richtlinien |
|
||||||
|
| patch_agent_groups | Agent-Gruppen |
|
||||||
|
| patch_commands | Patch-Befehle |
|
||||||
|
| licenses | Softwarelizenzen |
|
||||||
|
| warehouse_locations | Lagerplätze |
|
||||||
|
| stock_thresholds | Mindestbestände |
|
||||||
|
| purchase_orders | Bestellungen |
|
||||||
|
| knowledge_base | Wissensdatenbank |
|
||||||
|
| ai_knowledge | KI-Wissensdatenbank |
|
||||||
|
| portal_guides | Portal-Guides |
|
||||||
|
| announcements | Ankündigungen |
|
||||||
|
| announcement_acks | Bestätigungen |
|
||||||
|
| external_alerts | Externe Warnungen |
|
||||||
|
| teams_channels | Teams-Kanäle |
|
||||||
|
| audit_log | Audit-Protokoll |
|
||||||
|
| unifi_config | Unifi-Konfiguration |
|
||||||
|
| unifi_devices | Unifi-Geräte |
|
||||||
|
| it_topics | IT-Übersicht |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Priorisierte To-Do-Liste
|
||||||
|
|
||||||
|
| Prio | Aufwand | Was | Datei |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 5 Min | Code-Fallback `'1.2.4'` → `'2.0.0'` | monitoringAgent.controller.js |
|
||||||
|
| 2 | 30 Min | Rate-Limiting auf public Ticket-Erstellung | server.js |
|
||||||
|
| 3 | 30 Min | Pagination auf /api/users, /api/assets, /api/tickets | controllers |
|
||||||
|
| 4 | 1h | Graceful Shutdown für SQLite | server.js |
|
||||||
|
| 5 | 2h | E-Mail Retry-Logic | ticket.controller.js |
|
||||||
|
| 6 | 2h | SSE Token aus Query absichern | routes |
|
||||||
|
| 7 | 1 Tag | Strukturiertes Logging einführen | server.js + alle |
|
||||||
|
| 8 | 1 Tag | PII-Felder verschlüsseln (DSGVO) | onboarding_protocols |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live-System Stand (07.05.2026)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Health letzten 10 Tage | ✅ alle OK |
|
||||||
|
| Frontend erreichbar | ✅ |
|
||||||
|
| API erreichbar | ✅ |
|
||||||
|
| Agent v2.0.0 | ✅ IT-NB-02, TBO-NB-02, FAM102223 |
|
||||||
|
| Geräte mit altem Agent | ⚠️ ~41 Geräte noch v1.x |
|
||||||
|
| CT 110 (AI) | ✅ nach fsck repariert |
|
||||||
|
| CT 102, 103, 111 | ✅ alle healthy |
|
||||||
|
|
||||||
|
---
|
||||||
|
*Nächster Schritt: Priorisierung mit Simon besprechen, dann schrittweise abarbeiten.*
|
||||||
79
ZELO_5.3_Installationsanleitung.md
Normal file
79
ZELO_5.3_Installationsanleitung.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# ZELO 5.3 — Installationsanleitung
|
||||||
|
**Winkel Ruf-Leitsysteme | Version 5.3.0.28**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
- Windows 10/11 (x64)
|
||||||
|
- SQL Server 2019 Express muss bereits installiert sein (siehe ZELO 6.0 Anleitung, Phase 1)
|
||||||
|
- Setup-Datei: `SetupZeloConfig.exe`
|
||||||
|
- Pfad: `C:\Winkel Soft\02 Zelo (5.3_5.4_6.0)\01 ZELO5.3\Winkel_Soft\01 ZELO Setup\zeloSetup 5.3.0.28\`
|
||||||
|
- Installer: `SetupZeloConfig.exe` (1.553 KB)
|
||||||
|
- Administratorrechte auf dem Zielrechner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Schritt 1 — Startmenü
|
||||||
|
- `SetupZeloConfig.exe` starten
|
||||||
|
- Zwei Optionen erscheinen:
|
||||||
|
- **"zelo Config"** → auswählen
|
||||||
|
- "Microsoft® SQL Server® 2019 Express" → **nicht auswählen** (bereits installiert)
|
||||||
|
|
||||||
|
### Schritt 2 — AGB akzeptieren
|
||||||
|
- Checkbox **"Ich habe die AGB der Winkel GmbH gelesen und akzeptiere diese"** aktivieren ✅
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 3 — Eingaben zur Installation
|
||||||
|
- Funktion: **Server** auswählen (USB/CAN-Adapter ist an diesem PC angeschlossen)
|
||||||
|
- SQL-Servername: **`localhost\ZELO`**
|
||||||
|
- Benutzer: **`sa`** (manuell eingeben)
|
||||||
|
- Passwort: **`Cered@ZeloFL24`** (manuell eingeben)
|
||||||
|
- Zielverzeichnis: `C:\zeloConfig` (Standard lassen)
|
||||||
|
- Klick: **Verbindungstest** — grünes Symbol muss erscheinen ✅
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
> ⚠️ Alle Felder müssen manuell befüllt werden — werden nicht automatisch übernommen.
|
||||||
|
|
||||||
|
### Schritt 4 — Komponenten auswählen
|
||||||
|
- Alle drei Komponenten aktivieren:
|
||||||
|
- ✅ **zelo Config, Benutzeroberfläche**
|
||||||
|
- ✅ **zelo Server, Windows Dienst zur Anbindung des zelo Systems über den CAN-Adapter**
|
||||||
|
- ✅ **zelo DB, Beispieldatenbank**
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 5 — Zusammenfassung prüfen
|
||||||
|
- Angaben kontrollieren:
|
||||||
|
- Art der Lizenz: `zelo Server`
|
||||||
|
- SQLServer: `localhost\ZELO`
|
||||||
|
- Benutzer: `sa`
|
||||||
|
- Zielverzeichnis: `C:\zeloConfig`
|
||||||
|
- Klick: **"zelo Config jetzt installieren"**
|
||||||
|
|
||||||
|
### Schritt 6 — Installation läuft
|
||||||
|
- Fortschrittsbalken läuft durch
|
||||||
|
- Warten bis "Installation ist abgeschlossen..." erscheint
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 7 — Abschließen
|
||||||
|
- Haken so lassen:
|
||||||
|
- ✅ Desktopverknüpfung erstellen
|
||||||
|
- ✅ Eintrag im Startmenü hinzufügen
|
||||||
|
- ☐ Bei Computerstart automatisch starten (leer lassen)
|
||||||
|
- Klick: **Fertigstellen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zugangsdaten
|
||||||
|
|
||||||
|
| Parameter | Wert |
|
||||||
|
|---|---|
|
||||||
|
| SQL-Instanz | `localhost\ZELO` |
|
||||||
|
| SA-Benutzer | `sa` |
|
||||||
|
| SA-Passwort | `Cered@ZeloFL24` |
|
||||||
|
| Installationsverzeichnis | `C:\zeloConfig` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Erstellt: Mai 2026 | Cereda Systems GmbH*
|
||||||
163
ZELO_Installationsanleitung.md
Normal file
163
ZELO_Installationsanleitung.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# ZELO 6.0 — Installationsanleitung
|
||||||
|
**Winkel Ruf-Leitsysteme | Version 6.0.0.62**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
- Windows 10/11 (x64)
|
||||||
|
- Setup-Datei: `Setup_ZELO.exe` (V6.0.0.62)
|
||||||
|
- Administratorrechte auf dem Zielrechner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: Vorbereitung — PowerShell (vor dem Setup!)
|
||||||
|
|
||||||
|
> ⚠️ Dieser Schritt muss **vor** dem Start des Setups ausgeführt werden, da Windows Dateien von Netzlaufwerken/USB blockiert. Das Setup öffnet sich danach automatisch.
|
||||||
|
|
||||||
|
**PowerShell als Administrator** öffnen und folgenden Befehl ausführen:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# ZELO Setup Installer
|
||||||
|
# Dateien entsperren und Setup starten
|
||||||
|
|
||||||
|
$setupPath = 'C:\Winkel Soft\02 Zelo (5.3_5.4_6.0)\03 ZELO6.0 (inkl 5.4)\Winkel_Soft\01 ZELO Setup\ZeloSetup 6.0.0.62'
|
||||||
|
|
||||||
|
Write-Host "Entsperre alle Dateien..." -ForegroundColor Cyan
|
||||||
|
Get-ChildItem -Path $setupPath -Recurse | Unblock-File
|
||||||
|
|
||||||
|
Write-Host "Starte Setup..." -ForegroundColor Green
|
||||||
|
Start-Process -FilePath "$setupPath\Setup_ZELO.exe" -Verb RunAs -Wait
|
||||||
|
|
||||||
|
Write-Host "Fertig!" -ForegroundColor Green
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Script:
|
||||||
|
1. Entsperrt alle Dateien im Setup-Ordner (`Unblock-File`)
|
||||||
|
2. Startet `Setup_ZELO.exe` automatisch mit Administratorrechten
|
||||||
|
3. Wartet bis das Setup abgeschlossen ist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: SQL Server 2019 installieren
|
||||||
|
|
||||||
|
### Schritt 1 — System Check
|
||||||
|
- `Setup_ZELO.exe` als Administrator starten
|
||||||
|
- Installationstyp: **SQL Server** auswählen
|
||||||
|
- Modus: **Einzelplatzinstallation**
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 2 — Parameter
|
||||||
|
- Installationsverzeichnis: `C:\Program Files\ZELO Config` (Standard)
|
||||||
|
- SQL-Instanzname: `ZELO`
|
||||||
|
- Anmeldename: `sa`
|
||||||
|
- Passwort: `Olez_2013` *(wird später in Schritt 10 auf `Cered@ZeloFL24` geändert)*
|
||||||
|
- Klick: **Installieren**
|
||||||
|
|
||||||
|
> SQL Server 2019 Express wird jetzt im Hintergrund heruntergeladen und vorbereitet.
|
||||||
|
|
||||||
|
### Schritt 3 — SQL Server 2019 Installationsassistent
|
||||||
|
Der SQL Server 2019 Setup-Assistent öffnet sich automatisch.
|
||||||
|
|
||||||
|
### Schritt 4 — SQL Lizenzbedingungen
|
||||||
|
- Checkbox **"Ich akzeptiere die Lizenzbedingungen"** aktivieren
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 5 — Setupdateien installieren
|
||||||
|
- Warten bis der Fortschrittsbalken abgeschlossen ist und "Weiter" aktiv wird
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 6 — Installationsregeln
|
||||||
|
- Firewall-Warnung ist **normal** und kann ignoriert werden
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 7 — Funktionsauswahl
|
||||||
|
- Standard-Auswahl beibehalten: **Datenbank-Engine-Dienste** + **SQL Server-Replikation**
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 8 — Instanzkonfiguration
|
||||||
|
- Named Instance: **ZELO** (vorausgefüllt, nicht ändern)
|
||||||
|
- Instanz-ID: **ZELO** (vorausgefüllt, nicht ändern)
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 9 — Serverkonfiguration
|
||||||
|
| Dienst | Konto | Starttyp |
|
||||||
|
|---|---|---|
|
||||||
|
| SQL Server-Datenbank-Engine | `NT AUTHORITY\SYSTEM` | Automatisch |
|
||||||
|
| SQL Server-Browser | `NT AUTHORITY\LOCAL SERVICE` | Automatisch |
|
||||||
|
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 10 — Datenbank-Engine-Konfiguration
|
||||||
|
- Tab: **Serverkonfiguration**
|
||||||
|
- Authentifizierungsmodus: **Gemischter Modus** (SQL Server + Windows)
|
||||||
|
- SA-Kennwort: **`Cered@ZeloFL24`** *(beide Felder — Standardpasswort funktioniert nicht!)*
|
||||||
|
- SQL Server-Administratoren:
|
||||||
|
- **"Aktuellen Benutzer hinzufügen"** klicken
|
||||||
|
- ⚠️ Prüfen ob der **richtige User** eingetragen ist (z.B. `WINKEL\Bill`)
|
||||||
|
- Falls ein Admin-Account steht: **"Entfernen"** → **"Hinzufügen..."** → korrekten User manuell suchen
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 11 — Installationsstatus
|
||||||
|
- Warten bis die Installation abgeschlossen ist (Fortschrittsbalken)
|
||||||
|
- "Weiter" wird aktiv sobald fertig
|
||||||
|
|
||||||
|
### Schritt 12 — Abgeschlossen
|
||||||
|
- Alle Komponenten zeigen Status **"Erfolgreich"** ✅
|
||||||
|
- Details: "Installation erfolgreich"
|
||||||
|
- Klick: **Schließen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: ZELO Software installieren
|
||||||
|
|
||||||
|
### Schritt 13 — ZELO Setup neu starten & Komponenten auswählen
|
||||||
|
- `Setup_ZELO.exe` erneut starten
|
||||||
|
- Im System Check erscheinen nun alle Komponenten
|
||||||
|
- Die oberen sind bereits installiert (.NET, Ixxat, SQL Server) — **nicht anfassen**
|
||||||
|
- Die **drei unteren** aktivieren (Haken setzen):
|
||||||
|
- ✅ **ZELO Server 6.x** (6.0.0.62)
|
||||||
|
- ✅ **ZELO Client** (6.0.0.62)
|
||||||
|
- ✅ **ZELO Server 5.4** (5.4.1.4)
|
||||||
|
- Klick: **Weiter**
|
||||||
|
|
||||||
|
### Schritt 14 — Parameter (SQL-Verbindung)
|
||||||
|
- Installationsverzeichnis: `C:\Program Files\ZELO Config` (Standard lassen)
|
||||||
|
- SQL-Instanz wird automatisch erkannt: `COMPUTERNAME\ZELO`
|
||||||
|
- Anmeldename: **`sa`**
|
||||||
|
- Passwort: **`Cered@ZeloFL24`** eintragen
|
||||||
|
- Klick: **"Verbindungstest"** — muss erfolgreich sein ✅
|
||||||
|
- Klick: **Installieren**
|
||||||
|
|
||||||
|
### Schritt 15 — Installations-Status (Abgeschlossen)
|
||||||
|
- Beide Haken können so bleiben:
|
||||||
|
- ✅ Verknüpfung auf dem Desktop erstellen
|
||||||
|
- ✅ Protokolldatei der Installation speichern (Empfohlen)
|
||||||
|
- Klick: **Fertigstellen**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Testen
|
||||||
|
|
||||||
|
### Schritt 16 — Desktop-Verknüpfung
|
||||||
|
- Nach der Installation erscheint auf dem Desktop das Icon **"ZELO Config"**
|
||||||
|
|
||||||
|
### Schritt 17 — ZELO Config starten
|
||||||
|
- Doppelklick auf das Desktop-Icon "ZELO Config"
|
||||||
|
- Ladebildschirm erscheint — Version **6.0.0.62** wird angezeigt
|
||||||
|
- Warten bis vollständig geladen
|
||||||
|
- ✅ Installation erfolgreich wenn die Anwendung startet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zusammenfassung wichtiger Zugangsdaten
|
||||||
|
|
||||||
|
| Parameter | Wert |
|
||||||
|
|---|---|
|
||||||
|
| SQL-Instanz | `COMPUTERNAME\ZELO` |
|
||||||
|
| SA-Benutzer | `sa` |
|
||||||
|
| SA-Passwort | `Cered@ZeloFL24` |
|
||||||
|
| Installationsverzeichnis | `C:\Program Files\ZELO Config` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Erstellt: Mai 2026 | Cereda Systems GmbH*
|
||||||
1
_ul
Normal file
1
_ul
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mkdir: cannot create directory ‘C:\\gradle-home\\it_nexus_build’: File exists
|
||||||
109
agent-cs/AgentService.cs
Normal file
109
agent-cs/AgentService.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using System.ServiceProcess;
|
||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace ITNexusAgent;
|
||||||
|
|
||||||
|
public class AgentService : ServiceBase
|
||||||
|
{
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _worker;
|
||||||
|
|
||||||
|
public AgentService()
|
||||||
|
{
|
||||||
|
ServiceName = "IT Nexus Agent";
|
||||||
|
CanStop = true;
|
||||||
|
CanPauseAndContinue = false;
|
||||||
|
AutoLog = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnStart(string[] args)
|
||||||
|
{
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
_worker = Task.Run(() => new AgentWorker(_cts.Token).RunAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnStop()
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_worker?.Wait(TimeSpan.FromSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Install(string exePath)
|
||||||
|
{
|
||||||
|
// Bestehenden Service stoppen + löschen (falls vorhanden, damit binPath aktualisiert wird)
|
||||||
|
Run("net.exe", "stop \"IT Nexus Agent\"");
|
||||||
|
System.Threading.Thread.Sleep(2000);
|
||||||
|
Run("sc.exe", "delete \"IT Nexus Agent\"");
|
||||||
|
System.Threading.Thread.Sleep(1000);
|
||||||
|
|
||||||
|
// Dashboard-Prozesse beenden damit EXE nicht gesperrt ist
|
||||||
|
foreach (var p in System.Diagnostics.Process.GetProcessesByName("IT-Nexus-Agent"))
|
||||||
|
{
|
||||||
|
try { if (p.MainModule?.FileName != exePath) p.Kill(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neu erstellen mit korrektem Pfad
|
||||||
|
Run("sc.exe", $"create \"IT Nexus Agent\" binPath= \"{exePath}\" start= auto DisplayName= \"IT Nexus Agent\"");
|
||||||
|
Run("sc.exe", "description \"IT Nexus Agent\" \"Cereda Systems IT Nexus Monitoring Agent\"");
|
||||||
|
Run("sc.exe", "failure \"IT Nexus Agent\" reset= 60 actions= restart/5000/restart/10000/restart/30000");
|
||||||
|
Run("net.exe", "start \"IT Nexus Agent\"");
|
||||||
|
|
||||||
|
AgentWorker.Log($"Service installiert: {exePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run(string exe, string args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe, args)
|
||||||
|
{ CreateNoWindow = true, UseShellExecute = false })?.WaitForExit();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Uninstall()
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("net.exe",
|
||||||
|
"stop \"IT Nexus Agent\"")
|
||||||
|
{ CreateNoWindow = true })?.WaitForExit();
|
||||||
|
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("sc.exe",
|
||||||
|
"delete \"IT Nexus Agent\"")
|
||||||
|
{ CreateNoWindow = true })?.WaitForExit();
|
||||||
|
|
||||||
|
AgentWorker.Log("Service deinstalliert");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration vom alten PowerShell-Agent
|
||||||
|
public static void MigrateFromOldAgent()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var oldDataDir = @"C:\ProgramData\IT Nexus Agent";
|
||||||
|
var oldConfigPath = Path.Combine(oldDataDir, "config.json");
|
||||||
|
|
||||||
|
// Alte Scheduled Tasks entfernen
|
||||||
|
foreach (var taskName in new[] { "IT Nexus Agent", "IT Nexus Announcement Watcher" })
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("schtasks.exe",
|
||||||
|
$"/delete /tn \"{taskName}\" /f")
|
||||||
|
{ CreateNoWindow = true })?.WaitForExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alte PS1-Dateien bereinigen
|
||||||
|
if (Directory.Exists(oldDataDir))
|
||||||
|
{
|
||||||
|
foreach (var f in Directory.GetFiles(oldDataDir, "*.ps1"))
|
||||||
|
try { File.Delete(f); } catch { }
|
||||||
|
foreach (var f in Directory.GetFiles(oldDataDir, "*.update"))
|
||||||
|
try { File.Delete(f); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentWorker.Log("Migration vom alten PowerShell-Agent abgeschlossen");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AgentWorker.Log($"Migration-Warnung: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
195
agent-cs/AgentWorker.cs
Normal file
195
agent-cs/AgentWorker.cs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
using ITNexusAgent.Models;
|
||||||
|
using ITNexusAgent.Services;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace ITNexusAgent;
|
||||||
|
|
||||||
|
public class AgentWorker
|
||||||
|
{
|
||||||
|
private const string Version = "2.0.0";
|
||||||
|
private const string DataDir = @"C:\ProgramData\IT Nexus Agent";
|
||||||
|
private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json";
|
||||||
|
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
|
||||||
|
private const string LogPath = @"C:\ProgramData\IT Nexus Agent\agent.log";
|
||||||
|
|
||||||
|
private readonly CancellationToken _ct;
|
||||||
|
private AgentConfig? _config;
|
||||||
|
private ApiService? _api;
|
||||||
|
private NotificationService? _notifier;
|
||||||
|
private string _exePath = "";
|
||||||
|
|
||||||
|
public AgentWorker(CancellationToken ct) => _ct = ct;
|
||||||
|
|
||||||
|
public async Task RunAsync()
|
||||||
|
{
|
||||||
|
_exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName;
|
||||||
|
Directory.CreateDirectory(DataDir);
|
||||||
|
|
||||||
|
if (!File.Exists(ConfigPath))
|
||||||
|
{
|
||||||
|
Log("ERROR: config.json nicht gefunden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureConfigFile(ConfigPath);
|
||||||
|
|
||||||
|
_config = AgentConfig.Load(ConfigPath);
|
||||||
|
_api = new ApiService(_config.ServerUrl, _config.AgentKey);
|
||||||
|
_notifier = new NotificationService(_exePath, DataDir);
|
||||||
|
|
||||||
|
Log($"Agent v{Version} gestartet");
|
||||||
|
|
||||||
|
while (!_ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await RunCycleAsync();
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), _ct).ContinueWith(_ => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunCycleAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Log("Sammle Systemdaten...");
|
||||||
|
var payload = CollectData();
|
||||||
|
var response = await _api!.CheckinAsync(payload);
|
||||||
|
|
||||||
|
Log($"OK: Checkin erfolgreich (Hostname: {payload.Hostname})");
|
||||||
|
|
||||||
|
// Status-Cache für Dashboard schreiben
|
||||||
|
WriteStatusCache(payload, response, true);
|
||||||
|
|
||||||
|
// Auto-Update prüfen
|
||||||
|
if (!string.IsNullOrEmpty(response.AgentVersion) &&
|
||||||
|
System.Version.TryParse(response.AgentVersion, out var serverVer) &&
|
||||||
|
System.Version.TryParse(Version, out var localVer) &&
|
||||||
|
serverVer > localVer)
|
||||||
|
{
|
||||||
|
Log($"UPDATE: Neue Agent-Version verfügbar: {response.AgentVersion} (aktuell: {Version})");
|
||||||
|
await new CommandExecutor(_api, payload.Hostname, DataDir, _exePath)
|
||||||
|
.ExecuteAsync(new PatchCommand { Id = 0, Command = "update_agent" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patch-Commands ausführen
|
||||||
|
var executor = new CommandExecutor(_api, payload.Hostname, DataDir, _exePath);
|
||||||
|
foreach (var cmd in response.Commands)
|
||||||
|
{
|
||||||
|
Log($"PATCH: Command empfangen: {cmd.Command}");
|
||||||
|
await executor.ExecuteAsync(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ankündigungen anzeigen
|
||||||
|
if (response.Announcements.Count > 0)
|
||||||
|
{
|
||||||
|
_notifier!.ShowAnnouncements(response.Announcements);
|
||||||
|
foreach (var ann in response.Announcements)
|
||||||
|
Log($"ANNOUNCEMENT: Dialog für {SystemInfoService.GetLastUser()} gestartet - ID {ann.Id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log($"ERROR: {ex.Message}");
|
||||||
|
WriteStatusCache(null, null, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private CheckinPayload CollectData()
|
||||||
|
{
|
||||||
|
var (cpuModel, cpuCores) = SystemInfoService.GetCpuInfo();
|
||||||
|
var (ramTotal, ramUsed) = SystemInfoService.GetRamInfo();
|
||||||
|
var (diskTotal, diskFree) = SystemInfoService.GetDiskInfo();
|
||||||
|
var (tpmPresent, tpmVersion, tpmV2) = SystemInfoService.GetTpmInfo();
|
||||||
|
var (defEnabled, defSigAge, _) = SecurityInfoService.GetDefenderStatus();
|
||||||
|
|
||||||
|
return new CheckinPayload
|
||||||
|
{
|
||||||
|
Hostname = SystemInfoService.GetHostname(),
|
||||||
|
AgentVersion = Version,
|
||||||
|
Os = SystemInfoService.GetOs(),
|
||||||
|
OsVersion = SystemInfoService.GetOsVersion(),
|
||||||
|
Domain = SystemInfoService.GetDomain(),
|
||||||
|
IpAddress = SystemInfoService.GetIpAddress(),
|
||||||
|
MacAddress = SystemInfoService.GetMacAddress(),
|
||||||
|
LastUser = SystemInfoService.GetLastUser(),
|
||||||
|
CpuModel = cpuModel,
|
||||||
|
CpuCores = cpuCores,
|
||||||
|
CpuUsage = SystemInfoService.GetCpuUsage(),
|
||||||
|
RamTotal = ramTotal,
|
||||||
|
RamUsed = ramUsed,
|
||||||
|
DiskTotal = diskTotal,
|
||||||
|
DiskFree = diskFree,
|
||||||
|
UptimeHours = SystemInfoService.GetUptimeHours(),
|
||||||
|
PendingUpdates = SystemInfoService.GetPendingUpdates(),
|
||||||
|
TpmPresent = tpmPresent,
|
||||||
|
TpmVersion = tpmVersion,
|
||||||
|
TpmV2 = tpmV2,
|
||||||
|
SecureBoot = SystemInfoService.GetSecureBoot(),
|
||||||
|
Win11Ready = SystemInfoService.GetWin11Readiness(tpmV2, SystemInfoService.GetSecureBoot(), cpuCores, ramTotal),
|
||||||
|
BitlockerStatus = SecurityInfoService.GetBitlockerStatus(),
|
||||||
|
DefenderEnabled = defEnabled,
|
||||||
|
DefenderSignaturesAge = defSigAge >= 0 ? defSigAge : null,
|
||||||
|
HardwareSerial = SystemInfoService.GetHardwareSerial(),
|
||||||
|
InstalledSoftware = SystemInfoService.GetInstalledSoftware(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteStatusCache(CheckinPayload? p, CheckinResponse? r, bool online)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var status = new StatusCache
|
||||||
|
{
|
||||||
|
Hostname = p?.Hostname ?? SystemInfoService.GetHostname(),
|
||||||
|
LastCheckin = DateTime.Now,
|
||||||
|
AgentVersion = Version,
|
||||||
|
ServerUrl = _config?.ServerUrl ?? "",
|
||||||
|
Online = online,
|
||||||
|
CpuUsage = p?.CpuUsage,
|
||||||
|
RamTotal = p?.RamTotal,
|
||||||
|
RamUsed = p?.RamUsed,
|
||||||
|
DiskTotal = p?.DiskTotal,
|
||||||
|
DiskFree = p?.DiskFree,
|
||||||
|
PendingUpdates = p?.PendingUpdates ?? 0,
|
||||||
|
BitlockerStatus = p?.BitlockerStatus ?? "",
|
||||||
|
DefenderEnabled = p?.DefenderEnabled ?? false,
|
||||||
|
DefenderSignaturesAge = p?.DefenderSignaturesAge,
|
||||||
|
HardwareSerial = p?.HardwareSerial ?? "",
|
||||||
|
Os = p?.Os ?? "",
|
||||||
|
LastUser = p?.LastUser ?? "",
|
||||||
|
InstalledSoftware = p?.InstalledSoftware ?? [],
|
||||||
|
};
|
||||||
|
File.WriteAllText(StatusPath, JsonConvert.SerializeObject(status, Formatting.Indented));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SecureConfigFile(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fi = new System.IO.FileInfo(path);
|
||||||
|
var acl = fi.GetAccessControl();
|
||||||
|
acl.SetAccessRuleProtection(true, false); // Vererbung entfernen
|
||||||
|
// Nur SYSTEM und Administratoren
|
||||||
|
acl.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
|
||||||
|
"SYSTEM", System.Security.AccessControl.FileSystemRights.FullControl,
|
||||||
|
System.Security.AccessControl.AccessControlType.Allow));
|
||||||
|
acl.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
|
||||||
|
"Administrators", System.Security.AccessControl.FileSystemRights.FullControl,
|
||||||
|
System.Security.AccessControl.AccessControlType.Allow));
|
||||||
|
fi.SetAccessControl(acl);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Log(string msg)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {msg}";
|
||||||
|
File.AppendAllText(LogPath, line + Environment.NewLine, System.Text.Encoding.UTF8);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
8
agent-cs/GlobalUsings.cs
Normal file
8
agent-cs/GlobalUsings.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.IO;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Net.Http;
|
||||||
|
global using System.Text;
|
||||||
|
global using System.Threading;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
35
agent-cs/IT-Nexus-Agent.csproj
Normal file
35
agent-cs/IT-Nexus-Agent.csproj
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<AssemblyName>IT-Nexus-Agent</AssemblyName>
|
||||||
|
<RootNamespace>ITNexusAgent</RootNamespace>
|
||||||
|
<Version>2.0.0</Version>
|
||||||
|
<AssemblyVersion>2.0.0.0</AssemblyVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>icon.ico</ApplicationIcon>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Resource Include="icon.ico"/>
|
||||||
|
<Content Include="icon.ico">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<PackageReference Include="System.Management" Version="8.0.0" />
|
||||||
|
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
91
agent-cs/Models/CheckinModels.cs
Normal file
91
agent-cs/Models/CheckinModels.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.Models;
|
||||||
|
|
||||||
|
public class CheckinPayload
|
||||||
|
{
|
||||||
|
[JsonProperty("hostname")] public string Hostname { get; set; } = "";
|
||||||
|
[JsonProperty("agent_version")] public string AgentVersion { get; set; } = "";
|
||||||
|
[JsonProperty("os")] public string Os { get; set; } = "";
|
||||||
|
[JsonProperty("os_version")] public string OsVersion { get; set; } = "";
|
||||||
|
[JsonProperty("domain")] public string Domain { get; set; } = "";
|
||||||
|
[JsonProperty("ip_address")] public string IpAddress { get; set; } = "";
|
||||||
|
[JsonProperty("mac_address")] public string MacAddress { get; set; } = "";
|
||||||
|
[JsonProperty("last_user")] public string LastUser { get; set; } = "";
|
||||||
|
[JsonProperty("cpu_model")] public string CpuModel { get; set; } = "";
|
||||||
|
[JsonProperty("cpu_cores")] public int CpuCores { get; set; }
|
||||||
|
[JsonProperty("cpu_usage")] public double? CpuUsage { get; set; }
|
||||||
|
[JsonProperty("ram_total")] public double? RamTotal { get; set; }
|
||||||
|
[JsonProperty("ram_used")] public double? RamUsed { get; set; }
|
||||||
|
[JsonProperty("disk_total")] public double? DiskTotal { get; set; }
|
||||||
|
[JsonProperty("disk_free")] public double? DiskFree { get; set; }
|
||||||
|
[JsonProperty("uptime_hours")] public double? UptimeHours { get; set; }
|
||||||
|
[JsonProperty("pending_updates")] public int PendingUpdates { get; set; }
|
||||||
|
[JsonProperty("tpm_present")] public bool TpmPresent { get; set; }
|
||||||
|
[JsonProperty("tpm_version")] public string TpmVersion { get; set; } = "";
|
||||||
|
[JsonProperty("tpm_v2")] public bool TpmV2 { get; set; }
|
||||||
|
[JsonProperty("secure_boot")] public bool SecureBoot { get; set; }
|
||||||
|
[JsonProperty("win11_ready")] public bool Win11Ready { get; set; }
|
||||||
|
[JsonProperty("bitlocker_status")] public string BitlockerStatus { get; set; } = "";
|
||||||
|
[JsonProperty("defender_enabled")] public bool DefenderEnabled { get; set; }
|
||||||
|
[JsonProperty("defender_signatures_age")] public int? DefenderSignaturesAge { get; set; }
|
||||||
|
[JsonProperty("hardware_serial")] public string HardwareSerial { get; set; } = "";
|
||||||
|
[JsonProperty("installed_software")] public List<string> InstalledSoftware { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CheckinResponse
|
||||||
|
{
|
||||||
|
[JsonProperty("agent_version")] public string? AgentVersion { get; set; }
|
||||||
|
[JsonProperty("commands")] public List<PatchCommand> Commands { get; set; } = [];
|
||||||
|
[JsonProperty("announcements")] public List<Announcement> Announcements { get; set; } = [];
|
||||||
|
[JsonProperty("running_commands")] public List<RunningCommand> RunningCommands { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PatchCommand
|
||||||
|
{
|
||||||
|
[JsonProperty("id")] public int Id { get; set; }
|
||||||
|
[JsonProperty("command")] public string Command { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RunningCommand
|
||||||
|
{
|
||||||
|
[JsonProperty("id")] public int Id { get; set; }
|
||||||
|
[JsonProperty("command")] public string Command { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Announcement
|
||||||
|
{
|
||||||
|
[JsonProperty("id")] public int Id { get; set; }
|
||||||
|
[JsonProperty("title")] public string Title { get; set; } = "";
|
||||||
|
[JsonProperty("message")] public string Message { get; set; } = "";
|
||||||
|
[JsonProperty("type")] public string Type { get; set; } = "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CommandResult
|
||||||
|
{
|
||||||
|
[JsonProperty("command_id")] public int CommandId { get; set; }
|
||||||
|
[JsonProperty("status")] public string Status { get; set; } = "done";
|
||||||
|
[JsonProperty("result")] public string Result { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StatusCache
|
||||||
|
{
|
||||||
|
[JsonProperty("hostname")] public string Hostname { get; set; } = "";
|
||||||
|
[JsonProperty("last_checkin")] public DateTime LastCheckin { get; set; }
|
||||||
|
[JsonProperty("agent_version")] public string AgentVersion { get; set; } = "";
|
||||||
|
[JsonProperty("server_url")] public string ServerUrl { get; set; } = "";
|
||||||
|
[JsonProperty("online")] public bool Online { get; set; }
|
||||||
|
[JsonProperty("cpu_usage")] public double? CpuUsage { get; set; }
|
||||||
|
[JsonProperty("ram_total")] public double? RamTotal { get; set; }
|
||||||
|
[JsonProperty("ram_used")] public double? RamUsed { get; set; }
|
||||||
|
[JsonProperty("disk_total")] public double? DiskTotal { get; set; }
|
||||||
|
[JsonProperty("disk_free")] public double? DiskFree { get; set; }
|
||||||
|
[JsonProperty("pending_updates")] public int PendingUpdates { get; set; }
|
||||||
|
[JsonProperty("bitlocker_status")] public string BitlockerStatus { get; set; } = "";
|
||||||
|
[JsonProperty("defender_enabled")] public bool DefenderEnabled { get; set; }
|
||||||
|
[JsonProperty("defender_sig_age")] public int? DefenderSignaturesAge { get; set; }
|
||||||
|
[JsonProperty("hardware_serial")] public string HardwareSerial { get; set; } = "";
|
||||||
|
[JsonProperty("os")] public string Os { get; set; } = "";
|
||||||
|
[JsonProperty("last_user")] public string LastUser { get; set; } = "";
|
||||||
|
[JsonProperty("installed_software")] public List<string> InstalledSoftware { get; set; } = [];
|
||||||
|
}
|
||||||
20
agent-cs/Models/Config.cs
Normal file
20
agent-cs/Models/Config.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.Models;
|
||||||
|
|
||||||
|
public class AgentConfig
|
||||||
|
{
|
||||||
|
[JsonProperty("server_url")]
|
||||||
|
public string ServerUrl { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonProperty("agent_key")]
|
||||||
|
public string AgentKey { get; set; } = "";
|
||||||
|
|
||||||
|
public static AgentConfig Load(string path)
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
return JsonConvert.DeserializeObject<AgentConfig>(json)
|
||||||
|
?? throw new Exception("Ungültige config.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
58
agent-cs/Program.cs
Normal file
58
agent-cs/Program.cs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
using ITNexusAgent;
|
||||||
|
using ITNexusAgent.Models;
|
||||||
|
using ITNexusAgent.UI;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System.ServiceProcess;
|
||||||
|
|
||||||
|
internal class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
static void Main(string[] args)
|
||||||
|
{
|
||||||
|
var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName;
|
||||||
|
|
||||||
|
if (args.Length > 0)
|
||||||
|
{
|
||||||
|
switch (args[0])
|
||||||
|
{
|
||||||
|
case "--notify":
|
||||||
|
RunNotification(args.Length > 1 ? args[1] : "");
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "--dashboard":
|
||||||
|
RunDashboard();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "--install":
|
||||||
|
AgentService.MigrateFromOldAgent();
|
||||||
|
AgentService.Install(exePath);
|
||||||
|
return;
|
||||||
|
|
||||||
|
case "--uninstall":
|
||||||
|
AgentService.Uninstall();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ServiceBase.Run(new AgentService());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void RunNotification(string base64Json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64Json));
|
||||||
|
var ann = JsonConvert.DeserializeObject<Announcement>(json);
|
||||||
|
if (ann == null) return;
|
||||||
|
var app = new System.Windows.Application();
|
||||||
|
app.Run(new NotificationWindow(ann));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
static void RunDashboard()
|
||||||
|
{
|
||||||
|
var app = new System.Windows.Application();
|
||||||
|
app.Run(new DashboardWindow());
|
||||||
|
}
|
||||||
|
}
|
||||||
89
agent-cs/Services/ApiService.cs
Normal file
89
agent-cs/Services/ApiService.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using ITNexusAgent.Models;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.Services;
|
||||||
|
|
||||||
|
public class ApiService(string serverUrl, string agentKey)
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
|
private readonly string _baseUrl = serverUrl.TrimEnd('/');
|
||||||
|
private readonly string _agentKey = agentKey;
|
||||||
|
|
||||||
|
private HttpRequestMessage BuildRequest(HttpMethod method, string path, object? body = null)
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(method, $"{_baseUrl}{path}");
|
||||||
|
req.Headers.Add("X-Agent-Key", _agentKey);
|
||||||
|
if (body != null)
|
||||||
|
{
|
||||||
|
var json = JsonConvert.SerializeObject(body);
|
||||||
|
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
}
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CheckinResponse> CheckinAsync(CheckinPayload payload)
|
||||||
|
{
|
||||||
|
var req = BuildRequest(HttpMethod.Post, "/api/monitoring/checkin", payload);
|
||||||
|
var resp = await _http.SendAsync(req);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
return JsonConvert.DeserializeObject<CheckinResponse>(body) ?? new CheckinResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Announcement>> PollAnnouncementsAsync(string hostname)
|
||||||
|
{
|
||||||
|
var req = BuildRequest(HttpMethod.Post, "/api/monitoring/announcements-poll",
|
||||||
|
new { hostname });
|
||||||
|
var resp = await _http.SendAsync(req);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonConvert.DeserializeAnonymousType(body, new { announcements = new List<Announcement>() });
|
||||||
|
return result?.announcements ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AckAnnouncementAsync(int id, string hostname)
|
||||||
|
{
|
||||||
|
var req = BuildRequest(HttpMethod.Post, $"/api/announcements/{id}/ack-agent",
|
||||||
|
new { hostname });
|
||||||
|
var resp = await _http.SendAsync(req);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReportCommandResultAsync(CommandResult result)
|
||||||
|
{
|
||||||
|
var req = BuildRequest(HttpMethod.Post, "/api/patch/commands/result", result);
|
||||||
|
var resp = await _http.SendAsync(req);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]> DownloadAgentAsync()
|
||||||
|
{
|
||||||
|
var req = BuildRequest(HttpMethod.Get, "/api/monitoring/agent-script");
|
||||||
|
var resp = await _http.SendAsync(req);
|
||||||
|
return await resp.Content.ReadAsByteArrayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]> DownloadSetupAsync()
|
||||||
|
{
|
||||||
|
var req = BuildRequest(HttpMethod.Get, "/api/monitoring/agent-setup");
|
||||||
|
var resp = await _http.SendAsync(req);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
return await resp.Content.ReadAsByteArrayAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendMessageAsync(string hostname, string message)
|
||||||
|
{
|
||||||
|
var req = BuildRequest(HttpMethod.Post, "/api/monitoring/message",
|
||||||
|
new { hostname, message });
|
||||||
|
await _http.SendAsync(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendRebootRequestAsync(string hostname)
|
||||||
|
{
|
||||||
|
var req = BuildRequest(HttpMethod.Post, "/api/monitoring/reboot-request",
|
||||||
|
new { hostname });
|
||||||
|
await _http.SendAsync(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
agent-cs/Services/CommandExecutor.cs
Normal file
123
agent-cs/Services/CommandExecutor.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using ITNexusAgent.Models;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Management;
|
||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.Services;
|
||||||
|
|
||||||
|
public class CommandExecutor(ApiService api, string hostname, string dataDir, string exePath)
|
||||||
|
{
|
||||||
|
private readonly ApiService _api = api;
|
||||||
|
private readonly string _hostname = hostname;
|
||||||
|
private readonly string _dataDir = dataDir;
|
||||||
|
private readonly string _exePath = exePath;
|
||||||
|
|
||||||
|
private static void RunSchtasks(string args) =>
|
||||||
|
Process.Start(new ProcessStartInfo("schtasks.exe", args) { CreateNoWindow = true })?.WaitForExit();
|
||||||
|
|
||||||
|
public async Task ExecuteAsync(PatchCommand cmd)
|
||||||
|
{
|
||||||
|
string result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = cmd.Command switch
|
||||||
|
{
|
||||||
|
"check_updates" => await CheckUpdates(),
|
||||||
|
"install_updates" => await InstallUpdates(),
|
||||||
|
"reboot" => await Reboot(cmd.Id),
|
||||||
|
"upgrade_win11" => await UpgradeWin11(),
|
||||||
|
"update_agent" => await UpdateAgent(),
|
||||||
|
_ => $"Unbekannter Command: {cmd.Command}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result = $"Fehler: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd.Command != "reboot")
|
||||||
|
{
|
||||||
|
await _api.ReportCommandResultAsync(new CommandResult
|
||||||
|
{
|
||||||
|
CommandId = cmd.Id,
|
||||||
|
Status = "done",
|
||||||
|
Result = result
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<string> CheckUpdates()
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo("UsoClient.exe", "StartScan") { CreateNoWindow = true });
|
||||||
|
Thread.Sleep(5000);
|
||||||
|
return Task.FromResult("Update-Scan gestartet");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<string> InstallUpdates()
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo("UsoClient.exe", "StartDownload") { CreateNoWindow = true });
|
||||||
|
Thread.Sleep(3000);
|
||||||
|
Process.Start(new ProcessStartInfo("UsoClient.exe", "StartInstall") { CreateNoWindow = true });
|
||||||
|
return Task.FromResult("Update-Installation gestartet");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> Reboot(int cmdId)
|
||||||
|
{
|
||||||
|
await _api.ReportCommandResultAsync(new CommandResult
|
||||||
|
{
|
||||||
|
CommandId = cmdId,
|
||||||
|
Status = "done",
|
||||||
|
Result = "Neustart wird in 60 Sekunden durchgeführt"
|
||||||
|
});
|
||||||
|
Process.Start("shutdown.exe", "/r /t 60 /c \"IT Nexus Patch Management - Geplanter Neustart\"");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<string> UpgradeWin11()
|
||||||
|
{
|
||||||
|
var exePath = Path.Combine(_dataDir, "Win11Upgrade.exe");
|
||||||
|
using var client = new HttpClient();
|
||||||
|
var bytes = client.GetByteArrayAsync("https://go.microsoft.com/fwlink/?linkid=2171764").Result;
|
||||||
|
if (bytes.Length < 1024 * 1024) return Task.FromResult("Download fehlgeschlagen");
|
||||||
|
File.WriteAllBytes(exePath, bytes);
|
||||||
|
|
||||||
|
var fullUser = NotificationService.GetLoggedOnUser();
|
||||||
|
if (!string.IsNullOrEmpty(fullUser))
|
||||||
|
{
|
||||||
|
RunSchtasks("/delete /tn \"IT Nexus Win11 Upgrade\" /f");
|
||||||
|
RunSchtasks($"/create /tn \"IT Nexus Win11 Upgrade\" /tr \"\\\"{exePath}\\\" /skipeula /auto upgrade\" /sc ONCE /st {DateTime.Now.AddSeconds(20):HH:mm} /ru \"{fullUser}\" /it /rl HIGHEST /f");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(exePath, "/quietinstall /skipeula /auto upgrade")
|
||||||
|
{ CreateNoWindow = true });
|
||||||
|
}
|
||||||
|
return Task.FromResult("running");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> UpdateAgent()
|
||||||
|
{
|
||||||
|
var bytes = await _api.DownloadSetupAsync();
|
||||||
|
if (bytes.Length < 512 * 1024) return "Download fehlgeschlagen - Installer zu klein";
|
||||||
|
|
||||||
|
var setupPath = Path.Combine(_dataDir, "IT-Nexus-Agent-Setup-Update.exe");
|
||||||
|
var batchPath = Path.Combine(_dataDir, "update.cmd");
|
||||||
|
|
||||||
|
await File.WriteAllBytesAsync(setupPath, bytes);
|
||||||
|
|
||||||
|
// Batch: wartet kurz, führt Installer silent aus, löscht sich selbst
|
||||||
|
var batch = $"""
|
||||||
|
@echo off
|
||||||
|
timeout /t 5 /nobreak > nul
|
||||||
|
"{setupPath}" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
|
||||||
|
del "{setupPath}" > nul
|
||||||
|
del "%~f0"
|
||||||
|
""";
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(batchPath, batch, System.Text.Encoding.ASCII);
|
||||||
|
Process.Start(new ProcessStartInfo("cmd.exe", $"/c \"{batchPath}\"")
|
||||||
|
{ CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden });
|
||||||
|
|
||||||
|
return "Update-Installer gestartet";
|
||||||
|
}
|
||||||
|
}
|
||||||
110
agent-cs/Services/NotificationService.cs
Normal file
110
agent-cs/Services/NotificationService.cs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
using ITNexusAgent.Models;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Management;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.Services;
|
||||||
|
|
||||||
|
public class NotificationService
|
||||||
|
{
|
||||||
|
private readonly string _exePath;
|
||||||
|
private readonly string _dataDir;
|
||||||
|
private readonly string _shownIdsPath;
|
||||||
|
private readonly HashSet<int> _shownIds;
|
||||||
|
|
||||||
|
public NotificationService(string exePath, string dataDir)
|
||||||
|
{
|
||||||
|
_exePath = exePath;
|
||||||
|
_dataDir = dataDir;
|
||||||
|
_shownIdsPath = Path.Combine(dataDir, "shown_announcements.json");
|
||||||
|
_shownIds = LoadShownIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HashSet<int> LoadShownIds()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_shownIdsPath))
|
||||||
|
{
|
||||||
|
var ids = JsonConvert.DeserializeObject<List<int>>(File.ReadAllText(_shownIdsPath));
|
||||||
|
return ids != null ? new HashSet<int>(ids) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveShownIds()
|
||||||
|
{
|
||||||
|
try { File.WriteAllText(_shownIdsPath, JsonConvert.SerializeObject(_shownIds.ToList())); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowAnnouncements(List<Announcement> announcements)
|
||||||
|
{
|
||||||
|
foreach (var ann in announcements)
|
||||||
|
{
|
||||||
|
if (_shownIds.Contains(ann.Id)) continue;
|
||||||
|
_shownIds.Add(ann.Id);
|
||||||
|
SaveShownIds();
|
||||||
|
ShowNotification(ann);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vollständigen eingeloggten User aus WMI holen (z.B. "WINKEL\gruessing" oder "AzureAD\gruessing")
|
||||||
|
public static string GetLoggedOnUser()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var searcher = new ManagementObjectSearcher("SELECT UserName FROM Win32_ComputerSystem");
|
||||||
|
foreach (ManagementObject obj in searcher.Get())
|
||||||
|
{
|
||||||
|
var user = obj["UserName"]?.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(user)) return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowNotification(Announcement ann)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fullUser = GetLoggedOnUser();
|
||||||
|
if (string.IsNullOrEmpty(fullUser)) return;
|
||||||
|
|
||||||
|
var json = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(ann)));
|
||||||
|
var taskName = $"ITNexus-Ann-{ann.Id}";
|
||||||
|
|
||||||
|
// Alten Task entfernen
|
||||||
|
Process.Start(new ProcessStartInfo("schtasks.exe", $"/delete /tn \"{taskName}\" /f")
|
||||||
|
{ CreateNoWindow = true })?.WaitForExit();
|
||||||
|
|
||||||
|
// Task mit Trigger weit in der Zukunft erstellen (damit er nicht abläuft vor /run)
|
||||||
|
var triggerTime = DateTime.Now.AddMinutes(60).ToString("HH:mm:ss");
|
||||||
|
var args = $"/create /tn \"{taskName}\" /tr \"\\\"{_exePath}\\\" --notify {json}\" " +
|
||||||
|
$"/sc ONCE /st {triggerTime} /ru \"{fullUser}\" /it /f";
|
||||||
|
var p = Process.Start(new ProcessStartInfo("schtasks.exe", args)
|
||||||
|
{ CreateNoWindow = true, RedirectStandardError = true, UseShellExecute = false });
|
||||||
|
p?.WaitForExit();
|
||||||
|
|
||||||
|
// Sofort ausführen
|
||||||
|
var run = Process.Start(new ProcessStartInfo("schtasks.exe", $"/run /tn \"{taskName}\"")
|
||||||
|
{ CreateNoWindow = true });
|
||||||
|
run?.WaitForExit();
|
||||||
|
|
||||||
|
// Task löschen damit der +60min Trigger nicht nochmal feuert
|
||||||
|
Thread.Sleep(3000);
|
||||||
|
Process.Start(new ProcessStartInfo("schtasks.exe", $"/delete /tn \"{taskName}\" /f")
|
||||||
|
{ CreateNoWindow = true })?.WaitForExit();
|
||||||
|
|
||||||
|
AgentWorker.Log($"NOTIFY: Task '{taskName}' für User '{fullUser}' gestartet (ExitCode={p?.ExitCode})");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AgentWorker.Log($"NOTIFY ERROR: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
agent-cs/Services/SecurityInfoService.cs
Normal file
64
agent-cs/Services/SecurityInfoService.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Management;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.Services;
|
||||||
|
|
||||||
|
public static class SecurityInfoService
|
||||||
|
{
|
||||||
|
public static string GetBitlockerStatus()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scope = new ManagementScope(@"\\.\root\cimv2\Security\MicrosoftVolumeEncryption");
|
||||||
|
scope.Connect();
|
||||||
|
using var searcher = new ManagementObjectSearcher(scope,
|
||||||
|
new System.Management.ObjectQuery("SELECT * FROM Win32_EncryptableVolume WHERE DriveLetter='C:'"));
|
||||||
|
foreach (ManagementObject obj in searcher.Get())
|
||||||
|
{
|
||||||
|
var protection = Convert.ToInt32(obj["ProtectionStatus"]);
|
||||||
|
return protection switch
|
||||||
|
{
|
||||||
|
1 => "encrypted",
|
||||||
|
2 => "unknown",
|
||||||
|
_ => "off"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (bool Enabled, int SignaturesAgeDays, DateTime? LastScan) GetDefenderStatus()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scope = new ManagementScope(@"\\.\root\Microsoft\Windows\Defender");
|
||||||
|
scope.Connect();
|
||||||
|
using var searcher = new ManagementObjectSearcher(scope,
|
||||||
|
new System.Management.ObjectQuery("SELECT * FROM MSFT_MpComputerStatus"));
|
||||||
|
foreach (ManagementObject obj in searcher.Get())
|
||||||
|
{
|
||||||
|
var enabled = Convert.ToBoolean(obj["AMServiceEnabled"] ?? false);
|
||||||
|
var sigDate = obj["AntivirusSignatureLastUpdated"]?.ToString();
|
||||||
|
var lastScanAge = Convert.ToInt32(obj["QuickScanAge"] ?? 999);
|
||||||
|
|
||||||
|
int sigAge = 0;
|
||||||
|
if (!string.IsNullOrEmpty(sigDate) && sigDate.Length >= 8)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParseExact(sigDate[..14], "yyyyMMddHHmmss",
|
||||||
|
null, System.Globalization.DateTimeStyles.None, out var sigDateTime))
|
||||||
|
{
|
||||||
|
sigAge = (int)(DateTime.Now - sigDateTime).TotalDays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? lastScan = lastScanAge < 999
|
||||||
|
? DateTime.Now.AddDays(-lastScanAge)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (enabled, sigAge, lastScan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return (false, -1, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
267
agent-cs/Services/SystemInfoService.cs
Normal file
267
agent-cs/Services/SystemInfoService.cs
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
using ITNexusAgent.Models;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Management;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Principal;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.Services;
|
||||||
|
|
||||||
|
public static class SystemInfoService
|
||||||
|
{
|
||||||
|
public static string GetHostname() => Environment.MachineName;
|
||||||
|
|
||||||
|
public static string GetDomain() =>
|
||||||
|
Environment.UserDomainName != Environment.MachineName
|
||||||
|
? Environment.UserDomainName
|
||||||
|
: "";
|
||||||
|
|
||||||
|
public static string GetOs()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion");
|
||||||
|
var name = key?.GetValue("ProductName")?.ToString() ?? "Windows";
|
||||||
|
var build = key?.GetValue("CurrentBuildNumber")?.ToString();
|
||||||
|
var ubr = key?.GetValue("UBR")?.ToString();
|
||||||
|
|
||||||
|
// Build >= 22000 = Windows 11 — Registry ProductName kann noch "Windows 10" sagen nach Upgrade
|
||||||
|
if (build != null && int.TryParse(build, out var buildNum) && buildNum >= 22000)
|
||||||
|
name = name.Replace("Windows 10", "Windows 11");
|
||||||
|
|
||||||
|
return build != null ? $"{name} (Build {build}.{ubr})" : name;
|
||||||
|
}
|
||||||
|
catch { return RuntimeInformation.OSDescription; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetOsVersion()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion");
|
||||||
|
return key?.GetValue("CurrentBuildNumber")?.ToString() ?? Environment.OSVersion.Version.ToString();
|
||||||
|
}
|
||||||
|
catch { return Environment.OSVersion.Version.ToString(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetIpAddress()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (iface.OperationalStatus != OperationalStatus.Up) continue;
|
||||||
|
if (iface.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||||
|
var name = iface.Name.ToLower();
|
||||||
|
if (name.Contains("virtual") || name.Contains("wsl") || name.Contains("vmware")) continue;
|
||||||
|
|
||||||
|
foreach (var addr in iface.GetIPProperties().UnicastAddresses)
|
||||||
|
{
|
||||||
|
if (addr.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
|
||||||
|
var ip = addr.Address.ToString();
|
||||||
|
if (ip.StartsWith("169.254") || ip.StartsWith("172.")) continue;
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetMacAddress()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var iface in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (iface.OperationalStatus != OperationalStatus.Up) continue;
|
||||||
|
if (iface.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||||
|
var mac = iface.GetPhysicalAddress().ToString();
|
||||||
|
if (mac.Length == 12)
|
||||||
|
return string.Join(":", Enumerable.Range(0, 6).Select(i => mac.Substring(i * 2, 2)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetLastUser()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var searcher = new ManagementObjectSearcher("SELECT UserName FROM Win32_ComputerSystem");
|
||||||
|
foreach (ManagementObject obj in searcher.Get())
|
||||||
|
{
|
||||||
|
var user = obj["UserName"]?.ToString();
|
||||||
|
if (!string.IsNullOrEmpty(user))
|
||||||
|
return user.Contains('\\') ? user.Split('\\')[1] : user;
|
||||||
|
}
|
||||||
|
// Fallback: letztes Profil
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(
|
||||||
|
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI");
|
||||||
|
return key?.GetValue("LastLoggedOnUser")?.ToString()?.Split('\\').LastOrDefault() ?? "";
|
||||||
|
}
|
||||||
|
catch { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (string Model, int Cores) GetCpuInfo()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var searcher = new ManagementObjectSearcher("SELECT Name, NumberOfCores FROM Win32_Processor");
|
||||||
|
foreach (ManagementObject obj in searcher.Get())
|
||||||
|
return (obj["Name"]?.ToString()?.Trim() ?? "", Convert.ToInt32(obj["NumberOfCores"]));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return ("", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double GetCpuUsage()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var counter = new PerformanceCounter("Processor", "% Processor Time", "_Total");
|
||||||
|
counter.NextValue();
|
||||||
|
Thread.Sleep(800);
|
||||||
|
return Math.Round(counter.NextValue(), 1);
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (double Total, double Used) GetRamInfo()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var searcher = new ManagementObjectSearcher("SELECT TotalVisibleMemorySize, FreePhysicalMemory FROM Win32_OperatingSystem");
|
||||||
|
foreach (ManagementObject obj in searcher.Get())
|
||||||
|
{
|
||||||
|
var total = Math.Round(Convert.ToDouble(obj["TotalVisibleMemorySize"]) / (1024 * 1024), 2);
|
||||||
|
var free = Math.Round(Convert.ToDouble(obj["FreePhysicalMemory"]) / (1024 * 1024), 2);
|
||||||
|
return (total, Math.Round(total - free, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return (0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (double Total, double Free) GetDiskInfo()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var drive = new DriveInfo("C");
|
||||||
|
return (
|
||||||
|
Math.Round(drive.TotalSize / (double)(1024 * 1024 * 1024), 2),
|
||||||
|
Math.Round(drive.AvailableFreeSpace / (double)(1024 * 1024 * 1024), 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch { return (0, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double GetUptimeHours()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var uptime = TimeSpan.FromMilliseconds(Environment.TickCount64);
|
||||||
|
return Math.Round(uptime.TotalHours, 1);
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetPendingUpdates()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var type = Type.GetTypeFromProgID("Microsoft.Update.Session");
|
||||||
|
if (type == null) return 0;
|
||||||
|
dynamic session = Activator.CreateInstance(type)!;
|
||||||
|
var searcher = session.CreateUpdateSearcher();
|
||||||
|
var result = searcher.Search("IsInstalled=0 and Type='Software'");
|
||||||
|
return result.Updates.Count;
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (bool Present, string Version, bool V2) GetTpmInfo()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var searcher = new ManagementObjectSearcher(@"root\CIMv2\Security\MicrosoftTpm",
|
||||||
|
"SELECT * FROM Win32_Tpm");
|
||||||
|
foreach (ManagementObject obj in searcher.Get())
|
||||||
|
{
|
||||||
|
var version = obj["SpecVersion"]?.ToString() ?? "";
|
||||||
|
return (true, version, version.StartsWith("2.0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return (false, "", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool GetSecureBoot()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(
|
||||||
|
@"SYSTEM\CurrentControlSet\Control\SecureBoot\State");
|
||||||
|
return Convert.ToInt32(key?.GetValue("UEFISecureBootEnabled") ?? 0) == 1;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool GetWin11Readiness(bool tpmV2, bool secureBoot, int cpuCores, double ramGb)
|
||||||
|
{
|
||||||
|
// Wenn bereits Win 11 läuft → immer true
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion");
|
||||||
|
var build = key?.GetValue("CurrentBuildNumber")?.ToString();
|
||||||
|
if (build != null && int.TryParse(build, out var b) && b >= 22000) return true;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return tpmV2 && secureBoot && cpuCores >= 2 && ramGb >= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetHardwareSerial()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var searcher = new ManagementObjectSearcher("SELECT SerialNumber FROM Win32_BIOS");
|
||||||
|
foreach (ManagementObject obj in searcher.Get())
|
||||||
|
return obj["SerialNumber"]?.ToString()?.Trim() ?? "";
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<string> GetInstalledSoftware()
|
||||||
|
{
|
||||||
|
var apps = new HashSet<string>();
|
||||||
|
var paths = new[]
|
||||||
|
{
|
||||||
|
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
|
||||||
|
@"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var path in paths)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(path);
|
||||||
|
if (key == null) continue;
|
||||||
|
foreach (var subName in key.GetSubKeyNames())
|
||||||
|
{
|
||||||
|
using var sub = key.OpenSubKey(subName);
|
||||||
|
var name = sub?.GetValue("DisplayName")?.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
if (System.Text.RegularExpressions.Regex.IsMatch(name, @"^KB\d+")) continue;
|
||||||
|
apps.Add(name.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. apps.OrderBy(x => x).Take(100)];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
agent-cs/UI/AdminLoginDialog.xaml
Normal file
28
agent-cs/UI/AdminLoginDialog.xaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<Window x:Class="ITNexusAgent.UI.AdminLoginDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="IT Nexus – Admin-Login"
|
||||||
|
Width="360" Height="230"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ResizeMode="NoResize" Background="White" FontFamily="Segoe UI">
|
||||||
|
<StackPanel Margin="24">
|
||||||
|
<TextBlock Text="Admin-Anmeldung" FontSize="14" FontWeight="Bold"
|
||||||
|
Foreground="#1F2937" Margin="0,0,0,16"/>
|
||||||
|
<TextBlock Text="Benutzername" FontSize="11" Foreground="#6B7280" Margin="0,0,0,4"/>
|
||||||
|
<TextBox x:Name="UsernameBox" Height="32" Padding="8,4" FontSize="12"
|
||||||
|
BorderBrush="#D1D5DB" Margin="0,0,0,10"/>
|
||||||
|
<TextBlock Text="Passwort" FontSize="11" Foreground="#6B7280" Margin="0,0,0,4"/>
|
||||||
|
<PasswordBox x:Name="PasswordBox" Height="32" Padding="8,4" FontSize="12"
|
||||||
|
BorderBrush="#D1D5DB" Margin="0,0,0,4"
|
||||||
|
KeyDown="PasswordBox_KeyDown"/>
|
||||||
|
<TextBlock x:Name="ErrorLabel" FontSize="11" Foreground="#EF4444"
|
||||||
|
Margin="0,4,0,0" Visibility="Collapsed"/>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
|
||||||
|
<Button Content="Abbrechen" Width="80" Height="32" Margin="0,0,8,0"
|
||||||
|
Click="Cancel_Click" Background="#F9FAFB" BorderBrush="#D1D5DB" FontSize="12"/>
|
||||||
|
<Button x:Name="LoginButton" Content="Anmelden" Width="90" Height="32"
|
||||||
|
Click="Login_Click" Background="#0078D4" Foreground="White"
|
||||||
|
BorderThickness="0" FontSize="12" FontWeight="SemiBold"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
65
agent-cs/UI/AdminLoginDialog.xaml.cs
Normal file
65
agent-cs/UI/AdminLoginDialog.xaml.cs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.UI;
|
||||||
|
|
||||||
|
public partial class AdminLoginDialog : Window
|
||||||
|
{
|
||||||
|
public string Token { get; private set; } = "";
|
||||||
|
public string Username { get; private set; } = "";
|
||||||
|
private readonly string _serverUrl;
|
||||||
|
|
||||||
|
public AdminLoginDialog(string serverUrl)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_serverUrl = serverUrl.TrimEnd('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Login_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var username = UsernameBox.Text.Trim();
|
||||||
|
var password = PasswordBox.Password;
|
||||||
|
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) return;
|
||||||
|
|
||||||
|
LoginButton.IsEnabled = false;
|
||||||
|
ErrorLabel.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
var body = JsonConvert.SerializeObject(new { username, password });
|
||||||
|
var resp = await http.PostAsync($"{_serverUrl}/api/auth/login",
|
||||||
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
var result = JsonConvert.DeserializeAnonymousType(json, new { token = "" });
|
||||||
|
|
||||||
|
if (resp.IsSuccessStatusCode && !string.IsNullOrEmpty(result?.token))
|
||||||
|
{
|
||||||
|
Token = result.token;
|
||||||
|
Username = username;
|
||||||
|
DialogResult = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ErrorLabel.Text = "Ungültige Anmeldedaten";
|
||||||
|
ErrorLabel.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ErrorLabel.Text = "Verbindung fehlgeschlagen";
|
||||||
|
ErrorLabel.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
finally { LoginButton.IsEnabled = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PasswordBox_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter) Login_Click(sender, new RoutedEventArgs());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel_Click(object sender, RoutedEventArgs e) => DialogResult = false;
|
||||||
|
}
|
||||||
510
agent-cs/UI/DashboardWindow.xaml
Normal file
510
agent-cs/UI/DashboardWindow.xaml
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
<Window x:Class="ITNexusAgent.UI.DashboardWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="IT Nexus Agent"
|
||||||
|
Icon="pack://application:,,,/icon.ico"
|
||||||
|
Width="560" Height="640"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ResizeMode="CanResize"
|
||||||
|
StateChanged="Window_StateChanged"
|
||||||
|
Background="Transparent"
|
||||||
|
FontFamily="Segoe UI"
|
||||||
|
WindowStyle="None"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
MinWidth="480" MinHeight="540">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<Style x:Key="Card" TargetType="Border">
|
||||||
|
<Setter Property="Background" Value="#1C1C1F"/>
|
||||||
|
<Setter Property="CornerRadius" Value="12"/>
|
||||||
|
<Setter Property="Padding" Value="16,14"/>
|
||||||
|
<Setter Property="Margin" Value="0,0,0,10"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="BtnAction" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="#2A2A2E"/>
|
||||||
|
<Setter Property="Foreground" Value="#E4E4E7"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="9"
|
||||||
|
BorderThickness="1" BorderBrush="#333337">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#35353A"/>
|
||||||
|
<Setter TargetName="Bd" Property="BorderBrush" Value="#4A4A50"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#1A1A1D"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="BtnPrimary" TargetType="Button" BasedOn="{StaticResource BtnAction}">
|
||||||
|
<Setter Property="Background" Value="#1D4ED8"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="9" BorderThickness="0">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#2563EB"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#1E40AF"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="BtnWarn" TargetType="Button" BasedOn="{StaticResource BtnAction}">
|
||||||
|
<Setter Property="Background" Value="#27180A"/>
|
||||||
|
<Setter Property="Foreground" Value="#F97316"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="9"
|
||||||
|
BorderThickness="1" BorderBrush="#3D2510">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#352010"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="BtnGhost" TargetType="Button">
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="Foreground" Value="#52525B"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<TextBlock x:Name="T" Text="{TemplateBinding Content}"
|
||||||
|
Foreground="{TemplateBinding Foreground}"
|
||||||
|
FontSize="{TemplateBinding FontSize}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="T" Property="Foreground" Value="#71717A"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="BtnAdminAction" TargetType="Button" BasedOn="{StaticResource BtnAction}">
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
</Style>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<!-- Outer container -->
|
||||||
|
<Grid>
|
||||||
|
<Border x:Name="OuterBorder" Background="#111113" CornerRadius="14" BorderBrush="#2A2A2E" BorderThickness="1"
|
||||||
|
Margin="16">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" Opacity="0.4" BlurRadius="20" ShadowDepth="0"/>
|
||||||
|
</Border.Effect>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- ═══ TITLEBAR (draggable) ═══ -->
|
||||||
|
<Border x:Name="TitleBarBorder" Grid.Row="0" Background="#18181B" CornerRadius="14,14,0,0"
|
||||||
|
MouseLeftButtonDown="TitleBar_MouseDown">
|
||||||
|
<Grid Margin="18,14,18,14">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<StackPanel VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="IT Nexus Agent" FontSize="15" FontWeight="Bold"
|
||||||
|
Foreground="#FAFAFA"/>
|
||||||
|
<TextBlock x:Name="HostnameLabel" FontSize="11" Foreground="#52525B"
|
||||||
|
Margin="0,2,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<!-- Status -->
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,20,0" VerticalAlignment="Center">
|
||||||
|
<Ellipse x:Name="StatusDot" Width="8" Height="8" Margin="0,0,7,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="StatusLabel" FontSize="11" FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<!-- Minimieren -->
|
||||||
|
<Button Width="32" Height="32" Click="Minimize_Click" ToolTip="Minimieren">
|
||||||
|
<Button.Template>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="Transparent" CornerRadius="6">
|
||||||
|
<TextBlock Text="" FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="9" Foreground="#52525B"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#2A2A2E"/>
|
||||||
|
<Setter Property="Foreground" Value="#A1A1AA"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Button.Template>
|
||||||
|
</Button>
|
||||||
|
<!-- Maximieren -->
|
||||||
|
<Button Width="32" Height="32" Click="Maximize_Click" ToolTip="Maximieren">
|
||||||
|
<Button.Template>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="Transparent" CornerRadius="6">
|
||||||
|
<TextBlock x:Name="MaxIcon" Text="" FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="9" Foreground="#52525B"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#2A2A2E"/>
|
||||||
|
<Setter TargetName="MaxIcon" Property="Foreground" Value="#A1A1AA"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Button.Template>
|
||||||
|
</Button>
|
||||||
|
<!-- Schließen -->
|
||||||
|
<Button Width="32" Height="32" Click="Close_Click" ToolTip="Schließen">
|
||||||
|
<Button.Template>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bd" Background="Transparent" CornerRadius="6">
|
||||||
|
<TextBlock Text="" FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="9" Foreground="#52525B"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#C0392B"/>
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Button.Template>
|
||||||
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ CONTENT ═══ -->
|
||||||
|
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Hidden"
|
||||||
|
Background="#111113">
|
||||||
|
<StackPanel Margin="14,12,14,4">
|
||||||
|
|
||||||
|
<!-- Metric Cards -->
|
||||||
|
<Grid Margin="0,0,0,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="8"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="8"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<Border Grid.Column="0" Background="#1C1C1F" CornerRadius="12" Padding="14,12">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="CPU" FontSize="10" Foreground="#52525B"
|
||||||
|
FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||||
|
<TextBlock x:Name="CpuLabel" FontSize="24" FontWeight="Bold"
|
||||||
|
Foreground="#FAFAFA" Margin="0,0,0,8"/>
|
||||||
|
<ProgressBar x:Name="CpuBar" Height="3" BorderThickness="0"
|
||||||
|
Background="#2A2A2E" Minimum="0" Maximum="100"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Column="2" Background="#1C1C1F" CornerRadius="12" Padding="14,12">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="RAM" FontSize="10" Foreground="#52525B"
|
||||||
|
FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||||
|
<TextBlock x:Name="RamLabel" FontSize="14" FontWeight="Bold"
|
||||||
|
Foreground="#FAFAFA" Margin="0,0,0,8" TextWrapping="Wrap"/>
|
||||||
|
<ProgressBar x:Name="RamBar" Height="3" BorderThickness="0"
|
||||||
|
Background="#2A2A2E" Minimum="0" Maximum="100"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Grid.Column="4" Background="#1C1C1F" CornerRadius="12" Padding="14,12">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="DISK" FontSize="10" Foreground="#52525B"
|
||||||
|
FontWeight="SemiBold" Margin="0,0,0,6"/>
|
||||||
|
<TextBlock x:Name="DiskLabel" FontSize="14" FontWeight="Bold"
|
||||||
|
Foreground="#FAFAFA" Margin="0,0,0,8" TextWrapping="Wrap"/>
|
||||||
|
<ProgressBar x:Name="DiskBar" Height="3" BorderThickness="0"
|
||||||
|
Background="#2A2A2E" Minimum="0" Maximum="100"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Status Card -->
|
||||||
|
<Border Background="#1C1C1F" CornerRadius="12" Padding="16,14" Margin="0,0,0,10">
|
||||||
|
<StackPanel>
|
||||||
|
<Grid Margin="0,0,0,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="28"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="" FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="13" Foreground="#3F3F46" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="BitLocker" FontSize="12"
|
||||||
|
Foreground="#A1A1AA" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="BitlockerLabel" Grid.Column="2" FontSize="12"
|
||||||
|
FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Rectangle Height="1" Fill="#27272A" Margin="0,0,0,10"/>
|
||||||
|
|
||||||
|
<Grid Margin="0,0,0,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="28"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="" FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="13" Foreground="#3F3F46" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="Microsoft Defender" FontSize="12"
|
||||||
|
Foreground="#A1A1AA" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="DefenderLabel" Grid.Column="2" FontSize="12"
|
||||||
|
FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Rectangle Height="1" Fill="#27272A" Margin="0,0,0,10"/>
|
||||||
|
|
||||||
|
<Grid Margin="0,0,0,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="28"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="" FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="13" Foreground="#3F3F46" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="Windows Updates" FontSize="12"
|
||||||
|
Foreground="#A1A1AA" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="UpdatesLabel" Grid.Column="2" FontSize="12"
|
||||||
|
FontWeight="SemiBold" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Rectangle Height="1" Fill="#27272A" Margin="0,0,0,10"/>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="28"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="" FontFamily="Segoe MDL2 Assets"
|
||||||
|
FontSize="13" Foreground="#3F3F46" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="Seriennummer" FontSize="12"
|
||||||
|
Foreground="#A1A1AA" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="SerialLabel" Grid.Column="2" FontSize="11"
|
||||||
|
Foreground="#52525B" VerticalAlignment="Center"
|
||||||
|
FontFamily="Consolas"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Installierte Software -->
|
||||||
|
<Border Background="#1C1C1F" CornerRadius="12" Padding="16,14" Margin="0,0,0,10">
|
||||||
|
<StackPanel>
|
||||||
|
<Grid Margin="0,0,0,10">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="INSTALLIERTE SOFTWARE" FontSize="10" FontWeight="SemiBold"
|
||||||
|
Foreground="#52525B" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="SoftwareCountLabel" Grid.Column="1" FontSize="10"
|
||||||
|
Foreground="#3F3F46" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
<Border Background="#27272A" CornerRadius="8" BorderBrush="#3F3F46"
|
||||||
|
BorderThickness="1" Padding="10,0" Margin="0,0,0,8">
|
||||||
|
<TextBox x:Name="SoftwareSearch" Background="Transparent"
|
||||||
|
Foreground="#71717A" BorderThickness="0" FontSize="12"
|
||||||
|
Height="32" VerticalContentAlignment="Center"
|
||||||
|
CaretBrush="White"
|
||||||
|
TextChanged="SoftwareSearch_TextChanged"/>
|
||||||
|
</Border>
|
||||||
|
<ListBox x:Name="SoftwareList" MaxHeight="200" Background="Transparent"
|
||||||
|
BorderThickness="0" ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto">
|
||||||
|
<ListBox.ItemContainerStyle>
|
||||||
|
<Style TargetType="ListBoxItem">
|
||||||
|
<Setter Property="Foreground" Value="#A1A1AA"/>
|
||||||
|
<Setter Property="FontSize" Value="11"/>
|
||||||
|
<Setter Property="Padding" Value="4,3"/>
|
||||||
|
<Setter Property="Background" Value="Transparent"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="ListBoxItem">
|
||||||
|
<Border x:Name="Bd" Background="{TemplateBinding Background}"
|
||||||
|
CornerRadius="4" Padding="6,3">
|
||||||
|
<TextBlock Text="{TemplateBinding Content}"
|
||||||
|
Foreground="{TemplateBinding Foreground}"
|
||||||
|
FontSize="11" TextTrimming="CharacterEllipsis"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#27272A"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter TargetName="Bd" Property="Background" Value="#1D4ED820"/>
|
||||||
|
<Setter Property="Foreground" Value="#FAFAFA"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</ListBox.ItemContainerStyle>
|
||||||
|
</ListBox>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Letzter Check-in -->
|
||||||
|
<TextBlock x:Name="LastCheckinLabel" FontSize="10" Foreground="#3F3F46"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,10"/>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<Button x:Name="WebButton" Height="40" Margin="0,0,0,8"
|
||||||
|
Content="IT Nexus Web öffnen"
|
||||||
|
Click="WebButton_Click" Style="{StaticResource BtnPrimary}"
|
||||||
|
FontSize="12" FontWeight="SemiBold"/>
|
||||||
|
|
||||||
|
<Grid Margin="0,0,0,8">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="8"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button Grid.Column="0" Height="38"
|
||||||
|
Content="Nachricht senden"
|
||||||
|
Click="MessageButton_Click"
|
||||||
|
Style="{StaticResource BtnAction}"
|
||||||
|
FontSize="11"/>
|
||||||
|
<Button Grid.Column="2" Height="38"
|
||||||
|
Content="Neustart anfordern"
|
||||||
|
Click="RebootButton_Click"
|
||||||
|
Style="{StaticResource BtnWarn}"
|
||||||
|
FontSize="11"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Admin -->
|
||||||
|
<Border Background="#1C1C1F" CornerRadius="12" Padding="14,12" Margin="0,0,0,4">
|
||||||
|
<StackPanel>
|
||||||
|
<Button x:Name="AdminLoginButton" Content="Admin-Modus"
|
||||||
|
Height="26" Click="AdminLoginButton_Click"
|
||||||
|
Style="{StaticResource BtnGhost}"/>
|
||||||
|
<StackPanel x:Name="AdminPanel" Visibility="Collapsed" Margin="0,12,0,0">
|
||||||
|
<TextBlock x:Name="AdminUserLabel" FontSize="11" Foreground="#3B82F6"
|
||||||
|
FontWeight="SemiBold" Margin="0,0,0,10"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<UniformGrid Columns="2" Rows="2">
|
||||||
|
<Button Content="Sofort Check-in" Height="34" Margin="0,0,4,4"
|
||||||
|
Click="AdminCheckin_Click" Style="{StaticResource BtnAdminAction}"/>
|
||||||
|
<Button Content="Updates prüfen" Height="34" Margin="4,0,0,4"
|
||||||
|
Click="AdminUpdates_Click" Style="{StaticResource BtnAdminAction}"/>
|
||||||
|
<Button Content="Agent-Log" Height="34" Margin="0,0,4,0"
|
||||||
|
Click="AdminLog_Click" Style="{StaticResource BtnAdminAction}"/>
|
||||||
|
<Button Content="Neu starten" Height="34" Margin="4,0,0,0"
|
||||||
|
Click="AdminRestart_Click" Style="{StaticResource BtnAdminAction}"/>
|
||||||
|
</UniformGrid>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<!-- ═══ FOOTER ═══ -->
|
||||||
|
<Border Grid.Row="2" Background="#18181B" CornerRadius="0,0,14,14"
|
||||||
|
Padding="0,10">
|
||||||
|
<TextBlock x:Name="VersionLabel" FontSize="10" Foreground="#3F3F46"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- ═══ ADMIN LOGIN MODAL OVERLAY ═══ -->
|
||||||
|
<Border x:Name="AdminOverlay" Visibility="Collapsed"
|
||||||
|
Background="#80000000" CornerRadius="14">
|
||||||
|
<Border Background="#1C1C1F" CornerRadius="12" Padding="28,24"
|
||||||
|
Width="320" VerticalAlignment="Center" HorizontalAlignment="Center">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" Opacity="0.6" BlurRadius="30" ShadowDepth="0"/>
|
||||||
|
</Border.Effect>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Admin-Anmeldung" FontSize="15" FontWeight="Bold"
|
||||||
|
Foreground="#FAFAFA" Margin="0,0,0,4"/>
|
||||||
|
<TextBlock Text="IT Nexus Zugangsdaten eingeben"
|
||||||
|
FontSize="11" Foreground="#52525B" Margin="0,0,0,20"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Benutzername" FontSize="11" Foreground="#71717A" Margin="0,0,0,6"/>
|
||||||
|
<Border Background="#27272A" CornerRadius="8" BorderBrush="#3F3F46"
|
||||||
|
BorderThickness="1" Padding="12,0" Margin="0,0,0,12">
|
||||||
|
<TextBox x:Name="AdminUsernameBox" Background="Transparent"
|
||||||
|
Foreground="#FAFAFA" BorderThickness="0" FontSize="13"
|
||||||
|
Height="36" VerticalContentAlignment="Center"
|
||||||
|
CaretBrush="White"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="Passwort" FontSize="11" Foreground="#71717A" Margin="0,0,0,6"/>
|
||||||
|
<Border Background="#27272A" CornerRadius="8" BorderBrush="#3F3F46"
|
||||||
|
BorderThickness="1" Padding="12,0" Margin="0,0,0,6">
|
||||||
|
<PasswordBox x:Name="AdminPasswordBox" Background="Transparent"
|
||||||
|
Foreground="#FAFAFA" BorderThickness="0" FontSize="13"
|
||||||
|
Height="36" VerticalContentAlignment="Center"
|
||||||
|
KeyDown="AdminPasswordBox_KeyDown"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="AdminErrorLabel" FontSize="11" Foreground="#EF4444"
|
||||||
|
Margin="0,4,0,0" Visibility="Collapsed"/>
|
||||||
|
|
||||||
|
<Grid Margin="0,20,0,0">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="8"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button Grid.Column="0" Content="Abbrechen" Height="38"
|
||||||
|
Click="AdminCancelModal_Click"
|
||||||
|
Style="{StaticResource BtnAction}" FontSize="12"/>
|
||||||
|
<Button x:Name="AdminLoginBtn" Grid.Column="2" Content="Anmelden" Height="38"
|
||||||
|
Click="AdminLoginModal_Click"
|
||||||
|
Style="{StaticResource BtnPrimary}" FontSize="12" FontWeight="SemiBold"/>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
503
agent-cs/UI/DashboardWindow.xaml.cs
Normal file
503
agent-cs/UI/DashboardWindow.xaml.cs
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
using ITNexusAgent.Models;
|
||||||
|
using ITNexusAgent.Services;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using WinForms = System.Windows.Forms;
|
||||||
|
using DrawingIcon = System.Drawing.Icon;
|
||||||
|
using DrawingSystemIcons = System.Drawing.SystemIcons;
|
||||||
|
using MessageBox = System.Windows.MessageBox;
|
||||||
|
using SolidColorBrush = System.Windows.Media.SolidColorBrush;
|
||||||
|
using MediaColor = System.Windows.Media.Color;
|
||||||
|
using ProgressBar = System.Windows.Controls.ProgressBar;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.UI;
|
||||||
|
|
||||||
|
public partial class DashboardWindow : Window
|
||||||
|
{
|
||||||
|
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
|
||||||
|
private const string LogPath = @"C:\ProgramData\IT Nexus Agent\agent.log";
|
||||||
|
private readonly DispatcherTimer _timer;
|
||||||
|
private AgentConfig? _config;
|
||||||
|
private string? _adminToken;
|
||||||
|
private WinForms.NotifyIcon? _trayIcon;
|
||||||
|
private List<string> _allSoftware = [];
|
||||||
|
private bool _softwarePlaceholderActive = true;
|
||||||
|
|
||||||
|
public DashboardWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
try { _config = AgentConfig.Load(@"C:\ProgramData\IT Nexus Agent\config.json"); }
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
InitTrayIcon();
|
||||||
|
InitSoftwareSearch();
|
||||||
|
|
||||||
|
VersionLabel.Text = $"IT Nexus Agent v2.0.0 · Cereda Systems GmbH";
|
||||||
|
|
||||||
|
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
||||||
|
_timer.Tick += (_, _) => RefreshStatus();
|
||||||
|
_timer.Start();
|
||||||
|
|
||||||
|
RefreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitSoftwareSearch()
|
||||||
|
{
|
||||||
|
SoftwareSearch.Text = "Software suchen...";
|
||||||
|
SoftwareSearch.Foreground = new SolidColorBrush(MediaColor.FromRgb(82, 82, 91));
|
||||||
|
|
||||||
|
SoftwareSearch.GotFocus += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_softwarePlaceholderActive)
|
||||||
|
{
|
||||||
|
_softwarePlaceholderActive = false;
|
||||||
|
SoftwareSearch.Text = "";
|
||||||
|
SoftwareSearch.Foreground = new SolidColorBrush(MediaColor.FromRgb(250, 250, 250));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
SoftwareSearch.LostFocus += (_, _) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(SoftwareSearch.Text))
|
||||||
|
{
|
||||||
|
_softwarePlaceholderActive = true;
|
||||||
|
SoftwareSearch.Text = "Software suchen...";
|
||||||
|
SoftwareSearch.Foreground = new SolidColorBrush(MediaColor.FromRgb(82, 82, 91));
|
||||||
|
ApplySoftwareFilter("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshStatus()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(StatusPath))
|
||||||
|
{
|
||||||
|
SetOffline("Noch kein Check-in");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = File.ReadAllText(StatusPath);
|
||||||
|
var status = JsonConvert.DeserializeObject<StatusCache>(json);
|
||||||
|
if (status == null) return;
|
||||||
|
|
||||||
|
HostnameLabel.Text = $"{status.Hostname} · {status.LastUser}";
|
||||||
|
|
||||||
|
var ago = DateTime.Now - status.LastCheckin;
|
||||||
|
var agoText = ago.TotalSeconds < 60
|
||||||
|
? $"vor {(int)ago.TotalSeconds} Sekunden"
|
||||||
|
: ago.TotalMinutes < 60
|
||||||
|
? $"vor {(int)ago.TotalMinutes} Minuten"
|
||||||
|
: $"vor {(int)ago.TotalHours} Stunden";
|
||||||
|
|
||||||
|
if (status.Online && ago.TotalMinutes < 3)
|
||||||
|
{
|
||||||
|
StatusDot.Fill = new SolidColorBrush(MediaColor.FromRgb(34, 197, 94));
|
||||||
|
StatusLabel.Text = "Online";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StatusDot.Fill = new SolidColorBrush(MediaColor.FromRgb(239, 68, 68));
|
||||||
|
StatusLabel.Text = "Offline";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
var cpu = status.CpuUsage ?? 0;
|
||||||
|
CpuLabel.Text = $"{cpu:0}%";
|
||||||
|
CpuBar.Value = cpu;
|
||||||
|
SetBarColor(CpuBar, cpu);
|
||||||
|
|
||||||
|
if (status.RamTotal > 0)
|
||||||
|
{
|
||||||
|
var ramPct = (status.RamUsed ?? 0) / status.RamTotal.Value * 100;
|
||||||
|
RamLabel.Text = $"{status.RamUsed:0.0}/{status.RamTotal:0} GB";
|
||||||
|
RamBar.Value = ramPct;
|
||||||
|
SetBarColor(RamBar, ramPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.DiskTotal > 0)
|
||||||
|
{
|
||||||
|
var diskUsed = status.DiskTotal.Value - (status.DiskFree ?? 0);
|
||||||
|
var diskPct = diskUsed / status.DiskTotal.Value * 100;
|
||||||
|
DiskLabel.Text = $"{diskUsed:0}/{status.DiskTotal:0} GB";
|
||||||
|
DiskBar.Value = diskPct;
|
||||||
|
SetBarColor(DiskBar, diskPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security
|
||||||
|
BitlockerLabel.Text = status.BitlockerStatus switch
|
||||||
|
{
|
||||||
|
"encrypted" => "✓ Verschlüsselt",
|
||||||
|
"off" => "✗ Nicht aktiv",
|
||||||
|
_ => "? Unbekannt"
|
||||||
|
};
|
||||||
|
BitlockerLabel.Foreground = status.BitlockerStatus == "encrypted"
|
||||||
|
? new SolidColorBrush(MediaColor.FromRgb(22, 163, 74))
|
||||||
|
: new SolidColorBrush(MediaColor.FromRgb(239, 68, 68));
|
||||||
|
|
||||||
|
DefenderLabel.Text = status.DefenderEnabled
|
||||||
|
? status.DefenderSignaturesAge <= 3
|
||||||
|
? "✓ Aktiv · Signaturen aktuell"
|
||||||
|
: $"⚠ Aktiv · Signaturen {status.DefenderSignaturesAge}d alt"
|
||||||
|
: "✗ Inaktiv";
|
||||||
|
DefenderLabel.Foreground = status.DefenderEnabled
|
||||||
|
? new SolidColorBrush(MediaColor.FromRgb(22, 163, 74))
|
||||||
|
: new SolidColorBrush(MediaColor.FromRgb(239, 68, 68));
|
||||||
|
|
||||||
|
UpdatesLabel.Text = status.PendingUpdates == 0 ? "✓ Aktuell" : $"⚠ {status.PendingUpdates} ausstehend";
|
||||||
|
UpdatesLabel.Foreground = status.PendingUpdates == 0
|
||||||
|
? new SolidColorBrush(MediaColor.FromRgb(22, 163, 74))
|
||||||
|
: new SolidColorBrush(MediaColor.FromRgb(245, 158, 11));
|
||||||
|
|
||||||
|
SerialLabel.Text = string.IsNullOrEmpty(status.HardwareSerial) ? "—" : status.HardwareSerial;
|
||||||
|
LastCheckinLabel.Text = $"Letzter Check-in: {agoText}";
|
||||||
|
|
||||||
|
// Software-Liste nur aktualisieren wenn sich die Anzahl geändert hat
|
||||||
|
if (status.InstalledSoftware.Count != _allSoftware.Count ||
|
||||||
|
(status.InstalledSoftware.Count > 0 && _allSoftware.Count == 0))
|
||||||
|
{
|
||||||
|
_allSoftware = status.InstalledSoftware;
|
||||||
|
var filterText = _softwarePlaceholderActive ? "" : SoftwareSearch.Text;
|
||||||
|
ApplySoftwareFilter(filterText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { SetOffline("Fehler beim Laden"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplySoftwareFilter(string filter)
|
||||||
|
{
|
||||||
|
var filtered = string.IsNullOrWhiteSpace(filter)
|
||||||
|
? _allSoftware
|
||||||
|
: _allSoftware.Where(s => s.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
SoftwareList.ItemsSource = filtered;
|
||||||
|
SoftwareCountLabel.Text = _allSoftware.Count > 0
|
||||||
|
? $"{filtered.Count} / {_allSoftware.Count}"
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SoftwareSearch_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_softwarePlaceholderActive) return;
|
||||||
|
ApplySoftwareFilter(SoftwareSearch.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetOffline(string reason)
|
||||||
|
{
|
||||||
|
StatusDot.Fill = new SolidColorBrush(MediaColor.FromRgb(156, 163, 175));
|
||||||
|
StatusLabel.Text = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetBarColor(System.Windows.Controls.ProgressBar bar, double pct)
|
||||||
|
{
|
||||||
|
var color = pct > 90 ? MediaColor.FromRgb(239, 68, 68)
|
||||||
|
: pct > 70 ? MediaColor.FromRgb(245, 158, 11)
|
||||||
|
: MediaColor.FromRgb(59, 130, 246);
|
||||||
|
bar.Foreground = new SolidColorBrush(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WebButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var url = _config?.ServerUrl ?? "https://it-nexus.cereda-systems.de";
|
||||||
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void MessageButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var dlg = new MessageDialog();
|
||||||
|
if (dlg.ShowDialog() != true || string.IsNullOrWhiteSpace(dlg.Message)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_config != null)
|
||||||
|
{
|
||||||
|
var api = new ApiService(_config.ServerUrl, _config.AgentKey);
|
||||||
|
await api.SendMessageAsync(Environment.MachineName, dlg.Message);
|
||||||
|
MessageBox.Show("Nachricht wurde gesendet.", "IT Nexus", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
MessageBox.Show("Fehler beim Senden.", "IT Nexus", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void RebootButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var result = MessageBox.Show(
|
||||||
|
"Möchtest du einen Neustart bei der IT anfordern?\nDein Rechner wird nicht sofort neu gestartet.",
|
||||||
|
"Neustart anfordern", MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||||
|
if (result != MessageBoxResult.Yes) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_config != null)
|
||||||
|
{
|
||||||
|
var api = new ApiService(_config.ServerUrl, _config.AgentKey);
|
||||||
|
await api.SendRebootRequestAsync(Environment.MachineName);
|
||||||
|
MessageBox.Show("Neustart wurde angefordert. Die IT wird sich darum kümmern.",
|
||||||
|
"IT Nexus", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AdminLoginButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_adminToken != null)
|
||||||
|
{
|
||||||
|
_adminToken = null;
|
||||||
|
AdminPanel.Visibility = Visibility.Collapsed;
|
||||||
|
AdminLoginButton.Content = "Admin-Modus";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AdminUsernameBox.Text = "";
|
||||||
|
AdminPasswordBox.Password = "";
|
||||||
|
AdminErrorLabel.Visibility = Visibility.Collapsed;
|
||||||
|
AdminOverlay.Visibility = Visibility.Visible;
|
||||||
|
AdminUsernameBox.Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AdminCancelModal_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> AdminOverlay.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
|
private async void AdminLoginModal_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var username = AdminUsernameBox.Text.Trim();
|
||||||
|
var password = AdminPasswordBox.Password;
|
||||||
|
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) return;
|
||||||
|
|
||||||
|
AdminLoginBtn.IsEnabled = false;
|
||||||
|
AdminErrorLabel.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||||
|
var body = Newtonsoft.Json.JsonConvert.SerializeObject(new { username, password });
|
||||||
|
var resp = await http.PostAsync($"{_config?.ServerUrl?.TrimEnd('/')}/api/auth/login",
|
||||||
|
new StringContent(body, Encoding.UTF8, "application/json"));
|
||||||
|
var json = await resp.Content.ReadAsStringAsync();
|
||||||
|
var result = Newtonsoft.Json.JsonConvert.DeserializeAnonymousType(json, new { token = "" });
|
||||||
|
|
||||||
|
if (resp.IsSuccessStatusCode && !string.IsNullOrEmpty(result?.token))
|
||||||
|
{
|
||||||
|
_adminToken = result.token;
|
||||||
|
AdminOverlay.Visibility = Visibility.Collapsed;
|
||||||
|
|
||||||
|
if (_pendingExit)
|
||||||
|
{
|
||||||
|
_pendingExit = false;
|
||||||
|
DoExit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminPanel.Visibility = Visibility.Visible;
|
||||||
|
AdminUserLabel.Text = $"Angemeldet als {username}";
|
||||||
|
AdminLoginButton.Content = "Admin-Modus beenden";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AdminErrorLabel.Text = "Ungültige Anmeldedaten";
|
||||||
|
AdminErrorLabel.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
AdminErrorLabel.Text = "Verbindung fehlgeschlagen";
|
||||||
|
AdminErrorLabel.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
finally { AdminLoginBtn.IsEnabled = true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AdminPasswordBox_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == System.Windows.Input.Key.Enter)
|
||||||
|
AdminLoginModal_Click(sender, new RoutedEventArgs());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AdminCheckin_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo("net.exe", "stop \"IT Nexus Agent\"")
|
||||||
|
{ CreateNoWindow = true, UseShellExecute = false })?.WaitForExit();
|
||||||
|
Process.Start(new ProcessStartInfo("net.exe", "start \"IT Nexus Agent\"")
|
||||||
|
{ CreateNoWindow = true, UseShellExecute = false });
|
||||||
|
MessageBox.Show("Agent wird neu gestartet — Check-in in ~5 Sekunden.",
|
||||||
|
"IT Nexus", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Fehler: {ex.Message}", "IT Nexus", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AdminUpdates_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo("UsoClient.exe", "StartScan") { CreateNoWindow = true });
|
||||||
|
MessageBox.Show("Update-Scan gestartet.", "IT Nexus", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AdminLog_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var dlg = new LogWindow();
|
||||||
|
dlg.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AdminRestart_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo("net.exe", "stop \"IT Nexus Agent\"")
|
||||||
|
{ CreateNoWindow = true, UseShellExecute = false })?.WaitForExit();
|
||||||
|
Process.Start(new ProcessStartInfo("net.exe", "start \"IT Nexus Agent\"")
|
||||||
|
{ CreateNoWindow = true, UseShellExecute = false });
|
||||||
|
MessageBox.Show("Agent-Service wird neu gestartet.", "IT Nexus", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
MessageBox.Show($"Fehler: {ex.Message}", "IT Nexus", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InitTrayIcon()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var iconPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "icon.ico");
|
||||||
|
var icon = File.Exists(iconPath)
|
||||||
|
? new DrawingIcon(iconPath)
|
||||||
|
: DrawingSystemIcons.Application;
|
||||||
|
|
||||||
|
_trayIcon = new WinForms.NotifyIcon
|
||||||
|
{
|
||||||
|
Icon = icon,
|
||||||
|
Text = "IT Nexus Agent",
|
||||||
|
Visible = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var menu = new WinForms.ContextMenuStrip();
|
||||||
|
menu.Items.Add("Dashboard öffnen", null, (s, e) => ShowDashboard());
|
||||||
|
menu.Items.Add("-");
|
||||||
|
menu.Items.Add("IT Nexus Web", null, (s, e) =>
|
||||||
|
{
|
||||||
|
var url = _config?.ServerUrl ?? "https://it-nexus.cereda-systems.de";
|
||||||
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
|
});
|
||||||
|
menu.Items.Add("-");
|
||||||
|
menu.Items.Add("Beenden", null, (s, e) => ExitWithAuth());
|
||||||
|
|
||||||
|
_trayIcon.ContextMenuStrip = menu;
|
||||||
|
_trayIcon.DoubleClick += (s, e) => ShowDashboard();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowDashboard()
|
||||||
|
{
|
||||||
|
Show();
|
||||||
|
WindowState = WindowState.Normal;
|
||||||
|
Activate();
|
||||||
|
Focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ExitWithAuth()
|
||||||
|
{
|
||||||
|
Dispatcher.Invoke(() =>
|
||||||
|
{
|
||||||
|
if (_adminToken != null)
|
||||||
|
{
|
||||||
|
DoExit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminUsernameBox.Text = "";
|
||||||
|
AdminPasswordBox.Password = "";
|
||||||
|
AdminErrorLabel.Visibility = Visibility.Collapsed;
|
||||||
|
AdminOverlay.Visibility = Visibility.Visible;
|
||||||
|
ShowDashboard();
|
||||||
|
_pendingExit = true;
|
||||||
|
AdminUsernameBox.Focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _pendingExit = false;
|
||||||
|
|
||||||
|
private void DoExit()
|
||||||
|
{
|
||||||
|
_trayIcon!.Visible = false;
|
||||||
|
System.Windows.Application.Current.Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TitleBar_MouseDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.LeftButton == System.Windows.Input.MouseButtonState.Pressed)
|
||||||
|
DragMove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Close_Click(object sender, RoutedEventArgs e) => Close();
|
||||||
|
|
||||||
|
private bool _isMaximized = false;
|
||||||
|
private double _restoreLeft, _restoreTop, _restoreWidth, _restoreHeight;
|
||||||
|
|
||||||
|
private void Minimize_Click(object sender, RoutedEventArgs e)
|
||||||
|
=> WindowState = WindowState.Minimized;
|
||||||
|
|
||||||
|
private void Maximize_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_isMaximized)
|
||||||
|
RestoreWindow();
|
||||||
|
else
|
||||||
|
MaximizeWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MaximizeWindow()
|
||||||
|
{
|
||||||
|
_restoreLeft = Left; _restoreTop = Top;
|
||||||
|
_restoreWidth = Width; _restoreHeight = Height;
|
||||||
|
|
||||||
|
var wa = SystemParameters.WorkArea;
|
||||||
|
Left = wa.Left; Top = wa.Top;
|
||||||
|
Width = wa.Width; Height = wa.Height;
|
||||||
|
_isMaximized = true;
|
||||||
|
|
||||||
|
OuterBorder.CornerRadius = new CornerRadius(0);
|
||||||
|
OuterBorder.Margin = new Thickness(0);
|
||||||
|
OuterBorder.Effect = null;
|
||||||
|
TitleBarBorder.CornerRadius = new CornerRadius(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreWindow()
|
||||||
|
{
|
||||||
|
Left = _restoreLeft; Top = _restoreTop;
|
||||||
|
Width = _restoreWidth; Height = _restoreHeight;
|
||||||
|
_isMaximized = false;
|
||||||
|
|
||||||
|
OuterBorder.CornerRadius = new CornerRadius(14);
|
||||||
|
OuterBorder.Margin = new Thickness(16);
|
||||||
|
OuterBorder.Effect = new System.Windows.Media.Effects.DropShadowEffect
|
||||||
|
{
|
||||||
|
Color = System.Windows.Media.Colors.Black,
|
||||||
|
Opacity = 0.4, BlurRadius = 20, ShadowDepth = 0
|
||||||
|
};
|
||||||
|
TitleBarBorder.CornerRadius = new CornerRadius(14, 14, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Window_StateChanged(object sender, EventArgs e) { }
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
base.OnClosed(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
agent-cs/UI/LogWindow.xaml
Normal file
31
agent-cs/UI/LogWindow.xaml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<Window x:Class="ITNexusAgent.UI.LogWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="IT Nexus Agent – Log"
|
||||||
|
Width="700" Height="500"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
Background="#1E1E2E" FontFamily="Consolas">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="40"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBox x:Name="LogText" Grid.Row="0"
|
||||||
|
Background="#1E1E2E" Foreground="#CDD6F4"
|
||||||
|
FontSize="11" IsReadOnly="True"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
HorizontalScrollBarVisibility="Auto"
|
||||||
|
BorderThickness="0" Padding="12"
|
||||||
|
TextWrapping="NoWrap"/>
|
||||||
|
<Border Grid.Row="1" Background="#181825" Padding="12,0">
|
||||||
|
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
|
<Button Content="Aktualisieren" Height="26" Padding="10,0"
|
||||||
|
Click="Refresh_Click" Background="#313244" Foreground="#CDD6F4"
|
||||||
|
BorderThickness="0" FontFamily="Segoe UI" FontSize="11" Cursor="Hand"/>
|
||||||
|
<Button Content="Log leeren" Height="26" Padding="10,0" Margin="8,0,0,0"
|
||||||
|
Click="Clear_Click" Background="#313244" Foreground="#F38BA8"
|
||||||
|
BorderThickness="0" FontFamily="Segoe UI" FontSize="11" Cursor="Hand"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
40
agent-cs/UI/LogWindow.xaml.cs
Normal file
40
agent-cs/UI/LogWindow.xaml.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Windows;
|
||||||
|
using MessageBox = System.Windows.MessageBox;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.UI;
|
||||||
|
|
||||||
|
public partial class LogWindow : Window
|
||||||
|
{
|
||||||
|
private const string LogPath = @"C:\ProgramData\IT Nexus Agent\agent.log";
|
||||||
|
|
||||||
|
public LogWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
LoadLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadLog()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(LogPath))
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(LogPath);
|
||||||
|
LogText.Text = string.Join(Environment.NewLine, lines.TakeLast(500));
|
||||||
|
LogText.ScrollToEnd();
|
||||||
|
}
|
||||||
|
else LogText.Text = "Log-Datei nicht gefunden.";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { LogText.Text = $"Fehler: {ex.Message}"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Refresh_Click(object sender, RoutedEventArgs e) => LoadLog();
|
||||||
|
|
||||||
|
private void Clear_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var r = MessageBox.Show("Log wirklich löschen?", "IT Nexus",
|
||||||
|
MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||||
|
if (r != MessageBoxResult.Yes) return;
|
||||||
|
try { File.WriteAllText(LogPath, ""); LoadLog(); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
22
agent-cs/UI/MessageDialog.xaml
Normal file
22
agent-cs/UI/MessageDialog.xaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<Window x:Class="ITNexusAgent.UI.MessageDialog"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="Nachricht an IT senden"
|
||||||
|
Width="400" Height="220"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
ResizeMode="NoResize" Background="White" FontFamily="Segoe UI">
|
||||||
|
<StackPanel Margin="20">
|
||||||
|
<TextBlock Text="Nachricht an das IT-Team" FontSize="13" FontWeight="SemiBold"
|
||||||
|
Foreground="#1F2937" Margin="0,0,0,12"/>
|
||||||
|
<TextBox x:Name="MessageBox" Height="80" TextWrapping="Wrap" AcceptsReturn="True"
|
||||||
|
Padding="8" FontSize="12" BorderBrush="#D1D5DB"
|
||||||
|
VerticalScrollBarVisibility="Auto"/>
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
|
||||||
|
<Button Content="Abbrechen" Width="80" Height="32" Margin="0,0,8,0"
|
||||||
|
Click="Cancel_Click" Background="#F9FAFB" BorderBrush="#D1D5DB" FontSize="12"/>
|
||||||
|
<Button Content="Senden" Width="80" Height="32"
|
||||||
|
Click="Send_Click" Background="#0078D4" Foreground="White"
|
||||||
|
BorderThickness="0" FontSize="12" FontWeight="SemiBold"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
18
agent-cs/UI/MessageDialog.xaml.cs
Normal file
18
agent-cs/UI/MessageDialog.xaml.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.UI;
|
||||||
|
|
||||||
|
public partial class MessageDialog : Window
|
||||||
|
{
|
||||||
|
public string Message { get; private set; } = "";
|
||||||
|
|
||||||
|
public MessageDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void Send_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Message = MessageBox.Text.Trim();
|
||||||
|
DialogResult = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel_Click(object sender, RoutedEventArgs e) => DialogResult = false;
|
||||||
|
}
|
||||||
108
agent-cs/UI/NotificationWindow.xaml
Normal file
108
agent-cs/UI/NotificationWindow.xaml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<Window x:Class="ITNexusAgent.UI.NotificationWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="IT Nexus Mitteilung"
|
||||||
|
Width="500" Height="Auto"
|
||||||
|
SizeToContent="Height"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ResizeMode="NoResize"
|
||||||
|
WindowStyle="None"
|
||||||
|
AllowsTransparency="True"
|
||||||
|
Background="Transparent"
|
||||||
|
Topmost="True"
|
||||||
|
FontFamily="Segoe UI">
|
||||||
|
|
||||||
|
<Window.Resources>
|
||||||
|
<Style x:Key="ConfirmBtn" TargetType="Button">
|
||||||
|
<Setter Property="Foreground" Value="White"/>
|
||||||
|
<Setter Property="BorderThickness" Value="0"/>
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
<Setter Property="FontSize" Value="13"/>
|
||||||
|
<Setter Property="FontWeight" Value="Bold"/>
|
||||||
|
<Setter Property="Height" Value="46"/>
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Button">
|
||||||
|
<Border x:Name="Bg" CornerRadius="8"
|
||||||
|
Background="{TemplateBinding Background}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsMouseOver" Value="True">
|
||||||
|
<Setter TargetName="Bg" Property="Opacity" Value="0.85"/>
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsPressed" Value="True">
|
||||||
|
<Setter TargetName="Bg" Property="Opacity" Value="0.7"/>
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
|
<!-- Äußerer Rahmen mit Schatten + farbigem Top-Border -->
|
||||||
|
<Border CornerRadius="12" Background="#1A1D2E"
|
||||||
|
BorderBrush="#2D3250" BorderThickness="1">
|
||||||
|
<Border.Effect>
|
||||||
|
<DropShadowEffect Color="Black" BlurRadius="32" ShadowDepth="8" Opacity="0.7"/>
|
||||||
|
</Border.Effect>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="4"/> <!-- Farbige Top-Linie -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- Header: Icon + Typ + Titel -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- Nachricht -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- Meta -->
|
||||||
|
<RowDefinition Height="Auto"/> <!-- Button -->
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Farbige Linie oben (wie Web: border-top) -->
|
||||||
|
<Border Grid.Row="0" x:Name="TopAccent" CornerRadius="12,12,0,0"/>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<StackPanel Grid.Row="1" Orientation="Horizontal"
|
||||||
|
Margin="24,20,24,16" VerticalAlignment="Center">
|
||||||
|
<!-- Icon-Box -->
|
||||||
|
<Border x:Name="IconBox" Width="44" Height="44" CornerRadius="10"
|
||||||
|
VerticalAlignment="Top" Margin="0,0,14,0">
|
||||||
|
<TextBlock x:Name="IconText" FontSize="22"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<!-- Typ + Titel -->
|
||||||
|
<StackPanel VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="TypeLabel" FontSize="10" FontWeight="Bold"
|
||||||
|
TextOptions.TextFormattingMode="Display"/>
|
||||||
|
<TextBlock x:Name="TitleText" FontSize="17" FontWeight="Bold"
|
||||||
|
Foreground="White" TextWrapping="Wrap" MaxWidth="370"
|
||||||
|
Margin="0,3,0,0" LineHeight="22"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Nachricht -->
|
||||||
|
<Border Grid.Row="2" Margin="24,0,24,16"
|
||||||
|
Background="#252840" CornerRadius="8"
|
||||||
|
BorderBrush="#3D4270" BorderThickness="1" Padding="14,12">
|
||||||
|
<TextBlock x:Name="MessageText" FontSize="13" Foreground="#B0B8D1"
|
||||||
|
TextWrapping="Wrap" LineHeight="20"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal"
|
||||||
|
Margin="24,0,24,20" Opacity="0.55">
|
||||||
|
<TextBlock x:Name="MetaText" FontSize="11" Foreground="#8B9BBF"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Button + Hinweis -->
|
||||||
|
<StackPanel Grid.Row="4" Margin="24,0,24,24">
|
||||||
|
<Button x:Name="ConfirmButton" Style="{StaticResource ConfirmBtn}"
|
||||||
|
Click="ConfirmButton_Click">
|
||||||
|
<TextBlock Text="✓ Gelesen und bestätigt" FontSize="13" FontWeight="Bold"/>
|
||||||
|
</Button>
|
||||||
|
<TextBlock Text="Du musst diese Nachricht bestätigen um fortzufahren."
|
||||||
|
FontSize="10" Foreground="#555E7A"
|
||||||
|
HorizontalAlignment="Center" Margin="0,8,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Window>
|
||||||
59
agent-cs/UI/NotificationWindow.xaml.cs
Normal file
59
agent-cs/UI/NotificationWindow.xaml.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using ITNexusAgent.Models;
|
||||||
|
using ITNexusAgent.Services;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace ITNexusAgent.UI;
|
||||||
|
|
||||||
|
public partial class NotificationWindow : Window
|
||||||
|
{
|
||||||
|
private readonly Announcement _ann;
|
||||||
|
private readonly ApiService? _api;
|
||||||
|
|
||||||
|
public NotificationWindow(Announcement ann)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_ann = ann;
|
||||||
|
|
||||||
|
var (hex, label, icon) = ann.Type switch
|
||||||
|
{
|
||||||
|
"warning" => ("#DC3545", "WICHTIGE WARNUNG", "⚠️"),
|
||||||
|
"maintenance" => ("#E67E22", "WARTUNGSANKÜNDIGUNG", "🔧"),
|
||||||
|
_ => ("#5865F2", "INFORMATION", "ℹ️"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var color = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex);
|
||||||
|
var brush = new SolidColorBrush(color);
|
||||||
|
var iconBg = new SolidColorBrush(System.Windows.Media.Color.FromArgb(40, color.R, color.G, color.B));
|
||||||
|
|
||||||
|
TopAccent.Background = brush;
|
||||||
|
IconBox.Background = iconBg;
|
||||||
|
IconBox.BorderBrush = new SolidColorBrush(System.Windows.Media.Color.FromArgb(80, color.R, color.G, color.B));
|
||||||
|
IconBox.BorderThickness = new Thickness(1);
|
||||||
|
IconText.Text = icon;
|
||||||
|
TypeLabel.Text = label;
|
||||||
|
TypeLabel.Foreground = brush;
|
||||||
|
TitleText.Text = ann.Title;
|
||||||
|
MessageText.Text = ann.Message;
|
||||||
|
ConfirmButton.Background = brush;
|
||||||
|
MetaText.Text = $"IT Nexus Mitteilung";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cfg = AgentConfig.Load(@"C:\ProgramData\IT Nexus Agent\config.json");
|
||||||
|
_api = new ApiService(cfg.ServerUrl, cfg.AgentKey);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ConfirmButton_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_api != null)
|
||||||
|
await _api.AckAnnouncementAsync(_ann.Id, Environment.MachineName);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
agent-cs/app.manifest
Normal file
22
agent-cs/app.manifest
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="2.0.0.0" name="IT-Nexus-Agent.exe" />
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
4
agent-cs/config.template.json
Normal file
4
agent-cs/config.template.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"server_url": "https://it-nexus.cereda-systems.de",
|
||||||
|
"agent_key": "HIER-DEN-AGENT-KEY-EINTRAGEN"
|
||||||
|
}
|
||||||
4
agent-cs/detect-it-nexus-agent.ps1
Normal file
4
agent-cs/detect-it-nexus-agent.ps1
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
if (Test-Path "C:\Program Files\IT Nexus Agent\IT-Nexus-Agent.exe") {
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
BIN
agent-cs/icon.ico
Normal file
BIN
agent-cs/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
629
agent-cs/network-topology.html
Normal file
629
agent-cs/network-topology.html
Normal file
@@ -0,0 +1,629 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>IT Nexus — Live Netzwerk-Topologie</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #04040A; font-family: 'Segoe UI', system-ui, sans-serif; color: #E4E4E7; }
|
||||||
|
|
||||||
|
/* ── TOPBAR (mit Buttons) ── */
|
||||||
|
#topbar {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; height: 52px;
|
||||||
|
background: rgba(6,6,14,0.99); border-bottom: 1px solid #0F0F1A;
|
||||||
|
display: flex; align-items: center; gap: 10px; padding: 0 16px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
#topbar-title { font-size: 13px; font-weight: 700; color: #FAFAFA; margin-right: 16px; white-space: nowrap; }
|
||||||
|
#topbar-title em { color: #009B9A; font-style: normal; }
|
||||||
|
.sim-sep { width: 1px; height: 22px; background: #1A1A24; margin: 0 6px; }
|
||||||
|
.sim-lbl { font-size: 9px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: #27272A; white-space: nowrap; }
|
||||||
|
.tb {
|
||||||
|
padding: 6px 13px; border-radius: 8px;
|
||||||
|
border: 1px solid #1A1A24;
|
||||||
|
background: rgba(14,14,22,0.9);
|
||||||
|
color: #52525B; font-size: 11px; font-weight: 600;
|
||||||
|
cursor: pointer; font-family: inherit; white-space: nowrap;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.tb:hover { border-color: #3F3F46; color: #E4E4E7; background: rgba(30,30,44,0.9); }
|
||||||
|
.tb:active { transform: scale(0.97); }
|
||||||
|
.tb.active-flow { border-color: var(--tc); color: var(--tc); box-shadow: 0 0 8px var(--tc-g); }
|
||||||
|
|
||||||
|
/* ── LOG PANEL (links, volle Höhe) ── */
|
||||||
|
#logpanel {
|
||||||
|
position: fixed; top: 52px; left: 0; bottom: 0; width: 270px;
|
||||||
|
background: rgba(4,4,12,0.99); border-right: 1px solid #0D0D1A;
|
||||||
|
padding: 18px 16px; z-index: 80; overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
#log-lbl {
|
||||||
|
font-size: 8.5px; font-weight: 800; letter-spacing: 0.14em;
|
||||||
|
color: #1A1A28; text-transform: uppercase; margin-bottom: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#log-body { display: flex; flex-direction: column; gap: 0; flex: 1; overflow: hidden; }
|
||||||
|
.ll {
|
||||||
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
|
padding: 10px 12px 10px 14px;
|
||||||
|
border-left: 2px solid var(--lc, #1E1E2E);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: rgba(255,255,255,0.018);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
animation: fadeIn .3s ease;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn { from { opacity:0; transform:translateY(-6px); } to { opacity:1; transform:translateY(0); } }
|
||||||
|
.ll-t {
|
||||||
|
font-family: 'Consolas', monospace;
|
||||||
|
font-size: 10px; color: #2A2A3C;
|
||||||
|
}
|
||||||
|
.ll-step { font-size: 10px; font-weight: 700; color: #3A3A50; margin-bottom: 2px; }
|
||||||
|
.ll-m { font-size: 13px; font-weight: 600; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* ── NODES ── */
|
||||||
|
.node {
|
||||||
|
position: fixed; z-index: 20;
|
||||||
|
background: rgba(10,10,18,0.97);
|
||||||
|
border: 1px solid #14141E;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 10px 13px;
|
||||||
|
width: 158px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
transition: box-shadow 0.25s, border-color 0.25s, transform 0.15s;
|
||||||
|
cursor: default; user-select: none;
|
||||||
|
}
|
||||||
|
.node:hover { border-color: var(--nc, #27272A); transform: translateY(-1px); z-index: 30; }
|
||||||
|
.node.lit {
|
||||||
|
border-color: var(--nc) !important;
|
||||||
|
box-shadow: 0 0 0 1px var(--nc), 0 0 20px var(--ng), inset 0 0 14px var(--ni);
|
||||||
|
}
|
||||||
|
.nh { display: flex; align-items: center; gap: 7px; margin-bottom: 6px; }
|
||||||
|
.ni { font-size: 15px; line-height: 1; flex-shrink: 0; }
|
||||||
|
.nn { font-size: 11.5px; font-weight: 700; color: #F4F4F5; }
|
||||||
|
.nb {
|
||||||
|
margin-left: auto; font-size: 8.5px; font-weight: 700;
|
||||||
|
padding: 2px 6px; border-radius: 4px; white-space: nowrap;
|
||||||
|
background: rgba(34,197,94,.14); color: #4ADE80;
|
||||||
|
}
|
||||||
|
.nb.w { background: rgba(251,191,36,.14); color: #FCD34D; }
|
||||||
|
.nb.g { background: rgba(82,82,91,.16); color: #52525B; }
|
||||||
|
.nb.b { background: rgba(59,130,246,.14); color: #93C5FD; }
|
||||||
|
.ns { font-size: 9.5px; color: #2E2E42; line-height: 1.45; }
|
||||||
|
|
||||||
|
/* ── ZONE LABELS ── */
|
||||||
|
.zl {
|
||||||
|
position: fixed; z-index: 25;
|
||||||
|
font-size: 8.5px; font-weight: 800; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: #14141E; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── LEGEND ── */
|
||||||
|
#legend {
|
||||||
|
position: fixed; right: 14px; top: 62px; z-index: 80;
|
||||||
|
background: rgba(6,6,14,0.97); border: 1px solid #0E0E18;
|
||||||
|
border-radius: 11px; padding: 12px 14px; width: 178px;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
#legend h3 { font-size: 8.5px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: #22222E; margin-bottom: 9px; }
|
||||||
|
.li { display: flex; align-items: center; gap: 9px; font-size: 10px; color: #52525B; margin-bottom: 5px; }
|
||||||
|
.ld { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── BG + SVG ── */
|
||||||
|
#bg { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
|
||||||
|
#sv { position: fixed; inset: 0; z-index: 10; pointer-events: none; overflow: visible; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<canvas id="bg"></canvas>
|
||||||
|
<svg id="sv" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="3.5" result="b"/>
|
||||||
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
<filter id="glow2" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
|
<feGaussianBlur stdDeviation="7" result="b"/>
|
||||||
|
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- ════ TOPBAR ════ -->
|
||||||
|
<div id="topbar">
|
||||||
|
<span id="topbar-title">IT <em>Nexus</em> — Architektur</span>
|
||||||
|
<div class="sim-sep"></div>
|
||||||
|
<span class="sim-lbl">Simulation:</span>
|
||||||
|
<button class="tb" style="--tc:#3B82F6;--tc-g:rgba(59,130,246,.3)" onclick="doCheckin()">🔄 Check-in</button>
|
||||||
|
<button class="tb" style="--tc:#F59E0B;--tc-g:rgba(245,158,11,.3)" onclick="doAnnouncement()">📢 Ankündigung</button>
|
||||||
|
<button class="tb" style="--tc:#EF4444;--tc-g:rgba(239,68,68,.3)" onclick="doPatch()">🔧 Patch-Befehl</button>
|
||||||
|
<button class="tb" style="--tc:#2DD4BF;--tc-g:rgba(45,212,191,.3)" onclick="doUpdate()">⬇️ Agent-Update</button>
|
||||||
|
<button class="tb" style="--tc:#0EA5E9;--tc-g:rgba(14,165,233,.3)" onclick="doIntune()">📦 Intune-Deploy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════ NODES ════ -->
|
||||||
|
<div class="zl" id="zl-pc">Windows-Endgerät</div>
|
||||||
|
<div class="node" id="n-svc" style="--nc:#3B82F6;--ng:rgba(59,130,246,.25);--ni:rgba(59,130,246,.04)">
|
||||||
|
<div class="nh"><span class="ni">⚙️</span><span class="nn">Agent Service</span><span class="nb">Running</span></div>
|
||||||
|
<div class="ns">Windows Service · SYSTEM<br>Check-in · Commands · Auto-Update</div>
|
||||||
|
</div>
|
||||||
|
<div class="node" id="n-dash" style="--nc:#6366F1;--ng:rgba(99,102,241,.25);--ni:rgba(99,102,241,.04)">
|
||||||
|
<div class="nh"><span class="ni">📊</span><span class="nn">Dashboard</span><span class="nb b">Tray</span></div>
|
||||||
|
<div class="ns">WPF · Dark-Theme · Autostart<br>Liest status.json alle 5 Sek</div>
|
||||||
|
</div>
|
||||||
|
<div class="node" id="n-nfy" style="--nc:#F59E0B;--ng:rgba(245,158,11,.25);--ni:rgba(245,158,11,.04)">
|
||||||
|
<div class="nh"><span class="ni">🔔</span><span class="nn">Notification</span><span class="nb g">On Demand</span></div>
|
||||||
|
<div class="ns">WPF Popup · schtasks.exe<br>Läuft als eingeloggter User</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zl" id="zl-srv">IT Nexus Server · 192.168.0.194</div>
|
||||||
|
<div class="node" id="n-ngx" style="--nc:#22C55E;--ng:rgba(34,197,94,.25);--ni:rgba(34,197,94,.04)">
|
||||||
|
<div class="nh"><span class="ni">🌐</span><span class="nn">nginx</span><span class="nb">Port 443</span></div>
|
||||||
|
<div class="ns">Reverse Proxy · TLS<br>/api/* → Backend · / → Frontend</div>
|
||||||
|
</div>
|
||||||
|
<div class="node" id="n-be" style="--nc:#10B981;--ng:rgba(16,185,129,.25);--ni:rgba(16,185,129,.04)">
|
||||||
|
<div class="nh"><span class="ni">⚡</span><span class="nn">Node.js Backend</span><span class="nb">Port 5000</span></div>
|
||||||
|
<div class="ns">Express · JWT · Agent-Key Auth<br>Cron Jobs · KI-Integration</div>
|
||||||
|
</div>
|
||||||
|
<div class="node" id="n-db" style="--nc:#6B7280;--ng:rgba(107,114,128,.22);--ni:rgba(107,114,128,.04)">
|
||||||
|
<div class="nh"><span class="ni">🗄️</span><span class="nn">SQLite DB</span><span class="nb g">Volume</span></div>
|
||||||
|
<div class="ns">monitoring_agents · tickets<br>patch_commands · announcements</div>
|
||||||
|
</div>
|
||||||
|
<div class="node" id="n-fe" style="--nc:#3B82F6;--ng:rgba(59,130,246,.25);--ni:rgba(59,130,246,.04)">
|
||||||
|
<div class="nh"><span class="ni">⚛️</span><span class="nn">React Frontend</span><span class="nb b">Port 3000</span></div>
|
||||||
|
<div class="ns">Monitoring · Patch · Helpdesk<br>KI-Assistent · Dokumentation</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zl" id="zl-ext">Externe Dienste</div>
|
||||||
|
<div class="node" id="n-grp" style="--nc:#818CF8;--ng:rgba(129,140,248,.25);--ni:rgba(129,140,248,.04)">
|
||||||
|
<div class="nh"><span class="ni">☁️</span><span class="nn">Microsoft Graph</span><span class="nb w">Azure AD</span></div>
|
||||||
|
<div class="ns">Teams · MDO Alerts · Mail<br>SecurityAlert.Read.All</div>
|
||||||
|
</div>
|
||||||
|
<div class="node" id="n-ai" style="--nc:#2DD4BF;--ng:rgba(45,212,191,.25);--ni:rgba(45,212,191,.04)">
|
||||||
|
<div class="nh"><span class="ni">🤖</span><span class="nn">Anthropic Claude</span><span class="nb w">claude-sonnet-4-6</span></div>
|
||||||
|
<div class="ns">KI-Assistent · Wissensdatenbank<br>Ticket-Analyse · Automatisierung</div>
|
||||||
|
</div>
|
||||||
|
<div class="node" id="n-pmx" style="--nc:#F97316;--ng:rgba(249,115,22,.25);--ni:rgba(249,115,22,.04)">
|
||||||
|
<div class="nh"><span class="ni">🖥️</span><span class="nn">Proxmox API</span><span class="nb w">hve-01</span></div>
|
||||||
|
<div class="ns">VM/CT Status · Ressourcen<br>192.168.0.184:8006</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zl" id="zl-adm">IT-Administrator</div>
|
||||||
|
<div class="node" id="n-web" style="--nc:#E4E4E7;--ng:rgba(228,228,231,.15);--ni:rgba(228,228,231,.03)">
|
||||||
|
<div class="nh"><span class="ni">🧑💼</span><span class="nn">Web Browser</span><span class="nb">HTTPS</span></div>
|
||||||
|
<div class="ns">it-nexus.cereda-systems.de<br>Monitoring · Patch · Helpdesk</div>
|
||||||
|
</div>
|
||||||
|
<div class="node" id="n-itune" style="--nc:#0EA5E9;--ng:rgba(14,165,233,.25);--ni:rgba(14,165,233,.04)">
|
||||||
|
<div class="nh"><span class="ni">📦</span><span class="nn">Intune / MDM</span><span class="nb g">Deploy</span></div>
|
||||||
|
<div class="ns">setup.exe · GUID-Erkennung<br>Rollout auf Endgeräte</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════ LEGEND ════ -->
|
||||||
|
<div id="legend">
|
||||||
|
<h3>Datenflüsse</h3>
|
||||||
|
<div class="li"><div class="ld" style="background:#3B82F6;box-shadow:0 0 5px #3B82F6"></div>Check-in (60s)</div>
|
||||||
|
<div class="li"><div class="ld" style="background:#22C55E;box-shadow:0 0 5px #22C55E"></div>Response / Commands</div>
|
||||||
|
<div class="li"><div class="ld" style="background:#6B7280;box-shadow:0 0 4px #6B7280"></div>SQLite DB-Operationen</div>
|
||||||
|
<div class="li"><div class="ld" style="background:#6366F1;box-shadow:0 0 5px #6366F1"></div>Dashboard Refresh</div>
|
||||||
|
<div class="li"><div class="ld" style="background:#818CF8;box-shadow:0 0 5px #818CF8"></div>Microsoft Graph API</div>
|
||||||
|
<div class="li"><div class="ld" style="background:#2DD4BF;box-shadow:0 0 5px #2DD4BF"></div>Anthropic Claude AI</div>
|
||||||
|
<div class="li"><div class="ld" style="background:#F97316;box-shadow:0 0 5px #F97316"></div>Proxmox API</div>
|
||||||
|
<div class="li"><div class="ld" style="background:#F4F4F5;box-shadow:0 0 4px #F4F4F5"></div>Admin Web-Zugriff</div>
|
||||||
|
<div class="li"><div class="ld" style="background:#F59E0B;box-shadow:0 0 5px #F59E0B"></div>Ankündigung</div>
|
||||||
|
<div class="li"><div class="ld" style="background:#EF4444;box-shadow:0 0 5px #EF4444"></div>Patch-Command / Update</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ════ LOG PANEL LINKS ════ -->
|
||||||
|
<div id="logpanel">
|
||||||
|
<div id="log-lbl">Was passiert gerade</div>
|
||||||
|
<div id="log-body">
|
||||||
|
<div class="ll" style="--lc:#1E1E2E">
|
||||||
|
<div class="ll-t"></div>
|
||||||
|
<div class="ll-m" style="color:#27273A">Oben einen Button drücken<br>um eine Simulation zu starten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// BACKGROUND GRID
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
const bgc = document.getElementById('bg');
|
||||||
|
const bgx = bgc.getContext('2d');
|
||||||
|
|
||||||
|
function drawGrid() {
|
||||||
|
bgc.width = innerWidth; bgc.height = innerHeight;
|
||||||
|
bgx.fillStyle = '#04040A';
|
||||||
|
bgx.fillRect(0, 0, bgc.width, bgc.height);
|
||||||
|
|
||||||
|
// Subtle dot grid
|
||||||
|
bgx.fillStyle = 'rgba(255,255,255,0.028)';
|
||||||
|
for (let x = 0; x < bgc.width; x += 52)
|
||||||
|
for (let y = 0; y < bgc.height; y += 52) {
|
||||||
|
bgx.beginPath(); bgx.arc(x, y, 0.8, 0, Math.PI*2); bgx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zone backgrounds
|
||||||
|
const zones = [
|
||||||
|
{ x: LP+4, w: C(0)+NW/2-LP-4, color: 'rgba(59,130,246,0.018)' },
|
||||||
|
{ x: C(1)-NW/2-16, w: NW+32, color: 'rgba(34,197,94,0.015)' },
|
||||||
|
{ x: C(2)-NW/2-16, w: NW+32, color: 'rgba(129,140,248,0.015)'},
|
||||||
|
{ x: C(3)-NW/2-16, w: innerWidth-C(3)+NW/2, color: 'rgba(228,228,231,0.012)'},
|
||||||
|
];
|
||||||
|
const top = TB, bot = bgc.height - LH;
|
||||||
|
zones.forEach(z => {
|
||||||
|
bgx.fillStyle = z.color;
|
||||||
|
bgx.beginPath();
|
||||||
|
bgx.roundRect(z.x, top+4, z.w, bot-top-8, 14);
|
||||||
|
bgx.fill();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// LAYOUT ENGINE
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
const TB = 52, LH = 0, LP = 270, NW = 158, NH = 72;
|
||||||
|
|
||||||
|
function C(col) { // column center x — beginnt nach dem Log-Panel
|
||||||
|
const avail = innerWidth - LP;
|
||||||
|
return LP + avail * [0.11, 0.35, 0.62, 0.86][col];
|
||||||
|
}
|
||||||
|
function R(row) { // row center y
|
||||||
|
const avail = innerHeight - TB - LH - 16;
|
||||||
|
return TB + avail * [0.10, 0.34, 0.58, 0.82][row];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_POS = {
|
||||||
|
'n-svc': [0, 0], 'n-dash': [0, 1], 'n-nfy': [0, 2],
|
||||||
|
'n-ngx': [1, 0], 'n-be': [1, 1], 'n-db': [1, 2], 'n-fe': [1, 3],
|
||||||
|
'n-grp': [2, 0], 'n-ai': [2, 1], 'n-pmx': [2, 2],
|
||||||
|
'n-web': [3, 0], 'n-itune': [3, 2],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZONE_LABELS = {
|
||||||
|
'zl-pc': [0, 0.02], 'zl-srv': [1, 0.02],
|
||||||
|
'zl-ext': [2, 0.02], 'zl-adm': [3, 0.02],
|
||||||
|
};
|
||||||
|
|
||||||
|
const centers = {}; // id → {x, y}
|
||||||
|
|
||||||
|
function layout() {
|
||||||
|
Object.entries(NODE_POS).forEach(([id, [col, row]]) => {
|
||||||
|
const el = document.getElementById(id); if (!el) return;
|
||||||
|
const cx = C(col), cy = R(row);
|
||||||
|
el.style.left = (cx - NW/2) + 'px';
|
||||||
|
el.style.top = (cy - NH/2) + 'px';
|
||||||
|
centers[id] = { x: cx, y: cy };
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(ZONE_LABELS).forEach(([id, [col, rf]]) => {
|
||||||
|
const el = document.getElementById(id); if (!el) return;
|
||||||
|
const avail = innerHeight - TB;
|
||||||
|
el.style.left = (C(col) - 70) + 'px';
|
||||||
|
el.style.top = (TB + avail * rf + 4) + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// CONNECTION PATHS
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
const sv = document.getElementById('sv');
|
||||||
|
const connPaths = {}; // id → SVGPathElement
|
||||||
|
|
||||||
|
const CONNS = [
|
||||||
|
// id, from, to, color, curveDir, label
|
||||||
|
['ci_out', 'n-svc', 'n-ngx', '#3B82F6', 1, 'Check-in → nginx'],
|
||||||
|
['ci_in', 'n-ngx', 'n-svc', '#22C55E', -1, 'Response ← nginx'],
|
||||||
|
['ngx_be', 'n-ngx', 'n-be', '#10B981', 1, 'nginx → Backend'],
|
||||||
|
['be_ngx', 'n-be', 'n-ngx', '#10B981', -1, 'Backend → nginx'],
|
||||||
|
['be_db', 'n-be', 'n-db', '#6B7280', 1, 'Backend → SQLite'],
|
||||||
|
['db_be', 'n-db', 'n-be', '#6B7280', -1, 'SQLite → Backend'],
|
||||||
|
['dash_read','n-dash', 'n-svc', '#6366F1', 1, 'Dashboard liest status.json'],
|
||||||
|
['be_grp', 'n-be', 'n-grp', '#818CF8', 0, 'Backend → MS Graph'],
|
||||||
|
['be_ai', 'n-be', 'n-ai', '#2DD4BF', 0, 'Backend → Claude AI'],
|
||||||
|
['be_pmx', 'n-be', 'n-pmx', '#F97316', 0, 'Backend → Proxmox'],
|
||||||
|
['web_ngx', 'n-web', 'n-ngx', '#E4E4E7', -1, 'Admin → nginx'],
|
||||||
|
['ngx_fe', 'n-ngx', 'n-fe', '#3B82F6', -1, 'nginx → Frontend'],
|
||||||
|
['fe_web', 'n-fe', 'n-web', '#3B82F6', 1, 'Frontend → Browser'],
|
||||||
|
['ann', 'n-svc', 'n-nfy', '#F59E0B', 1, 'Ankündigung → Notification'],
|
||||||
|
['cmd', 'n-be', 'n-svc', '#EF4444', -1, 'Command → Service'],
|
||||||
|
['itune', 'n-itune','n-svc', '#0EA5E9', -1, 'Intune → Service (Deploy)'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function nodeEdge(fromId, toId) {
|
||||||
|
const f = centers[fromId], t = centers[toId];
|
||||||
|
if (!f || !t) return { fx:0,fy:0, tx:0,ty:0 };
|
||||||
|
const dx = t.x-f.x, dy = t.y-f.y;
|
||||||
|
const ang = Math.atan2(dy, dx);
|
||||||
|
const hw = NW/2+4, hh = NH/2+4;
|
||||||
|
// From
|
||||||
|
const sf = Math.min(hw/Math.abs(Math.cos(ang)||1e-9), hh/Math.abs(Math.sin(ang)||1e-9));
|
||||||
|
const fx = f.x + Math.cos(ang)*Math.min(sf, Math.hypot(hw,hh));
|
||||||
|
const fy = f.y + Math.sin(ang)*Math.min(sf, Math.hypot(hw,hh));
|
||||||
|
// To
|
||||||
|
const a2 = ang+Math.PI;
|
||||||
|
const st = Math.min(hw/Math.abs(Math.cos(a2)||1e-9), hh/Math.abs(Math.sin(a2)||1e-9));
|
||||||
|
const tx = t.x + Math.cos(a2)*Math.min(st, Math.hypot(hw,hh));
|
||||||
|
const ty = t.y + Math.sin(a2)*Math.min(st, Math.hypot(hw,hh));
|
||||||
|
return { fx, fy, tx, ty };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathD(fx, fy, tx, ty, curveDir) {
|
||||||
|
const dx = tx-fx, dy = ty-fy;
|
||||||
|
const len = Math.hypot(dx,dy)||1;
|
||||||
|
// Perpendicular offset
|
||||||
|
const nx = -dy/len, ny = dx/len;
|
||||||
|
const curve = len * 0.22 * curveDir;
|
||||||
|
const cx = (fx+tx)/2 + nx*curve;
|
||||||
|
const cy = (fy+ty)/2 + ny*curve;
|
||||||
|
return { d: `M${fx},${fy} Q${cx},${cy} ${tx},${ty}`, cx, cy };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-stored bezier control points for animation
|
||||||
|
const connData = {}; // id → { fx,fy,cx,cy,tx,ty, path }
|
||||||
|
|
||||||
|
function buildPaths() {
|
||||||
|
sv.querySelectorAll('.cp').forEach(e=>e.remove());
|
||||||
|
Object.keys(connPaths).forEach(k=>delete connPaths[k]);
|
||||||
|
|
||||||
|
CONNS.forEach(([id, fromId, toId, color, curveDir]) => {
|
||||||
|
const {fx,fy,tx,ty} = nodeEdge(fromId, toId);
|
||||||
|
const {d, cx, cy} = pathD(fx,fy,tx,ty, curveDir);
|
||||||
|
|
||||||
|
const p = document.createElementNS('http://www.w3.org/2000/svg','path');
|
||||||
|
p.setAttribute('d', d);
|
||||||
|
p.setAttribute('stroke', color+'1A');
|
||||||
|
p.setAttribute('stroke-width','1.5');
|
||||||
|
p.setAttribute('fill','none');
|
||||||
|
p.setAttribute('stroke-dasharray','3 10');
|
||||||
|
p.classList.add('cp');
|
||||||
|
sv.appendChild(p);
|
||||||
|
|
||||||
|
connPaths[id] = p;
|
||||||
|
connData[id] = { fx,fy,cx,cy,tx,ty, color, fromId, toId, curveDir };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// PACKET SYSTEM
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
let packets = [];
|
||||||
|
|
||||||
|
function quadBez(t, fx,fy,cx,cy,tx,ty) {
|
||||||
|
const m=1-t;
|
||||||
|
return { x: m*m*fx + 2*m*t*cx + t*t*tx, y: m*m*fy + 2*m*t*cy + t*t*ty };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawn(connId, color, speed, label, statKey, size=5) {
|
||||||
|
const d = connData[connId]; if(!d) return;
|
||||||
|
const {fx,fy,cx,cy,tx,ty,fromId,toId} = d;
|
||||||
|
|
||||||
|
// Glow trail
|
||||||
|
const trail = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||||||
|
trail.setAttribute('r', size*1.8);
|
||||||
|
trail.setAttribute('fill', color+'22');
|
||||||
|
|
||||||
|
// Core dot
|
||||||
|
const core = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||||||
|
core.setAttribute('r', size);
|
||||||
|
core.setAttribute('fill', color);
|
||||||
|
core.setAttribute('filter','url(#glow)');
|
||||||
|
|
||||||
|
// Bright center
|
||||||
|
const hi = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
||||||
|
hi.setAttribute('r', size*0.45);
|
||||||
|
hi.setAttribute('fill','#ffffff88');
|
||||||
|
|
||||||
|
const g = document.createElementNS('http://www.w3.org/2000/svg','g');
|
||||||
|
g.append(trail,core,hi);
|
||||||
|
sv.appendChild(g);
|
||||||
|
|
||||||
|
// Highlight path
|
||||||
|
const pEl = connPaths[connId];
|
||||||
|
if(pEl) pEl.setAttribute('stroke', color+'50');
|
||||||
|
|
||||||
|
packets.push({ g, t:0, speed, fx,fy,cx,cy,tx,ty, color, pEl, toId, label, done:false });
|
||||||
|
addLog(color, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// ANIMATION LOOP
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
let lastT=0;
|
||||||
|
|
||||||
|
function frame(now) {
|
||||||
|
const dt = Math.min(now-(lastT||now), 50);
|
||||||
|
lastT=now;
|
||||||
|
|
||||||
|
packets = packets.filter(p => {
|
||||||
|
if(p.done){ p.g.remove(); return false; }
|
||||||
|
p.t = Math.min(p.t + p.speed*dt/1000, 1);
|
||||||
|
const pos = quadBez(p.t, p.fx,p.fy,p.cx,p.cy,p.tx,p.ty);
|
||||||
|
const pos2 = quadBez(Math.max(0,p.t-0.04), p.fx,p.fy,p.cx,p.cy,p.tx,p.ty);
|
||||||
|
const dx=pos.x-pos2.x, dy=pos.y-pos2.y;
|
||||||
|
p.g.setAttribute('transform',`translate(${pos.x},${pos.y})`);
|
||||||
|
// Rotate trail in direction of travel
|
||||||
|
p.g.children[0].setAttribute('cx', -dx*3);
|
||||||
|
p.g.children[0].setAttribute('cy', -dy*3);
|
||||||
|
|
||||||
|
if(p.t>=1){
|
||||||
|
if(p.pEl) p.pEl.setAttribute('stroke', connData[Object.keys(connData).find(k=>connData[k].fromId===connData[Object.keys(connData).find(k2=>connData[k2].fromId===p.pEl?.dataset?.from)])]?.color+'1A' || '#ffffff1A');
|
||||||
|
if(p.pEl) p.pEl.setAttribute('stroke', p.color+'1A');
|
||||||
|
if(p.toId) pulseNode(p.toId, p.color);
|
||||||
|
p.done=true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pulseNode(id, color) {
|
||||||
|
const el = document.getElementById(id); if(!el) return;
|
||||||
|
el.classList.add('lit');
|
||||||
|
el.style.setProperty('--nc', color);
|
||||||
|
el.style.setProperty('--ng', hexToRgba(color, 0.28));
|
||||||
|
el.style.setProperty('--ni', hexToRgba(color, 0.05));
|
||||||
|
setTimeout(()=>el.classList.remove('lit'), 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex, a) {
|
||||||
|
const r=parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
|
||||||
|
return `rgba(${r},${g},${b},${a})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// TRIGGER SCENARIOS
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function doCheckin() {
|
||||||
|
// 1. Agent schickt alle 60 Sek seine Daten an den Server
|
||||||
|
addLog('#3B82F6', '1/4 · Agent schickt Systemdaten an Server (CPU, RAM, Disk, BitLocker…)');
|
||||||
|
setTimeout(()=>spawn('ci_out','#3B82F6',.45,'Agent → nginx: Systemdaten senden',null), 0);
|
||||||
|
setTimeout(()=>spawn('ngx_be','#3B82F6',.50,'nginx → Backend: weiterleiten',null), 900);
|
||||||
|
// 2. Backend speichert in DB
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#6B7280','2/4 · Backend speichert Daten in SQLite-Datenbank');
|
||||||
|
spawn('be_db','#6B7280',.55,'Backend → SQLite: Gerätedaten speichern',null);
|
||||||
|
}, 1700);
|
||||||
|
// 3. Backend antwortet mit Commands / Ankündigungen
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#22C55E','3/4 · Server antwortet: keine neuen Befehle — alles OK');
|
||||||
|
spawn('be_ngx','#22C55E',.50,'Backend → nginx: Antwort mit Status',null);
|
||||||
|
}, 2600);
|
||||||
|
setTimeout(()=>spawn('ci_in','#22C55E',.45,'nginx → Agent: Antwort empfangen',null), 3400);
|
||||||
|
// 4. Agent schreibt status.json, Dashboard liest sie
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#6366F1','4/4 · Agent aktualisiert status.json · Dashboard zeigt neue Werte');
|
||||||
|
spawn('dash_read','#6366F1',.65,'Dashboard liest status.json (alle 5 Sek)',null);
|
||||||
|
pulseNode('n-dash','#6366F1');
|
||||||
|
}, 4200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doAnnouncement() {
|
||||||
|
addLog('#F59E0B','1/5 · Admin tippt Ankündigung im Web-Dashboard ein');
|
||||||
|
// Admin → Server
|
||||||
|
setTimeout(()=>spawn('web_ngx','#F59E0B',.43,'Browser → nginx: Ankündigung speichern',null,6), 0);
|
||||||
|
setTimeout(()=>spawn('ngx_be','#F59E0B',.50,'nginx → Backend: weiterleiten',null,6), 800);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#F59E0B','2/5 · Backend speichert Ankündigung in Datenbank');
|
||||||
|
spawn('be_db','#F59E0B',.55,'Backend → SQLite: Ankündigung speichern',null,6);
|
||||||
|
}, 1500);
|
||||||
|
// Beim nächsten Check-in bekommt Agent die Ankündigung
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#F59E0B','3/5 · Beim nächsten Check-in: Agent empfängt Ankündigung');
|
||||||
|
spawn('ci_in','#F59E0B',.45,'nginx → Agent: Ankündigung mitschicken',null,6);
|
||||||
|
}, 3000);
|
||||||
|
// Agent startet Dialog als eingeloggter User
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#F59E0B','4/5 · Agent startet Popup-Dialog als eingeloggter Windows-User');
|
||||||
|
spawn('ann','#F59E0B',.38,'Agent → Notification-Fenster öffnen (schtasks)',null,6);
|
||||||
|
}, 4200);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#22C55E','5/5 · User klickt "Gelesen" → Bestätigung geht zurück an Server ✅');
|
||||||
|
pulseNode('n-nfy','#22C55E');
|
||||||
|
spawn('ci_out','#22C55E',.43,'Agent → Server: Ankündigung bestätigt (ACK)',null,6);
|
||||||
|
}, 6500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doPatch() {
|
||||||
|
addLog('#EF4444','1/5 · Admin klickt "Windows Updates installieren" im Patch-Management');
|
||||||
|
setTimeout(()=>spawn('web_ngx','#EF4444',.43,'Browser → nginx: Patch-Befehl senden',null,6), 0);
|
||||||
|
setTimeout(()=>spawn('ngx_be','#EF4444',.50,'nginx → Backend: weiterleiten',null,6), 800);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#EF4444','2/5 · Backend schreibt Befehl in die Datenbank (Command-Queue)');
|
||||||
|
spawn('be_db','#EF4444',.55,'Backend → SQLite: Befehl in Queue eintragen',null,6);
|
||||||
|
}, 1500);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#EF4444','3/5 · Beim nächsten Check-in (max. 60 Sek) bekommt Agent den Befehl');
|
||||||
|
spawn('ci_in','#EF4444',.45,'nginx → Agent: Befehl "install_updates" mitschicken',null,6);
|
||||||
|
}, 4000);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#EF4444','4/5 · Agent führt Windows Updates aus (UsoClient.exe)…');
|
||||||
|
pulseNode('n-svc','#EF4444');
|
||||||
|
}, 5200);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#22C55E','5/5 · Agent meldet Ergebnis zurück: "done" ✅');
|
||||||
|
spawn('ci_out','#22C55E',.43,'Agent → Server: Ergebnis melden (done)',null,6);
|
||||||
|
}, 8000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doUpdate() {
|
||||||
|
addLog('#2DD4BF','1/4 · Server hat neue Agent-Version — Agent merkt das beim Check-in');
|
||||||
|
setTimeout(()=>spawn('ci_in','#2DD4BF',.45,'nginx → Agent: "neue Version v2.1.0 verfügbar"',null,6), 200);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#2DD4BF','2/4 · Agent lädt neue EXE vom Server herunter');
|
||||||
|
spawn('cmd','#2DD4BF',.32,'Agent → Server: neue EXE herunterladen',null,7);
|
||||||
|
}, 1400);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#2DD4BF','3/4 · Agent stoppt Service, ersetzt EXE-Datei, startet Service neu');
|
||||||
|
pulseNode('n-svc','#2DD4BF');
|
||||||
|
}, 4500);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#22C55E','4/4 · Update fertig — Agent läuft mit neuer Version ✅');
|
||||||
|
spawn('ci_out','#22C55E',.43,'Agent → Server: Check-in mit neuer Version',null,6);
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doIntune() {
|
||||||
|
addLog('#0EA5E9','1/3 · Intune (MDM) erkennt: Agent fehlt auf diesem Gerät');
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#0EA5E9','2/3 · Intune schiebt setup.exe aufs Gerät und führt sie aus');
|
||||||
|
spawn('itune','#0EA5E9',.28,'Intune → Gerät: setup.exe installieren',null,7);
|
||||||
|
}, 500);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#0EA5E9',' Installer: Service registrieren · Autostart · Start-Menü Eintrag');
|
||||||
|
pulseNode('n-svc','#0EA5E9');
|
||||||
|
pulseNode('n-dash','#0EA5E9');
|
||||||
|
}, 5000);
|
||||||
|
setTimeout(()=>{
|
||||||
|
addLog('#22C55E','3/3 · Agent startet, meldet sich beim Server an ✅');
|
||||||
|
spawn('ci_out','#22C55E',.43,'Agent → Server: erster Check-in nach Installation',null,6);
|
||||||
|
}, 6500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// EVENT LOG
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
const logLines = [];
|
||||||
|
function addLog(color, msg) {
|
||||||
|
const t = new Date().toLocaleTimeString('de-DE',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||||
|
const stepMatch = msg.match(/^(\d+\/\d+)\s*·\s*(.*)/);
|
||||||
|
const step = stepMatch ? stepMatch[1] : '';
|
||||||
|
const text = stepMatch ? stepMatch[2] : msg;
|
||||||
|
logLines.unshift({t, step, text, color});
|
||||||
|
if(logLines.length>6) logLines.pop();
|
||||||
|
document.getElementById('log-body').innerHTML = logLines.map((l,i) => {
|
||||||
|
const opacity = i === 0 ? '1' : i === 1 ? '0.7' : i === 2 ? '0.5' : '0.3';
|
||||||
|
return `<div class="ll" style="--lc:${l.color};opacity:${opacity}">
|
||||||
|
<div class="ll-t">${l.t}${l.step ? ` · <b>${l.step}</b>` : ''}</div>
|
||||||
|
<div class="ll-m" style="color:${l.color}">${l.text}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
// INIT + RESIZE
|
||||||
|
// ════════════════════════════════════════════════════════
|
||||||
|
function init() {
|
||||||
|
layout();
|
||||||
|
drawGrid();
|
||||||
|
buildPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', ()=>{
|
||||||
|
clearTimeout(window._rt);
|
||||||
|
window._rt = setTimeout(()=>{ init(); },80);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('load', ()=>{
|
||||||
|
setTimeout(()=>{
|
||||||
|
init();
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
80
agent-cs/setup.iss
Normal file
80
agent-cs/setup.iss
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#define MyAppName "IT Nexus Agent"
|
||||||
|
#define MyAppVersion "2.0.0"
|
||||||
|
#define MyAppPublisher "Cereda Systems GmbH"
|
||||||
|
#define MyAppURL "https://it-nexus.cereda-systems.de"
|
||||||
|
#define MyAppExeName "IT-Nexus-Agent.exe"
|
||||||
|
#define MyAppGUID "B3F7A2C1-4E8D-4F2A-9B1C-7D3E5F6A8B2C"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
AppId={{{#MyAppGUID}}
|
||||||
|
AppName={#MyAppName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
AppVerName={#MyAppName} {#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
AppPublisherURL={#MyAppURL}
|
||||||
|
AppSupportURL={#MyAppURL}
|
||||||
|
AppUpdatesURL={#MyAppURL}
|
||||||
|
DefaultDirName={autopf}\{#MyAppName}
|
||||||
|
DefaultGroupName={#MyAppName}
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
DisableWelcomePage=no
|
||||||
|
OutputDir=..\installer
|
||||||
|
OutputBaseFilename=IT-Nexus-Agent-Setup-{#MyAppVersion}
|
||||||
|
SetupIconFile=icon.ico
|
||||||
|
Compression=lzma2/max
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
WizardResizable=no
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
MinVersion=10.0.17763
|
||||||
|
ArchitecturesInstallIn64BitMode=x64
|
||||||
|
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||||
|
UninstallDisplayName={#MyAppName} {#MyAppVersion}
|
||||||
|
VersionInfoVersion={#MyAppVersion}
|
||||||
|
VersionInfoCompany={#MyAppPublisher}
|
||||||
|
VersionInfoDescription={#MyAppName}
|
||||||
|
CloseApplications=yes
|
||||||
|
CloseApplicationsFilter=IT-Nexus-Agent.exe
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "bin\Publish\IT-Nexus-Agent.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "icon.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "config.template.json"; DestDir: "{commonappdata}\IT Nexus Agent"; DestName: "config.json"; Flags: onlyifdoesntexist uninsneveruninstall
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
; Kein Start-Menü-Icon und kein Desktop-Icon — Agent läuft unsichtbar im Hintergrund
|
||||||
|
|
||||||
|
[Registry]
|
||||||
|
; Kein Autostart-Eintrag — Dashboard wird NICHT automatisch gestartet
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; Parameters: "--install"; Flags: runhidden waituntilterminated; StatusMsg: "Installiere Windows Service..."
|
||||||
|
|
||||||
|
[UninstallRun]
|
||||||
|
Filename: "{app}\{#MyAppExeName}"; Parameters: "--uninstall"; Flags: runhidden waituntilterminated
|
||||||
|
|
||||||
|
[UninstallDelete]
|
||||||
|
Type: files; Name: "{commonappdata}\IT Nexus Agent\status.json"
|
||||||
|
Type: files; Name: "{commonappdata}\IT Nexus Agent\agent.log"
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
|
var
|
||||||
|
ResultCode: Integer;
|
||||||
|
begin
|
||||||
|
if CurStep = ssInstall then begin
|
||||||
|
// Service stoppen (neue Architektur)
|
||||||
|
Exec('net.exe', 'stop "IT Nexus Agent"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
// Alte Scheduled Tasks (v1.x PowerShell-Agent) entfernen
|
||||||
|
Exec('schtasks.exe', '/delete /tn "IT Nexus Agent" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
Exec('schtasks.exe', '/delete /tn "IT Nexus Agent Watcher" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
Exec('schtasks.exe', '/delete /tn "ITNexusAgent" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
Exec('schtasks.exe', '/delete /tn "ITNexusAgentWatcher" /f', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||||
|
// Autostart-Eintrag aus vorheriger Version entfernen (falls vorhanden)
|
||||||
|
RegDeleteValue(HKCU, 'SOFTWARE\Microsoft\Windows\CurrentVersion\Run', 'IT Nexus Agent');
|
||||||
|
Sleep(2000);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
671
agent-cs/topology.html
Normal file
671
agent-cs/topology.html
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>IT Nexus — Systemarchitektur</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: #0A0A0C;
|
||||||
|
color: #E4E4E7;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 40px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FAFAFA;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #52525B;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.subtitle span {
|
||||||
|
color: #009B9A;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topology {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 60px 1fr 60px 1fr;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
gap: 0;
|
||||||
|
align-items: start;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── COLUMNS ─── */
|
||||||
|
.col { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
/* ─── NODES ─── */
|
||||||
|
.node {
|
||||||
|
background: #18181B;
|
||||||
|
border: 1px solid #27272A;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.node-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid #27272A;
|
||||||
|
}
|
||||||
|
.node-icon {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.node-title { font-size: 13px; font-weight: 700; color: #FAFAFA; }
|
||||||
|
.node-sub { font-size: 10px; color: #52525B; margin-top: 1px; }
|
||||||
|
.node-body { padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
/* ─── COMPONENTS inside nodes ─── */
|
||||||
|
.comp {
|
||||||
|
background: #1C1C1F;
|
||||||
|
border: 1px solid #2A2A2E;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.comp-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.comp-name { font-size: 12px; font-weight: 600; color: #E4E4E7; }
|
||||||
|
.comp-desc { font-size: 10px; color: #52525B; margin-top: 2px; line-height: 1.4; }
|
||||||
|
.comp-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-green { background: #14532D; color: #4ADE80; }
|
||||||
|
.badge-blue { background: #1E3A5F; color: #60A5FA; }
|
||||||
|
.badge-orange { background: #431407; color: #FB923C; }
|
||||||
|
.badge-teal { background: #0D3030; color: #2DD4BF; }
|
||||||
|
.badge-red { background: #450A0A; color: #F87171; }
|
||||||
|
.badge-gray { background: #27272A; color: #71717A; }
|
||||||
|
|
||||||
|
/* ─── ARROWS / CONNECTORS ─── */
|
||||||
|
.arrow-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-top: 60px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.arrow-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.arrow-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #3F3F46;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 52px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.arrow-line {
|
||||||
|
width: 1px;
|
||||||
|
height: 28px;
|
||||||
|
background: linear-gradient(to bottom, #27272A, #3F3F46);
|
||||||
|
}
|
||||||
|
.arrow-head {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 6px solid #3F3F46;
|
||||||
|
}
|
||||||
|
.arrow-both { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.arrow-up { border-top: none; border-bottom: 6px solid #3F3F46; }
|
||||||
|
.arrow-bi { color: #3F3F46; font-size: 14px; }
|
||||||
|
|
||||||
|
/* ─── COLUMN HEADERS ─── */
|
||||||
|
.col-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3F3F46;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── FLOW LEGEND ─── */
|
||||||
|
.legend {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-top: 36px;
|
||||||
|
background: #18181B;
|
||||||
|
border: 1px solid #27272A;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
}
|
||||||
|
.legend h2 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #52525B;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.flow-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.flow-item {
|
||||||
|
background: #1C1C1F;
|
||||||
|
border: 1px solid #27272A;
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.flow-title { font-size: 11px; font-weight: 600; color: #E4E4E7; margin-bottom: 4px; }
|
||||||
|
.flow-desc { font-size: 10px; color: #52525B; line-height: 1.5; }
|
||||||
|
.flow-step { display: flex; align-items: baseline; gap: 6px; }
|
||||||
|
.flow-num { font-size: 9px; font-weight: 700; color: #009B9A; }
|
||||||
|
|
||||||
|
/* colors per zone */
|
||||||
|
.zone-pc .node-header { background: #0F1B2D; }
|
||||||
|
.zone-pc .node-icon { background: #1E3A5F; }
|
||||||
|
.zone-server .node-header { background: #0A1F0A; }
|
||||||
|
.zone-server .node-icon { background: #14532D; }
|
||||||
|
.zone-admin .node-header { background: #1A0F2E; }
|
||||||
|
.zone-admin .node-icon { background: #2E1065; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>IT Nexus — Systemarchitektur</h1>
|
||||||
|
<p class="subtitle">v2.0.0 · <span>Cereda Systems GmbH</span> · Stand: Mai 2026</p>
|
||||||
|
|
||||||
|
<div class="topology">
|
||||||
|
|
||||||
|
<!-- ═══ SPALTE 1: Windows Client ═══ -->
|
||||||
|
<div class="col zone-pc">
|
||||||
|
<div class="col-label">Windows-Endgerät</div>
|
||||||
|
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-icon">🖥️</div>
|
||||||
|
<div>
|
||||||
|
<div class="node-title">IT-Nexus-Agent.exe</div>
|
||||||
|
<div class="node-sub">C:\Program Files\IT Nexus Agent\</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">⚙️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Windows Service</div>
|
||||||
|
<div class="comp-desc">Läuft als SYSTEM · Autostart<br>Check-in alle 60 Sekunden</div>
|
||||||
|
<span class="comp-badge badge-green">Running</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">📊</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Dashboard (–-dashboard)</div>
|
||||||
|
<div class="comp-desc">WPF Dark-Theme · System Tray<br>Autostart bei Windows-Login</div>
|
||||||
|
<span class="comp-badge badge-blue">STAThread</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🔔</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Ankündigung (–-notify)</div>
|
||||||
|
<div class="comp-desc">WPF Dialog per schtasks.exe<br>Startet als eingeloggter User</div>
|
||||||
|
<span class="comp-badge badge-orange">On Demand</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-icon">📦</div>
|
||||||
|
<div>
|
||||||
|
<div class="node-title">Gesammelte Daten</div>
|
||||||
|
<div class="node-sub">status.json · agent.log · config.json</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🔒</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">BitLocker · Defender</div>
|
||||||
|
<div class="comp-desc">WMI-Abfragen als SYSTEM<br>Win32_EncryptableVolume · MSFT_MpComputerStatus</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">📈</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">CPU · RAM · Disk · Updates</div>
|
||||||
|
<div class="comp-desc">Performance Counter · WMI<br>Windows Update COM API</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🏷️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Hardware · Software · TPM</div>
|
||||||
|
<div class="comp-desc">BIOS Serial · Registry Uninstall<br>Win32_Tpm · Secure Boot</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-icon">🔧</div>
|
||||||
|
<div>
|
||||||
|
<div class="node-title">Installer (setup.exe)</div>
|
||||||
|
<div class="node-sub">Inno Setup 6 · GUID {B3F7A2C1…}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">📋</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Was der Installer macht</div>
|
||||||
|
<div class="comp-desc">
|
||||||
|
EXE → Program Files<br>
|
||||||
|
sc.exe create → Windows Service<br>
|
||||||
|
HKCU\Run → Autostart Dashboard<br>
|
||||||
|
Start-Menü Verknüpfung<br>
|
||||||
|
config.json (falls nicht vorhanden)<br>
|
||||||
|
Erkennungsregel für Intune
|
||||||
|
</div>
|
||||||
|
<span class="comp-badge badge-teal">Intune-kompatibel</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ ARROWS 1→2 ═══ -->
|
||||||
|
<div class="arrow-col">
|
||||||
|
<div class="arrow-group">
|
||||||
|
<div class="arrow-label">POST /checkin<br>X-Agent-Key</div>
|
||||||
|
<div class="arrow-line"></div>
|
||||||
|
<div class="arrow-head"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow-group">
|
||||||
|
<div class="arrow-label">Response<br>commands<br>anns</div>
|
||||||
|
<div class="arrow-line" style="background:linear-gradient(to bottom,#3F3F46,#27272A)"></div>
|
||||||
|
<div class="arrow-head arrow-up" style="border-bottom-color:#3F3F46;border-top:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow-group">
|
||||||
|
<div class="arrow-label">HTTPS 443</div>
|
||||||
|
<div class="arrow-line"></div>
|
||||||
|
<div class="arrow-head"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow-group">
|
||||||
|
<div class="arrow-label">GET<br>/agent-script</div>
|
||||||
|
<div class="arrow-line"></div>
|
||||||
|
<div class="arrow-head"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SPALTE 2: Server ═══ -->
|
||||||
|
<div class="col zone-server">
|
||||||
|
<div class="col-label">IT Nexus Server · 192.168.0.194</div>
|
||||||
|
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-icon">🐳</div>
|
||||||
|
<div>
|
||||||
|
<div class="node-title">Docker Stack</div>
|
||||||
|
<div class="node-sub">LXC 102 · Ubuntu 24.04 · docker compose</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🌐</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">nginx (Reverse Proxy)</div>
|
||||||
|
<div class="comp-desc">Port 80 → 443 · TLS · Let's Encrypt<br>Leitet /api/* → Backend · /* → Frontend</div>
|
||||||
|
<span class="comp-badge badge-green">Port 443</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">⚡</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">fido-backend (Node.js)</div>
|
||||||
|
<div class="comp-desc">Express · better-sqlite3<br>JWT Auth · Agent-Key Auth<br>Cron Jobs · KI (Claude claude-sonnet-4-6)</div>
|
||||||
|
<span class="comp-badge badge-blue">Port 5000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">⚛️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">fido-frontend (React)</div>
|
||||||
|
<div class="comp-desc">CRA · Custom CSS<br>Monitoring · Patch · Helpdesk<br>KI-Assistent · Docs</div>
|
||||||
|
<span class="comp-badge badge-blue">Port 3000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🗄️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">SQLite Datenbank</div>
|
||||||
|
<div class="comp-desc">monitoring_agents · tickets · users<br>patch_commands · announcements</div>
|
||||||
|
<span class="comp-badge badge-gray">Volume-Mount</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-icon">🔗</div>
|
||||||
|
<div>
|
||||||
|
<div class="node-title">Externe Dienste</div>
|
||||||
|
<div class="node-sub">Integrationen via Backend</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">☁️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Microsoft Graph API</div>
|
||||||
|
<div class="comp-desc">Azure AD · Teams · MDO Alerts<br>Mail.ReadWrite · SecurityAlert.Read.All</div>
|
||||||
|
<span class="comp-badge badge-blue">App Registration</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🤖</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Anthropic Claude claude-sonnet-4-6</div>
|
||||||
|
<div class="comp-desc">KI-Assistent · Wissensdatenbank<br>Ticket-Analyse · Automatisierung</div>
|
||||||
|
<span class="comp-badge badge-teal">claude-sonnet-4-6</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🖥️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Proxmox API</div>
|
||||||
|
<div class="comp-desc">192.168.0.184 · hve-01<br>VM/CT Status · Ressourcen</div>
|
||||||
|
<span class="comp-badge badge-orange">API Token</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-icon">📡</div>
|
||||||
|
<div>
|
||||||
|
<div class="node-title">Agent-Endpunkte</div>
|
||||||
|
<div class="node-sub">Kein JWT — nur X-Agent-Key Header</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">📤</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">POST /api/monitoring/checkin</div>
|
||||||
|
<div class="comp-desc">Systemdaten · Bekommt commands + announcements zurück</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">✅</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">POST /api/patch/commands/result</div>
|
||||||
|
<div class="comp-desc">Ergebnis von Patch-Befehlen melden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🔔</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">POST /api/announcements/:id/ack-agent</div>
|
||||||
|
<div class="comp-desc">Bestätigung nach Klick im Dialog</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">⬇️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">GET /api/monitoring/agent-script</div>
|
||||||
|
<div class="comp-desc">Neue EXE-Version für Auto-Update</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ ARROWS 2→3 ═══ -->
|
||||||
|
<div class="arrow-col">
|
||||||
|
<div class="arrow-group">
|
||||||
|
<div class="arrow-label">HTTPS<br>Browser</div>
|
||||||
|
<div class="arrow-line"></div>
|
||||||
|
<div class="arrow-head"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow-group">
|
||||||
|
<div class="arrow-label">JWT<br>Auth</div>
|
||||||
|
<div class="arrow-line"></div>
|
||||||
|
<div class="arrow-head"></div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow-group">
|
||||||
|
<div class="arrow-label">Real-time<br>Dashboard</div>
|
||||||
|
<div class="arrow-line"></div>
|
||||||
|
<div class="arrow-head"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ SPALTE 3: Admin / Browser ═══ -->
|
||||||
|
<div class="col zone-admin">
|
||||||
|
<div class="col-label">IT-Administrator</div>
|
||||||
|
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-icon">🧑💼</div>
|
||||||
|
<div>
|
||||||
|
<div class="node-title">IT Nexus Web</div>
|
||||||
|
<div class="node-sub">https://it-nexus.cereda-systems.de</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">📊</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Monitoring</div>
|
||||||
|
<div class="comp-desc">Alle Geräte · Online/Offline<br>CPU/RAM/Disk · BitLocker · Defender<br>Seriennummer · Patch-Status</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🔄</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Patch Management</div>
|
||||||
|
<div class="comp-desc">Staged Rollout: Test → Pilot → Produktion<br>Commands: Updates, Reboot, Win11<br>Auto-Update Agent</div>
|
||||||
|
<span class="comp-badge badge-orange">Rollout-gesichert</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">📢</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Ankündigungen</div>
|
||||||
|
<div class="comp-desc">Erstellen · An Gruppen senden<br>Agent zeigt WPF-Dialog auf Endgerät<br>ACK-Tracking</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🎫</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Helpdesk</div>
|
||||||
|
<div class="comp-desc">Tickets · Chat · KI-Assistent<br>Teams-Integration</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🤖</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">KI-Wissensdatenbank</div>
|
||||||
|
<div class="comp-desc">Artikel · Auto-Sync alle 30s<br>Claude claude-sonnet-4-6 Analyse</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-icon">🏢</div>
|
||||||
|
<div>
|
||||||
|
<div class="node-title">Infrastruktur-Übersicht</div>
|
||||||
|
<div class="node-sub">Proxmox · LXC · VMs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🖥️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Proxmox hve-01</div>
|
||||||
|
<div class="comp-desc">192.168.0.184 · LXC 102 (IT Nexus)<br>VM/CT Status via REST API</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">💾</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Synology NAS</div>
|
||||||
|
<div class="comp-desc">192.168.0.60 · iSCSI 500GB<br>iscsi-lvm auf Proxmox</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node">
|
||||||
|
<div class="node-header">
|
||||||
|
<div class="node-icon">🔐</div>
|
||||||
|
<div>
|
||||||
|
<div class="node-title">Rollen & Zugriffsschutz</div>
|
||||||
|
<div class="node-sub">JWT · Rollenbasiertes Routing</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="node-body">
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">👑</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">super_admin / admin</div>
|
||||||
|
<div class="comp-desc">Vollzugriff · alle Bereiche</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🛠️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">support / bearbeiter</div>
|
||||||
|
<div class="comp-desc">Helpdesk · IT-Verwaltung</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">👤</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">benutzer / hr / produktion</div>
|
||||||
|
<div class="comp-desc">Eingeschränkt · nur eigene Tickets</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comp">
|
||||||
|
<div class="comp-icon">🖥️</div>
|
||||||
|
<div>
|
||||||
|
<div class="comp-name">Dashboard Admin-Modus</div>
|
||||||
|
<div class="comp-desc">Login direkt am Endgerät<br>Agent-Steuerung · Log-Ansicht</div>
|
||||||
|
<span class="comp-badge badge-red">Auth geschützt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ ABLAUF-LEGENDE ═══ -->
|
||||||
|
<div class="legend">
|
||||||
|
<h2>Wichtige Abläufe</h2>
|
||||||
|
<div class="flow-grid">
|
||||||
|
|
||||||
|
<div class="flow-item">
|
||||||
|
<div class="flow-title">🔄 Normaler Check-in (60s)</div>
|
||||||
|
<div class="flow-desc">
|
||||||
|
<div class="flow-step"><span class="flow-num">1</span>Service sammelt Systemdaten via WMI + Registry</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">2</span>POST /api/monitoring/checkin mit X-Agent-Key</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">3</span>Server antwortet: commands + announcements</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">4</span>status.json → Dashboard liest alle 5s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-item">
|
||||||
|
<div class="flow-title">📢 Ankündigung senden</div>
|
||||||
|
<div class="flow-desc">
|
||||||
|
<div class="flow-step"><span class="flow-num">1</span>Admin erstellt Ankündigung im Web-Frontend</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">2</span>Beim nächsten Checkin: Agent empfängt Announcement</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">3</span>Service startet via schtasks.exe den Dialog als User</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">4</span>User klickt "Gelesen" → ACK an Server</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-item">
|
||||||
|
<div class="flow-title">🔄 Auto-Update Agent</div>
|
||||||
|
<div class="flow-desc">
|
||||||
|
<div class="flow-step"><span class="flow-num">1</span>Server-Response enthält neue agent_version</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">2</span>Agent lädt neue EXE via GET /agent-script</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">3</span>CMD-Script: Service stop → EXE ersetzen → Service start</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">4</span>Staged Rollout: Test → Pilot → Produktion</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-item">
|
||||||
|
<div class="flow-title">🔧 Patch-Command ausführen</div>
|
||||||
|
<div class="flow-desc">
|
||||||
|
<div class="flow-step"><span class="flow-num">1</span>Admin sendet Befehl im Patch-Management</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">2</span>Command kommt beim nächsten Checkin zurück</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">3</span>Service führt aus: UsoClient, shutdown, etc.</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">4</span>POST /commands/result meldet done/error</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-item">
|
||||||
|
<div class="flow-title">📦 Intune-Deployment</div>
|
||||||
|
<div class="flow-desc">
|
||||||
|
<div class="flow-step"><span class="flow-num">1</span>setup.exe via Intune Win32-App verteilen</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">2</span>Erkennungsregel: HKLM …\{B3F7A2C1…} DisplayVersion</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">3</span>Installer: Service + Autostart + config.json</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">4</span>Agent meldet sich selbstständig beim Server an</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow-item">
|
||||||
|
<div class="flow-title">🔐 Dashboard Exit-Schutz</div>
|
||||||
|
<div class="flow-desc">
|
||||||
|
<div class="flow-step"><span class="flow-num">1</span>X-Button → minimiert nur in Tray, schließt nie</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">2</span>"Beenden" im Tray → Admin-Login-Modal</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">3</span>POST /api/auth/login → JWT-Token</div>
|
||||||
|
<div class="flow-step"><span class="flow-num">4</span>Nur bei Erfolg: Shutdown()</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
agent-cs/uninstall-agent.ps1
Normal file
33
agent-cs/uninstall-agent.ps1
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# IT Nexus Agent deinstallieren (als Admin ausführen!)
|
||||||
|
Write-Host "Stoppe IT Nexus Agent Service..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
# Dashboard-Prozess killen
|
||||||
|
Get-Process -Name "IT-Nexus-Agent" -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
|
||||||
|
# Service stoppen
|
||||||
|
net stop "IT Nexus Agent" 2>$null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# Service löschen
|
||||||
|
sc.exe delete "IT Nexus Agent"
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
|
||||||
|
# Autostart entfernen
|
||||||
|
Remove-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "IT Nexus Agent" -ErrorAction SilentlyContinue
|
||||||
|
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run" -Name "IT Nexus Agent" -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Alten Installations-Ordner falls vorhanden entfernen
|
||||||
|
if (Test-Path "C:\Program Files\IT Nexus Agent") {
|
||||||
|
Remove-Item "C:\Program Files\IT Nexus Agent" -Recurse -Force
|
||||||
|
Write-Host "Installations-Ordner entfernt" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Status-Datei bleibt (config.json behalten, status.json löschen)
|
||||||
|
Remove-Item "C:\ProgramData\IT Nexus Agent\status.json" -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "status.json entfernt"
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Agent vollstaendig entfernt. Jetzt setup.exe ausfuehren!" -ForegroundColor Green
|
||||||
|
Write-Host "Druecke Enter zum Schliessen..." -ForegroundColor Gray
|
||||||
|
Read-Host
|
||||||
72
agent-cs/update-now.ps1
Normal file
72
agent-cs/update-now.ps1
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# IT Nexus Agent — Sofort-Update auf neues EXE (als Admin ausführen)
|
||||||
|
$newExe = "$PSScriptRoot\bin\Release\net8.0-windows\IT-Nexus-Agent.exe"
|
||||||
|
$svcExe = "C:\ProgramData\IT Nexus Agent\IT-Nexus-Agent.exe"
|
||||||
|
$pfExe = "C:\Program Files\IT Nexus Agent\IT-Nexus-Agent.exe"
|
||||||
|
$svcName = "IT Nexus Agent"
|
||||||
|
|
||||||
|
Write-Host "IT Nexus Agent — Update-Script" -ForegroundColor Cyan
|
||||||
|
Write-Host "Neue EXE: $newExe" -ForegroundColor Gray
|
||||||
|
|
||||||
|
if (-not (Test-Path $newExe)) {
|
||||||
|
Write-Host "FEHLER: Release-EXE nicht gefunden. Erst bauen!" -ForegroundColor Red
|
||||||
|
Read-Host; exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1. Tray/Dashboard-Prozesse beenden
|
||||||
|
Write-Host "1. Stoppe Dashboard-Prozesse..." -ForegroundColor Yellow
|
||||||
|
Get-Process -Name "IT-Nexus-Agent" -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
|
||||||
|
# 2. Service stoppen
|
||||||
|
Write-Host "2. Stoppe Windows Service..." -ForegroundColor Yellow
|
||||||
|
net stop $svcName 2>$null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
|
# 3. EXE an beiden Orten ersetzen
|
||||||
|
Write-Host "3. Kopiere neue EXE..." -ForegroundColor Yellow
|
||||||
|
$deps = @("IT-Nexus-Agent.dll","IT-Nexus-Agent.runtimeconfig.json",
|
||||||
|
"IT-Nexus-Agent.deps.json","Newtonsoft.Json.dll",
|
||||||
|
"System.Management.dll","System.ServiceProcess.ServiceController.dll")
|
||||||
|
|
||||||
|
foreach ($dest in @($svcExe, $pfExe)) {
|
||||||
|
if (Test-Path (Split-Path $dest)) {
|
||||||
|
Copy-Item $newExe $dest -Force
|
||||||
|
foreach ($d in $deps) {
|
||||||
|
$src = Join-Path (Split-Path $newExe) $d
|
||||||
|
if (Test-Path $src) { Copy-Item $src (Split-Path $dest) -Force }
|
||||||
|
}
|
||||||
|
Write-Host " OK: $dest" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Service-Binary-Pfad aktualisieren (falls nötig)
|
||||||
|
$regPath = (sc.exe qc $svcName 2>$null | Select-String "BINARY_PATH").ToString().Trim()
|
||||||
|
Write-Host "4. Aktueller Service-Pfad: $regPath" -ForegroundColor Gray
|
||||||
|
|
||||||
|
# Wenn Service auf ProgramData zeigt → auf Program Files umlenken
|
||||||
|
if ($regPath -like "*ProgramData*") {
|
||||||
|
Write-Host " Migriere Service-Pfad zu Program Files..." -ForegroundColor Yellow
|
||||||
|
sc.exe delete $svcName 2>$null
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
sc.exe create $svcName binPath= "`"$pfExe`"" start= auto DisplayName= "IT Nexus Agent" 2>$null
|
||||||
|
sc.exe description $svcName "Cereda Systems IT Nexus Monitoring Agent" 2>$null
|
||||||
|
sc.exe failure $svcName reset= 60 actions= restart/5000/restart/10000/restart/30000 2>$null
|
||||||
|
}
|
||||||
|
|
||||||
|
# 5. Service starten
|
||||||
|
Write-Host "5. Starte Windows Service..." -ForegroundColor Yellow
|
||||||
|
net start $svcName 2>$null
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
|
||||||
|
$svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
|
||||||
|
if ($svc.Status -eq "Running") {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Update erfolgreich! Service laeuft." -ForegroundColor Green
|
||||||
|
Write-Host "Naechster Check-in in max. 60 Sekunden." -ForegroundColor Gray
|
||||||
|
} else {
|
||||||
|
Write-Host "WARNUNG: Service nicht gestartet. Status: $($svc.Status)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Druecke Enter zum Schliessen..."
|
||||||
|
Read-Host
|
||||||
524
agent/IT-Nexus-Agent-Setup.hta
Normal file
524
agent/IT-Nexus-Agent-Setup.hta
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=11">
|
||||||
|
<title>IT Nexus Agent Setup</title>
|
||||||
|
<HTA:APPLICATION
|
||||||
|
APPLICATIONNAME="IT Nexus Agent Setup"
|
||||||
|
BORDER="thin"
|
||||||
|
BORDERSTYLE="normal"
|
||||||
|
CAPTION="yes"
|
||||||
|
CONTEXTMENU="no"
|
||||||
|
MAXIMIZEBUTTON="no"
|
||||||
|
MINIMIZEBUTTON="yes"
|
||||||
|
NAVIGABLE="no"
|
||||||
|
SCROLL="no"
|
||||||
|
SELECTION="no"
|
||||||
|
SHOWINTASKBAR="yes"
|
||||||
|
SINGLEINSTANCE="yes"
|
||||||
|
SYSMENU="yes"
|
||||||
|
WINDOWSTATE="normal"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, sans-serif;
|
||||||
|
background: #1e1e2e;
|
||||||
|
color: #cdd6f4;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 220px;
|
||||||
|
background: #181825;
|
||||||
|
border-right: 1px solid #313244;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 28px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
padding: 0 20px 28px;
|
||||||
|
border-bottom: 1px solid #313244;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.sidebar-logo-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #cdd6f4;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.sidebar-logo-sub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c7086;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 13px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #6c7086;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.nav-item.done { color: #a6e3a1; }
|
||||||
|
.nav-item.active { color: #89b4fa; font-weight: 600; background: rgba(137,180,250,0.08); border-right: 3px solid #89b4fa; }
|
||||||
|
|
||||||
|
.nav-num {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 13px; font-weight: 700;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #313244;
|
||||||
|
color: #6c7086;
|
||||||
|
}
|
||||||
|
.nav-item.done .nav-num { background: #a6e3a1; color: #1e1e2e; }
|
||||||
|
.nav-item.active .nav-num { background: #89b4fa; color: #1e1e2e; }
|
||||||
|
|
||||||
|
/* Main */
|
||||||
|
.main {
|
||||||
|
position: absolute;
|
||||||
|
left: 220px; top: 0; right: 0; bottom: 60px;
|
||||||
|
padding: 36px 36px 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step { display: none; }
|
||||||
|
.step.active { display: block; }
|
||||||
|
|
||||||
|
.step-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #cdd6f4;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.step-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #6c7086;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Feature list */
|
||||||
|
.feature-list { list-style: none; }
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid #313244;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.feature-item:last-child { border-bottom: none; }
|
||||||
|
.feature-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #89b4fa;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.field label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c7086;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #181825;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #cdd6f4;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: 'Segoe UI', sans-serif;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.field input:focus { outline: none; border-color: #89b4fa; }
|
||||||
|
.field input[readonly] { color: #6c7086; }
|
||||||
|
|
||||||
|
/* Summary */
|
||||||
|
.summary-box {
|
||||||
|
background: #181825;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
font-size: 15px;
|
||||||
|
border-bottom: 1px solid #313244;
|
||||||
|
}
|
||||||
|
.summary-row:last-child { border-bottom: none; }
|
||||||
|
.summary-key { color: #6c7086; }
|
||||||
|
.summary-val { color: #89b4fa; font-weight: 600; font-size: 12px; max-width: 200px; text-align: right; }
|
||||||
|
|
||||||
|
.warn-box {
|
||||||
|
background: rgba(250,179,135,0.08);
|
||||||
|
border: 1px solid rgba(250,179,135,0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fab387;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log */
|
||||||
|
#log {
|
||||||
|
background: #11111b;
|
||||||
|
border: 1px solid #313244;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
height: 50%;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Consolas', 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.log-ok { color: #a6e3a1; }
|
||||||
|
.log-err { color: #f38ba8; }
|
||||||
|
.log-info { color: #6c7086; }
|
||||||
|
.log-warn { color: #f9e2af; }
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.progress-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c7086;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.progress-wrap {
|
||||||
|
background: #313244;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: linear-gradient(90deg, #89b4fa, #b4befe);
|
||||||
|
width: 0%;
|
||||||
|
transition: width .35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success */
|
||||||
|
.success-wrap { text-align: center; padding-top: 10px; }
|
||||||
|
.success-check {
|
||||||
|
width: 64px; height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(166,227,161,0.12);
|
||||||
|
border: 2px solid #a6e3a1;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #a6e3a1;
|
||||||
|
}
|
||||||
|
.success-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #a6e3a1;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.success-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6c7086;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.success-hint {
|
||||||
|
margin-top: 14px;
|
||||||
|
background: rgba(137,180,250,0.08);
|
||||||
|
border: 1px solid rgba(137,180,250,0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #89b4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
left: 220px; right: 0; bottom: 0;
|
||||||
|
height: 60px;
|
||||||
|
border-top: 1px solid #313244;
|
||||||
|
background: #181825;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0 24px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 11px 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: 'Segoe UI', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(1.1); }
|
||||||
|
.btn:disabled { opacity: .35; cursor: not-allowed; }
|
||||||
|
.btn-ghost { background: transparent; color: #6c7086; border: 1px solid #313244; }
|
||||||
|
.btn-primary { background: #89b4fa; color: #1e1e2e; }
|
||||||
|
.btn-success { background: #a6e3a1; color: #1e1e2e; }
|
||||||
|
.btn-close { background: #585b70; color: #cdd6f4; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<div class="sidebar-logo-title">IT Nexus Agent</div>
|
||||||
|
<div class="sidebar-logo-sub">Setup 1.0.0</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item active" id="nav1"><div class="nav-num" id="nn1">1</div> Willkommen</div>
|
||||||
|
<div class="nav-item" id="nav2"><div class="nav-num" id="nn2">2</div> Konfiguration</div>
|
||||||
|
<div class="nav-item" id="nav3"><div class="nav-num" id="nn3">3</div> Zusammenfassung</div>
|
||||||
|
<div class="nav-item" id="nav4"><div class="nav-num" id="nn4">4</div> Installation</div>
|
||||||
|
<div class="nav-item" id="nav5"><div class="nav-num" id="nn5">5</div> Abgeschlossen</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="main">
|
||||||
|
|
||||||
|
<!-- Step 1 -->
|
||||||
|
<div class="step active" id="step1">
|
||||||
|
<div class="step-title">Willkommen</div>
|
||||||
|
<div class="step-subtitle">Der IT Nexus Agent wird als Windows-Dienst installiert und sendet alle 5 Minuten Systemdaten an das IT Nexus Dashboard.</div>
|
||||||
|
<ul class="feature-list">
|
||||||
|
<li class="feature-item"><span class="feature-dot"></span> CPU, RAM & Festplatten-Auslastung</li>
|
||||||
|
<li class="feature-item"><span class="feature-dot"></span> Installierte Software</li>
|
||||||
|
<li class="feature-item"><span class="feature-dot"></span> Windows Update Status</li>
|
||||||
|
<li class="feature-item"><span class="feature-dot"></span> Angemeldeter Benutzer & Uptime</li>
|
||||||
|
<li class="feature-item"><span class="feature-dot"></span> Netzwerk & Systeminformationen</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 -->
|
||||||
|
<div class="step" id="step2">
|
||||||
|
<div class="step-title">Konfiguration</div>
|
||||||
|
<div class="step-subtitle">Serververbindung konfigurieren.</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Server URL</label>
|
||||||
|
<input type="text" id="serverUrl" value="https://it-nexus.cereda-systems.de">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Agent API Key</label>
|
||||||
|
<input type="text" id="agentKey" value="cereda-agent-2024-secure-key">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Installationsverzeichnis</label>
|
||||||
|
<input type="text" id="installDir" value="C:\ProgramData\IT Nexus Agent" readonly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3 -->
|
||||||
|
<div class="step" id="step3">
|
||||||
|
<div class="step-title">Bereit zur Installation</div>
|
||||||
|
<div class="step-subtitle">Bitte alles pruefen, dann auf Installieren klicken.</div>
|
||||||
|
<div class="summary-box">
|
||||||
|
<div class="summary-row"><span class="summary-key">Verzeichnis</span><span class="summary-val" id="sum-dir"></span></div>
|
||||||
|
<div class="summary-row"><span class="summary-key">Server</span><span class="summary-val" id="sum-url"></span></div>
|
||||||
|
<div class="summary-row"><span class="summary-key">Scheduled Task</span><span class="summary-val">Alle 5 Minuten (SYSTEM)</span></div>
|
||||||
|
<div class="summary-row"><span class="summary-key">Agent Version</span><span class="summary-val">1.0.0</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="warn-box">Hinweis: Es erscheint eine UAC-Abfrage fuer Administrator-Rechte.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4 -->
|
||||||
|
<div class="step" id="step4">
|
||||||
|
<div class="step-title">Installation</div>
|
||||||
|
<div class="progress-label" id="progLabel">Vorbereitung...</div>
|
||||||
|
<div class="progress-wrap"><div class="progress-fill" id="progBar"></div></div>
|
||||||
|
<div id="log"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5 -->
|
||||||
|
<div class="step" id="step5">
|
||||||
|
<div class="success-wrap">
|
||||||
|
<div class="success-check">✓</div>
|
||||||
|
<div class="success-title">Installation abgeschlossen</div>
|
||||||
|
<div class="success-sub">Der IT Nexus Agent ist installiert und aktiv.<br>Er sendet alle 5 Minuten Daten an das Dashboard.</div>
|
||||||
|
<div class="success-hint">Sichtbar unter: IT Nexus → Administration → Agent Monitoring</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
<button class="btn btn-ghost" id="btnBack" onclick="prevStep()" style="display:none">Zurueck</button>
|
||||||
|
<button class="btn btn-ghost" id="btnCancel" onclick="window.close()">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" id="btnNext" onclick="nextStep()">Weiter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script language="JScript">
|
||||||
|
var step = 1;
|
||||||
|
var shell = new ActiveXObject("WScript.Shell");
|
||||||
|
var fso = new ActiveXObject("Scripting.FileSystemObject");
|
||||||
|
|
||||||
|
// Groesse anhand Bildschirmaufloesung berechnen (60% Breite, 70% Hoehe, min/max begrenzt)
|
||||||
|
var w = Math.min(Math.max(Math.round(screen.availWidth * 0.60), 700), 1100);
|
||||||
|
var h = Math.min(Math.max(Math.round(screen.availHeight * 0.70), 560), 820);
|
||||||
|
window.resizeTo(w, h);
|
||||||
|
window.moveTo((screen.availWidth-w)/2, (screen.availHeight-h)/2);
|
||||||
|
|
||||||
|
// Schriftgroesse skalieren
|
||||||
|
var scale = w / 780;
|
||||||
|
document.body.style.fontSize = Math.round(13 * scale) + "px";
|
||||||
|
|
||||||
|
function updateNav() {
|
||||||
|
for (var i=1;i<=5;i++) {
|
||||||
|
var n = document.getElementById("nav"+i);
|
||||||
|
var nn = document.getElementById("nn"+i);
|
||||||
|
if (i < step) { n.className="nav-item done"; nn.innerHTML="✓"; }
|
||||||
|
else if (i === step) { n.className="nav-item active"; nn.innerHTML=i; }
|
||||||
|
else { n.className="nav-item"; nn.innerHTML=i; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStep(n) {
|
||||||
|
for (var i=1;i<=5;i++) {
|
||||||
|
var el=document.getElementById("step"+i);
|
||||||
|
if(el) el.className=(i===n)?"step active":"step";
|
||||||
|
}
|
||||||
|
step=n;
|
||||||
|
updateNav();
|
||||||
|
|
||||||
|
var btnBack = document.getElementById("btnBack");
|
||||||
|
var btnCancel = document.getElementById("btnCancel");
|
||||||
|
var btnNext = document.getElementById("btnNext");
|
||||||
|
|
||||||
|
btnBack.style.display = (n>1 && n<4) ? "" : "none";
|
||||||
|
btnCancel.style.display = (n>=4) ? "none" : "";
|
||||||
|
|
||||||
|
if (n===3) {
|
||||||
|
document.getElementById("sum-dir").innerText = document.getElementById("installDir").value;
|
||||||
|
document.getElementById("sum-url").innerText = document.getElementById("serverUrl").value;
|
||||||
|
btnNext.className = "btn btn-success";
|
||||||
|
btnNext.innerText = "Installieren";
|
||||||
|
} else if (n===4) {
|
||||||
|
btnNext.style.display="none";
|
||||||
|
startInstall();
|
||||||
|
} else if (n===5) {
|
||||||
|
btnNext.className="btn btn-close";
|
||||||
|
btnNext.innerText="Schliessen";
|
||||||
|
btnNext.style.display="";
|
||||||
|
} else {
|
||||||
|
btnNext.className="btn btn-primary";
|
||||||
|
btnNext.innerText="Weiter";
|
||||||
|
btnNext.style.display="";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStep() { if(step===5){window.close();return;} showStep(step+1); }
|
||||||
|
function prevStep() { if(step>1) showStep(step-1); }
|
||||||
|
|
||||||
|
function log(msg, type) {
|
||||||
|
var el=document.getElementById("log");
|
||||||
|
el.innerHTML+='<div class="'+(type||"log-info")+'">'+msg+'</div>';
|
||||||
|
el.scrollTop=el.scrollHeight;
|
||||||
|
}
|
||||||
|
function setProgress(pct, label) {
|
||||||
|
document.getElementById("progBar").style.width=pct+"%";
|
||||||
|
if(label) document.getElementById("progLabel").innerText=label;
|
||||||
|
}
|
||||||
|
function sleep(ms) { var s=new Date().getTime(); while(new Date().getTime()-s<ms){} }
|
||||||
|
function writeFile(path, content) {
|
||||||
|
var f=fso.CreateTextFile(path,true,false);
|
||||||
|
f.Write(content); f.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startInstall() {
|
||||||
|
try {
|
||||||
|
var installDir = document.getElementById("installDir").value;
|
||||||
|
var serverUrl = document.getElementById("serverUrl").value;
|
||||||
|
var agentKey = document.getElementById("agentKey").value;
|
||||||
|
|
||||||
|
setProgress(10, "Erstelle Verzeichnis...");
|
||||||
|
sleep(300);
|
||||||
|
if (!fso.FolderExists(installDir)) fso.CreateFolder(installDir);
|
||||||
|
log("Verzeichnis erstellt: " + installDir, "log-ok");
|
||||||
|
setProgress(25, "Kopiere Agent-Dateien...");
|
||||||
|
sleep(300);
|
||||||
|
|
||||||
|
var htaDir = fso.GetParentFolderName(location.href.replace("file:///","").replace(/\//g,"\\"));
|
||||||
|
try { fso.CopyFile(htaDir+"\\it-nexus-agent.ps1", installDir+"\\it-nexus-agent.ps1", true); }
|
||||||
|
catch(e) { log("Agent-Script wird neu erstellt...", "log-warn"); }
|
||||||
|
log("Agent-Script kopiert", "log-ok");
|
||||||
|
|
||||||
|
setProgress(40, "Schreibe Konfiguration...");
|
||||||
|
sleep(200);
|
||||||
|
writeFile(installDir+"\\config.json", '{\n "server_url": "'+serverUrl+'",\n "agent_key": "'+agentKey+'"\n}');
|
||||||
|
log("Konfiguration gespeichert", "log-ok");
|
||||||
|
|
||||||
|
setProgress(60, "Registriere Scheduled Task...");
|
||||||
|
sleep(300);
|
||||||
|
|
||||||
|
var ps = [
|
||||||
|
'$d = "'+installDir+'"',
|
||||||
|
'$a = New-ScheduledTaskAction -Execute "powershell.exe" -Argument ("-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File `\\"$d\\it-nexus-agent.ps1`\\"")',
|
||||||
|
'$t = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 5) -Once -At (Get-Date)',
|
||||||
|
'$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -MultipleInstances IgnoreNew -StartWhenAvailable',
|
||||||
|
'$p = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest',
|
||||||
|
'Unregister-ScheduledTask -TaskName "IT Nexus Agent" -Confirm:$false -ErrorAction SilentlyContinue',
|
||||||
|
'Register-ScheduledTask -TaskName "IT Nexus Agent" -Action $a -Trigger $t -Settings $s -Principal $p -Force | Out-Null',
|
||||||
|
'Start-ScheduledTask -TaskName "IT Nexus Agent"'
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
writeFile(installDir+"\\setup-task.ps1", ps);
|
||||||
|
|
||||||
|
// Als Administrator ausfuehren (UAC-Prompt)
|
||||||
|
var shellApp = new ActiveXObject("Shell.Application");
|
||||||
|
shellApp.ShellExecute(
|
||||||
|
"powershell.exe",
|
||||||
|
"-ExecutionPolicy Bypass -File \"" + installDir + "\\setup-task.ps1\"",
|
||||||
|
"",
|
||||||
|
"runas",
|
||||||
|
1
|
||||||
|
);
|
||||||
|
// Warten bis Task registriert ist
|
||||||
|
sleep(4000);
|
||||||
|
|
||||||
|
setProgress(85, "Pruefe Installation...");
|
||||||
|
sleep(600);
|
||||||
|
log("Scheduled Task registriert (alle 5 Minuten)", "log-ok");
|
||||||
|
log("Erster Checkin wird ausgefuehrt...", "log-info");
|
||||||
|
sleep(400);
|
||||||
|
log("Agent ist aktiv!", "log-ok");
|
||||||
|
|
||||||
|
setProgress(100, "Abgeschlossen");
|
||||||
|
sleep(800);
|
||||||
|
showStep(5);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
log("Fehler: " + e.message, "log-err");
|
||||||
|
setProgress(0, "Fehler aufgetreten");
|
||||||
|
var btn=document.getElementById("btnNext");
|
||||||
|
btn.innerText="Schliessen"; btn.className="btn btn-ghost"; btn.style.display="";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
agent/ann-watcher.ps1
Normal file
74
agent/ann-watcher.ps1
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# IT Nexus Announcement Watcher
|
||||||
|
# Wird alle 15 Sekunden als SYSTEM ausgefuehrt
|
||||||
|
# Zeigt Ankuendigungen als Desktop-Popup fuer den eingeloggten Benutzer
|
||||||
|
|
||||||
|
param()
|
||||||
|
$cfg = Get-Content 'C:\ProgramData\IT Nexus Agent\config.json' -Raw | ConvertFrom-Json
|
||||||
|
$url = $cfg.server_url.TrimEnd('/')
|
||||||
|
$key = $cfg.agent_key
|
||||||
|
|
||||||
|
function Show-AnnDialog($ann) {
|
||||||
|
$id = $ann.id
|
||||||
|
$dlg = "C:\ProgramData\IT Nexus Agent\ann_$id.ps1"
|
||||||
|
if (Test-Path $dlg) { return }
|
||||||
|
|
||||||
|
$title = ($ann.title -replace "'", "''")
|
||||||
|
$message = ($ann.message -replace "'", "''")
|
||||||
|
$accent = switch ($ann.type) { 'warning'{'220,50,50'} 'maintenance'{'245,158,11'} default{'99,102,241'} }
|
||||||
|
$label = switch ($ann.type) { 'warning'{'WICHTIGE WARNUNG'} 'maintenance'{'WARTUNGSANKUENDIGUNG'} default{'INFORMATION'} }
|
||||||
|
$ackUrl = "$url/api/announcements/$id/ack-agent"
|
||||||
|
$n = [System.Environment]::NewLine
|
||||||
|
|
||||||
|
$headerColor = switch ($ann.type) { 'warning'{'220,53,69'} 'maintenance'{'255,140,0'} default{'0,120,212'} }
|
||||||
|
$iconText = switch ($ann.type) { 'warning'{'⚠'} 'maintenance'{'🔧'} default{'ℹ'} }
|
||||||
|
|
||||||
|
$code = "Add-Type -AssemblyName System.Windows.Forms,System.Drawing$n"
|
||||||
|
# Hauptfenster - weißer Hintergrund, professionell
|
||||||
|
$code += "`$f=New-Object System.Windows.Forms.Form;`$f.Text='IT Nexus Mitteilung';`$f.Size=New-Object System.Drawing.Size(480,320);`$f.StartPosition='CenterScreen';`$f.FormBorderStyle='FixedSingle';`$f.MaximizeBox=`$false;`$f.MinimizeBox=`$false;`$f.TopMost=`$true;`$f.BackColor=[System.Drawing.Color]::White$n"
|
||||||
|
# Farbiger Header-Balken
|
||||||
|
$code += "`$hdr=New-Object System.Windows.Forms.Panel;`$hdr.Size=New-Object System.Drawing.Size(480,56);`$hdr.Location=New-Object System.Drawing.Point(0,0);`$hdr.BackColor=[System.Drawing.Color]::FromArgb($headerColor);`$f.Controls.Add(`$hdr)$n"
|
||||||
|
# Logo-Text links im Header
|
||||||
|
$code += "`$logo=New-Object System.Windows.Forms.Label;`$logo.Text='IT Nexus';`$logo.Font=New-Object System.Drawing.Font('Segoe UI',11,[System.Drawing.FontStyle]::Bold);`$logo.ForeColor=[System.Drawing.Color]::White;`$logo.Location=New-Object System.Drawing.Point(16,8);`$logo.Size=New-Object System.Drawing.Size(120,20);`$hdr.Controls.Add(`$logo)$n"
|
||||||
|
# Typ-Label im Header
|
||||||
|
$code += "`$lbl=New-Object System.Windows.Forms.Label;`$lbl.Text='$label';`$lbl.Font=New-Object System.Drawing.Font('Segoe UI',8);`$lbl.ForeColor=[System.Drawing.Color]::FromArgb(220,240,255);`$lbl.Location=New-Object System.Drawing.Point(16,30);`$lbl.Size=New-Object System.Drawing.Size(420,18);`$hdr.Controls.Add(`$lbl)$n"
|
||||||
|
# Titel
|
||||||
|
$code += "`$lt=New-Object System.Windows.Forms.Label;`$lt.Text='$title';`$lt.Font=New-Object System.Drawing.Font('Segoe UI',12,[System.Drawing.FontStyle]::Bold);`$lt.ForeColor=[System.Drawing.Color]::FromArgb(30,30,30);`$lt.Location=New-Object System.Drawing.Point(16,72);`$lt.Size=New-Object System.Drawing.Size(444,28);`$f.Controls.Add(`$lt)$n"
|
||||||
|
# Trennlinie
|
||||||
|
$code += "`$sep=New-Object System.Windows.Forms.Panel;`$sep.Size=New-Object System.Drawing.Size(448,1);`$sep.Location=New-Object System.Drawing.Point(16,104);`$sep.BackColor=[System.Drawing.Color]::FromArgb(220,220,220);`$f.Controls.Add(`$sep)$n"
|
||||||
|
# Nachricht
|
||||||
|
$code += "`$lm=New-Object System.Windows.Forms.Label;`$lm.Text='$message';`$lm.Font=New-Object System.Drawing.Font('Segoe UI',10);`$lm.ForeColor=[System.Drawing.Color]::FromArgb(60,60,60);`$lm.Location=New-Object System.Drawing.Point(16,112);`$lm.Size=New-Object System.Drawing.Size(444,120);`$lm.AutoSize=`$false;`$f.Controls.Add(`$lm)$n"
|
||||||
|
# Button
|
||||||
|
$code += "`$btn=New-Object System.Windows.Forms.Button;`$btn.Text='✓ Gelesen und bestätigt';`$btn.Font=New-Object System.Drawing.Font('Segoe UI',10,[System.Drawing.FontStyle]::Bold);`$btn.ForeColor=[System.Drawing.Color]::White;`$btn.BackColor=[System.Drawing.Color]::FromArgb($headerColor);`$btn.FlatStyle='Flat';`$btn.FlatAppearance.BorderSize=0;`$btn.Location=New-Object System.Drawing.Point(16,244);`$btn.Size=New-Object System.Drawing.Size(444,36);`$btn.DialogResult=[System.Windows.Forms.DialogResult]::OK;`$f.Controls.Add(`$btn);`$f.AcceptButton=`$btn$n"
|
||||||
|
$code += "if(`$f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){$n"
|
||||||
|
$code += " `$b=[System.Text.Encoding]::UTF8.GetBytes('{""hostname"":""+`$env:COMPUTERNAME+'""}')$n"
|
||||||
|
$code += " `$r=[System.Net.WebRequest]::Create('$ackUrl')$n"
|
||||||
|
$code += " `$r.Method='POST';`$r.ContentType='application/json';`$r.Headers.Add('X-Agent-Key','$key');`$r.ContentLength=`$b.Length$n"
|
||||||
|
$code += " `$s=`$r.GetRequestStream();`$s.Write(`$b,0,`$b.Length);`$s.Close()$n"
|
||||||
|
$code += " try{`$r.GetResponse().Close()}catch{}$n"
|
||||||
|
$code += " Remove-Item '$dlg' -Force -ErrorAction SilentlyContinue$n"
|
||||||
|
$code += "}$n"
|
||||||
|
|
||||||
|
$code | Out-File -FilePath $dlg -Encoding UTF8 -Force
|
||||||
|
|
||||||
|
$loggedUser = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||||
|
if ($loggedUser) {
|
||||||
|
$tn = "ITNexus-AnnDlg-$id"
|
||||||
|
Unregister-ScheduledTask -TaskName $tn -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
$a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-ExecutionPolicy Bypass -WindowStyle Hidden -File `"$dlg`""
|
||||||
|
$t = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(2)
|
||||||
|
$p = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited
|
||||||
|
$s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 60) -StartWhenAvailable
|
||||||
|
Register-ScheduledTask -TaskName $tn -Action $a -Trigger $t -Principal $p -Settings $s -Force | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$body = [System.Text.Encoding]::UTF8.GetBytes('{"hostname":"'+$env:COMPUTERNAME+'"}')
|
||||||
|
$req = [System.Net.WebRequest]::Create("$url/api/monitoring/announcements-poll")
|
||||||
|
$req.Method = 'POST'; $req.ContentType = 'application/json'
|
||||||
|
$req.Headers.Add('X-Agent-Key', $key); $req.ContentLength = $body.Length
|
||||||
|
$st = $req.GetRequestStream(); $st.Write($body, 0, $body.Length); $st.Close()
|
||||||
|
$rd = New-Object System.IO.StreamReader($req.GetResponse().GetResponseStream())
|
||||||
|
$data = $rd.ReadToEnd() | ConvertFrom-Json
|
||||||
|
if ($data.announcements) { foreach ($a in $data.announcements) { Show-AnnDialog $a } }
|
||||||
|
} catch {}
|
||||||
4
agent/config.example.json
Normal file
4
agent/config.example.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"server_url": "https://it-nexus.cereda-systems.de",
|
||||||
|
"agent_key": "DEIN-AGENT-KEY-HIER"
|
||||||
|
}
|
||||||
4
agent/config.json.template
Normal file
4
agent/config.json.template
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"server_url": "https://it-nexus.cereda-systems.de",
|
||||||
|
"agent_key": "cereda-agent-2024-secure-key"
|
||||||
|
}
|
||||||
44
agent/install.ps1
Normal file
44
agent/install.ps1
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# IT Nexus Agent - Installer
|
||||||
|
# Cereda Systems GmbH
|
||||||
|
|
||||||
|
$InstallDir = "$env:ProgramData\IT Nexus Agent"
|
||||||
|
$AgentKey = "cereda-agent-2024-secure-key"
|
||||||
|
$ServerUrl = "https://it-nexus.cereda-systems.de"
|
||||||
|
$TaskName = "IT Nexus Agent"
|
||||||
|
|
||||||
|
Write-Host "=== IT Nexus Agent Installation ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# Verzeichnis erstellen
|
||||||
|
if (-not (Test-Path $InstallDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Dateien kopieren
|
||||||
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
Copy-Item "$ScriptDir\it-nexus-agent.ps1" "$InstallDir\it-nexus-agent.ps1" -Force
|
||||||
|
|
||||||
|
# Config schreiben
|
||||||
|
$config = @{ server_url = $ServerUrl; agent_key = $AgentKey } | ConvertTo-Json
|
||||||
|
Set-Content -Path "$InstallDir\config.json" -Value $config -Force
|
||||||
|
|
||||||
|
# Alten Task entfernen falls vorhanden
|
||||||
|
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Scheduled Task erstellen (laeuft alle 5 Minuten als SYSTEM)
|
||||||
|
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File `"$InstallDir\it-nexus-agent.ps1`""
|
||||||
|
$trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 5) -Once -At (Get-Date)
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -MultipleInstances IgnoreNew -StartWhenAvailable
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest -ErrorAction SilentlyContinue
|
||||||
|
if (-not $principal) {
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -RunLevel Highest
|
||||||
|
}
|
||||||
|
|
||||||
|
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Force | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Installiert in: $InstallDir" -ForegroundColor Green
|
||||||
|
Write-Host "Scheduled Task '$TaskName' erstellt (alle 5 Min)" -ForegroundColor Green
|
||||||
|
Write-Host "Server: $ServerUrl" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Erster Checkin wird jetzt ausgefuehrt..." -ForegroundColor Yellow
|
||||||
|
Start-ScheduledTask -TaskName $TaskName
|
||||||
|
Write-Host "Fertig!" -ForegroundColor Green
|
||||||
606
agent/it-nexus-agent.ps1
Normal file
606
agent/it-nexus-agent.ps1
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
# IT Nexus Monitoring Agent v1.3
|
||||||
|
# Cereda Systems GmbH
|
||||||
|
# Laedt Systemdaten und sendet sie an IT Nexus
|
||||||
|
|
||||||
|
$AgentVersion = "1.3.1"
|
||||||
|
$ConfigPath = Join-Path $PSScriptRoot "config.json"
|
||||||
|
$LogPath = Join-Path $PSScriptRoot "agent.log"
|
||||||
|
|
||||||
|
function Write-Log($msg) {
|
||||||
|
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $msg"
|
||||||
|
Add-Content -Path $LogPath -Value $line -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Config laden
|
||||||
|
if (-not (Test-Path $ConfigPath)) {
|
||||||
|
Write-Log "ERROR: config.json nicht gefunden"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
$config = Get-Content $ConfigPath | ConvertFrom-Json
|
||||||
|
$ServerUrl = $config.server_url.TrimEnd('/')
|
||||||
|
$AgentKey = $config.agent_key
|
||||||
|
|
||||||
|
function Get-CpuUsage {
|
||||||
|
try {
|
||||||
|
$pc = [System.Diagnostics.PerformanceCounter]::new("Processor", "% Processor Time", "_Total")
|
||||||
|
$pc.NextValue() | Out-Null
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
return [math]::Round($pc.NextValue(), 1)
|
||||||
|
} catch { return $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RamInfo {
|
||||||
|
try {
|
||||||
|
$os = Get-CimInstance Win32_OperatingSystem
|
||||||
|
$total = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
|
||||||
|
$free = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
|
||||||
|
return @{ total = $total; used = [math]::Round($total - $free, 2) }
|
||||||
|
} catch { return @{ total = $null; used = $null } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-DiskInfo {
|
||||||
|
try {
|
||||||
|
$disk = Get-PSDrive C
|
||||||
|
$total = [math]::Round(($disk.Used + $disk.Free) / 1GB, 2)
|
||||||
|
$free = [math]::Round($disk.Free / 1GB, 2)
|
||||||
|
return @{ total = $total; free = $free }
|
||||||
|
} catch { return @{ total = $null; free = $null } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-CpuInfo {
|
||||||
|
try {
|
||||||
|
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||||
|
return @{ model = $cpu.Name.Trim(); cores = $cpu.NumberOfCores }
|
||||||
|
} catch { return @{ model = $null; cores = $null } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-MacAddress {
|
||||||
|
try {
|
||||||
|
$mac = (Get-CimInstance Win32_NetworkAdapterConfiguration | Where-Object { $_.IPEnabled } | Select-Object -First 1).MACAddress
|
||||||
|
return $mac
|
||||||
|
} catch { return $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-InstalledSoftware {
|
||||||
|
try {
|
||||||
|
$paths = @(
|
||||||
|
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||||
|
'HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
|
||||||
|
)
|
||||||
|
$apps = Get-ItemProperty $paths -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.DisplayName -and $_.DisplayName -notmatch '^KB\d+' } |
|
||||||
|
Select-Object -ExpandProperty DisplayName -Unique |
|
||||||
|
ForEach-Object { [System.Text.RegularExpressions.Regex]::Replace($_, '[^\x20-\x7E]', '') } |
|
||||||
|
Where-Object { $_.Length -gt 2 } |
|
||||||
|
Sort-Object |
|
||||||
|
Select-Object -First 100
|
||||||
|
return $apps
|
||||||
|
} catch { return @() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PendingUpdates {
|
||||||
|
try {
|
||||||
|
$session = New-Object -ComObject Microsoft.Update.Session
|
||||||
|
$searcher = $session.CreateUpdateSearcher()
|
||||||
|
$result = $searcher.Search("IsInstalled=0 and Type='Software'")
|
||||||
|
return $result.Updates.Count
|
||||||
|
} catch { return 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-UptimeHours {
|
||||||
|
try {
|
||||||
|
$uptime = (Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime
|
||||||
|
return [math]::Round($uptime.TotalHours, 1)
|
||||||
|
} catch { return $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LastUser {
|
||||||
|
try {
|
||||||
|
$user = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||||
|
if ($user) { return $user.Split('\')[-1] }
|
||||||
|
$profile = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\*' |
|
||||||
|
Where-Object { $_.ProfileImagePath -like 'C:\Users\*' -and $_.ProfileImagePath -notlike '*default*' } |
|
||||||
|
Sort-Object { $_.PSChildName } | Select-Object -Last 1
|
||||||
|
return ($profile.ProfileImagePath -split '\\')[-1]
|
||||||
|
} catch { return $null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-TpmInfo {
|
||||||
|
try {
|
||||||
|
$tpm = Get-WmiObject -Namespace "Root\CIMv2\Security\MicrosoftTpm" -Class Win32_Tpm -ErrorAction Stop
|
||||||
|
if ($tpm) {
|
||||||
|
$specVer = $tpm.SpecVersion
|
||||||
|
$isV2 = $specVer -like "*2.0*"
|
||||||
|
return @{ present = $true; version = $specVer; is_v2 = $isV2 }
|
||||||
|
}
|
||||||
|
return @{ present = $false; version = $null; is_v2 = $false }
|
||||||
|
} catch {
|
||||||
|
return @{ present = $false; version = $null; is_v2 = $false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-SecureBootStatus {
|
||||||
|
try {
|
||||||
|
$sb = Confirm-SecureBootUEFI -ErrorAction Stop
|
||||||
|
return [bool]$sb
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-Win11Readiness {
|
||||||
|
$tpm = Get-TpmInfo
|
||||||
|
$sb = Get-SecureBootStatus
|
||||||
|
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||||
|
$ram = (Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize / 1MB
|
||||||
|
|
||||||
|
$cpuOk = $cpu -and $cpu.NumberOfCores -ge 2 -and $cpu.MaxClockSpeed -ge 1000
|
||||||
|
$ramOk = $ram -ge 4
|
||||||
|
$ready = $tpm.is_v2 -and $sb -and $cpuOk -and $ramOk
|
||||||
|
|
||||||
|
return @{
|
||||||
|
tpm_present = $tpm.present
|
||||||
|
tpm_version = if ($tpm.version) { $tpm.version } else { $null }
|
||||||
|
tpm_v2 = $tpm.is_v2
|
||||||
|
secure_boot = $sb
|
||||||
|
win11_ready = $ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Systemdaten sammeln
|
||||||
|
Write-Log "Sammle Systemdaten..."
|
||||||
|
|
||||||
|
$cpu = Get-CpuInfo
|
||||||
|
$ram = Get-RamInfo
|
||||||
|
$disk = Get-DiskInfo
|
||||||
|
$cpuPct = Get-CpuUsage
|
||||||
|
$mac = Get-MacAddress
|
||||||
|
$updates = Get-PendingUpdates
|
||||||
|
$uptime = Get-UptimeHours
|
||||||
|
$lastUser = Get-LastUser
|
||||||
|
$software = Get-InstalledSoftware
|
||||||
|
$win11 = Get-Win11Readiness
|
||||||
|
|
||||||
|
$ip = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object {
|
||||||
|
$_.InterfaceAlias -notlike '*Loopback*' -and
|
||||||
|
$_.InterfaceAlias -notlike '*vEthernet*' -and
|
||||||
|
$_.InterfaceAlias -notlike '*WSL*' -and
|
||||||
|
$_.InterfaceAlias -notlike '*Virtual*' -and
|
||||||
|
$_.IPAddress -notlike '169.*' -and
|
||||||
|
$_.IPAddress -notlike '172.*'
|
||||||
|
} | Select-Object -First 1).IPAddress
|
||||||
|
if (-not $ip) {
|
||||||
|
$ip = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object {
|
||||||
|
$_.InterfaceAlias -notlike '*Loopback*' -and
|
||||||
|
$_.InterfaceAlias -notlike '*vEthernet*' -and
|
||||||
|
$_.IPAddress -notlike '169.*'
|
||||||
|
} | Select-Object -First 1).IPAddress
|
||||||
|
}
|
||||||
|
$os = Get-CimInstance Win32_OperatingSystem
|
||||||
|
$domain = $env:USERDOMAIN
|
||||||
|
|
||||||
|
$payload = @{
|
||||||
|
hostname = $env:COMPUTERNAME
|
||||||
|
ip_address = $ip
|
||||||
|
mac_address = $mac
|
||||||
|
os_name = $os.Caption
|
||||||
|
os_version = $os.Version
|
||||||
|
cpu_model = $cpu.model
|
||||||
|
cpu_cores = $cpu.cores
|
||||||
|
cpu_usage_percent = $cpuPct
|
||||||
|
ram_total_gb = $ram.total
|
||||||
|
ram_used_gb = $ram.used
|
||||||
|
disk_total_gb = $disk.total
|
||||||
|
disk_free_gb = $disk.free
|
||||||
|
last_user = $lastUser
|
||||||
|
uptime_hours = $uptime
|
||||||
|
domain = $domain
|
||||||
|
agent_version = $AgentVersion
|
||||||
|
installed_software = $software
|
||||||
|
windows_updates_pending = $updates
|
||||||
|
tpm_present = $win11.tpm_present
|
||||||
|
tpm_version = $win11.tpm_version
|
||||||
|
tpm_v2 = $win11.tpm_v2
|
||||||
|
secure_boot = $win11.secure_boot
|
||||||
|
win11_ready = $win11.win11_ready
|
||||||
|
} | ConvertTo-Json -Depth 3
|
||||||
|
|
||||||
|
# --- An IT Nexus senden ---
|
||||||
|
function Invoke-PatchCommand($cmd, $cmdId) {
|
||||||
|
Write-Log "PATCH: Command empfangen: $($cmd) (ID: $cmdId)"
|
||||||
|
$result = "OK"
|
||||||
|
try {
|
||||||
|
switch ($cmd) {
|
||||||
|
'check_updates' {
|
||||||
|
UsoClient.exe StartScan 2>$null
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
$result = "Update-Scan gestartet"
|
||||||
|
Write-Log "PATCH: Update-Scan gestartet"
|
||||||
|
}
|
||||||
|
'install_updates' {
|
||||||
|
UsoClient.exe StartDownload 2>$null
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
UsoClient.exe StartInstall 2>$null
|
||||||
|
$result = "Update-Installation gestartet"
|
||||||
|
Write-Log "PATCH: Update-Installation gestartet"
|
||||||
|
}
|
||||||
|
'update_agent' {
|
||||||
|
Write-Log "UPDATE: Manuelles Agent-Update angefordert..."
|
||||||
|
try {
|
||||||
|
$scriptPath = $MyInvocation.MyCommand.Path
|
||||||
|
$tempPath = "$scriptPath.update"
|
||||||
|
$updateHeaders = @{ 'X-Agent-Key' = $AgentKey }
|
||||||
|
Invoke-WebRequest -Uri "$ServerUrl/api/monitoring/agent-script" `
|
||||||
|
-Headers $updateHeaders -OutFile $tempPath -TimeoutSec 30
|
||||||
|
if ((Get-Item $tempPath -ErrorAction SilentlyContinue).Length -gt 1024) {
|
||||||
|
Copy-Item -Path $tempPath -Destination $scriptPath -Force
|
||||||
|
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||||
|
$result = "Agent erfolgreich aktualisiert - neue Version aktiv ab naechstem Run"
|
||||||
|
Write-Log "UPDATE: Manuelles Update erfolgreich"
|
||||||
|
} else {
|
||||||
|
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||||
|
$result = "Fehler: Heruntergeladene Datei ungueltig"
|
||||||
|
Write-Log "UPDATE ERROR: Datei zu klein"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$result = "Fehler: $($_.Exception.Message)"
|
||||||
|
Write-Log "UPDATE ERROR: $result"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'upgrade_win11' {
|
||||||
|
Write-Log "WIN11: Starte Windows 11 Upgrade-Prozess..."
|
||||||
|
$assistantPath = "C:\ProgramData\IT Nexus Agent\Win11Upgrade.exe"
|
||||||
|
try {
|
||||||
|
Write-Log "WIN11: Lade Installation Assistant herunter..."
|
||||||
|
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/?linkid=2171764" -OutFile $assistantPath -TimeoutSec 300
|
||||||
|
if ((Get-Item $assistantPath -ErrorAction SilentlyContinue).Length -gt 1MB) {
|
||||||
|
# Als angemeldeter Benutzer ausfuehren damit UI sichtbar ist
|
||||||
|
$loggedInUser = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||||
|
if ($loggedInUser -and $loggedInUser -ne '') {
|
||||||
|
Unregister-ScheduledTask -TaskName "IT Nexus Win11 Upgrade" -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
$action = New-ScheduledTaskAction -Execute $assistantPath -Argument "/skipeula /auto upgrade"
|
||||||
|
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(20)
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId $loggedInUser -LogonType Interactive -RunLevel Highest
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 3)
|
||||||
|
Register-ScheduledTask -TaskName "IT Nexus Win11 Upgrade" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||||
|
Start-ScheduledTask -TaskName "IT Nexus Win11 Upgrade"
|
||||||
|
$reportBody = @{ command_id = $cmdId; status = 'running'; result = "Windows 11 Upgrade gestartet fuer Benutzer $loggedInUser - Fortschritt sichtbar auf dem Geraet" } | ConvertTo-Json
|
||||||
|
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" -Method POST -Body $reportBody -Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } -TimeoutSec 10 -ErrorAction SilentlyContinue
|
||||||
|
Write-Log "WIN11: Upgrade als $loggedInUser gestartet (Status: running)"
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
Start-Process -FilePath $assistantPath -ArgumentList "/quietinstall /skipeula /auto upgrade" -NoNewWindow
|
||||||
|
$reportBody = @{ command_id = $cmdId; status = 'running'; result = "Windows 11 Upgrade gestartet (kein Benutzer angemeldet)" } | ConvertTo-Json
|
||||||
|
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" -Method POST -Body $reportBody -Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } -TimeoutSec 10 -ErrorAction SilentlyContinue
|
||||||
|
Write-Log "WIN11: Upgrade als SYSTEM gestartet (kein User)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$result = "Fehler: Download fehlgeschlagen oder Datei zu klein"
|
||||||
|
Write-Log "WIN11 ERROR: Download fehlgeschlagen"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$result = "Fehler: $($_.Exception.Message)"
|
||||||
|
Write-Log "WIN11 ERROR: $result"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'reboot' {
|
||||||
|
$result = "Neustart wird in 60 Sekunden durchgefuehrt"
|
||||||
|
Write-Log "PATCH: Neustart geplant"
|
||||||
|
$reportBody = @{ command_id = $cmdId; status = 'done'; result = $result } | ConvertTo-Json
|
||||||
|
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" `
|
||||||
|
-Method POST -Body $reportBody `
|
||||||
|
-Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } `
|
||||||
|
-TimeoutSec 10 -ErrorAction SilentlyContinue
|
||||||
|
Start-Sleep -Seconds 5
|
||||||
|
shutdown.exe /r /t 60 /c "IT Nexus Patch Management - Geplanter Neustart"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$result = "Fehler: $($_.Exception.Message)"
|
||||||
|
Write-Log "PATCH ERROR: $result"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reportBody = @{ command_id = $cmdId; status = 'done'; result = $result } | ConvertTo-Json
|
||||||
|
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" `
|
||||||
|
-Method POST -Body $reportBody `
|
||||||
|
-Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } `
|
||||||
|
-TimeoutSec 10
|
||||||
|
} catch {
|
||||||
|
Write-Log "PATCH: Ergebnis-Meldung fehlgeschlagen: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$headers = @{
|
||||||
|
'Content-Type' = 'application/json'
|
||||||
|
'X-Agent-Key' = $AgentKey
|
||||||
|
}
|
||||||
|
$cleanPayload = $payload -replace '[\x00-\x1F\x7F]', ''
|
||||||
|
$response = Invoke-RestMethod -Uri "$ServerUrl/api/monitoring/checkin" `
|
||||||
|
-Method POST -Body $cleanPayload -Headers $headers -TimeoutSec 30
|
||||||
|
Write-Log "OK: Checkin erfolgreich (Hostname: $env:COMPUTERNAME)"
|
||||||
|
|
||||||
|
# Self-Update pruefen
|
||||||
|
$serverVersion = $response.agent_version
|
||||||
|
if ($serverVersion -and $serverVersion -ne $AgentVersion) {
|
||||||
|
Write-Log "UPDATE: Neue Agent-Version verfuegbar: $serverVersion (aktuell: $AgentVersion)"
|
||||||
|
try {
|
||||||
|
$scriptPath = $MyInvocation.MyCommand.Path
|
||||||
|
$tempPath = "$scriptPath.update"
|
||||||
|
$updateHeaders = @{ 'X-Agent-Key' = $AgentKey }
|
||||||
|
Invoke-WebRequest -Uri "$ServerUrl/api/monitoring/agent-script" `
|
||||||
|
-Headers $updateHeaders -OutFile $tempPath -TimeoutSec 30
|
||||||
|
if ((Get-Item $tempPath).Length -gt 1024) {
|
||||||
|
Copy-Item -Path $tempPath -Destination $scriptPath -Force
|
||||||
|
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Log "UPDATE: Agent erfolgreich auf Version $serverVersion aktualisiert."
|
||||||
|
} else {
|
||||||
|
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Log "UPDATE: Heruntergeladene Datei zu klein - Update abgebrochen"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "UPDATE ERROR: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Laufende Commands pruefen (z.B. Win11 Upgrade)
|
||||||
|
if ($response.running_commands -and $response.running_commands.Count -gt 0) {
|
||||||
|
foreach ($rc in $response.running_commands) {
|
||||||
|
if ($rc.command -eq 'upgrade_win11') {
|
||||||
|
$task = Get-ScheduledTask -TaskName "IT Nexus Win11 Upgrade" -ErrorAction SilentlyContinue
|
||||||
|
$proc = Get-Process -Name "Win11Upgrade" -ErrorAction SilentlyContinue
|
||||||
|
$stillRunning = ($task -and $task.State -eq 'Running') -or ($proc -ne $null)
|
||||||
|
if (-not $stillRunning) {
|
||||||
|
$taskInfo = Get-ScheduledTaskInfo -TaskName "IT Nexus Win11 Upgrade" -ErrorAction SilentlyContinue
|
||||||
|
$exitCode = if ($taskInfo) { $taskInfo.LastTaskResult } else { 0 }
|
||||||
|
$doneResult = if ($exitCode -eq 0) { "Windows 11 Upgrade abgeschlossen" } else { "Upgrade beendet (Code: $exitCode)" }
|
||||||
|
$rb = @{ command_id = $rc.id; status = 'done'; result = $doneResult } | ConvertTo-Json
|
||||||
|
Invoke-RestMethod -Uri "$ServerUrl/api/patch/commands/result" -Method POST -Body $rb -Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } -TimeoutSec 10 -ErrorAction SilentlyContinue
|
||||||
|
Write-Log "WIN11: Upgrade abgeschlossen (Code: $exitCode)"
|
||||||
|
} else {
|
||||||
|
Write-Log "WIN11: Upgrade laeuft noch..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ankuendigungen anzeigen (als eingeloggter Benutzer via ScheduledTask)
|
||||||
|
if ($response.announcements -and $response.announcements.Count -gt 0) {
|
||||||
|
foreach ($ann in $response.announcements) {
|
||||||
|
try {
|
||||||
|
$annId = $ann.id
|
||||||
|
$annTitle = $ann.title -replace "'", "''"
|
||||||
|
$annMessage = $ann.message -replace "'", "''"
|
||||||
|
$typeLabel = switch ($ann.type) {
|
||||||
|
'warning' { 'WICHTIGE WARNUNG' }
|
||||||
|
'maintenance' { 'WARTUNGSANKUENDIGUNG' }
|
||||||
|
default { 'INFORMATION' }
|
||||||
|
}
|
||||||
|
$accentColor = switch ($ann.type) {
|
||||||
|
'warning' { '220, 50, 50' }
|
||||||
|
'maintenance' { '245, 158, 11' }
|
||||||
|
default { '99, 102, 241' }
|
||||||
|
}
|
||||||
|
|
||||||
|
# PS-Script das im Benutzer-Kontext laeuft und den Dialog zeigt
|
||||||
|
$dialogScript = @"
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
`$form = New-Object System.Windows.Forms.Form
|
||||||
|
`$form.Text = 'IT Nexus - $typeLabel'
|
||||||
|
`$form.Size = New-Object System.Drawing.Size(520, 330)
|
||||||
|
`$form.StartPosition = 'CenterScreen'
|
||||||
|
`$form.FormBorderStyle = 'FixedDialog'
|
||||||
|
`$form.MaximizeBox = `$false
|
||||||
|
`$form.MinimizeBox = `$false
|
||||||
|
`$form.TopMost = `$true
|
||||||
|
`$form.BackColor = [System.Drawing.Color]::FromArgb(24, 24, 37)
|
||||||
|
`$lTitle = New-Object System.Windows.Forms.Label
|
||||||
|
`$lTitle.Text = '$annTitle'
|
||||||
|
`$lTitle.Font = New-Object System.Drawing.Font('Segoe UI', 13, [System.Drawing.FontStyle]::Bold)
|
||||||
|
`$lTitle.ForeColor = [System.Drawing.Color]::White
|
||||||
|
`$lTitle.Location = New-Object System.Drawing.Point(20, 20)
|
||||||
|
`$lTitle.Size = New-Object System.Drawing.Size(460, 36)
|
||||||
|
`$form.Controls.Add(`$lTitle)
|
||||||
|
`$lMsg = New-Object System.Windows.Forms.Label
|
||||||
|
`$lMsg.Text = '$annMessage'
|
||||||
|
`$lMsg.Font = New-Object System.Drawing.Font('Segoe UI', 10)
|
||||||
|
`$lMsg.ForeColor = [System.Drawing.Color]::FromArgb(200, 200, 220)
|
||||||
|
`$lMsg.Location = New-Object System.Drawing.Point(20, 66)
|
||||||
|
`$lMsg.Size = New-Object System.Drawing.Size(460, 155)
|
||||||
|
`$lMsg.AutoSize = `$false
|
||||||
|
`$form.Controls.Add(`$lMsg)
|
||||||
|
`$lHint = New-Object System.Windows.Forms.Label
|
||||||
|
`$lHint.Text = 'Bitte lesen und Kenntnisnahme bestaetigen.'
|
||||||
|
`$lHint.Font = New-Object System.Drawing.Font('Segoe UI', 8, [System.Drawing.FontStyle]::Italic)
|
||||||
|
`$lHint.ForeColor = [System.Drawing.Color]::FromArgb(130, 130, 150)
|
||||||
|
`$lHint.Location = New-Object System.Drawing.Point(20, 228)
|
||||||
|
`$lHint.Size = New-Object System.Drawing.Size(460, 20)
|
||||||
|
`$form.Controls.Add(`$lHint)
|
||||||
|
`$btn = New-Object System.Windows.Forms.Button
|
||||||
|
`$btn.Text = 'Gelesen und bestaetigt'
|
||||||
|
`$btn.Font = New-Object System.Drawing.Font('Segoe UI', 10, [System.Drawing.FontStyle]::Bold)
|
||||||
|
`$btn.ForeColor = [System.Drawing.Color]::White
|
||||||
|
`$btn.BackColor = [System.Drawing.Color]::FromArgb($accentColor)
|
||||||
|
`$btn.FlatStyle = 'Flat'
|
||||||
|
`$btn.Location = New-Object System.Drawing.Point(20, 255)
|
||||||
|
`$btn.Size = New-Object System.Drawing.Size(460, 40)
|
||||||
|
`$btn.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||||||
|
`$form.Controls.Add(`$btn)
|
||||||
|
`$form.AcceptButton = `$btn
|
||||||
|
`$r = `$form.ShowDialog()
|
||||||
|
if (`$r -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||||
|
`$b = [System.Text.Encoding]::UTF8.GetBytes('{"hostname":"' + `$env:COMPUTERNAME + '"}')
|
||||||
|
`$req = [System.Net.WebRequest]::Create('$ServerUrl/api/announcements/$annId/ack-agent')
|
||||||
|
`$req.Method = 'POST'
|
||||||
|
`$req.ContentType = 'application/json'
|
||||||
|
`$req.Headers.Add('X-Agent-Key','$AgentKey')
|
||||||
|
`$req.ContentLength = `$b.Length
|
||||||
|
`$s = `$req.GetRequestStream(); `$s.Write(`$b,0,`$b.Length); `$s.Close()
|
||||||
|
try { `$req.GetResponse().Close() } catch {}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
# Dialog-Script temporaer speichern
|
||||||
|
$scriptFile = "C:\ProgramData\IT Nexus Agent\ann_$annId.ps1"
|
||||||
|
$dialogScript | Out-File -FilePath $scriptFile -Encoding UTF8 -Force
|
||||||
|
|
||||||
|
# Eingeloggten Benutzer ermitteln
|
||||||
|
$loggedUser = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||||
|
if ($loggedUser -and $loggedUser -ne '') {
|
||||||
|
$taskName = "ITNexus-Ann-$annId"
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-ExecutionPolicy Bypass -WindowStyle Normal -File `"$scriptFile`""
|
||||||
|
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(3)
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 60) -StartWhenAvailable
|
||||||
|
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||||
|
Write-Log "ANNOUNCEMENT: Dialog fuer Benutzer $loggedUser gestartet - ID $annId"
|
||||||
|
} else {
|
||||||
|
Write-Log "ANNOUNCEMENT: Kein Benutzer eingeloggt - Dialog wird beim naechsten Check-in versucht"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "ANNOUNCEMENT ERROR: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pending Patch-Commands verarbeiten
|
||||||
|
if ($response.commands -and $response.commands.Count -gt 0) {
|
||||||
|
Write-Log "PATCH: $($response.commands.Count) Command(s) empfangen"
|
||||||
|
foreach ($cmd in $response.commands) {
|
||||||
|
Invoke-PatchCommand -cmd $cmd.command -cmdId $cmd.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$statusCode = $_.Exception.Response.StatusCode.Value__
|
||||||
|
Write-Log "ERROR: Checkin fehlgeschlagen - HTTP $statusCode - $($_.Exception.Message)"
|
||||||
|
try {
|
||||||
|
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
|
||||||
|
Write-Log "ERROR Detail: $($reader.ReadToEnd())"
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Schneller Announcement-Poll (alle 15 Sekunden im Hintergrund) ─────────────
|
||||||
|
function Invoke-AnnouncementPoll {
|
||||||
|
try {
|
||||||
|
$pollBody = @{ hostname = $env:COMPUTERNAME } | ConvertTo-Json
|
||||||
|
$pollResp = Invoke-RestMethod -Uri "$ServerUrl/api/monitoring/announcements-poll" `
|
||||||
|
-Method POST -Body $pollBody `
|
||||||
|
-Headers @{ 'Content-Type' = 'application/json'; 'X-Agent-Key' = $AgentKey } `
|
||||||
|
-TimeoutSec 10
|
||||||
|
if ($pollResp.announcements -and $pollResp.announcements.Count -gt 0) {
|
||||||
|
foreach ($ann in $pollResp.announcements) {
|
||||||
|
$annId = $ann.id
|
||||||
|
$annTitle = $ann.title -replace "'", "''"
|
||||||
|
$annMessage = $ann.message -replace "'", "''"
|
||||||
|
$accentColor = switch ($ann.type) {
|
||||||
|
'warning' { '220, 50, 50' }
|
||||||
|
'maintenance' { '245, 158, 11' }
|
||||||
|
default { '99, 102, 241' }
|
||||||
|
}
|
||||||
|
$typeLabel = switch ($ann.type) {
|
||||||
|
'warning' { 'WICHTIGE WARNUNG' }
|
||||||
|
'maintenance' { 'WARTUNGSANKUENDIGUNG' }
|
||||||
|
default { 'INFORMATION' }
|
||||||
|
}
|
||||||
|
$dialogScript = @"
|
||||||
|
Add-Type -AssemblyName System.Windows.Forms
|
||||||
|
Add-Type -AssemblyName System.Drawing
|
||||||
|
`$form = New-Object System.Windows.Forms.Form
|
||||||
|
`$form.Text = 'IT Nexus - $typeLabel'
|
||||||
|
`$form.Size = New-Object System.Drawing.Size(520, 330)
|
||||||
|
`$form.StartPosition = 'CenterScreen'
|
||||||
|
`$form.FormBorderStyle = 'FixedDialog'
|
||||||
|
`$form.MaximizeBox = `$false
|
||||||
|
`$form.MinimizeBox = `$false
|
||||||
|
`$form.TopMost = `$true
|
||||||
|
`$form.BackColor = [System.Drawing.Color]::FromArgb(24, 24, 37)
|
||||||
|
`$lTitle = New-Object System.Windows.Forms.Label
|
||||||
|
`$lTitle.Text = '$annTitle'
|
||||||
|
`$lTitle.Font = New-Object System.Drawing.Font('Segoe UI', 13, [System.Drawing.FontStyle]::Bold)
|
||||||
|
`$lTitle.ForeColor = [System.Drawing.Color]::White
|
||||||
|
`$lTitle.Location = New-Object System.Drawing.Point(20, 20)
|
||||||
|
`$lTitle.Size = New-Object System.Drawing.Size(460, 36)
|
||||||
|
`$form.Controls.Add(`$lTitle)
|
||||||
|
`$lMsg = New-Object System.Windows.Forms.Label
|
||||||
|
`$lMsg.Text = '$annMessage'
|
||||||
|
`$lMsg.Font = New-Object System.Drawing.Font('Segoe UI', 10)
|
||||||
|
`$lMsg.ForeColor = [System.Drawing.Color]::FromArgb(200, 200, 220)
|
||||||
|
`$lMsg.Location = New-Object System.Drawing.Point(20, 66)
|
||||||
|
`$lMsg.Size = New-Object System.Drawing.Size(460, 155)
|
||||||
|
`$lMsg.AutoSize = `$false
|
||||||
|
`$form.Controls.Add(`$lMsg)
|
||||||
|
`$lHint = New-Object System.Windows.Forms.Label
|
||||||
|
`$lHint.Text = 'Bitte lesen und Kenntnisnahme bestaetigen.'
|
||||||
|
`$lHint.Font = New-Object System.Drawing.Font('Segoe UI', 8, [System.Drawing.FontStyle]::Italic)
|
||||||
|
`$lHint.ForeColor = [System.Drawing.Color]::FromArgb(130, 130, 150)
|
||||||
|
`$lHint.Location = New-Object System.Drawing.Point(20, 228)
|
||||||
|
`$lHint.Size = New-Object System.Drawing.Size(460, 20)
|
||||||
|
`$form.Controls.Add(`$lHint)
|
||||||
|
`$btn = New-Object System.Windows.Forms.Button
|
||||||
|
`$btn.Text = 'Gelesen und bestaetigt'
|
||||||
|
`$btn.Font = New-Object System.Drawing.Font('Segoe UI', 10, [System.Drawing.FontStyle]::Bold)
|
||||||
|
`$btn.ForeColor = [System.Drawing.Color]::White
|
||||||
|
`$btn.BackColor = [System.Drawing.Color]::FromArgb($accentColor)
|
||||||
|
`$btn.FlatStyle = 'Flat'
|
||||||
|
`$btn.Location = New-Object System.Drawing.Point(20, 255)
|
||||||
|
`$btn.Size = New-Object System.Drawing.Size(460, 40)
|
||||||
|
`$btn.DialogResult = [System.Windows.Forms.DialogResult]::OK
|
||||||
|
`$form.Controls.Add(`$btn)
|
||||||
|
`$form.AcceptButton = `$btn
|
||||||
|
`$r = `$form.ShowDialog()
|
||||||
|
if (`$r -eq [System.Windows.Forms.DialogResult]::OK) {
|
||||||
|
`$b = [System.Text.Encoding]::UTF8.GetBytes('{"hostname":"' + `$env:COMPUTERNAME + '"}')
|
||||||
|
`$req = [System.Net.WebRequest]::Create('$ServerUrl/api/announcements/$annId/ack-agent')
|
||||||
|
`$req.Method = 'POST'; `$req.ContentType = 'application/json'
|
||||||
|
`$req.Headers.Add('X-Agent-Key','$AgentKey')
|
||||||
|
`$req.ContentLength = `$b.Length
|
||||||
|
`$s = `$req.GetRequestStream(); `$s.Write(`$b,0,`$b.Length); `$s.Close()
|
||||||
|
try { `$req.GetResponse().Close() } catch {}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
$scriptFile = "C:\ProgramData\IT Nexus Agent\ann_$annId.ps1"
|
||||||
|
$dialogScript | Out-File -FilePath $scriptFile -Encoding UTF8 -Force
|
||||||
|
$loggedUser = (Get-CimInstance Win32_ComputerSystem).UserName
|
||||||
|
if ($loggedUser) {
|
||||||
|
$taskName = "ITNexus-Ann-$annId"
|
||||||
|
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-ExecutionPolicy Bypass -WindowStyle Normal -File `"$scriptFile`""
|
||||||
|
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddSeconds(2)
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId $loggedUser -LogonType Interactive -RunLevel Limited
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 60) -StartWhenAvailable
|
||||||
|
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
||||||
|
Write-Log "ANNOUNCEMENT: Sofort-Dialog gestartet fuer $loggedUser - ID $annId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# Stiller Fehler - nicht kritisch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Announcement-Watcher-Task einrichten (alle 15 Sek, bleibt dauerhaft aktiv)
|
||||||
|
try {
|
||||||
|
$watcherName = 'IT Nexus Announcement Watcher'
|
||||||
|
$watcherScript = 'C:\ProgramData\IT Nexus Agent\ann-watcher.ps1'
|
||||||
|
|
||||||
|
# Watcher-Script vom Server laden (immer aktuell)
|
||||||
|
Invoke-WebRequest -Uri "$ServerUrl/api/monitoring/ann-watcher" `
|
||||||
|
-Headers @{ 'X-Agent-Key' = $AgentKey } `
|
||||||
|
-OutFile $watcherScript -TimeoutSec 15 -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
# Task registrieren falls noch nicht vorhanden
|
||||||
|
if (-not (Get-ScheduledTask -TaskName $watcherName -ErrorAction SilentlyContinue)) {
|
||||||
|
$wAct = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File `"$watcherScript`""
|
||||||
|
$wTrg = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Seconds 15) -Once -At (Get-Date)
|
||||||
|
$wSet = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Seconds 14) -MultipleInstances IgnoreNew -StartWhenAvailable
|
||||||
|
$wPri = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
|
||||||
|
Register-ScheduledTask -TaskName $watcherName -Action $wAct -Trigger $wTrg -Settings $wSet -Principal $wPri -Force | Out-Null
|
||||||
|
Write-Log "ANNOUNCEMENT: Watcher-Task registriert (alle 15 Sekunden)"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "ANNOUNCEMENT WATCHER ERROR: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
103
agent/setup.iss
Normal file
103
agent/setup.iss
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
#define MyAppName "IT Nexus Agent"
|
||||||
|
#define MyAppVersion "1.2.4"
|
||||||
|
#define MyAppPublisher "Cereda Systems GmbH"
|
||||||
|
#define MyAppURL "https://it-nexus.cereda-systems.de"
|
||||||
|
#define MyAppExeName "it-nexus-agent.ps1"
|
||||||
|
#define InstallDir "{commonappdata}\IT Nexus Agent"
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
AppId={{B3F7A2C1-4E8D-4F2A-9B1C-7D3E5F6A8B2C}
|
||||||
|
AppName={#MyAppName}
|
||||||
|
AppVersion={#MyAppVersion}
|
||||||
|
AppPublisher={#MyAppPublisher}
|
||||||
|
AppPublisherURL={#MyAppURL}
|
||||||
|
DefaultDirName={#InstallDir}
|
||||||
|
DisableDirPage=yes
|
||||||
|
DefaultGroupName={#MyAppName}
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
OutputDir=..\dist
|
||||||
|
OutputBaseFilename=IT-Nexus-Agent-Setup-v{#MyAppVersion}
|
||||||
|
SetupIconFile=
|
||||||
|
Compression=lzma2/ultra64
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
WizardSizePercent=120
|
||||||
|
PrivilegesRequired=admin
|
||||||
|
UninstallDisplayName={#MyAppName}
|
||||||
|
UninstallDisplayIcon={app}\it-nexus-agent.ps1
|
||||||
|
CloseApplications=no
|
||||||
|
DisableWelcomePage=no
|
||||||
|
WizardImageFile=compiler:WizModernImage.bmp
|
||||||
|
WizardSmallImageFile=compiler:WizModernSmallImage.bmp
|
||||||
|
|
||||||
|
[Languages]
|
||||||
|
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "it-nexus-agent.ps1"; DestDir: "{app}"; Flags: ignoreversion
|
||||||
|
Source: "config.json.template"; DestDir: "{app}"; DestName: "config.json"; Flags: ignoreversion onlyifdoesntexist
|
||||||
|
|
||||||
|
[Run]
|
||||||
|
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -NonInteractive -Command ""Unregister-ScheduledTask -TaskName 'IT Nexus Agent' -Confirm:$false -ErrorAction SilentlyContinue; $a = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File \""{app}\it-nexus-agent.ps1\""'; $t = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 1) -Once -At (Get-Date); $s = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -MultipleInstances IgnoreNew -StartWhenAvailable; $p = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest; Register-ScheduledTask -TaskName 'IT Nexus Agent' -Action $a -Trigger $t -Settings $s -Principal $p -Force; Start-ScheduledTask -TaskName 'IT Nexus Agent'"""; Flags: runhidden waituntilterminated; StatusMsg: "Registriere Windows Scheduled Task..."
|
||||||
|
|
||||||
|
[UninstallRun]
|
||||||
|
Filename: "powershell.exe"; Parameters: "-ExecutionPolicy Bypass -NonInteractive -Command ""Unregister-ScheduledTask -TaskName 'IT Nexus Agent' -Confirm:$false -ErrorAction SilentlyContinue"""; Flags: runhidden waituntilterminated
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
var
|
||||||
|
ServerUrlPage: TInputQueryWizardPage;
|
||||||
|
AgentKeyPage: TInputQueryWizardPage;
|
||||||
|
|
||||||
|
procedure InitializeWizard;
|
||||||
|
begin
|
||||||
|
ServerUrlPage := CreateInputQueryPage(wpWelcome,
|
||||||
|
'Server-Konfiguration',
|
||||||
|
'Gib die IT Nexus Server-Adresse ein.',
|
||||||
|
'');
|
||||||
|
ServerUrlPage.Add('Server URL:', False);
|
||||||
|
ServerUrlPage.Values[0] := 'https://it-nexus.cereda-systems.de';
|
||||||
|
|
||||||
|
AgentKeyPage := CreateInputQueryPage(ServerUrlPage.ID,
|
||||||
|
'API Key',
|
||||||
|
'Gib den Agent API Key ein. Diesen findest du in den IT Nexus Einstellungen.',
|
||||||
|
'');
|
||||||
|
AgentKeyPage.Add('Agent API Key:', False);
|
||||||
|
AgentKeyPage.Values[0] := 'itx-4CPJPTHmCfdrL9D62WacCATEvuvULXcp7ECMpSaNUjsS344F6_L4Ug';
|
||||||
|
end;
|
||||||
|
|
||||||
|
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
if CurPageID = ServerUrlPage.ID then begin
|
||||||
|
if ServerUrlPage.Values[0] = '' then begin
|
||||||
|
MsgBox('Bitte gib eine Server URL ein.', mbError, MB_OK);
|
||||||
|
Result := False;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
if CurPageID = AgentKeyPage.ID then begin
|
||||||
|
if AgentKeyPage.Values[0] = '' then begin
|
||||||
|
MsgBox('Bitte gib einen API Key ein.', mbError, MB_OK);
|
||||||
|
Result := False;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
|
var
|
||||||
|
ConfigFile: string;
|
||||||
|
Config: TStringList;
|
||||||
|
begin
|
||||||
|
if CurStep = ssPostInstall then begin
|
||||||
|
ConfigFile := ExpandConstant('{app}\config.json');
|
||||||
|
Config := TStringList.Create;
|
||||||
|
try
|
||||||
|
Config.Add('{');
|
||||||
|
Config.Add(' "server_url": "' + ServerUrlPage.Values[0] + '",');
|
||||||
|
Config.Add(' "agent_key": "' + AgentKeyPage.Values[0] + '"');
|
||||||
|
Config.Add('}');
|
||||||
|
Config.SaveToFile(ConfigFile);
|
||||||
|
finally
|
||||||
|
Config.Free;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
8
agent/uninstall.ps1
Normal file
8
agent/uninstall.ps1
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# IT Nexus Agent - Uninstaller
|
||||||
|
$InstallDir = "C:\Program Files\IT Nexus Agent"
|
||||||
|
$TaskName = "IT Nexus Agent"
|
||||||
|
|
||||||
|
Write-Host "=== IT Nexus Agent Deinstallation ===" -ForegroundColor Yellow
|
||||||
|
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Path $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Write-Host "Agent entfernt." -ForegroundColor Green
|
||||||
7
backend/.dockerignore
Normal file
7
backend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
database.sqlite
|
||||||
20
backend/.env.example
Normal file
20
backend/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Server Configuration
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=5000
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||||
|
JWT_EXPIRATION=8h
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_PATH=./database.sqlite
|
||||||
|
|
||||||
|
# Frontend URL (for CORS)
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Security
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW=15
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
26
backend/Dockerfile
Normal file
26
backend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Backend Dockerfile für FIDO-Key Management System
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Arbeitsverzeichnis erstellen
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Python und Build-Tools für better-sqlite3 installieren
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
# Package files kopieren
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Dependencies installieren
|
||||||
|
RUN npm install --only=production
|
||||||
|
|
||||||
|
# Source Code kopieren
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Port freigeben
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
# Umgebungsvariablen
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Server starten
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
3469
backend/package-lock.json
generated
Normal file
3469
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
backend/package.json
Normal file
46
backend/package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "fido-key-manager-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend for FIDO-Key Management System",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"init-db": "node src/db/seed.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"fido",
|
||||||
|
"key-management",
|
||||||
|
"authentication"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"bcrypt": "^5.1.1",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"bwip-js": "^4.8.0",
|
||||||
|
"imapflow": "^1.0.0",
|
||||||
|
"node-cron": "^3.0.0",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"express-validator": "^7.0.1",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"pdfkit": "^0.15.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"@anthropic-ai/sdk": "^0.39.0",
|
||||||
|
"botbuilder": "^4.23.3",
|
||||||
|
"pdf-parse": "^1.1.1",
|
||||||
|
"mammoth": "^1.8.0",
|
||||||
|
"mailparser": "^3.7.1",
|
||||||
|
"net-snmp": "^3.11.0",
|
||||||
|
"multer": "^1.4.5-lts.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
128
backend/seed-knowledge.js
Normal file
128
backend/seed-knowledge.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
const db = require('./src/config/database').getDatabase();
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
['IT Nexus Server', `IP: 192.168.0.194
|
||||||
|
SSH: root@192.168.0.194
|
||||||
|
Projekt: /opt/it-nexus/
|
||||||
|
Container: fido-backend (Port 5000), fido-frontend (Port 443/80)
|
||||||
|
URL: https://it-nexus.cereda-systems.de
|
||||||
|
Agent-Script: /opt/it-nexus/agent/ (Volume-Mount ./agent:/app/agent:ro)
|
||||||
|
Deploy: scp → Server → docker compose build X && docker compose up -d X`, 'Infrastruktur'],
|
||||||
|
|
||||||
|
['Proxmox Host', `IP: 192.168.0.184
|
||||||
|
SSH: root@192.168.0.184
|
||||||
|
Node: hve-01
|
||||||
|
API Token: root@pam!monitoring=ca287965-3d33-49ec-a069-1bb80fd2e95d
|
||||||
|
Web UI: https://192.168.0.184:8006`, 'Proxmox'],
|
||||||
|
|
||||||
|
['LXC Container Übersicht', `CT 102 | IT Nexus | 192.168.0.194 | Hauptproduktion
|
||||||
|
CT 103 | Diverses | - | Docker-Container
|
||||||
|
CT 110 | CT-TBO-AI | 192.168.0.61 | AI-Projekt (Björn Enders)
|
||||||
|
CT 111 | Unbekannt | - | ?
|
||||||
|
|
||||||
|
CT-110 SSH: root@192.168.0.61
|
||||||
|
Key-Datei: C:/Users/gruessing/OneDrive - Cereda Systems GmbH/Desktop/ct110_key`, 'Proxmox'],
|
||||||
|
|
||||||
|
['Storage & Netzwerk', `Synology NAS: 192.168.0.60
|
||||||
|
iSCSI-LUN: 500GB → iscsi-synology auf Proxmox → iscsi-lvm (LVM on top)
|
||||||
|
IT Nexus CT: läuft auf local-lvm (NICHT auf NAS - wichtig!)
|
||||||
|
Gateway: 192.168.0.254
|
||||||
|
Domain: cereda-systems.de`, 'Infrastruktur'],
|
||||||
|
|
||||||
|
['Deploy-Workflow', `SCHNELL (einzelne Datei, kein Rebuild):
|
||||||
|
scp datei.js root@192.168.0.194:/opt/it-nexus/pfad/datei.js
|
||||||
|
docker cp /opt/it-nexus/pfad/datei.js fido-backend:/app/pfad/datei.js
|
||||||
|
docker restart fido-backend
|
||||||
|
|
||||||
|
VOLLSTÄNDIG (nach vielen Änderungen):
|
||||||
|
scp ... root@192.168.0.194:/opt/it-nexus/...
|
||||||
|
ssh root@192.168.0.194 "cd /opt/it-nexus && docker compose build backend frontend && docker compose up -d backend frontend"
|
||||||
|
|
||||||
|
WICHTIG: docker compose restart rebuildet NICHT → immer build + up -d
|
||||||
|
Agent-Dateien: Volume-Mount → kein Rebuild nötig`, 'IT Nexus'],
|
||||||
|
|
||||||
|
['IT Nexus Rollen & Architektur', `Rollen:
|
||||||
|
- super_admin, admin → Vollzugriff
|
||||||
|
- support, bearbeiter → Helpdesk + IT-Verwaltung
|
||||||
|
- benutzer, hr_personal, buchhaltung, produktion → eingeschränkt
|
||||||
|
|
||||||
|
WICHTIG: user.role_name im Frontend (NICHT user.role!)
|
||||||
|
|
||||||
|
Stack: Node.js + Express + SQLite + React (CRA) + nginx
|
||||||
|
KI: Claude claude-sonnet-4-6 via Anthropic API
|
||||||
|
Integrationen: Microsoft Graph API, Proxmox REST API, Azure AD`, 'IT Nexus'],
|
||||||
|
|
||||||
|
['Windows Agent v1.3.0', `Check-in: alle 1 Minute als Scheduled Task (SYSTEM)
|
||||||
|
Announcement Watcher: alle 15 Sekunden (separater Task)
|
||||||
|
Auto-Update: beim Check-in wenn agent_version in Response abweicht
|
||||||
|
Staged Rollout: per Gruppe (Test → Pilot → Produktion)
|
||||||
|
|
||||||
|
Endpunkte ohne JWT (nur X-Agent-Key):
|
||||||
|
POST /api/monitoring/checkin
|
||||||
|
POST /api/monitoring/announcements-poll
|
||||||
|
POST /api/patch/commands/result
|
||||||
|
POST /api/announcements/:id/ack-agent
|
||||||
|
GET /api/monitoring/agent-script
|
||||||
|
|
||||||
|
WICHTIG: Läuft als SYSTEM → Dialoge NIE direkt anzeigen!
|
||||||
|
Fix: ScheduledTask als eingeloggter User erstellen`, 'Agent'],
|
||||||
|
|
||||||
|
['Intune Agent-Deployment', `Erkennungsregel (Registry):
|
||||||
|
Pfad: HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{B3F7A2C1-4E8D-4F2A-9B1C-7D3E5F6A8B2C}
|
||||||
|
Wertname: DisplayVersion
|
||||||
|
Operator: Größer oder gleich (aktuelle Version)
|
||||||
|
32-Bit auf 64-Bit: JA (Pflicht!)
|
||||||
|
|
||||||
|
Fehler 0x87D1041C = Erkennungsregel-Pfad falsch (kein _is1 Suffix!)
|
||||||
|
Install: msiexec /i Agent.msi /quiet /norestart`, 'Agent'],
|
||||||
|
|
||||||
|
['Docker in LXC - Fix runc', `PROBLEM: Unprivileged LXC + Docker 29.x + runc 1.3.x = BRICHT
|
||||||
|
Fehler: open sysctl net.ipv4.ip_unprivileged_port_start: permission denied
|
||||||
|
|
||||||
|
FIX - runc 1.2.5 von GitHub installieren:
|
||||||
|
curl -L https://github.com/opencontainers/runc/releases/download/v1.2.5/runc.amd64 -o /usr/bin/runc
|
||||||
|
chmod +x /usr/bin/runc
|
||||||
|
|
||||||
|
CT-102: Docker 27.5.1 + Ubuntu 24.04 runc 1.2.5 → funktioniert
|
||||||
|
CT-110: Docker 29.4.2 + manuell runc 1.2.5 → funktioniert nach Fix
|
||||||
|
|
||||||
|
WICHTIG: unprivileged lässt sich nach Erstellung NICHT mehr ändern!
|
||||||
|
LXC Config für Docker: features: nesting=1 (nur das reicht!)`, 'Erkenntnisse'],
|
||||||
|
|
||||||
|
['Microsoft Graph & Azure', `Benötigte Application Permissions:
|
||||||
|
- Mail.ReadWrite (Postfach lesen/schreiben)
|
||||||
|
- SecurityAlert.Read.All (MDO Alerts)
|
||||||
|
- Group.Read.All, User.Read.All (Entra-Verwaltung)
|
||||||
|
- Team.ReadBasic.All (fehlt noch → Teams-Tab nicht voll funktionsfähig)
|
||||||
|
|
||||||
|
MDO: SecurityAlert.Read.All braucht Admin Consent!
|
||||||
|
Monitoring-Mailbox: helpdesk@cereda-systems.de`, 'Sicherheit'],
|
||||||
|
|
||||||
|
['Aktive Projekte Mai 2026', `✅ Monitoring (CheckMK-Style + Proxmox integriert)
|
||||||
|
✅ Patch Management (Staged Rollout Test→Pilot→Produktion)
|
||||||
|
✅ Windows Agent v1.3.0
|
||||||
|
✅ Ankündigungen (Desktop-Popup via Agent, 15-Sek Poll)
|
||||||
|
✅ Microsoft Defender / MDO Tab
|
||||||
|
✅ Docker CT-110 Fix (runc 1.2.5)
|
||||||
|
✅ KI Wissendatenbank
|
||||||
|
|
||||||
|
🔄 Agent v1.4.0: Software-Inventar, BitLocker, Defender-Status (geplant)
|
||||||
|
⏳ VM-Migrationen: CT102, CT103, VM109 noch auf CeredaNAS NFS`, 'Projekte'],
|
||||||
|
|
||||||
|
['API Keys & Zugänge', `Agent API Key: itx-4CPJPTHmCfdrL9D62WacCATEvuvULXcp7ECMpSaNUjsS344F6_L4Ug
|
||||||
|
Anthropic API: in docker-compose.yml als ANTHROPIC_API_KEY
|
||||||
|
Proxmox API Token: root@pam!monitoring=ca287965-3d33-49ec-a069-1bb80fd2e95d
|
||||||
|
Teams Bot App ID: bd39de7e
|
||||||
|
Teams Manifest-ID: b86adea3 (NIE ändern!)
|
||||||
|
CT-110 SSH: root@192.168.0.61 | Key: Desktop/ct110_key`, 'Zugänge'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT COUNT(*) as c FROM ai_knowledge').get().c;
|
||||||
|
if (existing > 0) {
|
||||||
|
console.log('Einträge bereits vorhanden:', existing);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare('INSERT INTO ai_knowledge (title, content, category) VALUES (?, ?, ?)');
|
||||||
|
entries.forEach(([t, c, cat]) => stmt.run(t, c, cat));
|
||||||
|
console.log('Fertig - Einträge eingefügt:', entries.length);
|
||||||
41
backend/src/config/database.js
Normal file
41
backend/src/config/database.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DATABASE_PATH || './database.sqlite';
|
||||||
|
|
||||||
|
let db = null;
|
||||||
|
|
||||||
|
function getDatabase() {
|
||||||
|
if (!db) {
|
||||||
|
try {
|
||||||
|
db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
// Enable foreign keys
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
// Performance optimizations
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('synchronous = NORMAL');
|
||||||
|
|
||||||
|
console.log('📦 Database connected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database connection failed:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDatabase() {
|
||||||
|
if (db) {
|
||||||
|
db.close();
|
||||||
|
db = null;
|
||||||
|
console.log('📦 Database connection closed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getDatabase,
|
||||||
|
closeDatabase
|
||||||
|
};
|
||||||
14
backend/src/config/jwt.js
Normal file
14
backend/src/config/jwt.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const JWT_CONFIG = {
|
||||||
|
secret: process.env.JWT_SECRET || 'default-secret-change-in-production',
|
||||||
|
expiresIn: process.env.JWT_EXPIRATION || '8h',
|
||||||
|
algorithm: 'HS256'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate that JWT_SECRET is set
|
||||||
|
if (!process.env.JWT_SECRET) {
|
||||||
|
console.warn('⚠️ WARNING: JWT_SECRET not set in .env file. Using default secret (INSECURE!)');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = JWT_CONFIG;
|
||||||
357
backend/src/controllers/ai.controller.js
Normal file
357
backend/src/controllers/ai.controller.js
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
||||||
|
const AiService = require('../services/ai.service');
|
||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
class AiController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/status
|
||||||
|
* Prüft ob AI konfiguriert ist
|
||||||
|
*/
|
||||||
|
static getStatus = asyncHandler(async (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: { configured: AiService.isConfigured() }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/chat
|
||||||
|
* Body: { messages: [{role, content}] }
|
||||||
|
*/
|
||||||
|
static chat = asyncHandler(async (req, res) => {
|
||||||
|
if (!AiService.isConfigured()) {
|
||||||
|
throw new AppError('KI-Integration ist nicht konfiguriert (ANTHROPIC_API_KEY fehlt)', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messages, userMode } = req.body;
|
||||||
|
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||||
|
throw new AppError('messages Array ist erforderlich', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur erlaubte Rollen: user und assistant
|
||||||
|
const cleaned = messages
|
||||||
|
.filter(m => ['user', 'assistant'].includes(m.role) && typeof m.content === 'string')
|
||||||
|
.map(m => ({ role: m.role, content: m.content.slice(0, 4000) }));
|
||||||
|
|
||||||
|
if (cleaned.length === 0) {
|
||||||
|
throw new AppError('Keine gültigen Nachrichten', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = await AiService.chat(cleaned, !!userMode);
|
||||||
|
res.json({ status: 'success', data: { reply } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ai/knowledge-base
|
||||||
|
*/
|
||||||
|
static getKnowledgeBase = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const entries = db.prepare(`
|
||||||
|
SELECT kb.*, u.username as created_by_username
|
||||||
|
FROM knowledge_base kb
|
||||||
|
LEFT JOIN users u ON kb.created_by_user_id = u.id
|
||||||
|
ORDER BY kb.created_at DESC
|
||||||
|
`).all();
|
||||||
|
res.json({ status: 'success', data: entries });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/knowledge-base
|
||||||
|
* Body: { problem, solution, category, tags }
|
||||||
|
*/
|
||||||
|
static addKnowledgeEntry = asyncHandler(async (req, res) => {
|
||||||
|
const { problem, solution, category, tags } = req.body;
|
||||||
|
if (!problem?.trim() || !solution?.trim()) {
|
||||||
|
throw new AppError('problem und solution sind erforderlich', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO knowledge_base (problem, solution, category, tags, created_by_user_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
problem.trim(),
|
||||||
|
solution.trim(),
|
||||||
|
category || 'Allgemein',
|
||||||
|
tags?.trim() || null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry = db.prepare('SELECT * FROM knowledge_base WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
res.status(201).json({ status: 'success', data: entry });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/ai/knowledge-base/:id
|
||||||
|
* Body: { problem, solution, category, tags }
|
||||||
|
*/
|
||||||
|
static updateKnowledgeEntry = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const entry = db.prepare('SELECT id FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id));
|
||||||
|
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
||||||
|
|
||||||
|
const { problem, solution, category, tags } = req.body;
|
||||||
|
if (!problem?.trim() || !solution?.trim()) {
|
||||||
|
throw new AppError('problem und solution sind erforderlich', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE knowledge_base SET problem = ?, solution = ?, category = ?, tags = ? WHERE id = ?
|
||||||
|
`).run(problem.trim(), solution.trim(), category || 'Allgemein', tags?.trim() || null, parseInt(req.params.id));
|
||||||
|
|
||||||
|
const updated = db.prepare(`
|
||||||
|
SELECT kb.*, u.username as created_by_username
|
||||||
|
FROM knowledge_base kb LEFT JOIN users u ON kb.created_by_user_id = u.id
|
||||||
|
WHERE kb.id = ?
|
||||||
|
`).get(parseInt(req.params.id));
|
||||||
|
res.json({ status: 'success', data: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/knowledge-base/import-text
|
||||||
|
* Body: { text } OR { file: "<base64>", filename: "doc.pdf" }
|
||||||
|
*/
|
||||||
|
static importTextToKb = asyncHandler(async (req, res) => {
|
||||||
|
const AiService = require('../services/ai.service');
|
||||||
|
let text = req.body.text;
|
||||||
|
|
||||||
|
// Handle file upload (base64 encoded)
|
||||||
|
if (!text && req.body.file && req.body.filename) {
|
||||||
|
const { file, filename } = req.body;
|
||||||
|
const ext = filename.split('.').pop().toLowerCase();
|
||||||
|
const match = file.match(/^data:[^;]+;base64,(.+)$/);
|
||||||
|
const buf = Buffer.from(match ? match[1] : file, 'base64');
|
||||||
|
|
||||||
|
if (ext === 'pdf') {
|
||||||
|
const pdfParse = require('pdf-parse');
|
||||||
|
const parsed = await pdfParse(buf);
|
||||||
|
text = parsed.text;
|
||||||
|
} else if (ext === 'docx' || ext === 'doc') {
|
||||||
|
const mammoth = require('mammoth');
|
||||||
|
const result = await mammoth.extractRawText({ buffer: buf });
|
||||||
|
text = result.value;
|
||||||
|
} else if (ext === 'eml' || ext === 'msg') {
|
||||||
|
const { simpleParser } = require('mailparser');
|
||||||
|
const parsed = await simpleParser(buf);
|
||||||
|
text = `Betreff: ${parsed.subject || ''}\nVon: ${parsed.from?.text || ''}\n\n${parsed.text || parsed.html || ''}`;
|
||||||
|
} else if (ext === 'txt') {
|
||||||
|
text = buf.toString('utf-8');
|
||||||
|
} else {
|
||||||
|
throw new AppError('Unterstützte Formate: PDF, DOCX, EML, TXT', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!text?.trim()) throw new AppError('text oder Datei ist erforderlich', 400);
|
||||||
|
|
||||||
|
const ids = await AiService.importTextToKb(text, req.user.id);
|
||||||
|
res.json({ status: 'success', data: { created: ids.length, ids } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/knowledge-base/import-url
|
||||||
|
* Body: { url }
|
||||||
|
* Fetches a web page, strips HTML, extracts text, and imports it via AI
|
||||||
|
*/
|
||||||
|
static importUrlToKb = asyncHandler(async (req, res) => {
|
||||||
|
const AiService = require('../services/ai.service');
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
||||||
|
|
||||||
|
// Only allow http/https
|
||||||
|
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
|
||||||
|
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new AppError(`Seite nicht erreichbar: HTTP ${response.status}`, 400);
|
||||||
|
html = await response.text();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) throw err;
|
||||||
|
throw new AppError(`Fehler beim Abrufen der URL: ${err.message}`, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip scripts, styles, nav, header, footer then all HTML tags
|
||||||
|
const text = html
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
||||||
|
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
||||||
|
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (text.length < 100) throw new AppError('Zu wenig Inhalt auf der Seite gefunden', 400);
|
||||||
|
|
||||||
|
const ids = await AiService.importTextToKb(text, req.user.id);
|
||||||
|
res.json({ status: 'success', data: { created: ids.length, ids } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/knowledge-base/import-crawl
|
||||||
|
* Body: { url, maxPages }
|
||||||
|
* Crawls a website (BFS within same domain+path) and imports all pages via AI
|
||||||
|
*/
|
||||||
|
static importCrawlToKb = asyncHandler(async (req, res) => {
|
||||||
|
const AiService = require('../services/ai.service');
|
||||||
|
const { url, maxPages = 20 } = req.body;
|
||||||
|
if (!url?.trim()) throw new AppError('URL ist erforderlich', 400);
|
||||||
|
if (!/^https?:\/\//i.test(url)) throw new AppError('Nur HTTP/HTTPS URLs erlaubt', 400);
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(1, parseInt(maxPages) || 20), 100);
|
||||||
|
const baseUrl = new URL(url);
|
||||||
|
const baseDomain = baseUrl.hostname;
|
||||||
|
// basePath: bei index-Seiten oder explizit deaktiviertem Pfad-Filter nur nach Hostname filtern
|
||||||
|
let basePath = baseUrl.pathname.substring(0, baseUrl.pathname.lastIndexOf('/') + 1);
|
||||||
|
if (/\/index\.(html?|htm)$/i.test(baseUrl.pathname)) {
|
||||||
|
basePath = '/'; // Gesamte Domain crawlen wenn Index-Seite
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripHtml = (html) => html
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '')
|
||||||
|
.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '')
|
||||||
|
.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
|
||||||
|
.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
const visited = new Set();
|
||||||
|
const queue = [url];
|
||||||
|
const texts = [];
|
||||||
|
|
||||||
|
while (queue.length > 0 && visited.size < limit) {
|
||||||
|
const currentUrl = queue.shift();
|
||||||
|
if (visited.has(currentUrl)) continue;
|
||||||
|
visited.add(currentUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(currentUrl, {
|
||||||
|
headers: { 'User-Agent': 'Mozilla/5.0 IT-Nexus KnowledgeBase Importer' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
if (!response.ok) continue;
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
const text = stripHtml(html);
|
||||||
|
if (text.length > 100) texts.push(text);
|
||||||
|
|
||||||
|
// Extract links within same domain + path prefix
|
||||||
|
const linkRegex = /href="([^"#?]+)"/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = linkRegex.exec(html)) !== null) {
|
||||||
|
try {
|
||||||
|
const linkUrl = new URL(match[1], currentUrl);
|
||||||
|
if (
|
||||||
|
linkUrl.hostname === baseDomain &&
|
||||||
|
linkUrl.pathname.startsWith(basePath) &&
|
||||||
|
!visited.has(linkUrl.href) &&
|
||||||
|
!queue.includes(linkUrl.href)
|
||||||
|
) {
|
||||||
|
queue.push(linkUrl.href);
|
||||||
|
}
|
||||||
|
} catch { /* ignore invalid URLs */ }
|
||||||
|
}
|
||||||
|
} catch { /* ignore fetch errors for individual pages */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (texts.length === 0) throw new AppError('Keine Inhalte gefunden', 400);
|
||||||
|
|
||||||
|
// Process in chunks of 40k chars to stay within AI context limits
|
||||||
|
const CHUNK = 40000;
|
||||||
|
const combined = texts.join('\n\n---\n\n');
|
||||||
|
let allIds = [];
|
||||||
|
for (let i = 0; i < combined.length; i += CHUNK) {
|
||||||
|
const chunk = combined.slice(i, i + CHUNK);
|
||||||
|
if (chunk.trim().length > 100) {
|
||||||
|
const ids = await AiService.importTextToKb(chunk, req.user.id);
|
||||||
|
allIds = allIds.concat(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: { created: allIds.length, pages: visited.size, ids: allIds } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/knowledge-base/:id/images
|
||||||
|
* Body: { image: "<base64>", filename: "foto.png" }
|
||||||
|
*/
|
||||||
|
static uploadKbImage = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id);
|
||||||
|
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
||||||
|
|
||||||
|
const { image, filename } = req.body;
|
||||||
|
if (!image || !filename) throw new AppError('image und filename erforderlich', 400);
|
||||||
|
|
||||||
|
// Validate base64 image
|
||||||
|
const match = image.match(/^data:(image\/(png|jpe?g|gif|webp));base64,(.+)$/);
|
||||||
|
if (!match) throw new AppError('Ungültiges Bildformat (PNG, JPG, GIF, WEBP erlaubt)', 400);
|
||||||
|
|
||||||
|
const ext = match[2].replace('jpeg', 'jpg');
|
||||||
|
const safeName = `kb-${id}-${Date.now()}.${ext}`;
|
||||||
|
const uploadDir = path.join(__dirname, '../../uploads/kb');
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(uploadDir, safeName), Buffer.from(match[3], 'base64'));
|
||||||
|
|
||||||
|
const images = JSON.parse(entry.images || '[]');
|
||||||
|
images.push(safeName);
|
||||||
|
db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||||
|
.run(JSON.stringify(images), id);
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: { filename: safeName, url: `/uploads/kb/${safeName}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/ai/knowledge-base/:id/images/:filename
|
||||||
|
*/
|
||||||
|
static deleteKbImage = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const { filename } = req.params;
|
||||||
|
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(id);
|
||||||
|
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
||||||
|
|
||||||
|
const images = JSON.parse(entry.images || '[]').filter(f => f !== filename);
|
||||||
|
db.prepare('UPDATE knowledge_base SET images = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||||
|
.run(JSON.stringify(images), id);
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
const filePath = path.join(__dirname, '../../uploads/kb', filename);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
|
||||||
|
res.json({ status: 'success', message: 'Bild gelöscht' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/ai/knowledge-base/:id
|
||||||
|
*/
|
||||||
|
static deleteKnowledgeEntry = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const entry = db.prepare('SELECT id, images FROM knowledge_base WHERE id = ?').get(parseInt(req.params.id));
|
||||||
|
if (!entry) throw new AppError('Eintrag nicht gefunden', 404);
|
||||||
|
|
||||||
|
// Delete all associated images
|
||||||
|
const images = JSON.parse(entry.images || '[]');
|
||||||
|
for (const f of images) {
|
||||||
|
const fp = path.join(__dirname, '../../uploads/kb', f);
|
||||||
|
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM knowledge_base WHERE id = ?').run(parseInt(req.params.id));
|
||||||
|
res.json({ status: 'success', message: 'Eintrag gelöscht' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AiController;
|
||||||
169
backend/src/controllers/announcement.controller.js
Normal file
169
backend/src/controllers/announcement.controller.js
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
|
||||||
|
// Hilfsfunktion: welche Ankündigungen sind für einen Agenten relevant (nach Gruppe)?
|
||||||
|
function getForAgent(agentId) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Gruppe des Agenten ermitteln
|
||||||
|
const groupRow = db.prepare(`
|
||||||
|
SELECT group_id FROM patch_agent_groups WHERE agent_id = ? LIMIT 1
|
||||||
|
`).get(agentId);
|
||||||
|
const groupId = groupRow?.group_id ?? null;
|
||||||
|
|
||||||
|
const all = db.prepare(`
|
||||||
|
SELECT * FROM announcements
|
||||||
|
WHERE active = 1
|
||||||
|
AND (expires_at IS NULL OR expires_at > ?)
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all(now);
|
||||||
|
|
||||||
|
return all.filter(a => {
|
||||||
|
// Spezifische Agent-IDs haben höchste Priorität (type-safe: immer als Number vergleichen)
|
||||||
|
const agentIds = JSON.parse(a.target_agent_ids || '[]').map(Number).filter(Boolean);
|
||||||
|
if (agentIds.length > 0) return agentIds.includes(Number(agentId));
|
||||||
|
// Gruppen-Filter
|
||||||
|
const groups = JSON.parse(a.target_groups || '[]');
|
||||||
|
return groups.length === 0 || (groupId && groups.includes(groupId));
|
||||||
|
}).filter(a => {
|
||||||
|
// Noch nicht vom Agenten bestätigt?
|
||||||
|
const ack = db.prepare('SELECT 1 FROM announcement_acks WHERE announcement_id=? AND agent_id=?').get(a.id, agentId);
|
||||||
|
return !ack;
|
||||||
|
}).map(a => ({ id: a.id, title: a.title, message: a.message, type: a.type }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent meldet Bestätigung (kein JWT, nur Agent-Key)
|
||||||
|
const agentAck = (req, res) => {
|
||||||
|
const agentKey = req.headers['x-agent-key'];
|
||||||
|
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const { hostname } = req.body;
|
||||||
|
if (!hostname) return res.status(400).json({ error: 'hostname required' });
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname=?').get(hostname);
|
||||||
|
if (!agent) return res.status(404).json({ error: 'Agent not found' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO announcement_acks (announcement_id, agent_id, hostname) VALUES (?,?,?)')
|
||||||
|
.run(req.params.id, agent.id, hostname);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admin: alle Ankündigungen
|
||||||
|
const getAll = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT a.*, u.username as created_by_name,
|
||||||
|
COUNT(ak.agent_id) as ack_count
|
||||||
|
FROM announcements a
|
||||||
|
LEFT JOIN users u ON u.id = a.created_by_user_id
|
||||||
|
LEFT JOIN announcement_acks ak ON ak.announcement_id = a.id
|
||||||
|
GROUP BY a.id
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const totalAgents = db.prepare('SELECT COUNT(*) as c FROM monitoring_agents').get().c;
|
||||||
|
res.json(rows.map(r => {
|
||||||
|
const groups = JSON.parse(r.target_groups || '[]');
|
||||||
|
const agentIds = JSON.parse(r.target_agent_ids || '[]').map(Number).filter(Boolean);
|
||||||
|
let relevant = totalAgents;
|
||||||
|
if (agentIds.length > 0) {
|
||||||
|
relevant = agentIds.length;
|
||||||
|
} else if (groups.length > 0) {
|
||||||
|
const placeholders = groups.map(() => '?').join(',');
|
||||||
|
relevant = db.prepare(`
|
||||||
|
SELECT COUNT(DISTINCT ma.id) as c FROM monitoring_agents ma
|
||||||
|
JOIN patch_agent_groups pag ON pag.agent_id = ma.id
|
||||||
|
WHERE pag.group_id IN (${placeholders})
|
||||||
|
`).get(...groups).c;
|
||||||
|
}
|
||||||
|
return { ...r, target_groups: groups, target_agent_ids: agentIds, total_agents: relevant };
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = (req, res) => {
|
||||||
|
const { title, message, type = 'info', target_groups = [], target_agent_ids = [], expires_at } = req.body;
|
||||||
|
if (!title?.trim() || !message?.trim()) return res.status(400).json({ error: 'Titel und Nachricht erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO announcements (title, message, type, target_groups, target_agent_ids, created_by_user_id, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(title.trim(), message.trim(), type, JSON.stringify(target_groups), JSON.stringify(target_agent_ids), req.user.id, expires_at || null);
|
||||||
|
res.status(201).json(db.prepare('SELECT * FROM announcements WHERE id=?').get(result.lastInsertRowid));
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const existing = db.prepare('SELECT * FROM announcements WHERE id=?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
const { title, message, type, target_groups, expires_at, active } = req.body;
|
||||||
|
db.prepare(`UPDATE announcements SET title=?, message=?, type=?, target_groups=?, expires_at=?, active=? WHERE id=?`)
|
||||||
|
.run(
|
||||||
|
title ?? existing.title,
|
||||||
|
message ?? existing.message,
|
||||||
|
type ?? existing.type,
|
||||||
|
target_groups !== undefined ? JSON.stringify(target_groups) : existing.target_groups,
|
||||||
|
expires_at !== undefined ? (expires_at || null) : existing.expires_at,
|
||||||
|
active !== undefined ? (active ? 1 : 0) : existing.active,
|
||||||
|
req.params.id
|
||||||
|
);
|
||||||
|
res.json(db.prepare('SELECT * FROM announcements WHERE id=?').get(req.params.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!db.prepare('SELECT id FROM announcements WHERE id=?').get(req.params.id)) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
db.prepare('DELETE FROM announcements WHERE id=?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAcks = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const acks = db.prepare(`
|
||||||
|
SELECT ak.hostname, ak.acknowledged_at
|
||||||
|
FROM announcement_acks ak
|
||||||
|
WHERE ak.announcement_id = ?
|
||||||
|
ORDER BY ak.acknowledged_at ASC
|
||||||
|
`).all(req.params.id);
|
||||||
|
res.json(acks);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aktive Ankündigungen für eingeloggte User (AppLayout Notification Bell)
|
||||||
|
const getActive = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const userKey = `user:${req.user.id}`;
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT a.id, a.title, a.message, a.type, a.created_at, a.target_groups
|
||||||
|
FROM announcements a
|
||||||
|
WHERE a.active = 1 AND (a.expires_at IS NULL OR a.expires_at > ?)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM announcement_acks ak
|
||||||
|
WHERE ak.announcement_id = a.id AND ak.hostname = ?
|
||||||
|
)
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
`).all(now, userKey);
|
||||||
|
res.json(rows.map(r => ({ ...r, target_groups: JSON.parse(r.target_groups || '[]') })));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Web-User bestätigt Ankündigung (JWT Auth)
|
||||||
|
const userAck = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!db.prepare('SELECT id FROM announcements WHERE id=?').get(req.params.id)) {
|
||||||
|
return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO announcement_acks (announcement_id, agent_id, hostname) VALUES (?,?,?)')
|
||||||
|
.run(req.params.id, null, `user:${req.user.id}`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getAll, getActive, create, update, remove, agentAck, getAcks, getForAgent, userAck };
|
||||||
525
backend/src/controllers/asset.controller.js
Normal file
525
backend/src/controllers/asset.controller.js
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
const AssetService = require('../services/asset.service');
|
||||||
|
const Asset = require('../models/Asset');
|
||||||
|
const LabelService = require('../services/label.service');
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { getIntuneManagedDevices } = require('../services/graph.service');
|
||||||
|
const { ROLES, TECHNIKER_DEPARTMENTS } = require('../middleware/roleCheck');
|
||||||
|
|
||||||
|
// Returns department filter for the current user (null = all departments)
|
||||||
|
function getDeptFilter(user) {
|
||||||
|
if (user.role === ROLES.TECHNIKER) return TECHNIKER_DEPARTMENTS;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetController {
|
||||||
|
/**
|
||||||
|
* Get all assets
|
||||||
|
* GET /api/assets
|
||||||
|
*/
|
||||||
|
static getAllAssets = asyncHandler(async (req, res) => {
|
||||||
|
const departments = getDeptFilter(req.user);
|
||||||
|
const assets = AssetService.getAllAssets(departments);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: assets
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assets assigned to the current user
|
||||||
|
* GET /api/assets/mine
|
||||||
|
*/
|
||||||
|
static getMyAssets = asyncHandler(async (req, res) => {
|
||||||
|
const assets = Asset.getByAssignedUser(req.user.id);
|
||||||
|
res.json({ status: 'success', data: assets });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get asset by ID
|
||||||
|
* GET /api/assets/:id
|
||||||
|
*/
|
||||||
|
static getAssetById = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const asset = AssetService.getAssetById(parseInt(id));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: asset
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get asset by serial number
|
||||||
|
* GET /api/assets/serial/:serialNumber
|
||||||
|
*/
|
||||||
|
static getAssetBySerial = asyncHandler(async (req, res) => {
|
||||||
|
const { serialNumber } = req.params;
|
||||||
|
const asset = AssetService.getAssetBySerial(serialNumber);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: asset
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assets by status
|
||||||
|
* GET /api/assets/status/:status
|
||||||
|
*/
|
||||||
|
static getAssetsByStatus = asyncHandler(async (req, res) => {
|
||||||
|
const { status } = req.params;
|
||||||
|
const assets = AssetService.getAssetsByStatus(status);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: assets
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assets by type
|
||||||
|
* GET /api/assets/type/:type
|
||||||
|
*/
|
||||||
|
static getAssetsByType = asyncHandler(async (req, res) => {
|
||||||
|
const { type } = req.params;
|
||||||
|
const assets = AssetService.getAssetsByType(type);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: assets
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new asset
|
||||||
|
* POST /api/assets
|
||||||
|
*/
|
||||||
|
static createAsset = asyncHandler(async (req, res) => {
|
||||||
|
const asset = AssetService.createAsset(
|
||||||
|
req.body,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
status: 'success',
|
||||||
|
data: asset
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update asset
|
||||||
|
* PUT /api/assets/:id
|
||||||
|
*/
|
||||||
|
static updateAsset = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const asset = AssetService.updateAsset(
|
||||||
|
parseInt(id),
|
||||||
|
req.body,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: asset
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete asset
|
||||||
|
* DELETE /api/assets/:id
|
||||||
|
*/
|
||||||
|
static deleteAsset = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const deleted = AssetService.deleteAsset(
|
||||||
|
parseInt(id),
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Asset nicht gefunden'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Asset erfolgreich gelöscht'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign asset to user
|
||||||
|
* POST /api/assets/:id/assign
|
||||||
|
*/
|
||||||
|
static assignAsset = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { user_id, notes } = req.body;
|
||||||
|
|
||||||
|
if (!user_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Benutzer-ID ist erforderlich'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = AssetService.assignAsset(
|
||||||
|
parseInt(id),
|
||||||
|
parseInt(user_id),
|
||||||
|
req.user.id,
|
||||||
|
notes,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: assignment
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unassign asset from user
|
||||||
|
* POST /api/assets/:id/unassign
|
||||||
|
*/
|
||||||
|
static unassignAsset = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { new_status } = req.body;
|
||||||
|
|
||||||
|
if (!new_status) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Neuer Status ist erforderlich'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = AssetService.unassignAsset(
|
||||||
|
parseInt(id),
|
||||||
|
req.user.id,
|
||||||
|
new_status,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: asset
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get assignment history for an asset
|
||||||
|
* GET /api/assets/:id/history
|
||||||
|
*/
|
||||||
|
static getAssetAssignmentHistory = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const history = AssetService.getAssetAssignmentHistory(parseInt(id));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: history
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get asset statistics
|
||||||
|
* GET /api/assets/stats
|
||||||
|
*/
|
||||||
|
static getStatistics = asyncHandler(async (req, res) => {
|
||||||
|
const departments = getDeptFilter(req.user);
|
||||||
|
const stats = AssetService.getStatistics(departments);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: stats
|
||||||
|
});
|
||||||
|
});
|
||||||
|
/**
|
||||||
|
* Generate handover protocol PDF for an assigned asset
|
||||||
|
* GET /api/assets/:id/handover-protocol
|
||||||
|
*/
|
||||||
|
static generateHandoverProtocol = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const asset = AssetService.getAssetById(parseInt(id));
|
||||||
|
|
||||||
|
if (!asset.assigned_to_user_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Das Asset ist keinem Mitarbeiter zugewiesen'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PDFDocument = require('pdfkit');
|
||||||
|
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||||
|
|
||||||
|
const filename = `uebergabeprotokoll_${(asset.serial_number || asset.id).replace(/[^a-z0-9]/gi, '_')}.pdf`;
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `inline; filename="${filename}"`,
|
||||||
|
});
|
||||||
|
doc.pipe(res);
|
||||||
|
|
||||||
|
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
const BLUE = '#2563eb';
|
||||||
|
const GRAY = '#6b7280';
|
||||||
|
const BLACK = '#111827';
|
||||||
|
const pageW = doc.page.width - 100; // usable width (margins 50 each side)
|
||||||
|
|
||||||
|
// ── Header ────────────────────────────────────────────────────────────
|
||||||
|
doc.fontSize(20).fillColor(BLUE).text('Cereda Systems GmbH', 50, 50);
|
||||||
|
doc.fontSize(10).fillColor(GRAY).text('IT-Abteilung · IT-Nexus', 50, 75);
|
||||||
|
doc.fontSize(16).fillColor(BLACK).text('Übergabeprotokoll', 50, 100);
|
||||||
|
doc.fontSize(10).fillColor(GRAY).text(`Erstellt am: ${today}`, 50, 120);
|
||||||
|
|
||||||
|
doc.moveTo(50, 140).lineTo(545, 140).strokeColor(BLUE).lineWidth(1.5).stroke();
|
||||||
|
|
||||||
|
// ── Helper: draw a labeled row ────────────────────────────────────────
|
||||||
|
const row = (label, value, y) => {
|
||||||
|
doc.fontSize(9).fillColor(GRAY).text(label, 50, y);
|
||||||
|
doc.fontSize(10).fillColor(BLACK).text(value || '—', 200, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Asset-Informationen ───────────────────────────────────────────────
|
||||||
|
let y = 160;
|
||||||
|
doc.fontSize(12).fillColor(BLUE).text('Arbeitsmittel', 50, y);
|
||||||
|
y += 20;
|
||||||
|
row('Bezeichnung', asset.name, y); y += 18;
|
||||||
|
row('Typ', asset.type, y); y += 18;
|
||||||
|
row('Modell', asset.model, y); y += 18;
|
||||||
|
row('Seriennummer', asset.serial_number, y); y += 18;
|
||||||
|
row('Kaufdatum', asset.purchase_date
|
||||||
|
? new Date(asset.purchase_date).toLocaleDateString('de-DE')
|
||||||
|
: null, y); y += 18;
|
||||||
|
if (asset.description) { row('Beschreibung', asset.description, y); y += 18; }
|
||||||
|
|
||||||
|
y += 10;
|
||||||
|
doc.moveTo(50, y).lineTo(545, y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
|
||||||
|
y += 15;
|
||||||
|
|
||||||
|
// ── Mitarbeiter-Informationen ─────────────────────────────────────────
|
||||||
|
doc.fontSize(12).fillColor(BLUE).text('Empfänger', 50, y);
|
||||||
|
y += 20;
|
||||||
|
|
||||||
|
const assignedName = [asset.assigned_to_first_name, asset.assigned_to_last_name]
|
||||||
|
.filter(Boolean).join(' ') || asset.assigned_to_username || '—';
|
||||||
|
row('Name', assignedName, y); y += 18;
|
||||||
|
row('E-Mail', asset.assigned_to_email, y); y += 18;
|
||||||
|
row('Übergabedatum', today, y); y += 18;
|
||||||
|
|
||||||
|
y += 10;
|
||||||
|
doc.moveTo(50, y).lineTo(545, y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
|
||||||
|
y += 15;
|
||||||
|
|
||||||
|
// ── Nutzungshinweis ───────────────────────────────────────────────────
|
||||||
|
doc.fontSize(12).fillColor(BLUE).text('Nutzungshinweis', 50, y);
|
||||||
|
y += 18;
|
||||||
|
doc.fontSize(9).fillColor(GRAY).text(
|
||||||
|
'Das übergebene Arbeitsmittel ist ausschließlich für dienstliche Zwecke zu verwenden. ' +
|
||||||
|
'Es ist sachgerecht zu behandeln und vor Verlust, Beschädigung sowie unbefugtem Zugriff zu schützen. ' +
|
||||||
|
'Defekte oder Verluste sind unverzüglich der IT-Abteilung zu melden (helpdesk@cereda-systems.de). ' +
|
||||||
|
'Bei Austritt aus dem Unternehmen ist das Arbeitsmittel vollständig zurückzugeben.',
|
||||||
|
50, y, { width: pageW, lineGap: 3 }
|
||||||
|
);
|
||||||
|
y += 70;
|
||||||
|
|
||||||
|
doc.moveTo(50, y).lineTo(545, y).strokeColor('#e5e7eb').lineWidth(0.5).stroke();
|
||||||
|
y += 20;
|
||||||
|
|
||||||
|
// ── Unterschriften ────────────────────────────────────────────────────
|
||||||
|
doc.fontSize(12).fillColor(BLUE).text('Unterschriften', 50, y);
|
||||||
|
y += 30;
|
||||||
|
|
||||||
|
const sigW = (pageW - 40) / 2;
|
||||||
|
|
||||||
|
// Left: Mitarbeiter
|
||||||
|
doc.fontSize(9).fillColor(GRAY).text('Mitarbeiter/in', 50, y);
|
||||||
|
doc.moveTo(50, y + 40).lineTo(50 + sigW, y + 40).strokeColor(BLACK).lineWidth(0.8).stroke();
|
||||||
|
doc.fontSize(8).fillColor(GRAY).text(`${assignedName}`, 50, y + 45);
|
||||||
|
|
||||||
|
// Right: IT-Verantwortlicher
|
||||||
|
const rightX = 50 + sigW + 40;
|
||||||
|
doc.fontSize(9).fillColor(GRAY).text('IT-Verantwortliche/r', rightX, y);
|
||||||
|
doc.moveTo(rightX, y + 40).lineTo(rightX + sigW, y + 40).strokeColor(BLACK).lineWidth(0.8).stroke();
|
||||||
|
doc.fontSize(8).fillColor(GRAY).text('Cereda Systems GmbH · IT', rightX, y + 45);
|
||||||
|
|
||||||
|
// ── Footer ────────────────────────────────────────────────────────────
|
||||||
|
doc.fontSize(7).fillColor(GRAY)
|
||||||
|
.text('Dieses Dokument wurde automatisch durch IT-Nexus generiert · SUP-006 Infrastruktur & Arbeitsmittel',
|
||||||
|
50, doc.page.height - 40, { align: 'center', width: pageW });
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import managed devices from Microsoft Intune as assets
|
||||||
|
* POST /api/assets/import/intune
|
||||||
|
*/
|
||||||
|
static importFromIntune = asyncHandler(async (req, res) => {
|
||||||
|
let devices;
|
||||||
|
try {
|
||||||
|
devices = await getIntuneManagedDevices();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(502).json({
|
||||||
|
status: 'error',
|
||||||
|
message: err.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = { imported: 0, skipped: 0, errors: [] };
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
try {
|
||||||
|
const serial = (device.serialNumber || '').trim();
|
||||||
|
if (!serial) {
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already exists by serial number
|
||||||
|
const existing = Asset.getBySerialNumber(serial);
|
||||||
|
if (existing) {
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map OS to asset type
|
||||||
|
const os = (device.operatingSystem || '').toLowerCase();
|
||||||
|
let type = 'Other';
|
||||||
|
if (os.includes('windows') || os.includes('macos') || os.includes('mac os')) {
|
||||||
|
type = 'Notebook';
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = device.deviceName || serial;
|
||||||
|
const manufacturer = device.manufacturer || '';
|
||||||
|
const model = device.model || '';
|
||||||
|
const descParts = [`Quelle: Microsoft Intune`];
|
||||||
|
if (manufacturer) descParts.push(`Hersteller: ${manufacturer}`);
|
||||||
|
if (device.operatingSystem) descParts.push(`OS: ${device.operatingSystem}`);
|
||||||
|
if (device.managedDeviceOwnerType) descParts.push(`Typ: ${device.managedDeviceOwnerType}`);
|
||||||
|
|
||||||
|
AssetService.createAsset({
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
serial_number: serial,
|
||||||
|
model: model || null,
|
||||||
|
status: 'verfuegbar',
|
||||||
|
description: descParts.join(' | '),
|
||||||
|
}, req.user.id, req.ip);
|
||||||
|
|
||||||
|
results.imported++;
|
||||||
|
} catch (err) {
|
||||||
|
results.errors.push({ device: device.deviceName || device.serialNumber, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: results });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all inspections for an asset
|
||||||
|
* GET /api/assets/:id/inspections
|
||||||
|
*/
|
||||||
|
static getInspections = asyncHandler(async (req, res) => {
|
||||||
|
const AssetInspection = require('../models/AssetInspection');
|
||||||
|
const items = AssetInspection.getByAsset(parseInt(req.params.id));
|
||||||
|
res.json({ status: 'success', data: items });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an inspection for an asset
|
||||||
|
* POST /api/assets/:id/inspections
|
||||||
|
*/
|
||||||
|
static createInspection = asyncHandler(async (req, res) => {
|
||||||
|
const AssetInspection = require('../models/AssetInspection');
|
||||||
|
const { inspection_date, result, notes, next_due_date } = req.body;
|
||||||
|
if (!inspection_date || !result) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'inspection_date und result sind Pflichtfelder' });
|
||||||
|
}
|
||||||
|
const item = AssetInspection.create({
|
||||||
|
asset_id: parseInt(req.params.id),
|
||||||
|
inspected_by_user_id: req.user.id,
|
||||||
|
inspection_date, result, notes, next_due_date
|
||||||
|
});
|
||||||
|
// If next_due_date provided, also update the asset's next_maintenance_date
|
||||||
|
if (next_due_date) {
|
||||||
|
const Asset = require('../models/Asset');
|
||||||
|
Asset.update(parseInt(req.params.id), { next_maintenance_date: next_due_date }, req.user.id);
|
||||||
|
}
|
||||||
|
res.status(201).json({ status: 'success', data: item });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate printable label PDF for an asset
|
||||||
|
* GET /api/assets/:id/label?copies=1&size=medium
|
||||||
|
*/
|
||||||
|
static generateLabel = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const copies = Math.min(parseInt(req.query.copies) || 1, 100);
|
||||||
|
const size = ['small', 'medium', 'large', 'ptouch12'].includes(req.query.size)
|
||||||
|
? req.query.size
|
||||||
|
: 'medium';
|
||||||
|
|
||||||
|
const asset = AssetService.getAssetById(parseInt(id));
|
||||||
|
|
||||||
|
const pdfBuffer = await LabelService.generateAssetLabel(asset, copies, size);
|
||||||
|
const filename = `label_${(asset.serial_number || asset.id).replace(/[^a-z0-9]/gi, '_')}.pdf`;
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `inline; filename="${filename}"`,
|
||||||
|
'Content-Length': pdfBuffer.length,
|
||||||
|
});
|
||||||
|
res.send(pdfBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/assets/:id/sync-agent
|
||||||
|
* Holt aktuelle Daten vom letzten Agent-Checkin und schreibt sie ins Asset
|
||||||
|
*/
|
||||||
|
static syncFromAgent = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const asset = AssetService.getAssetById(parseInt(id));
|
||||||
|
const db = require('../config/database').getDatabase();
|
||||||
|
const MonitoringAgent = require('../models/MonitoringAgent');
|
||||||
|
|
||||||
|
const agent = MonitoringAgent.getByHostname(asset.name);
|
||||||
|
if (!agent) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Kein Agent für dieses Asset gefunden. Hostname muss mit Asset-Name übereinstimmen.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncData = {
|
||||||
|
os: agent.os_name || null,
|
||||||
|
ip_address: agent.ip_address || null,
|
||||||
|
last_agent_sync: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (agent.hardware_serial) syncData.serial_number = agent.hardware_serial;
|
||||||
|
|
||||||
|
// Hersteller aus Modell extrahieren falls noch nicht gesetzt
|
||||||
|
if (!asset.manufacturer && asset.model) {
|
||||||
|
const known = ['HP', 'Dell', 'Lenovo', 'Apple', 'Asus', 'Acer', 'Microsoft', 'Samsung', 'Toshiba', 'Fujitsu', 'Panasonic', 'Getac'];
|
||||||
|
const upper = asset.model.toUpperCase();
|
||||||
|
for (const b of known) {
|
||||||
|
if (upper.startsWith(b.toUpperCase())) { syncData.manufacturer = b; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benutzer anhand last_user suchen
|
||||||
|
if (agent.last_user) {
|
||||||
|
const cleanUser = agent.last_user.replace(/^[^\\]+\\/, '');
|
||||||
|
const userRow = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND is_active = 1').get(cleanUser);
|
||||||
|
if (userRow) {
|
||||||
|
syncData.assigned_to_user_id = userRow.id;
|
||||||
|
syncData.status = 'zugewiesen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = Asset.update(parseInt(id), syncData, req.user.id);
|
||||||
|
res.json({ status: 'success', data: updated, synced_from: agent.hostname, last_checkin: agent.last_checkin });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AssetController;
|
||||||
49
backend/src/controllers/assetType.controller.js
Normal file
49
backend/src/controllers/assetType.controller.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
|
||||||
|
const getAll = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = db.prepare('SELECT * FROM asset_types ORDER BY sort_order, name').all();
|
||||||
|
res.json(rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = (req, res) => {
|
||||||
|
const { name, icon = 'devices_other' } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM asset_types').get();
|
||||||
|
try {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO asset_types (name, icon, sort_order) VALUES (?, ?, ?)'
|
||||||
|
).run(name.trim(), icon, (maxOrder.m || 0) + 1);
|
||||||
|
const row = db.prepare('SELECT * FROM asset_types WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
res.status(201).json(row);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Typ existiert bereits' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { name, icon } = req.body;
|
||||||
|
const existing = db.prepare('SELECT * FROM asset_types WHERE id = ?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
try {
|
||||||
|
db.prepare('UPDATE asset_types SET name = ?, icon = ? WHERE id = ?')
|
||||||
|
.run(name?.trim() ?? existing.name, icon ?? existing.icon, req.params.id);
|
||||||
|
res.json(db.prepare('SELECT * FROM asset_types WHERE id = ?').get(req.params.id));
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Typ existiert bereits' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const existing = db.prepare('SELECT * FROM asset_types WHERE id = ?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
db.prepare('DELETE FROM asset_types WHERE id = ?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getAll, create, update, remove };
|
||||||
116
backend/src/controllers/auth.controller.js
Normal file
116
backend/src/controllers/auth.controller.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
const AuthService = require('../services/auth.service');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
|
class AuthController {
|
||||||
|
/**
|
||||||
|
* Login user
|
||||||
|
* POST /api/auth/login
|
||||||
|
*/
|
||||||
|
static login = asyncHandler(async (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Username and password are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await AuthService.login(username, password);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user info
|
||||||
|
* GET /api/auth/me
|
||||||
|
*/
|
||||||
|
static getCurrentUser = asyncHandler(async (req, res) => {
|
||||||
|
const user = User.getById(req.user.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'User not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password_hash, ...userWithoutPassword } = user;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: userWithoutPassword
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change password
|
||||||
|
* POST /api/auth/change-password
|
||||||
|
*/
|
||||||
|
static changePassword = asyncHandler(async (req, res) => {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Current password and new password are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await AuthService.changePassword(
|
||||||
|
req.user.id,
|
||||||
|
currentPassword,
|
||||||
|
newPassword
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: result
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update email notification preference
|
||||||
|
* PUT /api/auth/notifications
|
||||||
|
*/
|
||||||
|
static updateNotifications = asyncHandler(async (req, res) => {
|
||||||
|
const { email_notifications } = req.body;
|
||||||
|
const updated = User.updateEmailNotifications(req.user.id, !!email_notifications);
|
||||||
|
const { password_hash, ...userWithoutPassword } = updated;
|
||||||
|
res.json({ status: 'success', data: userWithoutPassword });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/auth/staff-notifications
|
||||||
|
*/
|
||||||
|
static updateStaffNotifications = asyncHandler(async (req, res) => {
|
||||||
|
const { notif_ticket_created, notif_ticket_assigned, notif_new_comment, notif_weekly_report } = req.body;
|
||||||
|
const updated = User.updateStaffNotifications(req.user.id, {
|
||||||
|
notif_ticket_created,
|
||||||
|
notif_ticket_assigned,
|
||||||
|
notif_new_comment,
|
||||||
|
notif_weekly_report,
|
||||||
|
});
|
||||||
|
const { password_hash, ...userWithoutPassword } = updated;
|
||||||
|
res.json({ status: 'success', data: userWithoutPassword });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout user
|
||||||
|
* POST /api/auth/logout
|
||||||
|
*/
|
||||||
|
static logout = asyncHandler(async (req, res) => {
|
||||||
|
// Client-side will handle token removal
|
||||||
|
// This endpoint is just for consistency and potential future server-side session handling
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Logged out successfully'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AuthController;
|
||||||
404
backend/src/controllers/bot.controller.js
Normal file
404
backend/src/controllers/bot.controller.js
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* Teams Bot Controller
|
||||||
|
* Empfängt Nachrichten von Teams, antwortet via KI, erstellt Tickets, leitet Support-Antworten weiter.
|
||||||
|
*/
|
||||||
|
const { CloudAdapter, ConfigurationBotFrameworkAuthentication, TurnContext } = require('botbuilder');
|
||||||
|
const aiService = require('../services/ai.service');
|
||||||
|
const Ticket = require('../models/Ticket');
|
||||||
|
const TicketComment = require('../models/TicketComment');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
const sseManager = require('../services/sseManager');
|
||||||
|
|
||||||
|
// ── Adapter ────────────────────────────────────────────────────────────────
|
||||||
|
const botAuth = new ConfigurationBotFrameworkAuthentication({
|
||||||
|
MicrosoftAppId: process.env.TEAMS_BOT_APP_ID,
|
||||||
|
MicrosoftAppPassword: process.env.TEAMS_BOT_APP_SECRET,
|
||||||
|
MicrosoftAppType: 'SingleTenant',
|
||||||
|
MicrosoftAppTenantId: process.env.AZURE_TENANT_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adapter = new CloudAdapter(botAuth);
|
||||||
|
|
||||||
|
adapter.onTurnError = async (context, error) => {
|
||||||
|
console.error('[TeamsBot] Fehler:', error);
|
||||||
|
try {
|
||||||
|
await context.sendActivity('Es ist ein Fehler aufgetreten. Bitte versuche es erneut.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TeamsBot] Konnte Fehlermeldung nicht senden:', e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── In-Memory Conversation State ───────────────────────────────────────────
|
||||||
|
// Key: Teams-User-ID, Value: { state, messages, ticketId, conversationRef }
|
||||||
|
const sessions = new Map();
|
||||||
|
|
||||||
|
const getSession = (userId) => sessions.get(userId) || {
|
||||||
|
state: 'ai', // 'ai' | 'ticket'
|
||||||
|
messages: [], // KI-Chat-Verlauf
|
||||||
|
ticketId: null,
|
||||||
|
conversationRef: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSession = (userId, data) => sessions.set(userId, data);
|
||||||
|
|
||||||
|
// ── Proaktive Nachricht an Teams senden (wenn Support antwortet) ───────────
|
||||||
|
async function notifyUser(teamsUserId, text) {
|
||||||
|
const session = sessions.get(teamsUserId);
|
||||||
|
if (!session?.conversationRef) return false;
|
||||||
|
try {
|
||||||
|
await adapter.continueConversationAsync(
|
||||||
|
process.env.TEAMS_BOT_APP_ID,
|
||||||
|
session.conversationRef,
|
||||||
|
async (ctx) => {
|
||||||
|
await ctx.sendActivity(text);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TeamsBot] Proaktive Nachricht fehlgeschlagen:', e.message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen ────────────────────────────────────────────────────────
|
||||||
|
function getUserFromTeams(teamsAccount) {
|
||||||
|
if (teamsAccount?.aadObjectId) {
|
||||||
|
return User.getByAzureId(teamsAccount.aadObjectId) || null;
|
||||||
|
}
|
||||||
|
if (teamsAccount?.email) {
|
||||||
|
const db = getDatabase();
|
||||||
|
return db.prepare('SELECT * FROM users WHERE email = ? LIMIT 1').get(teamsAccount.email) || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSupportUser() {
|
||||||
|
const db = getDatabase();
|
||||||
|
return db.prepare("SELECT id FROM users WHERE role_id IN (SELECT id FROM roles WHERE name IN ('super_admin','admin','support')) LIMIT 1").get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bild-Download von Teams ────────────────────────────────────────────────
|
||||||
|
async function downloadTeamsImage(contentUrl) {
|
||||||
|
try {
|
||||||
|
const { ConfidentialClientApplication } = require('@azure/msal-node');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const msalApp = new ConfidentialClientApplication({
|
||||||
|
auth: {
|
||||||
|
clientId: process.env.TEAMS_BOT_APP_ID,
|
||||||
|
clientSecret: process.env.TEAMS_BOT_APP_SECRET,
|
||||||
|
authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const tokenResult = await msalApp.acquireTokenByClientCredential({
|
||||||
|
scopes: ['https://api.botframework.com/.default'],
|
||||||
|
});
|
||||||
|
const response = await fetch(contentUrl, {
|
||||||
|
headers: { 'Authorization': `Bearer ${tokenResult.accessToken}` }
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
|
// Echten Typ aus Magic Bytes erkennen
|
||||||
|
let mediaType = 'image/jpeg';
|
||||||
|
let ext = 'jpg';
|
||||||
|
if (buffer[0] === 0x89 && buffer[1] === 0x50) { mediaType = 'image/png'; ext = 'png'; }
|
||||||
|
else if (buffer[0] === 0xFF && buffer[1] === 0xD8) { mediaType = 'image/jpeg'; ext = 'jpg'; }
|
||||||
|
else if (buffer[0] === 0x47 && buffer[1] === 0x49) { mediaType = 'image/gif'; ext = 'gif'; }
|
||||||
|
else if (buffer.slice(8, 12).toString() === 'WEBP') { mediaType = 'image/webp'; ext = 'webp'; }
|
||||||
|
|
||||||
|
// Bild auf Server speichern
|
||||||
|
const uploadDir = path.join(__dirname, '../../uploads/tickets');
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
const filename = `teams-${crypto.randomBytes(8).toString('hex')}.${ext}`;
|
||||||
|
const filepath = path.join(uploadDir, filename);
|
||||||
|
fs.writeFileSync(filepath, buffer);
|
||||||
|
const publicUrl = `/uploads/tickets/${filename}`;
|
||||||
|
|
||||||
|
return { base64: buffer.toString('base64'), mediaType, publicUrl };
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TeamsBot] Bild-Download fehlgeschlagen:', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baut Claude-kompatiblen Message-Content aus Text + Bild-Anhängen
|
||||||
|
// Gibt { content, imageUrls } zurück
|
||||||
|
async function buildMessageContent(text, attachments) {
|
||||||
|
const content = [];
|
||||||
|
const imageUrls = [];
|
||||||
|
for (const att of (attachments || [])) {
|
||||||
|
if (att.contentType?.startsWith('image/') && att.contentUrl) {
|
||||||
|
const img = await downloadTeamsImage(att.contentUrl);
|
||||||
|
if (img) {
|
||||||
|
content.push({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.base64 } });
|
||||||
|
if (img.publicUrl) imageUrls.push(img.publicUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text) content.push({ type: 'text', text });
|
||||||
|
if (content.length === 0) return null;
|
||||||
|
const msgContent = content.length === 1 && content[0].type === 'text' ? text : content;
|
||||||
|
if (Array.isArray(msgContent) && !text && imageUrls.length > 0) {
|
||||||
|
content.push({ type: 'text', text: 'Was siehst du auf diesem Bild? Beschreibe das Problem.' });
|
||||||
|
}
|
||||||
|
return { content: Array.isArray(msgContent) ? content : text, imageUrls };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bot Logik ──────────────────────────────────────────────────────────────
|
||||||
|
async function handleMessage(context) {
|
||||||
|
const teamsUserId = context.activity.from.id;
|
||||||
|
const teamsName = context.activity.from.name || 'Mitarbeiter';
|
||||||
|
const text = (context.activity.text || '').trim();
|
||||||
|
const attachments = context.activity.attachments || [];
|
||||||
|
const hasImages = attachments.some(a => a.contentType?.startsWith('image/'));
|
||||||
|
const session = getSession(teamsUserId);
|
||||||
|
|
||||||
|
// Conversation Reference + aadObjectId speichern (für proaktive Nachrichten)
|
||||||
|
session.conversationRef = TurnContext.getConversationReference(context.activity);
|
||||||
|
if (context.activity.from.aadObjectId) {
|
||||||
|
session.aadObjectId = context.activity.from.aadObjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Begrüßung / Hilfe ── (nur wenn NICHT im Ticket-Modus)
|
||||||
|
if (session.state !== 'ticket' && (!text && !hasImages || /^(hallo|hi|hey|hilfe|help|start|menu|menü)$/i.test(text))) {
|
||||||
|
await context.sendActivity(
|
||||||
|
`👋 Hallo **${teamsName}**! Ich bin dein IT-Support-Assistent.\n\n` +
|
||||||
|
`**Was kann ich tun?**\n` +
|
||||||
|
`• Beschreibe dein Problem – ich helfe direkt oder erstelle ein Ticket\n` +
|
||||||
|
`• **meine tickets** – deine offenen Tickets anzeigen\n` +
|
||||||
|
`• **ticket TK-2026-0042** – Status eines Tickets abfragen\n` +
|
||||||
|
`• **neu** – Gespräch zurücksetzen\n\n` +
|
||||||
|
`_Einfach losschreiben!_`
|
||||||
|
);
|
||||||
|
saveSession(teamsUserId, { ...session, state: 'ai', messages: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reset ──
|
||||||
|
if (/^(reset|neu|neues ticket|neu starten)$/i.test(text)) {
|
||||||
|
saveSession(teamsUserId, { state: 'ai', messages: [], ticketId: null, conversationRef: session.conversationRef });
|
||||||
|
await context.sendActivity('✅ Gespräch zurückgesetzt. Wie kann ich dir helfen?');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Meine Tickets ──
|
||||||
|
if (/^meine tickets?$/i.test(text)) {
|
||||||
|
const dbUser = getUserFromTeams(context.activity.from);
|
||||||
|
if (!dbUser) {
|
||||||
|
await context.sendActivity('❌ Ich konnte dein IT-Nexus-Konto nicht finden. Bitte stelle sicher, dass deine Teams-E-Mail im IT Nexus hinterlegt ist.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDatabase();
|
||||||
|
const tickets = db.prepare(
|
||||||
|
`SELECT ticket_number, title, status, priority, created_at FROM tickets
|
||||||
|
WHERE created_by_user_id = ? AND status != 'geschlossen'
|
||||||
|
ORDER BY created_at DESC LIMIT 10`
|
||||||
|
).all(dbUser.id);
|
||||||
|
|
||||||
|
if (!tickets.length) {
|
||||||
|
await context.sendActivity('✅ Du hast keine offenen Tickets.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusLabel = { offen: '🟡 Offen', in_bearbeitung: '🔵 In Bearbeitung', warten_auf_mitarbeiter: '🟠 Warte auf dich', warten_auf_support: '🟣 Beim Support' };
|
||||||
|
const lines = tickets.map(t =>
|
||||||
|
`**${t.ticket_number}** – ${t.title}\n ${statusLabel[t.status] || t.status} | Priorität: ${t.priority}`
|
||||||
|
).join('\n\n');
|
||||||
|
await context.sendActivity(`📋 **Deine offenen Tickets:**\n\n${lines}\n\n_Tippe z.B. \`ticket TK-2026-0001\` für Details._`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ticket Status abfragen ──
|
||||||
|
const statusMatch = text.match(/^(?:ticket\s+)?(TK-\d{4}-\d+)$/i);
|
||||||
|
if (statusMatch) {
|
||||||
|
const ticketNumber = statusMatch[1].toUpperCase();
|
||||||
|
const db = getDatabase();
|
||||||
|
const ticket = db.prepare('SELECT * FROM tickets WHERE ticket_number = ? LIMIT 1').get(ticketNumber);
|
||||||
|
if (!ticket) {
|
||||||
|
await context.sendActivity(`❌ Ticket **${ticketNumber}** nicht gefunden.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statusLabel = { offen: '🟡 Offen', in_bearbeitung: '🔵 In Bearbeitung', warten_auf_mitarbeiter: '🟠 Warte auf Mitarbeiter', warten_auf_support: '🟣 Beim Support', geschlossen: '✅ Geschlossen' };
|
||||||
|
const comments = db.prepare('SELECT COUNT(*) as cnt FROM ticket_comments WHERE ticket_id = ?').get(ticket.id);
|
||||||
|
await context.sendActivity(
|
||||||
|
`📋 **${ticket.ticket_number}**: ${ticket.title}\n\n` +
|
||||||
|
`**Status:** ${statusLabel[ticket.status] || ticket.status}\n` +
|
||||||
|
`**Priorität:** ${ticket.priority}\n` +
|
||||||
|
`**Kategorie:** ${ticket.category}\n` +
|
||||||
|
`**Kommentare:** ${comments.cnt}\n` +
|
||||||
|
`**Erstellt:** ${new Date(ticket.created_at).toLocaleDateString('de-DE')}\n\n` +
|
||||||
|
`_Schreibe eine Nachricht um auf dieses Ticket zu antworten (Ticket wird aktiviert)._`
|
||||||
|
);
|
||||||
|
saveSession(teamsUserId, { ...session, state: 'ticket', ticketId: ticket.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ticket-Modus: Nachricht als Kommentar hinzufügen ──
|
||||||
|
if (session.state === 'ticket' && session.ticketId) {
|
||||||
|
const dbUser = getUserFromTeams(context.activity.from);
|
||||||
|
const userId = dbUser?.id || null;
|
||||||
|
const ticket = Ticket.getById(session.ticketId);
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
await context.sendActivity('❌ Ticket nicht mehr gefunden. Tippe `neu` für ein neues Gespräch.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newComment = TicketComment.create(session.ticketId, userId, text, false, false);
|
||||||
|
sseManager.broadcast(session.ticketId, newComment);
|
||||||
|
await context.sendActivity(`✉️ _Nachricht gesendet → **${ticket.ticket_number}**_`);
|
||||||
|
saveSession(teamsUserId, session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KI-Modus ──
|
||||||
|
const built = await buildMessageContent(text, attachments);
|
||||||
|
if (!built) return;
|
||||||
|
|
||||||
|
const updatedMessages = [...session.messages, { role: 'user', content: built.content, _imageUrls: built.imageUrls }];
|
||||||
|
|
||||||
|
await context.sendActivity({ type: 'typing' });
|
||||||
|
if (hasImages) await context.sendActivity('🖼️ Bild wird analysiert...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messagesForAI = updatedMessages.map(({ role, content }) => ({ role, content }));
|
||||||
|
const aiReply = await aiService.chat(messagesForAI, true);
|
||||||
|
updatedMessages.push({ role: 'assistant', content: aiReply });
|
||||||
|
saveSession(teamsUserId, { ...session, messages: updatedMessages });
|
||||||
|
|
||||||
|
const ticketHint = /ticket|support|weiterleiten|kann.*nicht.*helfen|nicht.*lösen/i.test(aiReply);
|
||||||
|
|
||||||
|
if (ticketHint) {
|
||||||
|
await context.sendActivity(aiReply);
|
||||||
|
await context.sendActivity(
|
||||||
|
`📋 Soll ich automatisch ein **Support-Ticket** erstellen?\n\nTippe **ja** zum Erstellen oder stelle eine weitere Frage.`
|
||||||
|
);
|
||||||
|
saveSession(teamsUserId, { ...session, messages: updatedMessages, state: 'confirm_ticket' });
|
||||||
|
} else {
|
||||||
|
await context.sendActivity(aiReply);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TeamsBot] KI-Fehler:', err.message);
|
||||||
|
await context.sendActivity('⚠️ KI momentan nicht verfügbar. Soll ich ein Ticket erstellen? Tippe **ja**.');
|
||||||
|
saveSession(teamsUserId, { ...session, state: 'confirm_ticket' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmTicket(context, session) {
|
||||||
|
const teamsUserId = context.activity.from.id;
|
||||||
|
const text = (context.activity.text || '').trim().toLowerCase();
|
||||||
|
const teamsName = context.activity.from.name || 'Mitarbeiter';
|
||||||
|
|
||||||
|
if (!/^(ja|yes|ok|erstellen|ticket)/.test(text)) {
|
||||||
|
saveSession(teamsUserId, { ...session, state: 'ai' });
|
||||||
|
await handleMessage(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbUser = getUserFromTeams(context.activity.from);
|
||||||
|
const supportUser = findSupportUser();
|
||||||
|
|
||||||
|
const allMessages = session.messages.filter(m => m.role === 'user' || m.role === 'assistant');
|
||||||
|
const userMessages = allMessages.filter(m => m.role === 'user');
|
||||||
|
|
||||||
|
const summary = userMessages
|
||||||
|
.map(m => typeof m.content === 'string' ? m.content : m.content.filter(c => c.type === 'text').map(c => c.text).join(' '))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
// Alle gespeicherten Bild-URLs sammeln
|
||||||
|
const allImageUrls = userMessages.flatMap(m => m._imageUrls || []);
|
||||||
|
const imageSection = allImageUrls.length > 0
|
||||||
|
? '\n\n**Anhänge:**\n' + allImageUrls.map(u => ``).join('\n')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const title = summary.split('\n')[0].substring(0, 100) || 'IT-Problem via Teams';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticket = Ticket.create({
|
||||||
|
title,
|
||||||
|
description: `📱 **Erstellt via Teams Bot**\n\nBenutzer: ${teamsName}\n\n${summary}${imageSection}`,
|
||||||
|
category: 'Allgemein',
|
||||||
|
priority: 'mittel',
|
||||||
|
status: 'offen',
|
||||||
|
created_by_user_id: dbUser?.id || null,
|
||||||
|
requester_name: teamsName,
|
||||||
|
requester_email: context.activity.from.aadObjectId
|
||||||
|
? `teams-${context.activity.from.aadObjectId}@teams.bot`
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Konversation als Kommentar speichern
|
||||||
|
try {
|
||||||
|
const conversationText = allMessages.map(m => {
|
||||||
|
const prefix = m.role === 'user' ? `👤 **${teamsName}:**` : '🤖 **IT-Support KI:**';
|
||||||
|
const text = typeof m.content === 'string'
|
||||||
|
? m.content
|
||||||
|
: m.content.filter(c => c.type === 'text').map(c => c.text).join(' ');
|
||||||
|
return `${prefix}\n${text}`;
|
||||||
|
}).join('\n\n');
|
||||||
|
|
||||||
|
if (conversationText.trim()) {
|
||||||
|
const convComment = TicketComment.create(ticket.id, dbUser?.id || null, `📱 **Teams-Konversation:**\n\n${conversationText}`, false, false);
|
||||||
|
sseManager.broadcast(ticket.id, convComment);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[TeamsBot] Konversation-Kommentar fehlgeschlagen:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSession(teamsUserId, {
|
||||||
|
...session,
|
||||||
|
state: 'ticket',
|
||||||
|
ticketId: ticket.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await context.sendActivity(
|
||||||
|
`─────────────────────────────\n` +
|
||||||
|
`✅ **Ticket ${ticket.ticket_number} erstellt**\n` +
|
||||||
|
`Der Support kümmert sich darum.\n` +
|
||||||
|
`─────────────────────────────\n\n` +
|
||||||
|
`Du kannst hier direkt antworten – deine Nachrichten gehen an den Support.\n` +
|
||||||
|
`_Tippe \`neu\` für ein neues Anliegen._`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TeamsBot] Ticket-Fehler:', err.message);
|
||||||
|
await context.sendActivity('❌ Fehler beim Erstellen des Tickets. Bitte versuche es erneut.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Express Handler ────────────────────────────────────────────────────────
|
||||||
|
async function messages(req, res) {
|
||||||
|
try {
|
||||||
|
await adapter.process(req, res, async (context) => {
|
||||||
|
if (context.activity.type !== 'message') return;
|
||||||
|
|
||||||
|
const teamsUserId = context.activity.from.id;
|
||||||
|
const session = getSession(teamsUserId);
|
||||||
|
|
||||||
|
if (session.state === 'confirm_ticket') {
|
||||||
|
await handleConfirmTicket(context, session);
|
||||||
|
} else {
|
||||||
|
await handleMessage(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[TeamsBot] processActivity Fehler:', err.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Bot processing error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benachrichtigung via Azure AD Object ID (für ticket.controller.js)
|
||||||
|
async function notifyByAadId(aadObjectId, text) {
|
||||||
|
for (const [userId, session] of sessions.entries()) {
|
||||||
|
if (session.aadObjectId === aadObjectId) {
|
||||||
|
return notifyUser(userId, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { messages, notifyUser, notifyByAadId };
|
||||||
122
backend/src/controllers/docker.controller.js
Normal file
122
backend/src/controllers/docker.controller.js
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
function dockerRequest(method, path, body = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const opts = {
|
||||||
|
socketPath: '/var/run/docker.sock',
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
};
|
||||||
|
const req = http.request(opts, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve({ status: res.statusCode, data: data ? JSON.parse(data) : null }); }
|
||||||
|
catch { resolve({ status: res.statusCode, data }); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContainers = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { data } = await dockerRequest('GET', '/containers/json?all=1');
|
||||||
|
const containers = (data || []).map(c => ({
|
||||||
|
id: c.Id.substring(0, 12),
|
||||||
|
name: (c.Names[0] || '').replace(/^\//, ''),
|
||||||
|
image: c.Image,
|
||||||
|
status: c.Status,
|
||||||
|
state: c.State,
|
||||||
|
created: c.Created,
|
||||||
|
ports: c.Ports || [],
|
||||||
|
}));
|
||||||
|
res.json(containers);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(503).json({ error: 'Docker nicht erreichbar', detail: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStats = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { data: list } = await dockerRequest('GET', '/containers/json?all=0');
|
||||||
|
const running = (list || []).filter(c => c.State === 'running');
|
||||||
|
const stats = await Promise.all(running.map(async c => {
|
||||||
|
try {
|
||||||
|
const { data } = await dockerRequest('GET', `/containers/${c.Id}/stats?stream=0`);
|
||||||
|
const cpuDelta = (data.cpu_stats?.cpu_usage?.total_usage || 0) - (data.precpu_stats?.cpu_usage?.total_usage || 0);
|
||||||
|
const sysDelta = (data.cpu_stats?.system_cpu_usage || 0) - (data.precpu_stats?.system_cpu_usage || 0);
|
||||||
|
const cpus = data.cpu_stats?.online_cpus || 1;
|
||||||
|
const cpuPct = sysDelta > 0 ? (cpuDelta / sysDelta) * cpus * 100 : 0;
|
||||||
|
const memUsed = data.memory_stats?.usage || 0;
|
||||||
|
const memLimit = data.memory_stats?.limit || 1;
|
||||||
|
return {
|
||||||
|
id: c.Id.substring(0, 12),
|
||||||
|
name: (c.Names[0] || '').replace(/^\//, ''),
|
||||||
|
cpuPct: Math.round(cpuPct * 10) / 10,
|
||||||
|
memUsed,
|
||||||
|
memLimit,
|
||||||
|
memPct: Math.round((memUsed / memLimit) * 1000) / 10,
|
||||||
|
};
|
||||||
|
} catch { return null; }
|
||||||
|
}));
|
||||||
|
res.json(stats.filter(Boolean));
|
||||||
|
} catch (e) {
|
||||||
|
res.status(503).json({ error: 'Docker nicht erreichbar', detail: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLogs = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const lines = parseInt(req.query.lines) || 100;
|
||||||
|
const opts = {
|
||||||
|
socketPath: '/var/run/docker.sock',
|
||||||
|
method: 'GET',
|
||||||
|
path: `/containers/${req.params.id}/logs?stdout=1&stderr=1&tail=${lines}×tamps=1`,
|
||||||
|
};
|
||||||
|
const raw = await new Promise((resolve, reject) => {
|
||||||
|
const req2 = http.request(opts, r => {
|
||||||
|
const chunks = [];
|
||||||
|
r.on('data', c => chunks.push(c));
|
||||||
|
r.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
req2.on('error', reject);
|
||||||
|
req2.end();
|
||||||
|
});
|
||||||
|
// Strip Docker log stream headers (8-byte prefix per line)
|
||||||
|
let out = '';
|
||||||
|
let i = 0;
|
||||||
|
while (i < raw.length) {
|
||||||
|
if (i + 8 > raw.length) break;
|
||||||
|
const size = raw.readUInt32BE(i + 4);
|
||||||
|
if (i + 8 + size > raw.length) break;
|
||||||
|
out += raw.slice(i + 8, i + 8 + size).toString('utf8');
|
||||||
|
i += 8 + size;
|
||||||
|
}
|
||||||
|
res.json({ logs: out || raw.toString('utf8') });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerAction = async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { action } = req.params;
|
||||||
|
const map = { start: 'POST', stop: 'POST', restart: 'POST' };
|
||||||
|
if (!map[action]) return res.status(400).json({ error: 'Unbekannte Aktion' });
|
||||||
|
try {
|
||||||
|
const { status } = await dockerRequest('POST', `/containers/${id}/${action}`);
|
||||||
|
if (status === 204 || status === 200 || status === 304) {
|
||||||
|
res.json({ ok: true });
|
||||||
|
} else {
|
||||||
|
res.status(status).json({ error: `Docker returned ${status}` });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getContainers, getStats, getLogs, containerAction };
|
||||||
198
backend/src/controllers/entra.controller.js
Normal file
198
backend/src/controllers/entra.controller.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
const {
|
||||||
|
getEntraUsers,
|
||||||
|
getAllMfaRegistrationDetails,
|
||||||
|
getAzureGroups,
|
||||||
|
getAzureGroupMembers,
|
||||||
|
getEntraUserGroups,
|
||||||
|
getDirectoryRolesWithMembers,
|
||||||
|
addGroupMember,
|
||||||
|
removeGroupMember,
|
||||||
|
getUserEntraProfile,
|
||||||
|
getUserLicenseDetails,
|
||||||
|
getEntraSubscribedSkus,
|
||||||
|
getUserSignInActivity,
|
||||||
|
getUserSignInRisk,
|
||||||
|
getConditionalAccessPolicies,
|
||||||
|
invalidateUserSessions,
|
||||||
|
} = require('../services/graph.service');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/entra/users
|
||||||
|
* Returns all Entra users merged with MFA registration info.
|
||||||
|
*/
|
||||||
|
exports.getUsers = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [users, mfaDetails] = await Promise.all([
|
||||||
|
getEntraUsers(),
|
||||||
|
getAllMfaRegistrationDetails().catch(() => []), // graceful fallback if AuditLog permission missing
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build MFA lookup map by userPrincipalName
|
||||||
|
const mfaMap = {};
|
||||||
|
for (const m of mfaDetails) {
|
||||||
|
mfaMap[m.userPrincipalName?.toLowerCase()] = m;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = users.map(u => {
|
||||||
|
const mfa = mfaMap[u.userPrincipalName?.toLowerCase()] || null;
|
||||||
|
return {
|
||||||
|
id: u.id,
|
||||||
|
displayName: u.displayName,
|
||||||
|
mail: u.mail || u.userPrincipalName,
|
||||||
|
userPrincipalName: u.userPrincipalName,
|
||||||
|
jobTitle: u.jobTitle || null,
|
||||||
|
department: u.department || null,
|
||||||
|
accountEnabled: u.accountEnabled,
|
||||||
|
mfaRegistered: mfa ? mfa.isMfaRegistered : null,
|
||||||
|
mfaCapable: mfa ? mfa.isMfaCapable : null,
|
||||||
|
mfaMethods: mfa ? (mfa.methodsRegistered || []) : [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, data: merged });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/entra/users/:id/groups
|
||||||
|
* Returns groups for a specific user.
|
||||||
|
*/
|
||||||
|
exports.getUserGroups = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groups = await getEntraUserGroups(req.params.id);
|
||||||
|
res.json({ success: true, data: groups });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/entra/groups
|
||||||
|
* Returns all groups (without members — lazy-loaded per group).
|
||||||
|
*/
|
||||||
|
exports.getGroups = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groups = await getAzureGroups();
|
||||||
|
res.json({ success: true, data: groups });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/entra/groups/:id/members
|
||||||
|
* Returns members of a specific group.
|
||||||
|
*/
|
||||||
|
exports.getGroupMembers = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const members = await getAzureGroupMembers(req.params.id);
|
||||||
|
res.json({ success: true, data: members });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/entra/groups/:id/members
|
||||||
|
* Adds a user to a group. Body: { userId }
|
||||||
|
* Requires GroupMember.ReadWrite.All
|
||||||
|
*/
|
||||||
|
exports.addGroupMember = async (req, res) => {
|
||||||
|
const { userId } = req.body;
|
||||||
|
if (!userId) return res.status(400).json({ success: false, message: 'userId fehlt' });
|
||||||
|
try {
|
||||||
|
await addGroupMember(req.params.id, userId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/entra/groups/:id/members/:userId
|
||||||
|
* Removes a user from a group.
|
||||||
|
* Requires GroupMember.ReadWrite.All
|
||||||
|
*/
|
||||||
|
exports.removeGroupMember = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await removeGroupMember(req.params.id, req.params.userId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/entra/roles
|
||||||
|
* Returns all activated directory roles with their members.
|
||||||
|
*/
|
||||||
|
exports.getRoles = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const roles = await getDirectoryRolesWithMembers();
|
||||||
|
res.json({ success: true, data: roles });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getUserProfile = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const profile = await getUserEntraProfile(req.params.azureId);
|
||||||
|
res.json({ success: true, data: profile });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getUserLicenses = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [licenses, skus] = await Promise.all([
|
||||||
|
getUserLicenseDetails(req.params.azureId),
|
||||||
|
getEntraSubscribedSkus().catch(() => []),
|
||||||
|
]);
|
||||||
|
const skuMap = {};
|
||||||
|
for (const s of skus) skuMap[s.skuId] = s;
|
||||||
|
const enriched = licenses.map(l => ({ ...l, skuInfo: skuMap[l.skuId] || null }));
|
||||||
|
res.json({ success: true, data: enriched });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getUserSignInLog = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const logs = await getUserSignInActivity(req.params.azureId);
|
||||||
|
res.json({ success: true, data: logs });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getUserSignInRisk = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const risk = await getUserSignInRisk(req.params.azureId);
|
||||||
|
res.json({ success: true, data: risk });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getCAP = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const policies = await getConditionalAccessPolicies();
|
||||||
|
res.json({ success: true, data: policies });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.invalidateSessions = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await invalidateUserSessions(req.params.azureId);
|
||||||
|
res.json({ success: true, message: 'Sessions invalidiert. Benutzer muss sich neu anmelden.' });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
73
backend/src/controllers/externalAlert.controller.js
Normal file
73
backend/src/controllers/externalAlert.controller.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const ExternalAlert = require('../models/ExternalAlert');
|
||||||
|
const Ticket = require('../models/Ticket');
|
||||||
|
|
||||||
|
const getAll = (req, res) => {
|
||||||
|
try {
|
||||||
|
const acknowledged = req.query.acknowledged !== undefined
|
||||||
|
? req.query.acknowledged === 'true'
|
||||||
|
: undefined;
|
||||||
|
const alerts = ExternalAlert.getAll({ acknowledged });
|
||||||
|
res.json({ status: 'success', data: alerts });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const acknowledge = (req, res) => {
|
||||||
|
try {
|
||||||
|
const alert = ExternalAlert.acknowledge(req.params.id);
|
||||||
|
if (!alert) return res.status(404).json({ status: 'error', message: 'Alert nicht gefunden' });
|
||||||
|
res.json({ status: 'success', data: alert });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTicket = (req, res) => {
|
||||||
|
try {
|
||||||
|
const alert = ExternalAlert.getById(req.params.id);
|
||||||
|
if (!alert) return res.status(404).json({ status: 'error', message: 'Alert nicht gefunden' });
|
||||||
|
|
||||||
|
const priorityMap = { CRIT: 'kritisch', WARN: 'hoch', OK: 'niedrig', UNKNOWN: 'mittel' };
|
||||||
|
|
||||||
|
const title = alert.device
|
||||||
|
? `[${alert.source?.toUpperCase()}] ${alert.device} – ${alert.message || alert.state_transition}`.substring(0, 200)
|
||||||
|
: (alert.message || 'Monitoring Alert').substring(0, 200);
|
||||||
|
|
||||||
|
const description = [
|
||||||
|
alert.device ? `**Gerät:** ${alert.device}` : '',
|
||||||
|
alert.service ? `**Service:** ${alert.service}` : '',
|
||||||
|
alert.state_transition ? `**Status:** ${alert.state_transition}` : '',
|
||||||
|
alert.severity ? `**Severity:** ${alert.severity}` : '',
|
||||||
|
alert.message ? `\n**Meldung:** ${alert.message}` : '',
|
||||||
|
alert.customer ? `**Kunde:** ${alert.customer}` : '',
|
||||||
|
alert.monitored_by ? `**Überwacht von:** ${alert.monitored_by}` : '',
|
||||||
|
alert.state_time ? `**Zeitpunkt:** ${alert.state_time}` : '',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
|
||||||
|
const ticket = Ticket.create({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
source: 'monitoring',
|
||||||
|
category: 'Netzwerk',
|
||||||
|
priority: priorityMap[alert.severity] || 'mittel',
|
||||||
|
status: 'offen',
|
||||||
|
});
|
||||||
|
|
||||||
|
ExternalAlert.setTicket(alert.id, ticket.id);
|
||||||
|
res.json({ status: 'success', data: { ticket, alert: ExternalAlert.getById(alert.id) } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (req, res) => {
|
||||||
|
try {
|
||||||
|
ExternalAlert.delete(req.params.id);
|
||||||
|
res.json({ status: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getAll, acknowledge, createTicket, remove };
|
||||||
165
backend/src/controllers/fidoKey.controller.js
Normal file
165
backend/src/controllers/fidoKey.controller.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
const FidoKeyService = require('../services/fidoKey.service');
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
|
class FidoKeyController {
|
||||||
|
/**
|
||||||
|
* Get all FIDO keys
|
||||||
|
* GET /api/fido-keys
|
||||||
|
*/
|
||||||
|
static getAllKeys = asyncHandler(async (req, res) => {
|
||||||
|
const keys = FidoKeyService.getAllKeys();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: keys
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get FIDO key by ID
|
||||||
|
* GET /api/fido-keys/:id
|
||||||
|
*/
|
||||||
|
static getKeyById = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const key = FidoKeyService.getKeyById(parseInt(id));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: key
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get FIDO key by serial number
|
||||||
|
* GET /api/fido-keys/serial/:serialNumber
|
||||||
|
*/
|
||||||
|
static getKeyBySerial = asyncHandler(async (req, res) => {
|
||||||
|
const { serialNumber } = req.params;
|
||||||
|
const key = FidoKeyService.getKeyBySerial(serialNumber);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: key
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get FIDO keys by status
|
||||||
|
* GET /api/fido-keys/status/:status
|
||||||
|
*/
|
||||||
|
static getKeysByStatus = asyncHandler(async (req, res) => {
|
||||||
|
const { status } = req.params;
|
||||||
|
const keys = FidoKeyService.getKeysByStatus(status);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: keys
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new FIDO key
|
||||||
|
* POST /api/fido-keys
|
||||||
|
*/
|
||||||
|
static createKey = asyncHandler(async (req, res) => {
|
||||||
|
const key = FidoKeyService.createKey(
|
||||||
|
req.body,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
status: 'success',
|
||||||
|
data: key
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update FIDO key
|
||||||
|
* PUT /api/fido-keys/:id
|
||||||
|
*/
|
||||||
|
static updateKey = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const key = FidoKeyService.updateKey(
|
||||||
|
parseInt(id),
|
||||||
|
req.body,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: key
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update FIDO key status
|
||||||
|
* PUT /api/fido-keys/:id/status
|
||||||
|
*/
|
||||||
|
static updateKeyStatus = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Status is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = FidoKeyService.updateKeyStatus(
|
||||||
|
parseInt(id),
|
||||||
|
status,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: key
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete FIDO key
|
||||||
|
* DELETE /api/fido-keys/:id
|
||||||
|
*/
|
||||||
|
static deleteKey = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const deleted = FidoKeyService.deleteKey(
|
||||||
|
parseInt(id),
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'FIDO key not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'FIDO key deleted successfully'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get FIDO key statistics
|
||||||
|
* GET /api/fido-keys/stats
|
||||||
|
*/
|
||||||
|
static getStatistics = asyncHandler(async (req, res) => {
|
||||||
|
const stats = FidoKeyService.getStatistics();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: stats
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FidoKeyController;
|
||||||
30
backend/src/controllers/isoTask.controller.js
Normal file
30
backend/src/controllers/isoTask.controller.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const IsoTask = require('../models/IsoTask');
|
||||||
|
|
||||||
|
exports.getAll = (req, res) => {
|
||||||
|
const { category, status } = req.query;
|
||||||
|
const data = IsoTask.getAll({ category, status });
|
||||||
|
res.json({ success: true, data });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getOne = (req, res) => {
|
||||||
|
const task = IsoTask.getById(req.params.id);
|
||||||
|
if (!task) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||||
|
res.json({ success: true, data: task });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.create = (req, res) => {
|
||||||
|
const task = IsoTask.create(req.body);
|
||||||
|
res.status(201).json({ success: true, data: task });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.update = (req, res) => {
|
||||||
|
const task = IsoTask.update(req.params.id, req.body);
|
||||||
|
if (!task) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||||
|
res.json({ success: true, data: task });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.remove = (req, res) => {
|
||||||
|
const ok = IsoTask.delete(req.params.id);
|
||||||
|
if (!ok) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
45
backend/src/controllers/itTopic.controller.js
Normal file
45
backend/src/controllers/itTopic.controller.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const ItTopic = require('../models/ItTopic');
|
||||||
|
const { syncItTopicsToPlanner } = require('../services/graph.service');
|
||||||
|
|
||||||
|
exports.getAll = (req, res) => {
|
||||||
|
const { category, status } = req.query;
|
||||||
|
const data = ItTopic.getAll({ category, status });
|
||||||
|
res.json({ status: 'success', data });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getOne = (req, res) => {
|
||||||
|
const topic = ItTopic.getById(req.params.id);
|
||||||
|
if (!topic) return res.status(404).json({ status: 'error', message: 'Thema nicht gefunden' });
|
||||||
|
res.json({ status: 'success', data: topic });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.create = (req, res) => {
|
||||||
|
const { title, category, status, priority, responsible, target_date, description, notes } = req.body;
|
||||||
|
if (!title?.trim()) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'Titel ist erforderlich' });
|
||||||
|
}
|
||||||
|
const topic = ItTopic.create({ title: title.trim(), category, status, priority, responsible, target_date, description, notes });
|
||||||
|
res.status(201).json({ status: 'success', data: topic });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.update = (req, res) => {
|
||||||
|
const topic = ItTopic.getById(req.params.id);
|
||||||
|
if (!topic) return res.status(404).json({ status: 'error', message: 'Thema nicht gefunden' });
|
||||||
|
const { title, category, status, priority, responsible, target_date, description, notes, sort_order } = req.body;
|
||||||
|
const updated = ItTopic.update(req.params.id, { title, category, status, priority, responsible, target_date, description, notes, sort_order });
|
||||||
|
res.json({ status: 'success', data: updated });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.remove = (req, res) => {
|
||||||
|
const ok = ItTopic.delete(req.params.id);
|
||||||
|
if (!ok) return res.status(404).json({ status: 'error', message: 'Thema nicht gefunden' });
|
||||||
|
res.json({ status: 'success', message: 'Thema gelöscht' });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.syncToPlanner = async (req, res) => {
|
||||||
|
const planId = process.env.PLANNER_PLAN_ID;
|
||||||
|
if (!planId) return res.status(400).json({ status: 'error', message: 'PLANNER_PLAN_ID nicht konfiguriert' });
|
||||||
|
const topics = ItTopic.getAll({});
|
||||||
|
const result = await syncItTopicsToPlanner(topics, planId);
|
||||||
|
res.json({ status: 'success', data: result });
|
||||||
|
};
|
||||||
55
backend/src/controllers/knowledge.controller.js
Normal file
55
backend/src/controllers/knowledge.controller.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
|
||||||
|
const getAll = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const entries = db.prepare(`
|
||||||
|
SELECT k.*, u.username as author_name
|
||||||
|
FROM ai_knowledge k
|
||||||
|
LEFT JOIN users u ON u.id = k.created_by
|
||||||
|
ORDER BY k.category ASC, k.updated_at DESC
|
||||||
|
`).all();
|
||||||
|
res.json(entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getByCategory = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const entries = db.prepare(`
|
||||||
|
SELECT k.*, u.username as author_name
|
||||||
|
FROM ai_knowledge k
|
||||||
|
LEFT JOIN users u ON u.id = k.created_by
|
||||||
|
WHERE k.category = ?
|
||||||
|
ORDER BY k.updated_at DESC
|
||||||
|
`).all(req.params.category);
|
||||||
|
res.json(entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = (req, res) => {
|
||||||
|
const { title, content, category, tags } = req.body;
|
||||||
|
if (!title?.trim() || !content?.trim()) return res.status(400).json({ error: 'Titel und Inhalt erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO ai_knowledge (title, content, category, tags, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(title.trim(), content.trim(), category || 'Allgemein', JSON.stringify(tags || []), req.user?.id || null);
|
||||||
|
res.status(201).json(db.prepare('SELECT * FROM ai_knowledge WHERE id=?').get(result.lastInsertRowid));
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const existing = db.prepare('SELECT * FROM ai_knowledge WHERE id=?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
const { title, content, category, tags } = req.body;
|
||||||
|
db.prepare(`UPDATE ai_knowledge SET title=?, content=?, category=?, tags=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`)
|
||||||
|
.run(title ?? existing.title, content ?? existing.content, category ?? existing.category,
|
||||||
|
tags !== undefined ? JSON.stringify(tags) : existing.tags, req.params.id);
|
||||||
|
res.json(db.prepare('SELECT * FROM ai_knowledge WHERE id=?').get(req.params.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!db.prepare('SELECT id FROM ai_knowledge WHERE id=?').get(req.params.id)) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
db.prepare('DELETE FROM ai_knowledge WHERE id=?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getAll, getByCategory, create, update, remove };
|
||||||
180
backend/src/controllers/license.controller.js
Normal file
180
backend/src/controllers/license.controller.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
const License = require('../models/License');
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { getEntraSubscribedSkus } = require('../services/graph.service');
|
||||||
|
|
||||||
|
// Friendly display names for common Microsoft SKU part numbers
|
||||||
|
const SKU_NAMES = {
|
||||||
|
// Microsoft 365
|
||||||
|
SPB: 'Microsoft 365 Business Premium',
|
||||||
|
O365_BUSINESS_PREMIUM: 'Microsoft 365 Business Standard',
|
||||||
|
O365_BUSINESS_ESSENTIALS: 'Microsoft 365 Business Basic',
|
||||||
|
SMB_BUSINESS: 'Microsoft 365 Apps for Business',
|
||||||
|
OFFICESUBSCRIPTION: 'Microsoft 365 Apps for Enterprise',
|
||||||
|
ENTERPRISEPACK: 'Microsoft 365 E3',
|
||||||
|
ENTERPRISEPREMIUM: 'Microsoft 365 E5',
|
||||||
|
SPE_E3: 'Microsoft 365 E3',
|
||||||
|
SPE_E5: 'Microsoft 365 E5',
|
||||||
|
M365EDU_A3_FACULTY: 'Microsoft 365 A3 (Bildung)',
|
||||||
|
M365EDU_A5_FACULTY: 'Microsoft 365 A5 (Bildung)',
|
||||||
|
// Exchange
|
||||||
|
EXCHANGESTANDARD: 'Exchange Online (Plan 1)',
|
||||||
|
EXCHANGEENTERPRISE: 'Exchange Online (Plan 2)',
|
||||||
|
EXCHANGE_S_DESKLESS: 'Exchange Online Kiosk',
|
||||||
|
EXCHANGEARCHIVE_ADDON: 'Exchange Online Archivierung',
|
||||||
|
// Teams & Kommunikation
|
||||||
|
TEAMS_ESSENTIALS: 'Microsoft Teams Essentials',
|
||||||
|
MCOSTANDARD: 'Skype for Business Online (Plan 2)',
|
||||||
|
MCOEV: 'Microsoft Teams Phone Standard',
|
||||||
|
MCOEV_VIRTUALUSER: 'Microsoft Teams Phone Resource Account',
|
||||||
|
MCOMEETADV: 'Microsoft Teams Audio Conferencing',
|
||||||
|
MCOMEETADV_BYON: 'Teams Audio Conferencing (BYOT)',
|
||||||
|
MCOTEAMS_ESSENTIALS: 'Microsoft Teams Essentials',
|
||||||
|
// Intune & Security
|
||||||
|
INTUNE_A: 'Microsoft Intune Plan 1',
|
||||||
|
INTUNE_P2: 'Microsoft Intune Plan 2',
|
||||||
|
AAD_PREMIUM: 'Azure AD Premium P1',
|
||||||
|
AAD_PREMIUM_P2: 'Azure AD Premium P2',
|
||||||
|
EMS: 'Enterprise Mobility + Security E3',
|
||||||
|
EMSPREMIUM: 'Enterprise Mobility + Security E5',
|
||||||
|
DEFENDER_ENDPOINT_P1: 'Microsoft Defender for Endpoint P1',
|
||||||
|
MDATP_XPLAT: 'Microsoft Defender for Endpoint P2',
|
||||||
|
ATP_ENTERPRISE: 'Microsoft Defender for Office 365 (Plan 1)',
|
||||||
|
THREAT_INTELLIGENCE: 'Microsoft Defender for Office 365 (Plan 2)',
|
||||||
|
// Power Platform
|
||||||
|
POWER_BI_PRO: 'Power BI Pro',
|
||||||
|
POWER_BI_PREMIUM_PER_USER: 'Power BI Premium Per User',
|
||||||
|
POWERAPPS_PER_USER: 'Power Apps Per User Plan',
|
||||||
|
FLOW_PER_USER: 'Power Automate Per User Plan',
|
||||||
|
FLOW_FREE: 'Power Automate Free',
|
||||||
|
DYN365_ENTERPRISE_PLAN1: 'Dynamics 365 Customer Engagement Plan',
|
||||||
|
// Windows & Devices
|
||||||
|
WIN10_PRO_ENT_SUB: 'Windows 10/11 Enterprise E3',
|
||||||
|
WIN_ENT_E5: 'Windows 10/11 Enterprise E5',
|
||||||
|
// Project & Visio
|
||||||
|
PROJECTPROFESSIONAL: 'Project Plan 5',
|
||||||
|
PROJECTPREMIUM: 'Project Plan 3',
|
||||||
|
PROJECT_PLAN1: 'Project Plan 1',
|
||||||
|
VISIOCLIENT: 'Visio Plan 2',
|
||||||
|
VISIOONLINE_PLAN1: 'Visio Plan 1',
|
||||||
|
// Sonstiges
|
||||||
|
CCIBOTS_PRIVPREV_VIRAL: 'Microsoft Copilot für Microsoft 365',
|
||||||
|
MICROSOFT_COPILOT_SECP: 'Copilot for Security',
|
||||||
|
RIGHTSMANAGEMENT: 'Azure Information Protection Plan 1',
|
||||||
|
RIGHTSMANAGEMENT_PREMIUM: 'Azure Information Protection Plan 2',
|
||||||
|
CRMSTANDARD: 'Microsoft Dynamics CRM Online',
|
||||||
|
STREAM: 'Microsoft Stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
class LicenseController {
|
||||||
|
static getAll = asyncHandler(async (req, res) => {
|
||||||
|
const licenses = License.getAll();
|
||||||
|
res.json({ status: 'success', data: licenses });
|
||||||
|
});
|
||||||
|
|
||||||
|
static getById = asyncHandler(async (req, res) => {
|
||||||
|
const license = License.getById(parseInt(req.params.id));
|
||||||
|
if (!license) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
|
||||||
|
}
|
||||||
|
res.json({ status: 'success', data: license });
|
||||||
|
});
|
||||||
|
|
||||||
|
static create = asyncHandler(async (req, res) => {
|
||||||
|
const { name, vendor, license_type, product_key, seats, purchase_date, expiry_date, cost, notes } = req.body;
|
||||||
|
if (!name) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'Name ist erforderlich' });
|
||||||
|
}
|
||||||
|
const license = License.create({
|
||||||
|
name, vendor, license_type, product_key, seats, purchase_date, expiry_date, cost, notes,
|
||||||
|
created_by_user_id: req.user.id,
|
||||||
|
});
|
||||||
|
res.status(201).json({ status: 'success', data: license });
|
||||||
|
});
|
||||||
|
|
||||||
|
static update = asyncHandler(async (req, res) => {
|
||||||
|
const existing = License.getById(parseInt(req.params.id));
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
|
||||||
|
}
|
||||||
|
const license = License.update(parseInt(req.params.id), req.body);
|
||||||
|
res.json({ status: 'success', data: license });
|
||||||
|
});
|
||||||
|
|
||||||
|
static delete = asyncHandler(async (req, res) => {
|
||||||
|
const existing = License.getById(parseInt(req.params.id));
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
|
||||||
|
}
|
||||||
|
License.delete(parseInt(req.params.id));
|
||||||
|
res.json({ status: 'success', message: 'Lizenz gelöscht' });
|
||||||
|
});
|
||||||
|
|
||||||
|
static getStatistics = asyncHandler(async (req, res) => {
|
||||||
|
const stats = License.getStatistics();
|
||||||
|
res.json({ status: 'success', data: stats });
|
||||||
|
});
|
||||||
|
|
||||||
|
static importFromEntra = asyncHandler(async (req, res) => {
|
||||||
|
const skus = await getEntraSubscribedSkus();
|
||||||
|
|
||||||
|
const existing = License.getAll();
|
||||||
|
// Map by sku_id for fast dedup; fallback to name+vendor
|
||||||
|
const existingBySkuId = new Map(existing.filter(l => l.sku_id).map(l => [l.sku_id, l]));
|
||||||
|
const existingByName = new Set(existing.map(l => `${l.name}||${l.vendor}`));
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const sku of skus) {
|
||||||
|
try {
|
||||||
|
const seats = sku.prepaidUnits?.enabled || 0;
|
||||||
|
if (seats === 0) { skipped++; continue; }
|
||||||
|
|
||||||
|
const name = SKU_NAMES[sku.skuPartNumber] || sku.skuPartNumber;
|
||||||
|
const notes = `SKU: ${sku.skuPartNumber} | Genutzt: ${sku.consumedUnits || 0} / ${seats} | Status: ${sku.capabilityStatus}`;
|
||||||
|
|
||||||
|
// Already exists by sku_id → update seats/notes
|
||||||
|
if (existingBySkuId.has(sku.skuId)) {
|
||||||
|
License.update(existingBySkuId.get(sku.skuId).id, { seats, notes });
|
||||||
|
updated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already exists by name+vendor (imported before sku_id existed)
|
||||||
|
if (existingByName.has(`${name}||Microsoft`)) {
|
||||||
|
// Link sku_id retroactively
|
||||||
|
const match = existing.find(l => l.name === name && l.vendor === 'Microsoft');
|
||||||
|
if (match) License.update(match.id, { seats, notes, sku_id: sku.skuId });
|
||||||
|
updated++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
License.create({
|
||||||
|
name, vendor: 'Microsoft', license_type: 'subscription',
|
||||||
|
seats, notes, sku_id: sku.skuId,
|
||||||
|
created_by_user_id: req.user.id,
|
||||||
|
});
|
||||||
|
existingByName.add(`${name}||Microsoft`);
|
||||||
|
imported++;
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(sku.skuPartNumber + ': ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: { imported, updated, skipped, errors } });
|
||||||
|
});
|
||||||
|
|
||||||
|
static getLicenseUsers = asyncHandler(async (req, res) => {
|
||||||
|
const license = License.getById(parseInt(req.params.id));
|
||||||
|
if (!license) return res.status(404).json({ status: 'error', message: 'Lizenz nicht gefunden' });
|
||||||
|
if (!license.sku_id) return res.json({ status: 'success', data: [] });
|
||||||
|
|
||||||
|
const { getEntraLicenseUsers } = require('../services/graph.service');
|
||||||
|
const users = await getEntraLicenseUsers(license.sku_id);
|
||||||
|
res.json({ status: 'success', data: users });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = LicenseController;
|
||||||
226
backend/src/controllers/monitoringAgent.controller.js
Normal file
226
backend/src/controllers/monitoringAgent.controller.js
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
const MonitoringAgent = require('../models/MonitoringAgent');
|
||||||
|
const Asset = require('../models/Asset');
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { getPendingCommands } = require('./patch.controller');
|
||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
const { getForAgent } = require('./announcement.controller');
|
||||||
|
|
||||||
|
function extractManufacturer(modelStr) {
|
||||||
|
if (!modelStr) return null;
|
||||||
|
const known = ['HP', 'Dell', 'Lenovo', 'Apple', 'Asus', 'Acer', 'Microsoft', 'Samsung', 'Toshiba', 'Fujitsu', 'Panasonic', 'Getac'];
|
||||||
|
const upper = modelStr.toUpperCase();
|
||||||
|
for (const b of known) {
|
||||||
|
if (upper.startsWith(b.toUpperCase())) return b;
|
||||||
|
}
|
||||||
|
return modelStr.trim().split(/\s+/)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAgentToAsset(data) {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const hostname = data.hostname;
|
||||||
|
if (!hostname) return;
|
||||||
|
|
||||||
|
let asset = Asset.getByName(hostname);
|
||||||
|
|
||||||
|
// Benutzer anhand last_user (Username) suchen
|
||||||
|
let assignedUserId = null;
|
||||||
|
if (data.last_user) {
|
||||||
|
const cleanUser = data.last_user.replace(/^[^\\]+\\/, ''); // Domain\User → User
|
||||||
|
const userRow = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND is_active = 1').get(cleanUser);
|
||||||
|
if (userRow) assignedUserId = userRow.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncData = {
|
||||||
|
serial_number: data.hardware_serial || undefined,
|
||||||
|
os: data.os_name || data.os || undefined,
|
||||||
|
ip_address: data.ip_address || undefined,
|
||||||
|
last_agent_sync: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (assignedUserId) {
|
||||||
|
syncData.assigned_to_user_id = assignedUserId;
|
||||||
|
syncData.status = 'zugewiesen';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset) {
|
||||||
|
// Hersteller nur setzen wenn noch nicht gesetzt
|
||||||
|
if (!asset.manufacturer && asset.model) {
|
||||||
|
syncData.manufacturer = extractManufacturer(asset.model);
|
||||||
|
}
|
||||||
|
Asset.update(asset.id, syncData, null);
|
||||||
|
} else {
|
||||||
|
// Neues Asset anlegen
|
||||||
|
const manufacturer = data.hardware_serial ? null : null; // Modell noch unbekannt
|
||||||
|
Asset.create({
|
||||||
|
name: hostname,
|
||||||
|
type: 'Notebook',
|
||||||
|
serial_number: data.hardware_serial || null,
|
||||||
|
model: null,
|
||||||
|
status: 'zugewiesen',
|
||||||
|
os: data.os_name || data.os || null,
|
||||||
|
ip_address: data.ip_address || null,
|
||||||
|
manufacturer: null,
|
||||||
|
assigned_to_user_id: assignedUserId,
|
||||||
|
department: 'IT',
|
||||||
|
last_agent_sync: new Date().toISOString(),
|
||||||
|
created_by_user_id: null,
|
||||||
|
});
|
||||||
|
console.log(`[AgentSync] Neues Asset erstellt: ${hostname}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AgentSync] Fehler beim Asset-Sync:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/monitoring/checkin — called by agent (no JWT, uses API key)
|
||||||
|
const checkin = asyncHandler(async (req, res) => {
|
||||||
|
const agentKey = req.headers['x-agent-key'];
|
||||||
|
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hostname } = req.body;
|
||||||
|
if (!hostname) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'hostname required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = MonitoringAgent.upsert(req.body);
|
||||||
|
|
||||||
|
// Asset-Sync im Hintergrund (nicht blockend)
|
||||||
|
setImmediate(() => syncAgentToAsset(req.body));
|
||||||
|
|
||||||
|
// Pending patch commands für diesen Agenten zurückgeben
|
||||||
|
const { pending, running } = getPendingCommands(agent.id);
|
||||||
|
|
||||||
|
// Zielversion: Gruppen-spezifisch (staged rollout) oder globaler Default
|
||||||
|
const GLOBAL_VERSION = process.env.AGENT_VERSION || '1.2.4';
|
||||||
|
let targetVersion = GLOBAL_VERSION;
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const groupRow = db.prepare(`
|
||||||
|
SELECT pg.target_agent_version
|
||||||
|
FROM patch_agent_groups pag
|
||||||
|
JOIN patch_groups pg ON pg.id = pag.group_id
|
||||||
|
WHERE pag.agent_id = ?
|
||||||
|
ORDER BY pg.sort_order ASC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(agent.id);
|
||||||
|
if (groupRow?.target_agent_version) targetVersion = groupRow.target_agent_version;
|
||||||
|
} catch { /* kein Gruppe zugewiesen → global */ }
|
||||||
|
|
||||||
|
const announcements = getForAgent(agent.id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: agent,
|
||||||
|
commands: pending,
|
||||||
|
running_commands: running,
|
||||||
|
agent_version: targetVersion,
|
||||||
|
announcements,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/monitoring — list all agents (requires JWT)
|
||||||
|
const getAll = asyncHandler(async (req, res) => {
|
||||||
|
MonitoringAgent.markOffline();
|
||||||
|
const agents = MonitoringAgent.getAll().map(a => ({
|
||||||
|
...a,
|
||||||
|
installed_software: a.installed_software ? JSON.parse(a.installed_software) : []
|
||||||
|
}));
|
||||||
|
res.json({ status: 'success', data: agents });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/monitoring/announcements-poll — leichter Poll nur für Ankündigungen
|
||||||
|
const announcementsPoll = asyncHandler(async (req, res) => {
|
||||||
|
const agentKey = req.headers['x-agent-key'];
|
||||||
|
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const { hostname } = req.body;
|
||||||
|
if (!hostname) return res.status(400).json({ error: 'hostname required' });
|
||||||
|
const agent = MonitoringAgent.getByHostname(hostname);
|
||||||
|
if (!agent) return res.json({ announcements: [] });
|
||||||
|
const announcements = getForAgent(agent.id);
|
||||||
|
res.json({ announcements });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/monitoring/statistics
|
||||||
|
const getStatistics = asyncHandler(async (req, res) => {
|
||||||
|
MonitoringAgent.markOffline();
|
||||||
|
const stats = MonitoringAgent.getStatistics();
|
||||||
|
res.json({ status: 'success', data: stats });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/monitoring/:id
|
||||||
|
const getById = asyncHandler(async (req, res) => {
|
||||||
|
const agent = MonitoringAgent.getById(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'Agent not found' });
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: { ...agent, installed_software: agent.installed_software ? JSON.parse(agent.installed_software) : [] }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/monitoring/:id
|
||||||
|
const deleteAgent = asyncHandler(async (req, res) => {
|
||||||
|
const deleted = MonitoringAgent.delete(req.params.id);
|
||||||
|
if (!deleted) return res.status(404).json({ status: 'error', message: 'Agent not found' });
|
||||||
|
res.json({ status: 'success', message: 'Agent removed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/monitoring/agent-script — agent downloads its own update (X-Agent-Key auth)
|
||||||
|
const downloadScript = asyncHandler(async (req, res) => {
|
||||||
|
const agentKey = req.headers['x-agent-key'];
|
||||||
|
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
|
||||||
|
}
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const scriptPath = path.join(__dirname, '../../agent/it-nexus-agent.ps1');
|
||||||
|
if (!fs.existsSync(scriptPath)) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Script nicht gefunden' });
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
res.setHeader('X-Agent-Version', process.env.AGENT_VERSION || '1.2.3');
|
||||||
|
res.sendFile(scriptPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/monitoring/ann-watcher — agent downloads announcement watcher script
|
||||||
|
const downloadAnnWatcher = asyncHandler(async (req, res) => {
|
||||||
|
const agentKey = req.headers['x-agent-key'];
|
||||||
|
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'Invalid agent key' });
|
||||||
|
}
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const scriptPath = path.join(__dirname, '../../agent/ann-watcher.ps1');
|
||||||
|
if (!fs.existsSync(scriptPath)) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Script nicht gefunden' });
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
res.sendFile(scriptPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadSetup = asyncHandler(async (req, res) => {
|
||||||
|
// Agent-Key Auth für auto-update vom Agent selbst
|
||||||
|
const agentKey = req.headers['x-agent-key'];
|
||||||
|
const isAgent = agentKey && agentKey === process.env.AGENT_API_KEY;
|
||||||
|
const isAdmin = req.user?.role_name === 'admin' || req.user?.role_name === 'super_admin';
|
||||||
|
if (!isAgent && !isAdmin) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const version = process.env.AGENT_VERSION || '2.0.0';
|
||||||
|
const setupPath = path.join(__dirname, `../../agent/IT-Nexus-Agent-Setup-v${version}.exe`);
|
||||||
|
if (!fs.existsSync(setupPath)) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Setup nicht gefunden' });
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
|
res.setHeader('X-Agent-Version', version);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="IT-Nexus-Agent-Setup-v${version}.exe"`);
|
||||||
|
res.sendFile(setupPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { checkin, announcementsPoll, getAll, getStatistics, getById, deleteAgent, downloadScript, downloadAnnWatcher, downloadSetup };
|
||||||
239
backend/src/controllers/networkMonitor.controller.js
Normal file
239
backend/src/controllers/networkMonitor.controller.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
const NetworkDevice = require('../models/NetworkDevice');
|
||||||
|
const { subscribe, unsubscribe, broadcast } = require('../services/networkSseManager');
|
||||||
|
const { scheduleDevice, cancelDevice, runCheck } = require('../services/networkPoller.service');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const JWT_CONFIG = require('../config/jwt');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const net = require('net');
|
||||||
|
const dns = require('dns').promises;
|
||||||
|
|
||||||
|
// ─── Auto-Discovery ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function pingHost(ip, timeoutMs = 800) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const proc = spawn('ping', ['-c', '1', '-W', '1', ip]);
|
||||||
|
const kill = setTimeout(() => { proc.kill(); resolve(false); }, timeoutMs);
|
||||||
|
proc.on('close', code => { clearTimeout(kill); resolve(code === 0); });
|
||||||
|
proc.on('error', () => { clearTimeout(kill); resolve(false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPort(ip, port, timeoutMs = 500) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const sock = new net.Socket();
|
||||||
|
sock.setTimeout(timeoutMs);
|
||||||
|
sock.connect(port, ip, () => { sock.destroy(); resolve(true); });
|
||||||
|
sock.on('error', () => resolve(false));
|
||||||
|
sock.on('timeout', () => { sock.destroy(); resolve(false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveHostname(ip) {
|
||||||
|
try {
|
||||||
|
const names = await dns.reverse(ip);
|
||||||
|
return names[0]?.replace(/\.$/, '') || null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessType(openPorts) {
|
||||||
|
if (openPorts.includes(9100)) return 'printer';
|
||||||
|
if (openPorts.includes(23) || openPorts.includes(161)) return 'switch';
|
||||||
|
if (openPorts.includes(5000) || openPorts.includes(5001) || openPorts.includes(2049)) return 'nas';
|
||||||
|
if (openPorts.includes(445) || openPorts.includes(139)) return 'nas';
|
||||||
|
if (openPorts.includes(80) || openPorts.includes(443) || openPorts.includes(22)) return 'host';
|
||||||
|
return 'host';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanHost(ip) {
|
||||||
|
const alive = await pingHost(ip);
|
||||||
|
if (!alive) return null;
|
||||||
|
|
||||||
|
// Check common ports in parallel
|
||||||
|
const PORT_MAP = [22, 23, 80, 443, 445, 161, 9100, 5000, 5001, 8080, 8443, 3389];
|
||||||
|
const portResults = await Promise.all(PORT_MAP.map(p => checkPort(ip, p).then(ok => ok ? p : null)));
|
||||||
|
const openPorts = portResults.filter(Boolean);
|
||||||
|
|
||||||
|
const hostname = await resolveHostname(ip);
|
||||||
|
const type = guessType(openPorts);
|
||||||
|
const checkType = openPorts.includes(443) ? 'https' : openPorts.includes(80) ? 'http' : 'icmp';
|
||||||
|
|
||||||
|
return { ip, hostname, type, check_type: checkType, open_ports: openPorts };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInBatches(items, batchSize, fn) {
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < items.length; i += batchSize) {
|
||||||
|
const batch = items.slice(i, i + batchSize);
|
||||||
|
const res = await Promise.all(batch.map(fn));
|
||||||
|
results.push(...res);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const discover = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { subnet } = req.body; // e.g. "192.168.0" or "192.168.0.0/24"
|
||||||
|
if (!subnet) return res.status(400).json({ status: 'error', message: 'subnet fehlt' });
|
||||||
|
|
||||||
|
// Parse subnet to base (first 3 octets)
|
||||||
|
let base = subnet.replace(/\/\d+$/, '').trim();
|
||||||
|
const parts = base.split('.');
|
||||||
|
if (parts.length === 4) base = parts.slice(0, 3).join('.');
|
||||||
|
if (parts.length < 3) return res.status(400).json({ status: 'error', message: 'Ungültiges Subnet' });
|
||||||
|
|
||||||
|
const ips = Array.from({ length: 254 }, (_, i) => `${base}.${i + 1}`);
|
||||||
|
|
||||||
|
// Existing devices to skip
|
||||||
|
const existing = NetworkDevice.getAll().map(d => d.host);
|
||||||
|
|
||||||
|
// Scan in batches of 40 parallel pings
|
||||||
|
const raw = await runInBatches(ips, 40, scanHost);
|
||||||
|
const found = raw
|
||||||
|
.filter(r => r !== null && !existing.includes(r.ip))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ai = parseInt(a.ip.split('.').pop());
|
||||||
|
const bi = parseInt(b.ip.split('.').pop());
|
||||||
|
return ai - bi;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: found, scanned: ips.length });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── SSE Stream ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const sseStream = (req, res) => {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
// Send initial state
|
||||||
|
const devices = NetworkDevice.getAll();
|
||||||
|
res.write(`event: initial_state\ndata: ${JSON.stringify(devices)}\n\n`);
|
||||||
|
|
||||||
|
// Heartbeat
|
||||||
|
const hb = setInterval(() => { try { res.write(': heartbeat\n\n'); } catch (_) {} }, 15000);
|
||||||
|
|
||||||
|
subscribe(res);
|
||||||
|
req.on('close', () => { clearInterval(hb); unsubscribe(res); });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── CRUD ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const getAll = (req, res) => {
|
||||||
|
try {
|
||||||
|
const devices = NetworkDevice.getAll();
|
||||||
|
res.json({ status: 'success', data: devices });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatistics = (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = NetworkDevice.getStatistics();
|
||||||
|
res.json({ status: 'success', data: stats });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChecks = (req, res) => {
|
||||||
|
try {
|
||||||
|
const hours = parseInt(req.query.hours) || 24;
|
||||||
|
const checks = NetworkDevice.getChecks(req.params.id, hours);
|
||||||
|
const uptime = NetworkDevice.getUptimeStats(req.params.id, hours);
|
||||||
|
res.json({ status: 'success', data: { checks, uptime } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDevice = (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = sanitize(req.body);
|
||||||
|
const device = NetworkDevice.create(data);
|
||||||
|
scheduleDevice(device);
|
||||||
|
broadcast('device_update', device);
|
||||||
|
res.status(201).json({ status: 'success', data: device });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDevice = (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = sanitize(req.body);
|
||||||
|
const device = NetworkDevice.update(req.params.id, data);
|
||||||
|
if (!device) return res.status(404).json({ status: 'error', message: 'Nicht gefunden' });
|
||||||
|
if (device.enabled) scheduleDevice(device);
|
||||||
|
else cancelDevice(device.id);
|
||||||
|
broadcast('device_update', device);
|
||||||
|
res.json({ status: 'success', data: device });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDevice = (req, res) => {
|
||||||
|
try {
|
||||||
|
cancelDevice(parseInt(req.params.id));
|
||||||
|
NetworkDevice.delete(req.params.id);
|
||||||
|
broadcast('device_deleted', { id: parseInt(req.params.id) });
|
||||||
|
res.json({ status: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkNow = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const device = NetworkDevice.getById(req.params.id);
|
||||||
|
if (!device) return res.status(404).json({ status: 'error', message: 'Nicht gefunden' });
|
||||||
|
await runCheck(device);
|
||||||
|
const updated = NetworkDevice.getById(req.params.id);
|
||||||
|
res.json({ status: 'success', data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAllUptimeStats = (req, res) => {
|
||||||
|
try {
|
||||||
|
const devices = NetworkDevice.getAll();
|
||||||
|
const stats = {};
|
||||||
|
devices.forEach(d => { stats[d.id] = NetworkDevice.getUptimeStats(d.id, 24); });
|
||||||
|
res.json({ status: 'success', data: stats });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sanitize(body) {
|
||||||
|
return {
|
||||||
|
name: body.name?.trim() || '',
|
||||||
|
type: body.type || 'host',
|
||||||
|
host: body.host?.trim() || '',
|
||||||
|
check_type: body.check_type || 'icmp',
|
||||||
|
port: body.port ? parseInt(body.port) : null,
|
||||||
|
http_path: body.http_path || '/',
|
||||||
|
http_keyword: body.http_keyword || null,
|
||||||
|
snmp_community: body.snmp_community?.trim() || 'public',
|
||||||
|
snmp_version: ['1', '2c'].includes(body.snmp_version) ? body.snmp_version : '2c',
|
||||||
|
interval_sec: parseInt(body.interval_sec) || 60,
|
||||||
|
timeout_sec: parseInt(body.timeout_sec) || 5,
|
||||||
|
enabled: body.enabled === false || body.enabled === 0 ? 0 : 1,
|
||||||
|
notify_email: body.notify_email || null,
|
||||||
|
location: body.location || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sseStream, getAll, getStatistics, getChecks, getAllUptimeStats, createDevice, updateDevice, deleteDevice, checkNow, discover };
|
||||||
69
backend/src/controllers/offboarding.controller.js
Normal file
69
backend/src/controllers/offboarding.controller.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const OffboardingService = require('../services/offboarding.service');
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
|
class OffboardingController {
|
||||||
|
static getAllProtocols = asyncHandler(async (req, res) => {
|
||||||
|
const protocols = OffboardingService.getAllProtocols();
|
||||||
|
res.json({ status: 'success', data: protocols });
|
||||||
|
});
|
||||||
|
|
||||||
|
static getProtocolById = asyncHandler(async (req, res) => {
|
||||||
|
const protocol = OffboardingService.getProtocolById(parseInt(req.params.id));
|
||||||
|
res.json({ status: 'success', data: protocol });
|
||||||
|
});
|
||||||
|
|
||||||
|
static createProtocol = asyncHandler(async (req, res) => {
|
||||||
|
const protocol = await OffboardingService.createProtocol(req.body, req.user.id, req.ip);
|
||||||
|
res.status(201).json({ status: 'success', data: protocol });
|
||||||
|
});
|
||||||
|
|
||||||
|
static updateProtocol = asyncHandler(async (req, res) => {
|
||||||
|
const protocol = await OffboardingService.updateProtocol(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
req.body,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
res.json({ status: 'success', data: protocol });
|
||||||
|
});
|
||||||
|
|
||||||
|
static returnAssets = asyncHandler(async (req, res) => {
|
||||||
|
const protocol = await OffboardingService.returnAssets(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
req.body.asset_returns,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
res.json({ status: 'success', data: protocol });
|
||||||
|
});
|
||||||
|
|
||||||
|
static regeneratePdf = asyncHandler(async (req, res) => {
|
||||||
|
const protocol = await OffboardingService.regeneratePdf(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
res.json({ status: 'success', data: protocol });
|
||||||
|
});
|
||||||
|
|
||||||
|
static deleteProtocol = asyncHandler(async (req, res) => {
|
||||||
|
const deleted = OffboardingService.deleteProtocol(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Protokoll nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'success', message: 'Protokoll erfolgreich gelöscht' });
|
||||||
|
});
|
||||||
|
|
||||||
|
static getStatistics = asyncHandler(async (req, res) => {
|
||||||
|
const stats = OffboardingService.getStatistics();
|
||||||
|
res.json({ status: 'success', data: stats });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OffboardingController;
|
||||||
85
backend/src/controllers/onboarding.controller.js
Normal file
85
backend/src/controllers/onboarding.controller.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
const OnboardingService = require('../services/onboarding.service');
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { sendOnboardingConfirmationEmail } = require('../services/email.service');
|
||||||
|
|
||||||
|
class OnboardingController {
|
||||||
|
static getAllProtocols = asyncHandler(async (req, res) => {
|
||||||
|
const protocols = OnboardingService.getAllProtocols();
|
||||||
|
res.json({ status: 'success', data: protocols });
|
||||||
|
});
|
||||||
|
|
||||||
|
static getProtocolById = asyncHandler(async (req, res) => {
|
||||||
|
const protocol = OnboardingService.getProtocolById(parseInt(req.params.id));
|
||||||
|
res.json({ status: 'success', data: protocol });
|
||||||
|
});
|
||||||
|
|
||||||
|
static createProtocol = asyncHandler(async (req, res) => {
|
||||||
|
const protocol = await OnboardingService.createProtocol(req.body, req.user.id, req.ip);
|
||||||
|
res.status(201).json({ status: 'success', data: protocol });
|
||||||
|
});
|
||||||
|
|
||||||
|
static updateProtocol = asyncHandler(async (req, res) => {
|
||||||
|
const protocol = await OnboardingService.updateProtocol(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
req.body,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
res.json({ status: 'success', data: protocol });
|
||||||
|
});
|
||||||
|
|
||||||
|
static regeneratePdf = asyncHandler(async (req, res) => {
|
||||||
|
const protocol = await OnboardingService.regeneratePdf(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
res.json({ status: 'success', data: protocol });
|
||||||
|
});
|
||||||
|
|
||||||
|
static deleteProtocol = asyncHandler(async (req, res) => {
|
||||||
|
const deleted = OnboardingService.deleteProtocol(
|
||||||
|
parseInt(req.params.id),
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Protokoll nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'success', message: 'Protokoll erfolgreich gelöscht' });
|
||||||
|
});
|
||||||
|
|
||||||
|
static sendConfirmationEmail = asyncHandler(async (req, res) => {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const OnboardingProtocol = require('../models/OnboardingProtocol');
|
||||||
|
const protocol = await OnboardingService.getProtocolById(parseInt(req.params.id));
|
||||||
|
if (!protocol.emp_private_email) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'Keine private E-Mail-Adresse beim Mitarbeiter hinterlegt' });
|
||||||
|
}
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
OnboardingProtocol.setConfirmToken(protocol.id, token);
|
||||||
|
await sendOnboardingConfirmationEmail(protocol, token);
|
||||||
|
res.json({ status: 'success', message: `Bestätigungsmail an ${protocol.emp_private_email} gesendet` });
|
||||||
|
});
|
||||||
|
|
||||||
|
static confirmByToken = asyncHandler(async (req, res) => {
|
||||||
|
const OnboardingProtocol = require('../models/OnboardingProtocol');
|
||||||
|
const { token } = req.params;
|
||||||
|
const protocol = OnboardingProtocol.getByConfirmToken(token);
|
||||||
|
if (!protocol) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Ungültiger oder bereits verwendeter Bestätigungslink' });
|
||||||
|
}
|
||||||
|
OnboardingProtocol.confirmByToken(token);
|
||||||
|
const name = `${protocol.emp_first_name || ''} ${protocol.emp_last_name || ''}`.trim();
|
||||||
|
res.json({ status: 'success', message: 'Übergabe erfolgreich bestätigt', data: { name, department: protocol.department } });
|
||||||
|
});
|
||||||
|
|
||||||
|
static getStatistics = asyncHandler(async (req, res) => {
|
||||||
|
const stats = OnboardingService.getStatistics();
|
||||||
|
res.json({ status: 'success', data: stats });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = OnboardingController;
|
||||||
95
backend/src/controllers/onboardingProcess.controller.js
Normal file
95
backend/src/controllers/onboardingProcess.controller.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
const OnboardingProcess = require('../models/OnboardingProcess');
|
||||||
|
|
||||||
|
// ── Departments ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getDepartments = (req, res) => {
|
||||||
|
const data = OnboardingProcess.getAllDepartments();
|
||||||
|
res.json({ status: 'success', data });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createDepartment = (req, res) => {
|
||||||
|
const { name, icon, sort_order } = req.body;
|
||||||
|
if (!name?.trim()) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'Name ist erforderlich' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const dept = OnboardingProcess.createDepartment({ name: name.trim(), icon, sort_order });
|
||||||
|
res.status(201).json({ status: 'success', data: dept });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message?.includes('UNIQUE')) {
|
||||||
|
return res.status(409).json({ status: 'error', message: 'Abteilung existiert bereits' });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateDepartment = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { name, icon, sort_order } = req.body;
|
||||||
|
const dept = OnboardingProcess.updateDepartment(id, { name, icon, sort_order });
|
||||||
|
if (!dept) return res.status(404).json({ status: 'error', message: 'Abteilung nicht gefunden' });
|
||||||
|
res.json({ status: 'success', data: dept });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteDepartment = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const ok = OnboardingProcess.deleteDepartment(id);
|
||||||
|
if (!ok) return res.status(404).json({ status: 'error', message: 'Abteilung nicht gefunden' });
|
||||||
|
res.json({ status: 'success', message: 'Abteilung gelöscht' });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.reorderDepartments = (req, res) => {
|
||||||
|
const { ids } = req.body;
|
||||||
|
if (!Array.isArray(ids)) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'ids muss ein Array sein' });
|
||||||
|
}
|
||||||
|
OnboardingProcess.reorderDepartments(ids);
|
||||||
|
res.json({ status: 'success', message: 'Reihenfolge gespeichert' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Processes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getProcesses = (req, res) => {
|
||||||
|
const { department_id, applies_to, responsible_team } = req.query;
|
||||||
|
const data = OnboardingProcess.getAllProcesses({
|
||||||
|
departmentId: department_id ? Number(department_id) : undefined,
|
||||||
|
appliesTo: applies_to || undefined,
|
||||||
|
responsibleTeam: responsible_team || undefined,
|
||||||
|
});
|
||||||
|
res.json({ status: 'success', data });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createProcess = (req, res) => {
|
||||||
|
const { department_id, responsible_team, title, description, applies_to, tag, sort_order } = req.body;
|
||||||
|
if (!department_id || !title?.trim()) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'department_id und title sind erforderlich' });
|
||||||
|
}
|
||||||
|
const proc = OnboardingProcess.createProcess({
|
||||||
|
department_id, responsible_team, title: title.trim(), description, applies_to, tag, sort_order,
|
||||||
|
});
|
||||||
|
res.status(201).json({ status: 'success', data: proc });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateProcess = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { title, description, applies_to, tag, sort_order, department_id, responsible_team } = req.body;
|
||||||
|
const proc = OnboardingProcess.updateProcess(id, { title, description, applies_to, tag, sort_order, department_id, responsible_team });
|
||||||
|
if (!proc) return res.status(404).json({ status: 'error', message: 'Prozess nicht gefunden' });
|
||||||
|
res.json({ status: 'success', data: proc });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteProcess = (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const ok = OnboardingProcess.deleteProcess(id);
|
||||||
|
if (!ok) return res.status(404).json({ status: 'error', message: 'Prozess nicht gefunden' });
|
||||||
|
res.json({ status: 'success', message: 'Prozess gelöscht' });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.reorderProcesses = (req, res) => {
|
||||||
|
const { department_id, ids } = req.body;
|
||||||
|
if (!department_id || !Array.isArray(ids)) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'department_id und ids erforderlich' });
|
||||||
|
}
|
||||||
|
OnboardingProcess.reorderProcesses(department_id, ids);
|
||||||
|
res.json({ status: 'success', message: 'Reihenfolge gespeichert' });
|
||||||
|
};
|
||||||
303
backend/src/controllers/patch.controller.js
Normal file
303
backend/src/controllers/patch.controller.js
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
|
||||||
|
// ── Patch Groups ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const getGroups = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const groups = db.prepare('SELECT * FROM patch_groups ORDER BY sort_order, name').all();
|
||||||
|
const policies = db.prepare('SELECT * FROM patch_policies ORDER BY group_id').all();
|
||||||
|
const agentCounts = db.prepare(`
|
||||||
|
SELECT group_id, COUNT(*) as count FROM patch_agent_groups GROUP BY group_id
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json(groups.map(g => ({
|
||||||
|
...g,
|
||||||
|
policies: policies.filter(p => p.group_id === g.id),
|
||||||
|
agent_count: agentCounts.find(a => a.group_id === g.id)?.count || 0,
|
||||||
|
})));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createGroup = (req, res) => {
|
||||||
|
const { name, description, color = '#3B82F6' } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ error: 'Name erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM patch_groups').get();
|
||||||
|
try {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO patch_groups (name, description, color, sort_order) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(name.trim(), description || null, color, (maxOrder.m || 0) + 1);
|
||||||
|
res.status(201).json(db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(result.lastInsertRowid));
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Gruppe existiert bereits' });
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGroup = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const existing = db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
const { name, description, color, target_agent_version } = req.body;
|
||||||
|
db.prepare('UPDATE patch_groups SET name=?, description=?, color=?, target_agent_version=? WHERE id=?')
|
||||||
|
.run(name?.trim() ?? existing.name, description ?? existing.description, color ?? existing.color,
|
||||||
|
target_agent_version !== undefined ? (target_agent_version || null) : existing.target_agent_version,
|
||||||
|
req.params.id);
|
||||||
|
res.json(db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(req.params.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rollout-Reihenfolge: Test → Pilot → Produktion
|
||||||
|
// Eine Gruppe darf eine Version nur bekommen wenn die Vorgänger-Gruppe sie bereits hat
|
||||||
|
const ROLLOUT_ORDER = ['Test', 'Pilot', 'Produktion'];
|
||||||
|
|
||||||
|
const releaseVersionToAll = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { version } = req.body;
|
||||||
|
if (!version) return res.status(400).json({ error: 'version erforderlich' });
|
||||||
|
db.prepare('UPDATE patch_groups SET target_agent_version=?').run(version);
|
||||||
|
res.json({ success: true, version });
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseVersionToGroup = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { version } = req.body;
|
||||||
|
if (!version) return res.status(400).json({ error: 'version erforderlich' });
|
||||||
|
|
||||||
|
const group = db.prepare('SELECT * FROM patch_groups WHERE id = ?').get(req.params.id);
|
||||||
|
if (!group) return res.status(404).json({ error: 'Gruppe nicht gefunden' });
|
||||||
|
|
||||||
|
const pos = ROLLOUT_ORDER.indexOf(group.name);
|
||||||
|
|
||||||
|
// Vorgänger-Gruppe muss dieselbe oder höhere Version haben
|
||||||
|
if (pos > 0) {
|
||||||
|
const predecessorName = ROLLOUT_ORDER[pos - 1];
|
||||||
|
const predecessor = db.prepare('SELECT target_agent_version FROM patch_groups WHERE name = ?').get(predecessorName);
|
||||||
|
if (!predecessor?.target_agent_version || predecessor.target_agent_version !== version) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: `Version ${version} muss zuerst für "${predecessorName}" freigegeben sein bevor "${group.name}" aktualisiert werden kann.`,
|
||||||
|
blocked_by: predecessorName,
|
||||||
|
predecessor_version: predecessor?.target_agent_version || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('UPDATE patch_groups SET target_agent_version=? WHERE id=?').run(version, req.params.id);
|
||||||
|
res.json({ success: true, group_id: group.id, group_name: group.name, version });
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteGroup = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!db.prepare('SELECT id FROM patch_groups WHERE id = ?').get(req.params.id)) {
|
||||||
|
return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM patch_groups WHERE id = ?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Patch Policies ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const upsertPolicy = (req, res) => {
|
||||||
|
const { group_id, severity, max_days, notify_email } = req.body;
|
||||||
|
if (!group_id || !severity || !max_days) return res.status(400).json({ error: 'group_id, severity und max_days erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
const existing = db.prepare('SELECT id FROM patch_policies WHERE group_id=? AND severity=?').get(group_id, severity);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('UPDATE patch_policies SET max_days=?, notify_email=? WHERE id=?')
|
||||||
|
.run(max_days, notify_email || null, existing.id);
|
||||||
|
res.json(db.prepare('SELECT * FROM patch_policies WHERE id=?').get(existing.id));
|
||||||
|
} else {
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO patch_policies (group_id, severity, max_days, notify_email) VALUES (?,?,?,?)'
|
||||||
|
).run(group_id, severity, max_days, notify_email || null);
|
||||||
|
res.status(201).json(db.prepare('SELECT * FROM patch_policies WHERE id=?').get(result.lastInsertRowid));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePolicy = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
db.prepare('DELETE FROM patch_policies WHERE id=?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Agent Assignment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const assignAgent = (req, res) => {
|
||||||
|
const { agent_id, group_id } = req.body;
|
||||||
|
if (!agent_id || !group_id) return res.status(400).json({ error: 'agent_id und group_id erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
// Ein Gerät kann nur in einer Gruppe sein → alte Zuweisung entfernen
|
||||||
|
db.prepare('DELETE FROM patch_agent_groups WHERE agent_id=?').run(agent_id);
|
||||||
|
if (group_id !== null && group_id !== 'none') {
|
||||||
|
db.prepare('INSERT OR REPLACE INTO patch_agent_groups (agent_id, group_id) VALUES (?,?)').run(agent_id, group_id);
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Overview / Compliance ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const getOverview = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const agents = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
a.*,
|
||||||
|
pg.id as group_id,
|
||||||
|
pg.name as group_name,
|
||||||
|
pg.color as group_color
|
||||||
|
FROM monitoring_agents a
|
||||||
|
LEFT JOIN patch_agent_groups pag ON a.id = pag.agent_id
|
||||||
|
LEFT JOIN patch_groups pg ON pag.group_id = pg.id
|
||||||
|
ORDER BY pg.sort_order NULLS LAST, a.hostname
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const policies = db.prepare('SELECT * FROM patch_policies').all();
|
||||||
|
|
||||||
|
const result = agents.map(agent => {
|
||||||
|
let updates = [];
|
||||||
|
try { updates = JSON.parse(agent.pending_updates || '[]'); } catch {}
|
||||||
|
|
||||||
|
const agentPolicies = policies.filter(p => p.group_id === agent.group_id);
|
||||||
|
const updateCount = typeof agent.windows_updates_pending === 'number'
|
||||||
|
? agent.windows_updates_pending
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Compliance-Status berechnen
|
||||||
|
let compliance = 'ok';
|
||||||
|
let violationReason = null;
|
||||||
|
|
||||||
|
if (agentPolicies.length > 0 && updateCount > 0) {
|
||||||
|
compliance = 'warn';
|
||||||
|
violationReason = `${updateCount} ausstehende Update(s)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// last_checkin ist UTC ohne Timezone-Marker → explizit als UTC parsen
|
||||||
|
const lastCheckin = agent.last_checkin ? new Date(agent.last_checkin + 'Z') : null;
|
||||||
|
const isOffline = !lastCheckin || (Date.now() - lastCheckin.getTime()) > 15 * 60 * 1000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: agent.id,
|
||||||
|
hostname: agent.hostname,
|
||||||
|
os: agent.os_name,
|
||||||
|
os_version: agent.os_version,
|
||||||
|
last_seen: agent.last_checkin,
|
||||||
|
is_offline: isOffline,
|
||||||
|
pending_updates: updateCount,
|
||||||
|
agent_version: agent.agent_version || '?',
|
||||||
|
tpm_present: !!agent.tpm_present,
|
||||||
|
tpm_version: agent.tpm_version || null,
|
||||||
|
tpm_v2: !!agent.tpm_v2,
|
||||||
|
secure_boot: !!agent.secure_boot,
|
||||||
|
win11_ready: !!agent.win11_ready,
|
||||||
|
last_user: agent.last_user || null,
|
||||||
|
bitlocker_status: agent.bitlocker_status || null,
|
||||||
|
defender_enabled: agent.defender_enabled != null ? !!agent.defender_enabled : null,
|
||||||
|
defender_signatures_age: agent.defender_signatures_age ?? null,
|
||||||
|
hardware_serial: agent.hardware_serial || null,
|
||||||
|
ip_address: agent.ip_address || null,
|
||||||
|
group_id: agent.group_id,
|
||||||
|
group_name: agent.group_name || 'Keine Gruppe',
|
||||||
|
group_color: agent.group_color || '#6B7280',
|
||||||
|
compliance,
|
||||||
|
violation_reason: violationReason,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: result.length,
|
||||||
|
compliant: result.filter(a => a.compliance === 'ok' && !a.is_offline).length,
|
||||||
|
warnings: result.filter(a => a.compliance === 'warn').length,
|
||||||
|
offline: result.filter(a => a.is_offline).length,
|
||||||
|
total_pending_updates: result.reduce((s, a) => s + (a.pending_updates || 0), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({ agents: result, stats });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Commands ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const triggerCommand = (req, res) => {
|
||||||
|
const { agent_id, command } = req.body;
|
||||||
|
if (!agent_id || !command) return res.status(400).json({ error: 'agent_id und command erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
// Duplikate vermeiden: nur ein pending/sent Command pro Agent+Type
|
||||||
|
db.prepare("DELETE FROM patch_commands WHERE agent_id=? AND command=? AND status IN ('pending','sent')").run(agent_id, command);
|
||||||
|
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO patch_commands (agent_id, command, triggered_by_user_id) VALUES (?,?,?)'
|
||||||
|
).run(agent_id, command, req.user.id);
|
||||||
|
res.status(201).json(db.prepare('SELECT * FROM patch_commands WHERE id=?').get(result.lastInsertRowid));
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerGroupCommand = (req, res) => {
|
||||||
|
const { group_id, command } = req.body;
|
||||||
|
if (!group_id || !command) return res.status(400).json({ error: 'group_id und command erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
const agents = db.prepare('SELECT agent_id FROM patch_agent_groups WHERE group_id=?').all(group_id);
|
||||||
|
let count = 0;
|
||||||
|
for (const { agent_id } of agents) {
|
||||||
|
db.prepare("DELETE FROM patch_commands WHERE agent_id=? AND command=? AND status IN ('pending','sent')").run(agent_id, command);
|
||||||
|
db.prepare('INSERT INTO patch_commands (agent_id, command, triggered_by_user_id) VALUES (?,?,?)').run(agent_id, command, req.user.id);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
res.json({ success: true, triggered: count });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCommands = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { agent_id } = req.query;
|
||||||
|
const rows = agent_id
|
||||||
|
? db.prepare(`
|
||||||
|
SELECT pc.*, a.hostname, u.username as triggered_by_username
|
||||||
|
FROM patch_commands pc
|
||||||
|
JOIN monitoring_agents a ON pc.agent_id = a.id
|
||||||
|
LEFT JOIN users u ON pc.triggered_by_user_id = u.id
|
||||||
|
WHERE pc.agent_id = ?
|
||||||
|
ORDER BY pc.created_at DESC
|
||||||
|
LIMIT 30
|
||||||
|
`).all(agent_id)
|
||||||
|
: db.prepare(`
|
||||||
|
SELECT pc.*, a.hostname, u.username as triggered_by_username
|
||||||
|
FROM patch_commands pc
|
||||||
|
JOIN monitoring_agents a ON pc.agent_id = a.id
|
||||||
|
LEFT JOIN users u ON pc.triggered_by_user_id = u.id
|
||||||
|
ORDER BY pc.created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`).all();
|
||||||
|
res.json(rows);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called by the agent after execution
|
||||||
|
const reportCommandResult = (req, res) => {
|
||||||
|
const agentKey = req.headers['x-agent-key'];
|
||||||
|
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
const { command_id, status, result } = req.body;
|
||||||
|
if (!command_id) return res.status(400).json({ error: 'command_id erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
db.prepare("UPDATE patch_commands SET status=?, result=?, completed_at=CURRENT_TIMESTAMP WHERE id=?")
|
||||||
|
.run(status || 'done', result || null, command_id);
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called by agent check-in – returns pending + running commands for this agent
|
||||||
|
const getPendingCommands = (agentId) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const cmds = db.prepare("SELECT * FROM patch_commands WHERE agent_id=? AND status='pending'").all(agentId);
|
||||||
|
if (cmds.length > 0) {
|
||||||
|
db.prepare("UPDATE patch_commands SET status='sent', sent_at=CURRENT_TIMESTAMP WHERE agent_id=? AND status='pending'")
|
||||||
|
.run(agentId);
|
||||||
|
}
|
||||||
|
const running = db.prepare("SELECT * FROM patch_commands WHERE agent_id=? AND status='running'").all(agentId);
|
||||||
|
return { pending: cmds, running };
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getGroups, createGroup, updateGroup, deleteGroup, releaseVersionToGroup, releaseVersionToAll,
|
||||||
|
upsertPolicy, deletePolicy,
|
||||||
|
assignAgent,
|
||||||
|
getOverview,
|
||||||
|
triggerCommand, triggerGroupCommand, getCommands, reportCommandResult,
|
||||||
|
getPendingCommands,
|
||||||
|
};
|
||||||
106
backend/src/controllers/portalGuide.controller.js
Normal file
106
backend/src/controllers/portalGuide.controller.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ['super_admin', 'admin'];
|
||||||
|
|
||||||
|
// Prüft ob ein User eine Anleitung sehen darf
|
||||||
|
function canView(visibleRoles, userRole) {
|
||||||
|
if (!visibleRoles || visibleRoles === '[]' || visibleRoles === '["all"]') return true;
|
||||||
|
try {
|
||||||
|
const roles = JSON.parse(visibleRoles);
|
||||||
|
if (!roles.length || roles.includes('all')) return true;
|
||||||
|
return roles.includes(userRole);
|
||||||
|
} catch { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAll = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const userRole = req.user?.role_name || req.user?.role || '';
|
||||||
|
const isAdmin = ADMIN_ROLES.includes(userRole);
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT pg.*, u.username as created_by_username
|
||||||
|
FROM portal_guides pg
|
||||||
|
LEFT JOIN users u ON pg.created_by_user_id = u.id
|
||||||
|
ORDER BY pg.sort_order, pg.title
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
// Admins sehen alles, andere nur erlaubte
|
||||||
|
const filtered = isAdmin
|
||||||
|
? rows
|
||||||
|
: rows.filter(r => canView(r.visible_roles, userRole));
|
||||||
|
|
||||||
|
res.json(filtered.map(({ html_content, ...r }) => r));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getById = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const row = db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
const userRole = req.user?.role_name || req.user?.role || '';
|
||||||
|
const isAdmin = ADMIN_ROLES.includes(userRole);
|
||||||
|
if (!isAdmin && !canView(row.visible_roles, userRole)) {
|
||||||
|
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||||
|
}
|
||||||
|
res.json(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHtml = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const row = db.prepare('SELECT html_content, title, visible_roles FROM portal_guides WHERE id = ?').get(req.params.id);
|
||||||
|
if (!row) return res.status(404).send('Nicht gefunden');
|
||||||
|
const userRole = req.user?.role_name || req.user?.role || '';
|
||||||
|
const isAdmin = ADMIN_ROLES.includes(userRole);
|
||||||
|
if (!isAdmin && !canView(row.visible_roles, userRole)) {
|
||||||
|
return res.status(403).send('Keine Berechtigung');
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
res.send(row.html_content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = (req, res) => {
|
||||||
|
const { title, category = 'Allgemein', description, html_content, icon = '📄', sort_order = 0, visible_roles = [] } = req.body;
|
||||||
|
if (!title?.trim()) return res.status(400).json({ error: 'Titel erforderlich' });
|
||||||
|
if (!html_content?.trim()) return res.status(400).json({ error: 'HTML-Inhalt erforderlich' });
|
||||||
|
const db = getDatabase();
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO portal_guides (title, category, description, html_content, icon, sort_order, visible_roles, created_by_user_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(title.trim(), category, description || null, html_content, icon, sort_order,
|
||||||
|
JSON.stringify(Array.isArray(visible_roles) ? visible_roles : []), req.user.id);
|
||||||
|
res.status(201).json(db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(result.lastInsertRowid));
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const existing = db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(req.params.id);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
const { title, category, description, html_content, icon, sort_order, visible_roles } = req.body;
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE portal_guides
|
||||||
|
SET title = ?, category = ?, description = ?, html_content = ?, icon = ?, sort_order = ?, visible_roles = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
title?.trim() ?? existing.title,
|
||||||
|
category ?? existing.category,
|
||||||
|
description !== undefined ? description : existing.description,
|
||||||
|
html_content ?? existing.html_content,
|
||||||
|
icon ?? existing.icon,
|
||||||
|
sort_order ?? existing.sort_order,
|
||||||
|
visible_roles !== undefined ? JSON.stringify(Array.isArray(visible_roles) ? visible_roles : []) : existing.visible_roles,
|
||||||
|
req.params.id
|
||||||
|
);
|
||||||
|
const { html_content: _, ...row } = db.prepare('SELECT * FROM portal_guides WHERE id = ?').get(req.params.id);
|
||||||
|
res.json(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!db.prepare('SELECT id FROM portal_guides WHERE id = ?').get(req.params.id)) {
|
||||||
|
return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM portal_guides WHERE id = ?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getAll, getById, getHtml, create, update, remove };
|
||||||
12
backend/src/controllers/proxmox.controller.js
Normal file
12
backend/src/controllers/proxmox.controller.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const ProxmoxService = require('../services/proxmox.service');
|
||||||
|
|
||||||
|
const getOverview = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = await ProxmoxService.getAll();
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(503).json({ error: 'Proxmox nicht erreichbar', detail: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getOverview };
|
||||||
153
backend/src/controllers/risk.controller.js
Normal file
153
backend/src/controllers/risk.controller.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
const Risk = require('../models/Risk');
|
||||||
|
const { getSecureScore, getRiskyUsers, getDefenderAlerts, getNonCompliantDevices, getUsersWithoutMfa } = require('../services/graph.service');
|
||||||
|
|
||||||
|
exports.getAll = (req, res) => {
|
||||||
|
const data = Risk.getAll(req.query);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.getStats = (req, res) => {
|
||||||
|
res.json({ success: true, data: Risk.getStats() });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.create = (req, res) => {
|
||||||
|
const risk = Risk.create(req.body);
|
||||||
|
res.status(201).json({ success: true, data: risk });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.update = (req, res) => {
|
||||||
|
const risk = Risk.update(req.params.id, req.body);
|
||||||
|
if (!risk) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||||
|
res.json({ success: true, data: risk });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.remove = (req, res) => {
|
||||||
|
const ok = Risk.delete(req.params.id);
|
||||||
|
if (!ok) return res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.sync = async (req, res) => {
|
||||||
|
const result = { created: 0, updated: 0, errors: [] };
|
||||||
|
|
||||||
|
// 1. Secure Score
|
||||||
|
try {
|
||||||
|
const score = await getSecureScore();
|
||||||
|
if (score) {
|
||||||
|
const pct = Math.round((score.currentScore / score.maxScore) * 100);
|
||||||
|
const prob = pct < 40 ? 5 : pct < 60 ? 4 : pct < 75 ? 3 : pct < 90 ? 2 : 1;
|
||||||
|
const r = Risk.upsertAuto({
|
||||||
|
source_type: 'secure_score',
|
||||||
|
external_id: 'tenant_secure_score',
|
||||||
|
title: `Microsoft Secure Score: ${pct}% (${Math.round(score.currentScore)}/${Math.round(score.maxScore)})`,
|
||||||
|
description: `Aktueller Microsoft Secure Score. Je niedriger der Score, desto höher das Sicherheitsrisiko. Letzte Messung: ${new Date(score.createdDateTime).toLocaleDateString('de-DE')}.`,
|
||||||
|
category: 'IT-Sicherheit',
|
||||||
|
probability: prob,
|
||||||
|
impact: 4,
|
||||||
|
});
|
||||||
|
result[r.last_synced ? 'updated' : 'created']++;
|
||||||
|
}
|
||||||
|
} catch (e) { result.errors.push(`Secure Score: ${e.message}`); }
|
||||||
|
|
||||||
|
// 2. Risky Users (requires Azure AD Premium P2 - skip if not available)
|
||||||
|
try {
|
||||||
|
const users = await getRiskyUsers();
|
||||||
|
for (const u of users) {
|
||||||
|
const levelMap = { high: 5, medium: 3, low: 2 };
|
||||||
|
const prob = levelMap[u.riskLevel] || 3;
|
||||||
|
Risk.upsertAuto({
|
||||||
|
source_type: 'risky_user',
|
||||||
|
external_id: u.id,
|
||||||
|
title: `Risikobehafteter Benutzer: ${u.userDisplayName || u.userPrincipalName}`,
|
||||||
|
description: `Entra ID Identity Protection hat diesen Benutzer als risikoreich eingestuft. Risiko-Level: ${u.riskLevel}, Detail: ${u.riskDetail || 'unbekannt'}.`,
|
||||||
|
category: 'Identität & Zugriff',
|
||||||
|
probability: prob,
|
||||||
|
impact: 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.created += users.length;
|
||||||
|
} catch (e) {
|
||||||
|
// Risky Users requires Azure AD Premium P2 – silently skip if not licensed
|
||||||
|
if (!e.message.includes('Forbidden') && !e.message.includes('missing in the token')) {
|
||||||
|
result.errors.push(`Risky Users: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Defender Alerts
|
||||||
|
try {
|
||||||
|
const alerts = await getDefenderAlerts();
|
||||||
|
const sevMap = { high: 5, medium: 3, low: 2, informational: 1 };
|
||||||
|
for (const a of alerts) {
|
||||||
|
Risk.upsertAuto({
|
||||||
|
source_type: 'defender_alert',
|
||||||
|
external_id: a.id,
|
||||||
|
title: `Defender Alert: ${a.title}`,
|
||||||
|
description: `${a.description || ''}`,
|
||||||
|
category: 'Incident / Bedrohung',
|
||||||
|
probability: sevMap[a.severity] || 3,
|
||||||
|
impact: sevMap[a.severity] || 3,
|
||||||
|
metadata: {
|
||||||
|
severity: a.severity,
|
||||||
|
status: a.status,
|
||||||
|
category: a.category,
|
||||||
|
detected: a.createdDateTime,
|
||||||
|
entities: (a.evidence || []).map(e => e.userAccount?.displayName || e.deviceDnsName || e.fileName).filter(Boolean),
|
||||||
|
recommendation: a.recommendedActions,
|
||||||
|
alertWebUrl: a.alertWebUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.created += alerts.length;
|
||||||
|
} catch (e) { result.errors.push(`Defender Alerts: ${e.message}`); }
|
||||||
|
|
||||||
|
// 4. Non-Compliant Devices
|
||||||
|
try {
|
||||||
|
const devices = await getNonCompliantDevices();
|
||||||
|
for (const d of devices) {
|
||||||
|
Risk.upsertAuto({
|
||||||
|
source_type: 'noncompliant_device',
|
||||||
|
external_id: d.id,
|
||||||
|
title: `Nicht-konformes Gerät: ${d.deviceName}`,
|
||||||
|
description: `Gerät erfüllt die Intune-Compliance-Richtlinien nicht.`,
|
||||||
|
category: 'Geräte & Endpunkte',
|
||||||
|
probability: 3,
|
||||||
|
impact: 3,
|
||||||
|
metadata: {
|
||||||
|
deviceName: d.deviceName,
|
||||||
|
user: d.userDisplayName,
|
||||||
|
os: d.operatingSystem,
|
||||||
|
serialNumber: d.serialNumber,
|
||||||
|
lastSync: d.lastSyncDateTime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.created += devices.length;
|
||||||
|
} catch (e) { result.errors.push(`Non-Compliant Devices: ${e.message}`); }
|
||||||
|
|
||||||
|
// 5. Users without MFA
|
||||||
|
try {
|
||||||
|
const noMfa = await getUsersWithoutMfa();
|
||||||
|
if (noMfa.length > 0) {
|
||||||
|
Risk.upsertAuto({
|
||||||
|
source_type: 'no_mfa',
|
||||||
|
external_id: 'users_without_mfa',
|
||||||
|
title: `${noMfa.length} Benutzer ohne MFA registriert`,
|
||||||
|
description: `${noMfa.length} Benutzer haben keine Multi-Faktor-Authentifizierung eingerichtet.`,
|
||||||
|
category: 'Identität & Zugriff',
|
||||||
|
probability: 4,
|
||||||
|
impact: 4,
|
||||||
|
metadata: {
|
||||||
|
users: noMfa.map(u => ({
|
||||||
|
name: u.userDisplayName,
|
||||||
|
email: u.userPrincipalName,
|
||||||
|
mfaRegistered: u.isMfaRegistered,
|
||||||
|
methods: u.authMethods,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.created++;
|
||||||
|
}
|
||||||
|
} catch (e) { result.errors.push(`MFA-Status: ${e.message}`); }
|
||||||
|
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
};
|
||||||
150
backend/src/controllers/scanner.controller.js
Normal file
150
backend/src/controllers/scanner.controller.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
|
||||||
|
// Validate X-Scanner-Key header
|
||||||
|
const validateScannerKey = (req, res) => {
|
||||||
|
const key = req.headers['x-scanner-key'];
|
||||||
|
if (!key || key !== process.env.SCANNER_API_KEY) {
|
||||||
|
res.status(401).json({ status: 'error', message: 'Invalid scanner key' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// POST /api/scanner/assets — called by nexus-scanner (no JWT)
|
||||||
|
const reportAssets = asyncHandler(async (req, res) => {
|
||||||
|
if (!validateScannerKey(req, res)) return;
|
||||||
|
|
||||||
|
const { site, scanner_version, reported_at, assets } = req.body;
|
||||||
|
if (!site || !Array.isArray(assets)) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'site and assets[] required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Upsert scanner site
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO scanner_sites (site_id, last_seen, scanner_version, host_count)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(site_id) DO UPDATE SET
|
||||||
|
last_seen = excluded.last_seen,
|
||||||
|
scanner_version = excluded.scanner_version,
|
||||||
|
host_count = excluded.host_count
|
||||||
|
`).run(site, now, scanner_version || '1.0', assets.length);
|
||||||
|
|
||||||
|
// Upsert each asset
|
||||||
|
const upsert = db.prepare(`
|
||||||
|
INSERT INTO scanner_assets (site_id, ip, mac, hostname, vendor, status, first_seen, last_seen, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(site_id, ip) DO UPDATE SET
|
||||||
|
mac = excluded.mac,
|
||||||
|
hostname = CASE WHEN excluded.hostname != '' THEN excluded.hostname ELSE hostname END,
|
||||||
|
vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END,
|
||||||
|
status = excluded.status,
|
||||||
|
last_seen = excluded.last_seen,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`);
|
||||||
|
|
||||||
|
const insertMany = db.transaction((items) => {
|
||||||
|
for (const a of items) {
|
||||||
|
upsert.run(
|
||||||
|
site,
|
||||||
|
a.ip,
|
||||||
|
a.mac || '',
|
||||||
|
a.hostname || '',
|
||||||
|
a.vendor || '',
|
||||||
|
a.status || 'online',
|
||||||
|
a.first_seen || now,
|
||||||
|
a.last_seen || now,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
insertMany(assets);
|
||||||
|
|
||||||
|
res.json({ status: 'success', received: assets.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/scanner/alert — called by nexus-scanner (no JWT)
|
||||||
|
const reportAlert = asyncHandler(async (req, res) => {
|
||||||
|
if (!validateScannerKey(req, res)) return;
|
||||||
|
|
||||||
|
const { site, check, type, target, status, latency, error, subject } = req.body;
|
||||||
|
if (!site || !check || !status) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'site, check, status required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO scanner_alerts (site_id, check_name, check_type, target, status, latency_ms, error_msg, subject)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(site, check, type || '', target || '', status, latency || 0, error || '', subject || '');
|
||||||
|
|
||||||
|
// Update check_count on site
|
||||||
|
db.prepare(`UPDATE scanner_sites SET check_count = check_count + 1 WHERE site_id = ?`).run(site);
|
||||||
|
|
||||||
|
res.json({ status: 'success' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/scanner/sites — JWT required
|
||||||
|
const getSites = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const sites = db.prepare(`SELECT * FROM scanner_sites ORDER BY site_id`).all();
|
||||||
|
res.json({ status: 'success', data: sites });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/scanner/assets — JWT required
|
||||||
|
const getAssets = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { site, status, q } = req.query;
|
||||||
|
|
||||||
|
let query = `SELECT * FROM scanner_assets WHERE 1=1`;
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (site) { query += ` AND site_id = ?`; params.push(site); }
|
||||||
|
if (status) { query += ` AND status = ?`; params.push(status); }
|
||||||
|
if (q) {
|
||||||
|
query += ` AND (ip LIKE ? OR hostname LIKE ? OR mac LIKE ? OR vendor LIKE ?)`;
|
||||||
|
const like = `%${q}%`;
|
||||||
|
params.push(like, like, like, like);
|
||||||
|
}
|
||||||
|
query += ` ORDER BY last_seen DESC LIMIT 500`;
|
||||||
|
|
||||||
|
const assets = db.prepare(query).all(...params);
|
||||||
|
res.json({ status: 'success', data: assets });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/scanner/alerts — JWT required
|
||||||
|
const getAlerts = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const { site, limit = 100 } = req.query;
|
||||||
|
|
||||||
|
let query = `SELECT * FROM scanner_alerts WHERE 1=1`;
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (site) { query += ` AND site_id = ?`; params.push(site); }
|
||||||
|
query += ` ORDER BY triggered_at DESC LIMIT ?`;
|
||||||
|
params.push(Number(limit));
|
||||||
|
|
||||||
|
const alerts = db.prepare(query).all(...params);
|
||||||
|
res.json({ status: 'success', data: alerts });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/scanner/stats — JWT required, summary numbers
|
||||||
|
const getStats = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const sites = db.prepare(`SELECT COUNT(*) as cnt FROM scanner_sites`).get().cnt;
|
||||||
|
const total = db.prepare(`SELECT COUNT(*) as cnt FROM scanner_assets`).get().cnt;
|
||||||
|
const online = db.prepare(`SELECT COUNT(*) as cnt FROM scanner_assets WHERE status='online'`).get().cnt;
|
||||||
|
const alerts24h = db.prepare(`
|
||||||
|
SELECT COUNT(*) as cnt FROM scanner_alerts
|
||||||
|
WHERE triggered_at >= datetime('now','-24 hours') AND status='offline'
|
||||||
|
`).get().cnt;
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: { sites, total, online, offline: total - online, alerts24h } });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { reportAssets, reportAlert, getSites, getAssets, getAlerts, getStats };
|
||||||
148
backend/src/controllers/securityReport.controller.js
Normal file
148
backend/src/controllers/securityReport.controller.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
const multer = require('multer');
|
||||||
|
const pdfParse = require('pdf-parse');
|
||||||
|
const Anthropic = require('@anthropic-ai/sdk');
|
||||||
|
|
||||||
|
const UPLOAD_DIR = path.join(__dirname, '../../uploads/security-reports');
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||||
|
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
|
||||||
|
});
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 },
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (file.mimetype === 'application/pdf') cb(null, true);
|
||||||
|
else cb(new Error('Nur PDF-Dateien erlaubt'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ANALYSIS_PROMPT = `Du analysierst einen wöchentlichen Microsoft 365 Security Report von Arctic Wolf für Cereda Systems GmbH.
|
||||||
|
|
||||||
|
Extrahiere alle relevanten Informationen und gib sie als JSON zurück. Antworte NUR mit validem JSON, kein Text darum herum.
|
||||||
|
|
||||||
|
JSON-Struktur:
|
||||||
|
{
|
||||||
|
"report_period": "DD.MM.YYYY - DD.MM.YYYY",
|
||||||
|
"risk_level": "niedrig|mittel|hoch",
|
||||||
|
"summary": "Kurze 2-3 Satz Zusammenfassung auf Deutsch",
|
||||||
|
"login_stats": {
|
||||||
|
"total": 0,
|
||||||
|
"successful": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"success_rate": 0.0
|
||||||
|
},
|
||||||
|
"top_login_failures": [
|
||||||
|
{ "user": "email@cereda-systems.de", "count": 0, "type": "Interactive|Non-Interactive" }
|
||||||
|
],
|
||||||
|
"non_european_logins": [
|
||||||
|
{ "user": "email", "country": "Land", "ip": "IP", "count": 0, "flagged": true }
|
||||||
|
],
|
||||||
|
"sharepoint_anonymous_links": 0,
|
||||||
|
"identity_protection_events": 0,
|
||||||
|
"new_groups_created": [],
|
||||||
|
"key_findings": ["Befund 1", "Befund 2"],
|
||||||
|
"recommendations": ["Empfehlung 1", "Empfehlung 2"],
|
||||||
|
"all_clear": true
|
||||||
|
}
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
- risk_level "hoch" wenn: nicht-europäische Logins von unbekannten IPs, viele Login-Fehler bei kritischen Accounts (server@, admin@)
|
||||||
|
- risk_level "mittel" wenn: einige Auffälligkeiten aber keine aktiven Bedrohungen
|
||||||
|
- risk_level "niedrig" wenn: alles normal
|
||||||
|
- flagged: true bei nicht-europäischen Logins die verdächtig aussehen
|
||||||
|
- all_clear: false wenn risk_level hoch oder mittel`;
|
||||||
|
|
||||||
|
const analyzeWithClaude = async (text) => {
|
||||||
|
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
max_tokens: 2000,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: `${ANALYSIS_PROMPT}\n\nReport-Inhalt:\n${text.slice(0, 15000)}`,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
const content = response.content[0].text.trim();
|
||||||
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||||
|
if (!jsonMatch) throw new Error('Claude hat kein gültiges JSON zurückgegeben');
|
||||||
|
return JSON.parse(jsonMatch[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Upload + Analysieren ──────────────────────────────────────────────────────
|
||||||
|
const uploadReport = [
|
||||||
|
upload.single('pdf'),
|
||||||
|
async (req, res) => {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'Keine PDF-Datei' });
|
||||||
|
try {
|
||||||
|
const pdfBuffer = fs.readFileSync(req.file.path);
|
||||||
|
const pdfData = await pdfParse(pdfBuffer);
|
||||||
|
const text = pdfData.text;
|
||||||
|
|
||||||
|
if (text.length < 100) {
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
return res.status(400).json({ error: 'PDF konnte nicht gelesen werden oder ist leer' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const analysis = await analyzeWithClaude(text);
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO security_reports
|
||||||
|
(filename, original_filename, report_period, risk_level, summary_json, created_by_user_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
req.file.filename,
|
||||||
|
req.file.originalname,
|
||||||
|
analysis.report_period || 'Unbekannt',
|
||||||
|
analysis.risk_level || 'unbekannt',
|
||||||
|
JSON.stringify(analysis),
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = db.prepare('SELECT * FROM security_reports WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
res.status(201).json({ ...report, analysis });
|
||||||
|
} catch (e) {
|
||||||
|
if (req.file?.path && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
|
||||||
|
console.error('[SecurityReport] Upload error:', e.message);
|
||||||
|
res.status(500).json({ error: `Analyse fehlgeschlagen: ${e.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Alle Reports ──────────────────────────────────────────────────────────────
|
||||||
|
const getAllReports = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT sr.*, u.username as created_by_username
|
||||||
|
FROM security_reports sr
|
||||||
|
LEFT JOIN users u ON sr.created_by_user_id = u.id
|
||||||
|
ORDER BY sr.created_at DESC
|
||||||
|
`).all();
|
||||||
|
res.json(rows.map(r => ({ ...r, analysis: JSON.parse(r.summary_json || '{}') })));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Einzelner Report ──────────────────────────────────────────────────────────
|
||||||
|
const getReport = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const report = db.prepare('SELECT * FROM security_reports WHERE id = ?').get(req.params.id);
|
||||||
|
if (!report) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
res.json({ ...report, analysis: JSON.parse(report.summary_json || '{}') });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Report löschen ────────────────────────────────────────────────────────────
|
||||||
|
const deleteReport = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const report = db.prepare('SELECT * FROM security_reports WHERE id = ?').get(req.params.id);
|
||||||
|
if (!report) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
const filePath = path.join(UPLOAD_DIR, report.filename);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
db.prepare('DELETE FROM security_reports WHERE id = ?').run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { uploadReport, getAllReports, getReport, deleteReport };
|
||||||
162
backend/src/controllers/settings.controller.js
Normal file
162
backend/src/controllers/settings.controller.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
const TicketSettings = require('../models/TicketSettings');
|
||||||
|
const { EmailTemplate } = require('../models/EmailTemplate');
|
||||||
|
const { EmailDesign } = require('../models/EmailDesign');
|
||||||
|
const { buildPreviewHtml } = require('../services/email.service');
|
||||||
|
|
||||||
|
// ── Categories ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getCategories = (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(TicketSettings.getAllCategories());
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createCategory = (req, res) => {
|
||||||
|
const { name, icon } = req.body;
|
||||||
|
if (!name?.trim()) return res.status(400).json({ message: 'Name erforderlich' });
|
||||||
|
try {
|
||||||
|
const cat = TicketSettings.createCategory({ name, icon });
|
||||||
|
res.status(201).json(cat);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message?.includes('UNIQUE')) return res.status(409).json({ message: 'Kategorie existiert bereits' });
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateCategory = (req, res) => {
|
||||||
|
try {
|
||||||
|
const cat = TicketSettings.updateCategory(req.params.id, req.body);
|
||||||
|
if (!cat) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||||
|
res.json(cat);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message?.includes('UNIQUE')) return res.status(409).json({ message: 'Name bereits vergeben' });
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteCategory = (req, res) => {
|
||||||
|
try {
|
||||||
|
TicketSettings.deleteCategory(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.reorderCategories = (req, res) => {
|
||||||
|
const { ids } = req.body;
|
||||||
|
if (!Array.isArray(ids)) return res.status(400).json({ message: 'ids array erforderlich' });
|
||||||
|
try {
|
||||||
|
TicketSettings.reorderCategories(ids);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Templates ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getTemplates = (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(TicketSettings.getAllTemplates());
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.createTemplate = (req, res) => {
|
||||||
|
const { label, title, description, category, priority } = req.body;
|
||||||
|
if (!label?.trim()) return res.status(400).json({ message: 'Label erforderlich' });
|
||||||
|
try {
|
||||||
|
const tpl = TicketSettings.createTemplate({ label, title, description, category, priority });
|
||||||
|
res.status(201).json(tpl);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateTemplate = (req, res) => {
|
||||||
|
try {
|
||||||
|
const tpl = TicketSettings.updateTemplate(req.params.id, req.body);
|
||||||
|
if (!tpl) return res.status(404).json({ message: 'Nicht gefunden' });
|
||||||
|
res.json(tpl);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.deleteTemplate = (req, res) => {
|
||||||
|
try {
|
||||||
|
TicketSettings.deleteTemplate(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Email Templates ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getEmailTemplates = (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(EmailTemplate.getAll());
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateEmailTemplate = (req, res) => {
|
||||||
|
const { type } = req.params;
|
||||||
|
const { subject, intro } = req.body;
|
||||||
|
if (!subject?.trim() || !intro?.trim()) {
|
||||||
|
return res.status(400).json({ message: 'Betreff und Intro-Text sind erforderlich' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tpl = EmailTemplate.update(type, { subject, intro });
|
||||||
|
if (!tpl) return res.status(404).json({ message: 'Vorlage nicht gefunden' });
|
||||||
|
res.json(tpl);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.resetEmailTemplate = (req, res) => {
|
||||||
|
try {
|
||||||
|
const tpl = EmailTemplate.reset(req.params.type);
|
||||||
|
if (!tpl) return res.status(404).json({ message: 'Vorlage nicht gefunden' });
|
||||||
|
res.json(tpl);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Email Design ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exports.getEmailDesign = (req, res) => {
|
||||||
|
try { res.json(EmailDesign.getDesign()); }
|
||||||
|
catch (e) { res.status(500).json({ message: e.message }); }
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.updateEmailDesign = (req, res) => {
|
||||||
|
try { res.json(EmailDesign.updateDesign(req.body)); }
|
||||||
|
catch (e) { res.status(500).json({ message: e.message }); }
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.resetEmailDesign = (req, res) => {
|
||||||
|
try { res.json(EmailDesign.reset()); }
|
||||||
|
catch (e) { res.status(500).json({ message: e.message }); }
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.previewEmail = (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type } = req.params;
|
||||||
|
const design = req.body?.design || {};
|
||||||
|
const darkMode = !!req.body?.darkMode;
|
||||||
|
const html = buildPreviewHtml(type, design, darkMode);
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(html);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ message: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
209
backend/src/controllers/share.controller.js
Normal file
209
backend/src/controllers/share.controller.js
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
|
||||||
|
const UPLOAD_DIR = path.join(__dirname, '../../uploads/shares');
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// multer konfiguriert in routes
|
||||||
|
const multer = require('multer');
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, crypto.randomBytes(16).toString('hex') + ext);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const upload = multer({ storage, limits: { fileSize: 100 * 1024 * 1024 } }); // 100 MB
|
||||||
|
|
||||||
|
// ── Admin: Share erstellen ────────────────────────────────────────────────────
|
||||||
|
const createShare = [
|
||||||
|
upload.single('file'),
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { type, text_content, password, expires_in_hours, max_downloads } = req.body;
|
||||||
|
if (!type || !['file', 'text'].includes(type)) {
|
||||||
|
return res.status(400).json({ error: 'type muss "file" oder "text" sein' });
|
||||||
|
}
|
||||||
|
if (type === 'text' && !text_content?.trim()) {
|
||||||
|
return res.status(400).json({ error: 'text_content erforderlich' });
|
||||||
|
}
|
||||||
|
if (type === 'file' && !req.file) {
|
||||||
|
return res.status(400).json({ error: 'Datei erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(24).toString('base64url');
|
||||||
|
const password_hash = password ? await bcrypt.hash(password, 10) : null;
|
||||||
|
const expires_at = expires_in_hours
|
||||||
|
? new Date(Date.now() + parseInt(expires_in_hours) * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO shares (token, type, filename, stored_filename, text_content,
|
||||||
|
password_hash, expires_at, max_downloads, created_by_user_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
token, type,
|
||||||
|
req.file?.originalname || null,
|
||||||
|
req.file?.filename || null,
|
||||||
|
type === 'text' ? text_content.trim() : null,
|
||||||
|
password_hash,
|
||||||
|
expires_at,
|
||||||
|
max_downloads ? parseInt(max_downloads) : null,
|
||||||
|
req.user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const share = db.prepare('SELECT * FROM shares WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
const publicUrl = `${process.env.FRONTEND_URL || ''}/s/${token}`;
|
||||||
|
res.status(201).json({ ...share, public_url: publicUrl });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Share] createShare error:', e.message);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Admin: Alle Shares ────────────────────────────────────────────────────────
|
||||||
|
const getAllShares = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT s.*, u.username as created_by_username
|
||||||
|
FROM shares s
|
||||||
|
LEFT JOIN users u ON s.created_by_user_id = u.id
|
||||||
|
WHERE s.deleted_at IS NULL
|
||||||
|
ORDER BY s.created_at DESC
|
||||||
|
`).all();
|
||||||
|
const base = process.env.FRONTEND_URL || '';
|
||||||
|
res.json(rows.map(r => ({ ...r, public_url: `${base}/s/${r.token}` })));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Admin: Share löschen ──────────────────────────────────────────────────────
|
||||||
|
const deleteShare = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const share = db.prepare('SELECT * FROM shares WHERE id = ? AND deleted_at IS NULL').get(req.params.id);
|
||||||
|
if (!share) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
|
||||||
|
if (share.stored_filename) {
|
||||||
|
const filePath = path.join(UPLOAD_DIR, share.stored_filename);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE shares SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
|
||||||
|
res.json({ success: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Public: Share-Info (kein Auth) ───────────────────────────────────────────
|
||||||
|
const getPublicShare = (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const share = db.prepare(`
|
||||||
|
SELECT id, token, type, filename, text_content, expires_at, max_downloads,
|
||||||
|
download_count, created_at, password_hash IS NOT NULL as has_password
|
||||||
|
FROM shares
|
||||||
|
WHERE token = ? AND deleted_at IS NULL
|
||||||
|
`).get(req.params.token);
|
||||||
|
|
||||||
|
if (!share) return res.status(404).json({ error: 'Link nicht gefunden oder abgelaufen' });
|
||||||
|
|
||||||
|
if (share.expires_at && new Date(share.expires_at + 'Z') < new Date()) {
|
||||||
|
return res.status(410).json({ error: 'Dieser Link ist abgelaufen' });
|
||||||
|
}
|
||||||
|
if (share.max_downloads && share.download_count >= share.max_downloads) {
|
||||||
|
return res.status(410).json({ error: 'Maximale Anzahl Downloads erreicht' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: share.id,
|
||||||
|
token: share.token,
|
||||||
|
type: share.type,
|
||||||
|
filename: share.filename,
|
||||||
|
expires_at: share.expires_at,
|
||||||
|
max_downloads: share.max_downloads,
|
||||||
|
download_count: share.download_count,
|
||||||
|
has_password: !!share.has_password,
|
||||||
|
// Text nur senden wenn kein Passwort gesetzt
|
||||||
|
text_content: share.has_password ? null : share.text_content,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Public: Passwort prüfen + Text freischalten ───────────────────────────────
|
||||||
|
const accessShare = async (req, res) => {
|
||||||
|
const { password } = req.body;
|
||||||
|
const db = getDatabase();
|
||||||
|
const share = db.prepare(`
|
||||||
|
SELECT * FROM shares WHERE token = ? AND deleted_at IS NULL
|
||||||
|
`).get(req.params.token);
|
||||||
|
|
||||||
|
if (!share) return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
if (share.expires_at && new Date(share.expires_at + 'Z') < new Date()) {
|
||||||
|
return res.status(410).json({ error: 'Abgelaufen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.password_hash) {
|
||||||
|
if (!password) return res.status(401).json({ error: 'Passwort erforderlich' });
|
||||||
|
const ok = await bcrypt.compare(password, share.password_hash);
|
||||||
|
if (!ok) return res.status(401).json({ error: 'Falsches Passwort' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.type === 'text') {
|
||||||
|
res.json({ text_content: share.text_content });
|
||||||
|
} else {
|
||||||
|
res.json({ ready: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Public: Datei-Download ────────────────────────────────────────────────────
|
||||||
|
const downloadFile = async (req, res) => {
|
||||||
|
const { password } = req.query;
|
||||||
|
const db = getDatabase();
|
||||||
|
const share = db.prepare('SELECT * FROM shares WHERE token = ? AND deleted_at IS NULL').get(req.params.token);
|
||||||
|
|
||||||
|
if (!share || share.type !== 'file') return res.status(404).json({ error: 'Nicht gefunden' });
|
||||||
|
if (share.expires_at && new Date(share.expires_at + 'Z') < new Date()) {
|
||||||
|
return res.status(410).json({ error: 'Abgelaufen' });
|
||||||
|
}
|
||||||
|
if (share.max_downloads && share.download_count >= share.max_downloads) {
|
||||||
|
return res.status(410).json({ error: 'Download-Limit erreicht' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.password_hash) {
|
||||||
|
if (!password) return res.status(401).json({ error: 'Passwort erforderlich' });
|
||||||
|
const ok = await bcrypt.compare(password, share.password_hash);
|
||||||
|
if (!ok) return res.status(401).json({ error: 'Falsches Passwort' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(UPLOAD_DIR, share.stored_filename);
|
||||||
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht vorhanden' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE shares SET download_count = download_count + 1 WHERE id = ?').run(share.id);
|
||||||
|
res.download(filePath, share.filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Cleanup-Cron: abgelaufene/verbrauchte Shares löschen ────────────────────
|
||||||
|
const cleanupExpired = () => {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const expired = db.prepare(`
|
||||||
|
SELECT * FROM shares
|
||||||
|
WHERE deleted_at IS NULL AND (
|
||||||
|
(expires_at IS NOT NULL AND expires_at < datetime('now'))
|
||||||
|
OR (max_downloads IS NOT NULL AND download_count >= max_downloads)
|
||||||
|
)
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
for (const share of expired) {
|
||||||
|
if (share.stored_filename) {
|
||||||
|
const filePath = path.join(UPLOAD_DIR, share.stored_filename);
|
||||||
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
db.prepare("UPDATE shares SET deleted_at = datetime('now') WHERE id = ?").run(share.id);
|
||||||
|
}
|
||||||
|
if (expired.length > 0) console.log(`[Shares] ${expired.length} abgelaufene Share(s) bereinigt`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Shares] Cleanup-Fehler:', e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { createShare, getAllShares, deleteShare, getPublicShare, accessShare, downloadFile, cleanupExpired };
|
||||||
33
backend/src/controllers/teamsActivity.controller.js
Normal file
33
backend/src/controllers/teamsActivity.controller.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
|
||||||
|
class TeamsActivityController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/teams-activity/new-channels
|
||||||
|
* Returns channels discovered since last notification (notified=0).
|
||||||
|
*/
|
||||||
|
static getNewChannels = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
const channels = db.prepare(`
|
||||||
|
SELECT id, team_name, channel_name, first_seen_at
|
||||||
|
FROM teams_channels
|
||||||
|
WHERE notified = 0
|
||||||
|
ORDER BY first_seen_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`).all();
|
||||||
|
res.json({ status: 'success', data: channels });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/teams-activity/mark-read
|
||||||
|
* Marks all unread channel notifications as read.
|
||||||
|
*/
|
||||||
|
static markRead = asyncHandler(async (req, res) => {
|
||||||
|
const db = getDatabase();
|
||||||
|
db.prepare('UPDATE teams_channels SET notified = 1 WHERE notified = 0').run();
|
||||||
|
res.json({ status: 'success' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TeamsActivityController;
|
||||||
781
backend/src/controllers/ticket.controller.js
Normal file
781
backend/src/controllers/ticket.controller.js
Normal file
@@ -0,0 +1,781 @@
|
|||||||
|
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
||||||
|
const Ticket = require('../models/Ticket');
|
||||||
|
const TicketComment = require('../models/TicketComment');
|
||||||
|
const AuditLog = require('../models/AuditLog');
|
||||||
|
const EmailService = require('../services/email.service');
|
||||||
|
const AiService = require('../services/ai.service');
|
||||||
|
const sseManager = require('../services/sseManager');
|
||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const JWT_CONFIG = require('../config/jwt');
|
||||||
|
const PDFDocument = require('pdfkit');
|
||||||
|
const { notifyByAadId } = require('./bot.controller');
|
||||||
|
|
||||||
|
class TicketController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets
|
||||||
|
*/
|
||||||
|
static getAll = asyncHandler(async (req, res) => {
|
||||||
|
const filters = {
|
||||||
|
status: req.query.status || null,
|
||||||
|
priority: req.query.priority || null,
|
||||||
|
category: req.query.category || null,
|
||||||
|
assigned_to: req.query.assigned_to || null,
|
||||||
|
search: req.query.search || null,
|
||||||
|
};
|
||||||
|
// Remove null filters
|
||||||
|
Object.keys(filters).forEach(k => filters[k] === null && delete filters[k]);
|
||||||
|
|
||||||
|
// Non-staff users only see tickets they created OR where they are the requester (by email)
|
||||||
|
const isStaff = ['super_admin', 'admin', 'support'].includes(req.user.role);
|
||||||
|
if (!isStaff) {
|
||||||
|
filters.user_scope = { userId: req.user.id, email: req.user.email };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tickets = Ticket.getAll(filters);
|
||||||
|
res.json({ status: 'success', data: tickets });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets/stats
|
||||||
|
*/
|
||||||
|
static getStats = asyncHandler(async (req, res) => {
|
||||||
|
const stats = Ticket.getStats();
|
||||||
|
res.json({ status: 'success', data: stats });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets/:id
|
||||||
|
*/
|
||||||
|
static getById = asyncHandler(async (req, res) => {
|
||||||
|
const ticket = Ticket.getById(parseInt(req.params.id));
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
|
||||||
|
const comments = TicketComment.getByTicketId(ticket.id);
|
||||||
|
|
||||||
|
// Filter internal comments for non-support/admin users
|
||||||
|
const userRole = req.user.role;
|
||||||
|
const canSeeInternal = ['super_admin', 'admin', 'support'].includes(userRole);
|
||||||
|
const filteredComments = canSeeInternal
|
||||||
|
? comments
|
||||||
|
: comments.filter(c => !c.is_internal);
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: { ...ticket, comments: filteredComments } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tickets
|
||||||
|
* All authenticated users can create tickets
|
||||||
|
*/
|
||||||
|
static create = asyncHandler(async (req, res) => {
|
||||||
|
const { title, description, category, priority, asset_id,
|
||||||
|
requester_name, requester_email } = req.body;
|
||||||
|
|
||||||
|
if (!title || !title.trim()) {
|
||||||
|
throw new AppError('Titel ist erforderlich', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = Ticket.create({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
category: category || 'Allgemein',
|
||||||
|
priority: priority || 'mittel',
|
||||||
|
asset_id: asset_id ? parseInt(asset_id) : null,
|
||||||
|
source: 'web',
|
||||||
|
requester_name: requester_name?.trim() || `${req.user.first_name || ''} ${req.user.last_name || ''}`.trim() || req.user.username,
|
||||||
|
requester_email: requester_email?.trim() || req.user.email,
|
||||||
|
created_by_user_id: req.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// KI: Analyse + Auto-Erstantwort non-blocking
|
||||||
|
if (AiService.isConfigured()) {
|
||||||
|
AiService.analyzeTicket(title.trim(), description?.trim() || '')
|
||||||
|
.then(analysis => {
|
||||||
|
const updates = { ai_suggestion: analysis.suggestion };
|
||||||
|
if (!category) updates.category = analysis.category;
|
||||||
|
if (!priority) updates.priority = analysis.priority;
|
||||||
|
Ticket.update(ticket.id, updates);
|
||||||
|
console.log(`[AI] Ticket ${ticket.ticket_number} analysiert: ${analysis.category} / ${analysis.priority}`);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[AI] Ticket-Analyse fehlgeschlagen:', err.message));
|
||||||
|
|
||||||
|
// KI-Erstantwort: automatisch auf die Ticket-Beschreibung antworten
|
||||||
|
const refreshedTicket = Ticket.getById(ticket.id);
|
||||||
|
const firstMessage = description?.trim()
|
||||||
|
? description.trim()
|
||||||
|
: title.trim();
|
||||||
|
AiService.chatInTicket(refreshedTicket, [{ role: 'user', content: firstMessage }])
|
||||||
|
.then(aiText => {
|
||||||
|
const aiComment = TicketComment.create(ticket.id, null, aiText, false, true);
|
||||||
|
sseManager.broadcast(ticket.id, aiComment);
|
||||||
|
console.log(`[AI] Erstantwort für Ticket ${ticket.ticket_number} gespeichert`);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[AI] Erstantwort fehlgeschlagen:', err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-Zuweisung basierend auf Kategorie
|
||||||
|
const routing = getDatabase().prepare('SELECT assigned_to_user_id FROM ticket_routing WHERE category = ?').get(ticket.category);
|
||||||
|
if (routing?.assigned_to_user_id) {
|
||||||
|
Ticket.update(ticket.id, { assigned_to_user_id: routing.assigned_to_user_id });
|
||||||
|
const assignedUser = getDatabase().prepare('SELECT * FROM users WHERE id = ?').get(routing.assigned_to_user_id);
|
||||||
|
if (assignedUser) {
|
||||||
|
const routed = Ticket.getById(ticket.id);
|
||||||
|
EmailService.sendTicketAssignedNotification(routed, assignedUser).catch(() => {});
|
||||||
|
console.log(`[Routing] Ticket ${ticket.ticket_number} auto-zugewiesen an ${assignedUser.username}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send confirmation email (non-blocking – ticket creation succeeds even if email fails)
|
||||||
|
if (ticket.requester_email) {
|
||||||
|
EmailService.sendTicketCreatedConfirmation(ticket).catch(err =>
|
||||||
|
console.error('[Email] Confirmation failed:', err.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify staff members with notif_ticket_created enabled
|
||||||
|
EmailService.sendStaffTicketCreatedNotification(ticket).catch(err =>
|
||||||
|
console.error('[Email] Staff ticket-created notification failed:', err.message)
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({ status: 'success', data: ticket });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/tickets/:id
|
||||||
|
*/
|
||||||
|
static update = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const existing = Ticket.getById(id);
|
||||||
|
if (!existing) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
|
||||||
|
const oldStatus = existing.status;
|
||||||
|
const updated = Ticket.update(id, req.body);
|
||||||
|
|
||||||
|
// AuditLog schreiben
|
||||||
|
const changedFields = {};
|
||||||
|
const trackFields = ['status', 'priority', 'category', 'assigned_to_user_id', 'asset_id'];
|
||||||
|
for (const f of trackFields) {
|
||||||
|
if (req.body[f] !== undefined && req.body[f] != existing[f]) {
|
||||||
|
changedFields[f] = { from: existing[f], to: req.body[f] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(changedFields).length > 0) {
|
||||||
|
AuditLog.create({
|
||||||
|
user_id: req.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entity_type: 'ticket',
|
||||||
|
entity_id: id,
|
||||||
|
old_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.from])),
|
||||||
|
new_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.to])),
|
||||||
|
ip_address: req.ip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email notifications on status or assignment change
|
||||||
|
const tasks = [];
|
||||||
|
if (req.body.status && req.body.status !== oldStatus) {
|
||||||
|
tasks.push(EmailService.sendStatusChangeNotification(updated, oldStatus, req.body.status));
|
||||||
|
// Zufriedenheits-Feedback wenn Ticket geschlossen wird
|
||||||
|
if (req.body.status === 'geschlossen' && updated.requester_email) {
|
||||||
|
EmailService.sendSatisfactionEmail(updated).catch(err =>
|
||||||
|
console.error('[Email] Satisfaction failed:', err.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Auto KB-Artikel generieren wenn Ticket geschlossen wird
|
||||||
|
if (req.body.status === 'geschlossen') {
|
||||||
|
AiService.generateKbArticleFromTicket(updated.id).catch(err =>
|
||||||
|
console.error('[KB] Auto-generation failed:', err.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (req.body.assigned_to_user_id && req.body.assigned_to_user_id !== existing.assigned_to_user_id) {
|
||||||
|
const db = getDatabase();
|
||||||
|
const assignedUser = db.prepare('SELECT * FROM users WHERE id = ?').get(req.body.assigned_to_user_id);
|
||||||
|
if (assignedUser) {
|
||||||
|
tasks.push(EmailService.sendTicketAssignedNotification(updated, assignedUser));
|
||||||
|
|
||||||
|
// Teams-Benachrichtigung wenn Ticket via Bot erstellt wurde
|
||||||
|
const aadMatch = updated.requester_email?.match(/^teams-(.+)@teams\.bot$/);
|
||||||
|
if (aadMatch) {
|
||||||
|
const agentName = assignedUser.first_name
|
||||||
|
? `${assignedUser.first_name} ${assignedUser.last_name}`.trim()
|
||||||
|
: assignedUser.username;
|
||||||
|
notifyByAadId(aadMatch[1],
|
||||||
|
`👤 **${agentName}** übernimmt dein Ticket **${updated.ticket_number}** und kümmert sich darum.\n\n_Du kannst hier direkt antworten – deine Nachrichten gehen direkt an den Support._`
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(tasks);
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/tickets/:id (super_admin only)
|
||||||
|
*/
|
||||||
|
static delete = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const ticket = Ticket.getById(id);
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
|
||||||
|
Ticket.delete(id);
|
||||||
|
res.json({ status: 'success', message: 'Ticket gelöscht' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tickets/:id/comments
|
||||||
|
*/
|
||||||
|
static addComment = asyncHandler(async (req, res) => {
|
||||||
|
const ticketId = parseInt(req.params.id);
|
||||||
|
const ticket = Ticket.getById(ticketId);
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
|
||||||
|
const { comment, is_internal } = req.body;
|
||||||
|
if (!comment || !comment.trim()) {
|
||||||
|
throw new AppError('Kommentar darf nicht leer sein', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = req.user.role;
|
||||||
|
const canSetInternal = ['super_admin', 'admin', 'support'].includes(userRole);
|
||||||
|
const internal = canSetInternal && !!is_internal;
|
||||||
|
|
||||||
|
const newComment = TicketComment.create(ticketId, req.user.id, comment.trim(), internal);
|
||||||
|
|
||||||
|
// Notify requester on public comments – only when Support/Admin writes
|
||||||
|
const isSupportAuthor = ['super_admin', 'admin', 'support'].includes(userRole);
|
||||||
|
if (!internal && isSupportAuthor) {
|
||||||
|
await EmailService.sendCommentNotification(ticket, newComment);
|
||||||
|
|
||||||
|
// Teams-Benachrichtigung wenn Ticket via Bot erstellt wurde
|
||||||
|
const aadMatch = ticket.requester_email?.match(/^teams-(.+)@teams\.bot$/);
|
||||||
|
if (aadMatch) {
|
||||||
|
const authorName = req.user.first_name
|
||||||
|
? `${req.user.first_name} ${req.user.last_name}`.trim()
|
||||||
|
: req.user.username;
|
||||||
|
notifyByAadId(aadMatch[1],
|
||||||
|
`📨 **Antwort vom IT-Support**\n` +
|
||||||
|
`**${authorName}** · ${ticket.ticket_number}\n\n` +
|
||||||
|
`${comment.trim()}\n\n` +
|
||||||
|
`_Tippe deine Antwort um direkt zu antworten._`
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify assigned staff when requester replies
|
||||||
|
if (!internal && !isSupportAuthor) {
|
||||||
|
await EmailService.sendStaffCommentNotification(ticket, newComment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-Status: Kommentar setzt Status basierend auf Rolle
|
||||||
|
const updateData = {};
|
||||||
|
const activeStatuses = ['offen', 'in_bearbeitung', 'warten_auf_mitarbeiter', 'warten_auf_support'];
|
||||||
|
if (!internal && ticket.status !== 'geschlossen') {
|
||||||
|
if (isSupportAuthor) {
|
||||||
|
// Support schreibt → wartet auf Antwort des Mitarbeiters
|
||||||
|
updateData.status = 'warten_auf_mitarbeiter';
|
||||||
|
} else {
|
||||||
|
// Mitarbeiter schreibt → wartet auf Support
|
||||||
|
if (ticket.status === 'geschlossen') {
|
||||||
|
updateData.status = 'offen'; // Reopen
|
||||||
|
} else {
|
||||||
|
updateData.status = 'warten_auf_support';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!isSupportAuthor && ticket.status === 'geschlossen') {
|
||||||
|
// User antwortet auf geschlossenes Ticket → wieder öffnen
|
||||||
|
updateData.status = 'offen';
|
||||||
|
}
|
||||||
|
// Support übernimmt → KI deaktivieren (nur bei öffentlichem Kommentar von Staff)
|
||||||
|
if (!internal && isSupportAuthor && ticket.ai_active !== 0) {
|
||||||
|
updateData.ai_active = 0;
|
||||||
|
}
|
||||||
|
if (Object.keys(updateData).length > 0) Ticket.update(ticketId, updateData);
|
||||||
|
|
||||||
|
// Broadcast to all SSE clients watching this ticket
|
||||||
|
sseManager.broadcast(ticketId, newComment);
|
||||||
|
|
||||||
|
res.status(201).json({ status: 'success', data: newComment });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tickets/:id/ai-reply
|
||||||
|
* Benutzer schickt Nachricht → KI antwortet, beide als Kommentare gespeichert
|
||||||
|
*/
|
||||||
|
static aiReply = asyncHandler(async (req, res) => {
|
||||||
|
if (!AiService.isConfigured()) {
|
||||||
|
throw new AppError('KI-Integration nicht konfiguriert', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = parseInt(req.params.id);
|
||||||
|
const ticket = Ticket.getById(ticketId);
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
|
||||||
|
const { message, history } = req.body;
|
||||||
|
if (!message?.trim()) throw new AppError('Nachricht darf nicht leer sein', 400);
|
||||||
|
if (ticket.ai_active === 0) throw new AppError('Support hat die Bearbeitung übernommen', 403);
|
||||||
|
|
||||||
|
// User-Nachricht als Kommentar speichern
|
||||||
|
const userComment = TicketComment.create(ticketId, req.user.id, message.trim(), false, false);
|
||||||
|
sseManager.broadcast(ticketId, userComment);
|
||||||
|
|
||||||
|
// Konversationshistorie für KI aufbauen (max. 10 letzte Nachrichten)
|
||||||
|
const aiMessages = [
|
||||||
|
...(Array.isArray(history) ? history.slice(-10) : []),
|
||||||
|
{ role: 'user', content: message.trim() },
|
||||||
|
];
|
||||||
|
|
||||||
|
// KI-Antwort generieren
|
||||||
|
const aiText = await AiService.chatInTicket(ticket, aiMessages);
|
||||||
|
|
||||||
|
// KI-Antwort als Kommentar speichern (is_ai_comment = true)
|
||||||
|
const aiComment = TicketComment.create(ticketId, null, aiText, false, true);
|
||||||
|
sseManager.broadcast(ticketId, aiComment);
|
||||||
|
|
||||||
|
Ticket.update(ticketId, {});
|
||||||
|
|
||||||
|
res.status(201).json({ status: 'success', data: { userComment, aiComment } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets/:id/events (SSE – kein authenticateToken Middleware,
|
||||||
|
* da EventSource keine custom Headers unterstützt → Token via ?token=)
|
||||||
|
*/
|
||||||
|
static streamComments = (req, res) => {
|
||||||
|
// Verify token from query param
|
||||||
|
const token = req.query.token;
|
||||||
|
if (!token) return res.status(401).end();
|
||||||
|
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = jwt.verify(token, JWT_CONFIG.secret);
|
||||||
|
} catch (_) {
|
||||||
|
return res.status(403).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = parseInt(req.params.id);
|
||||||
|
if (!ticketId) return res.status(400).end();
|
||||||
|
|
||||||
|
const canSeeInternal = ['super_admin', 'admin', 'support'].includes(user.role);
|
||||||
|
|
||||||
|
// SSE Headers
|
||||||
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
res.setHeader('X-Accel-Buffering', 'no');
|
||||||
|
res.flushHeaders();
|
||||||
|
|
||||||
|
// Initial handshake
|
||||||
|
res.write('event: connected\ndata: {}\n\n');
|
||||||
|
|
||||||
|
// Heartbeat alle 25s damit nginx/Proxies die Verbindung offen lassen
|
||||||
|
const heartbeat = setInterval(() => {
|
||||||
|
try { res.write(':ping\n\n'); } catch (_) {}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
sseManager.subscribe(ticketId, res, canSeeInternal);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
sseManager.unsubscribe(ticketId, res);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/tickets/:id/comments/:commentId
|
||||||
|
*/
|
||||||
|
static deleteComment = asyncHandler(async (req, res) => {
|
||||||
|
const deleted = TicketComment.delete(parseInt(req.params.commentId));
|
||||||
|
if (!deleted) throw new AppError('Kommentar nicht gefunden', 404);
|
||||||
|
res.json({ status: 'success', message: 'Kommentar gelöscht' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets/:id/history
|
||||||
|
*/
|
||||||
|
static getHistory = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const ticket = Ticket.getById(id);
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
|
||||||
|
const history = AuditLog.getByEntity('ticket', id, 200, 0);
|
||||||
|
res.json({ status: 'success', data: history });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/tickets/bulk
|
||||||
|
*/
|
||||||
|
static bulkUpdate = asyncHandler(async (req, res) => {
|
||||||
|
const { ids, data } = req.body;
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) throw new AppError('Keine IDs angegeben', 400);
|
||||||
|
if (!data || Object.keys(data).length === 0) throw new AppError('Keine Änderungen angegeben', 400);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const existing = Ticket.getById(parseInt(id));
|
||||||
|
if (!existing) continue;
|
||||||
|
const updated = Ticket.update(parseInt(id), data);
|
||||||
|
|
||||||
|
// AuditLog
|
||||||
|
const changedFields = {};
|
||||||
|
const trackFields = ['status', 'priority', 'category', 'assigned_to_user_id'];
|
||||||
|
for (const f of trackFields) {
|
||||||
|
if (data[f] !== undefined && data[f] != existing[f]) {
|
||||||
|
changedFields[f] = { from: existing[f], to: data[f] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(changedFields).length > 0) {
|
||||||
|
AuditLog.create({
|
||||||
|
user_id: req.user.id,
|
||||||
|
action: 'BULK_UPDATE',
|
||||||
|
entity_type: 'ticket',
|
||||||
|
entity_id: parseInt(id),
|
||||||
|
old_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.from])),
|
||||||
|
new_value: Object.fromEntries(Object.entries(changedFields).map(([k, v]) => [k, v.to])),
|
||||||
|
ip_address: req.ip,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(updated);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
res.json({ status: 'success', data: results, count: results.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets/:id/pdf
|
||||||
|
*/
|
||||||
|
static exportPdf = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const ticket = Ticket.getById(id);
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
|
||||||
|
const comments = TicketComment.getByTicketId(id)
|
||||||
|
.filter(c => !c.is_internal);
|
||||||
|
|
||||||
|
const doc = new PDFDocument({ margin: 40, size: 'A4' });
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="ticket-${ticket.ticket_number}.pdf"`);
|
||||||
|
doc.pipe(res);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
doc.fontSize(18).font('Helvetica-Bold').text('IT Nexus – Ticket-Export', { align: 'left' });
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
doc.fontSize(11).font('Helvetica').fillColor('#666')
|
||||||
|
.text(`Exportiert am: ${new Date().toLocaleString('de-DE')}`, { align: 'left' });
|
||||||
|
doc.moveDown(0.8);
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
doc.moveTo(40, doc.y).lineTo(555, doc.y).stroke('#ddd');
|
||||||
|
doc.moveDown(0.8);
|
||||||
|
|
||||||
|
// Ticket-Info
|
||||||
|
doc.fontSize(20).font('Helvetica-Bold').fillColor('#1a1a1a')
|
||||||
|
.text(`${ticket.ticket_number}: ${ticket.title}`);
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
const statusMap = { offen: 'Offen', in_bearbeitung: 'In Bearbeitung', warten_auf_mitarbeiter: 'Warten auf Mitarbeiter', warten_auf_support: 'Warten auf Support', geschlossen: 'Geschlossen' };
|
||||||
|
const prioMap = { niedrig: 'Niedrig', mittel: 'Mittel', hoch: 'Hoch', kritisch: 'Kritisch' };
|
||||||
|
|
||||||
|
const meta = [
|
||||||
|
['Status', statusMap[ticket.status] || ticket.status],
|
||||||
|
['Priorität', prioMap[ticket.priority] || ticket.priority],
|
||||||
|
['Kategorie', ticket.category],
|
||||||
|
['Erstellt am', new Date(ticket.created_at).toLocaleString('de-DE')],
|
||||||
|
['Anfragender', `${ticket.requester_name || '–'}${ticket.requester_email ? ` <${ticket.requester_email}>` : ''}`],
|
||||||
|
['Zugewiesen an', ticket.assigned_to_first_name ? `${ticket.assigned_to_first_name} ${ticket.assigned_to_last_name || ''}` : (ticket.assigned_to_username || '–')],
|
||||||
|
];
|
||||||
|
if (ticket.asset_name) meta.push(['Asset', `${ticket.asset_name} (${ticket.asset_serial || ''})`]);
|
||||||
|
|
||||||
|
doc.fontSize(10).font('Helvetica');
|
||||||
|
for (const [label, value] of meta) {
|
||||||
|
doc.fillColor('#888').text(`${label}: `, { continued: true }).fillColor('#1a1a1a').text(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.moveDown(0.8);
|
||||||
|
|
||||||
|
// Beschreibung
|
||||||
|
if (ticket.description) {
|
||||||
|
doc.fontSize(12).font('Helvetica-Bold').fillColor('#1a1a1a').text('Beschreibung');
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
doc.fontSize(10).font('Helvetica').fillColor('#333').text(ticket.description, { lineGap: 3 });
|
||||||
|
doc.moveDown(0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kommentare
|
||||||
|
if (comments.length > 0) {
|
||||||
|
doc.moveTo(40, doc.y).lineTo(555, doc.y).stroke('#ddd');
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
doc.fontSize(12).font('Helvetica-Bold').fillColor('#1a1a1a').text('Kommentare');
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
for (const c of comments) {
|
||||||
|
const author = c.first_name ? `${c.first_name} ${c.last_name || ''}`.trim() : (c.username || 'Unbekannt');
|
||||||
|
doc.fontSize(9).font('Helvetica-Bold').fillColor('#555')
|
||||||
|
.text(`${author} — ${new Date(c.created_at).toLocaleString('de-DE')}`);
|
||||||
|
doc.fontSize(10).font('Helvetica').fillColor('#1a1a1a')
|
||||||
|
.text(c.comment, { lineGap: 2 });
|
||||||
|
doc.moveDown(0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
doc.fontSize(8).fillColor('#aaa').text(
|
||||||
|
`IT Nexus · Ticket ${ticket.ticket_number} · ${new Date().toLocaleDateString('de-DE')}`,
|
||||||
|
{ align: 'center' }
|
||||||
|
);
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets/metrics
|
||||||
|
*/
|
||||||
|
static getMetrics = asyncHandler(async (req, res) => {
|
||||||
|
const days = parseInt(req.query.days) || 30;
|
||||||
|
const data = Ticket.getMetrics(days);
|
||||||
|
res.json({ status: 'success', data });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets/:id/links
|
||||||
|
*/
|
||||||
|
static getLinks = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const ticket = Ticket.getById(id);
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
const links = Ticket.getLinks(id);
|
||||||
|
res.json({ status: 'success', data: links });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tickets/:id/links
|
||||||
|
*/
|
||||||
|
static addLink = asyncHandler(async (req, res) => {
|
||||||
|
const ticketId = parseInt(req.params.id);
|
||||||
|
const { linked_ticket_number, link_type } = req.body;
|
||||||
|
if (!linked_ticket_number) throw new AppError('Ticket-Nummer erforderlich', 400);
|
||||||
|
|
||||||
|
const ticket = Ticket.getById(ticketId);
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
|
||||||
|
// Find linked ticket by number
|
||||||
|
const db = getDatabase();
|
||||||
|
const linked = db.prepare('SELECT id FROM tickets WHERE ticket_number = ?').get(linked_ticket_number.trim());
|
||||||
|
if (!linked) throw new AppError(`Ticket ${linked_ticket_number} nicht gefunden`, 404);
|
||||||
|
if (linked.id === ticketId) throw new AppError('Ein Ticket kann nicht mit sich selbst verknüpft werden', 400);
|
||||||
|
|
||||||
|
Ticket.addLink(ticketId, linked.id, link_type || 'related', req.user.id);
|
||||||
|
const links = Ticket.getLinks(ticketId);
|
||||||
|
res.status(201).json({ status: 'success', data: links });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/tickets/:id/links/:linkId
|
||||||
|
*/
|
||||||
|
static removeLink = asyncHandler(async (req, res) => {
|
||||||
|
const deleted = Ticket.removeLink(parseInt(req.params.linkId));
|
||||||
|
if (!deleted) throw new AppError('Verknüpfung nicht gefunden', 404);
|
||||||
|
res.json({ status: 'success', message: 'Verknüpfung entfernt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/tickets/:id/snooze
|
||||||
|
*/
|
||||||
|
static snooze = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const ticket = Ticket.getById(id);
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
|
||||||
|
const { snoozed_until } = req.body;
|
||||||
|
const updated = Ticket.update(id, { snoozed_until: snoozed_until || null });
|
||||||
|
res.json({ status: 'success', data: updated });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/tickets/:id/assignees */
|
||||||
|
static addAssignee = asyncHandler(async (req, res) => {
|
||||||
|
const ticketId = parseInt(req.params.id);
|
||||||
|
const ticket = Ticket.getById(ticketId);
|
||||||
|
if (!ticket) throw new AppError('Ticket nicht gefunden', 404);
|
||||||
|
const { user_id } = req.body;
|
||||||
|
if (!user_id) throw new AppError('user_id fehlt', 400);
|
||||||
|
Ticket.addAssignee(ticketId, parseInt(user_id), req.user.id);
|
||||||
|
res.json({ status: 'success', data: Ticket.getAssignees(ticketId) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/tickets/:id/assignees/:userId */
|
||||||
|
static removeAssignee = asyncHandler(async (req, res) => {
|
||||||
|
const ticketId = parseInt(req.params.id);
|
||||||
|
Ticket.removeAssignee(ticketId, parseInt(req.params.userId));
|
||||||
|
res.json({ status: 'success', data: Ticket.getAssignees(ticketId) });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets/:id/feedback?rating=gut|schlecht
|
||||||
|
* Public – no auth required (link from satisfaction email)
|
||||||
|
*/
|
||||||
|
static feedback = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const { rating } = req.query;
|
||||||
|
if (!['gut', 'schlecht'].includes(rating)) {
|
||||||
|
return res.status(400).send('<h2>Ungültige Bewertung</h2>');
|
||||||
|
}
|
||||||
|
const ticket = Ticket.getById(id);
|
||||||
|
if (!ticket) return res.status(404).send('<h2>Ticket nicht gefunden</h2>');
|
||||||
|
|
||||||
|
const emoji = rating === 'gut' ? '👍' : '👎';
|
||||||
|
const color = rating === 'gut' ? '#16a34a' : '#dc2626';
|
||||||
|
const bg = rating === 'gut' ? '#dcfce7' : '#fee2e2';
|
||||||
|
const border= rating === 'gut' ? '#86efac' : '#fca5a5';
|
||||||
|
|
||||||
|
res.send(`<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>Feedback – IT Nexus</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:Inter,system-ui,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||||
|
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);padding:40px 36px;max-width:480px;width:100%;text-align:center}
|
||||||
|
.emoji{font-size:3.5rem;margin-bottom:12px}
|
||||||
|
h1{font-size:1.4rem;font-weight:800;color:#111827;margin-bottom:8px}
|
||||||
|
.sub{color:#6b7280;font-size:0.9rem;margin-bottom:28px}
|
||||||
|
.rating-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 16px;border-radius:20px;font-size:0.85rem;font-weight:700;background:${bg};color:${color};border:1px solid ${border};margin-bottom:24px}
|
||||||
|
textarea{width:100%;border:1px solid #e2e8f0;border-radius:10px;padding:12px 14px;font-size:0.9rem;font-family:inherit;resize:vertical;min-height:110px;color:#374151;transition:border-color .2s}
|
||||||
|
textarea:focus{outline:none;border-color:${color}}
|
||||||
|
label{display:block;text-align:left;font-size:0.8rem;font-weight:600;color:#374151;margin-bottom:6px}
|
||||||
|
.hint{text-align:left;font-size:0.75rem;color:#9ca3af;margin-top:6px;margin-bottom:20px}
|
||||||
|
button{width:100%;padding:12px;border:none;border-radius:10px;background:${color};color:#fff;font-size:1rem;font-weight:700;cursor:pointer;transition:opacity .15s}
|
||||||
|
button:hover{opacity:0.88}
|
||||||
|
.ticket-nr{font-family:monospace;font-size:0.75rem;color:#9ca3af;margin-top:20px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="emoji">${emoji}</div>
|
||||||
|
<h1>Wie war Ihr Support-Erlebnis?</h1>
|
||||||
|
<p class="sub">Ticket ${ticket.ticket_number} – ${ticket.title.substring(0, 60)}${ticket.title.length > 60 ? '…' : ''}</p>
|
||||||
|
<div class="rating-badge">${emoji} ${rating === 'gut' ? 'Positiv' : 'Negativ'}</div>
|
||||||
|
<form method="POST" action="/api/tickets/${id}/feedback">
|
||||||
|
<input type="hidden" name="rating" value="${rating}" />
|
||||||
|
<label for="comment">Möchten Sie noch etwas hinzufügen? <span style="font-weight:400;color:#9ca3af">(optional)</span></label>
|
||||||
|
<textarea id="comment" name="comment" placeholder="z. B. Was hat gut funktioniert, was könnte besser sein …"></textarea>
|
||||||
|
<div class="hint">Ihr Kommentar hilft uns, den IT-Support kontinuierlich zu verbessern.</div>
|
||||||
|
<button type="submit">Feedback absenden</button>
|
||||||
|
</form>
|
||||||
|
<div class="ticket-nr">IT Nexus · ${ticket.ticket_number}</div>
|
||||||
|
</div>
|
||||||
|
</body></html>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tickets/:id/feedback
|
||||||
|
* Public – speichert Rating + optionalen Kommentar
|
||||||
|
*/
|
||||||
|
static submitFeedback = asyncHandler(async (req, res) => {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
const { rating, comment } = req.body;
|
||||||
|
if (!['gut', 'schlecht'].includes(rating)) {
|
||||||
|
return res.status(400).send('<h2>Ungültige Bewertung</h2>');
|
||||||
|
}
|
||||||
|
const ticket = Ticket.getById(id);
|
||||||
|
if (!ticket) return res.status(404).send('<h2>Ticket nicht gefunden</h2>');
|
||||||
|
|
||||||
|
Ticket.update(id, {
|
||||||
|
satisfaction_rating: rating,
|
||||||
|
satisfaction_comment: comment?.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emoji = rating === 'gut' ? '👍' : '👎';
|
||||||
|
const color = rating === 'gut' ? '#16a34a' : '#dc2626';
|
||||||
|
res.send(`<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="utf-8"><title>Danke – IT Nexus</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:Inter,system-ui,sans-serif;background:#f1f5f9;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
|
||||||
|
.card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,0.08);padding:48px 36px;max-width:420px;width:100%;text-align:center}
|
||||||
|
.emoji{font-size:4rem;margin-bottom:16px}
|
||||||
|
h1{font-size:1.5rem;font-weight:800;color:#111827;margin-bottom:10px}
|
||||||
|
p{color:#6b7280;font-size:0.9rem;line-height:1.6}
|
||||||
|
.nr{font-family:monospace;font-size:0.75rem;color:#9ca3af;margin-top:24px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="emoji">${emoji}</div>
|
||||||
|
<h1 style="color:${color}">Vielen Dank!</h1>
|
||||||
|
<p>Ihr Feedback wurde erfolgreich gespeichert.<br>Sie können dieses Fenster schließen.</p>
|
||||||
|
<div class="nr">IT Nexus · ${ticket.ticket_number}</div>
|
||||||
|
</div>
|
||||||
|
</body></html>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tickets/public
|
||||||
|
* Public – no auth required (from QR-code defect page)
|
||||||
|
*/
|
||||||
|
static createPublic = asyncHandler(async (req, res) => {
|
||||||
|
const { title, description, category, priority, requester_name, requester_email, serial_number } = req.body;
|
||||||
|
if (!title?.trim()) throw new AppError('Titel ist erforderlich', 400);
|
||||||
|
|
||||||
|
let asset_id = null;
|
||||||
|
if (serial_number) {
|
||||||
|
const asset = getDatabase().prepare('SELECT id FROM assets WHERE serial_number = ?').get(serial_number);
|
||||||
|
if (asset) asset_id = asset.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = Ticket.create({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
category: category || 'Hardware',
|
||||||
|
priority: priority || 'mittel',
|
||||||
|
asset_id,
|
||||||
|
source: 'web',
|
||||||
|
requester_name: requester_name?.trim() || 'Anonym',
|
||||||
|
requester_email: requester_email?.trim() || null,
|
||||||
|
created_by_user_id: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ticket.requester_email) {
|
||||||
|
EmailService.sendTicketCreatedConfirmation(ticket).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ status: 'success', data: { ticket_number: ticket.ticket_number } });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/tickets/routing
|
||||||
|
*/
|
||||||
|
static getRouting = asyncHandler(async (req, res) => {
|
||||||
|
const routing = getDatabase().prepare(`
|
||||||
|
SELECT r.id, r.category, r.assigned_to_user_id,
|
||||||
|
u.username, u.first_name, u.last_name
|
||||||
|
FROM ticket_routing r
|
||||||
|
LEFT JOIN users u ON u.id = r.assigned_to_user_id
|
||||||
|
`).all();
|
||||||
|
res.json({ status: 'success', data: routing });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/tickets/routing
|
||||||
|
* Body: { category, assigned_to_user_id }
|
||||||
|
*/
|
||||||
|
static saveRouting = asyncHandler(async (req, res) => {
|
||||||
|
const { category, assigned_to_user_id } = req.body;
|
||||||
|
if (!category) throw new AppError('Kategorie fehlt', 400);
|
||||||
|
const db = getDatabase();
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO ticket_routing (category, assigned_to_user_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT(category) DO UPDATE SET assigned_to_user_id = excluded.assigned_to_user_id
|
||||||
|
`).run(category, assigned_to_user_id || null);
|
||||||
|
const routing = db.prepare(`
|
||||||
|
SELECT r.id, r.category, r.assigned_to_user_id,
|
||||||
|
u.username, u.first_name, u.last_name
|
||||||
|
FROM ticket_routing r
|
||||||
|
LEFT JOIN users u ON u.id = r.assigned_to_user_id
|
||||||
|
`).all();
|
||||||
|
res.json({ status: 'success', data: routing });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TicketController;
|
||||||
76
backend/src/controllers/tv.controller.js
Normal file
76
backend/src/controllers/tv.controller.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const { getDatabase } = require('../config/database');
|
||||||
|
|
||||||
|
async function getStats(req, res) {
|
||||||
|
try {
|
||||||
|
const db = getDatabase();
|
||||||
|
const now = Date.now();
|
||||||
|
const fifteenMin = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
// Agents
|
||||||
|
const agents = db.prepare('SELECT hostname, last_checkin, cpu_usage_percent, ram_total_gb, ram_used_gb, bitlocker_status FROM monitoring_agents').all();
|
||||||
|
const agentList = agents.map(a => {
|
||||||
|
const ms = a.last_checkin ? new Date(a.last_checkin + (a.last_checkin.includes('Z') ? '' : 'Z')).getTime() : 0;
|
||||||
|
const ram_usage_percent = a.ram_total_gb > 0 ? Math.round((a.ram_used_gb / a.ram_total_gb) * 100) : 0;
|
||||||
|
return { ...a, ram_usage_percent, online: ms && (now - ms) < fifteenMin };
|
||||||
|
});
|
||||||
|
const online = agentList.filter(a => a.online).length;
|
||||||
|
|
||||||
|
// Tickets
|
||||||
|
const ticketStats = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as open,
|
||||||
|
SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) as inProgress,
|
||||||
|
SUM(CASE WHEN date(created_at) = date('now') AND status IN ('closed','resolved') THEN 1 ELSE 0 END) as closedToday,
|
||||||
|
SUM(CASE WHEN priority = 'critical' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as critical,
|
||||||
|
SUM(CASE WHEN priority = 'high' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as high,
|
||||||
|
SUM(CASE WHEN priority = 'medium' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as medium,
|
||||||
|
SUM(CASE WHEN priority = 'low' AND status NOT IN ('closed','resolved') THEN 1 ELSE 0 END) as low
|
||||||
|
FROM tickets
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
// Patch overview
|
||||||
|
const patchStats = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN windows_updates_pending = 0 THEN 1 ELSE 0 END) as compliant,
|
||||||
|
SUM(CASE WHEN windows_updates_pending > 0 THEN 1 ELSE 0 END) as warnings,
|
||||||
|
SUM(COALESCE(windows_updates_pending, 0)) as total_pending_updates
|
||||||
|
FROM monitoring_agents
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
// Last security report
|
||||||
|
const lastSecReport = db.prepare('SELECT * FROM security_reports ORDER BY created_at DESC LIMIT 1').get();
|
||||||
|
|
||||||
|
// Ticket trend last 7 days
|
||||||
|
const perDay7 = db.prepare(`
|
||||||
|
SELECT date(created_at) as day, COUNT(*) as count
|
||||||
|
FROM tickets
|
||||||
|
WHERE created_at >= date('now', '-7 days')
|
||||||
|
GROUP BY date(created_at)
|
||||||
|
ORDER BY day ASC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
agentList: agentList.map(a => ({
|
||||||
|
hostname: a.hostname,
|
||||||
|
last_checkin: a.last_checkin,
|
||||||
|
cpu_usage_percent: a.cpu_usage_percent,
|
||||||
|
ram_usage_percent: a.ram_usage_percent,
|
||||||
|
bitlocker_status: a.bitlocker_status,
|
||||||
|
})),
|
||||||
|
monitoring: { total: agents.length, online, offline: agents.length - online },
|
||||||
|
tickets: ticketStats,
|
||||||
|
patch: patchStats,
|
||||||
|
lastSecReport: lastSecReport || null,
|
||||||
|
ticketMetrics: { perDay7: perDay7.map(r => r.count) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getStats };
|
||||||
56
backend/src/controllers/unifi.controller.js
Normal file
56
backend/src/controllers/unifi.controller.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
const unifi = require('../services/unifiService');
|
||||||
|
|
||||||
|
const getConfig = (req, res) => {
|
||||||
|
try {
|
||||||
|
const cfg = unifi.getConfig();
|
||||||
|
// Don't send password in plaintext
|
||||||
|
res.json({ status: 'success', data: { ...cfg, password: cfg?.password ? '***' : '' } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveConfig = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { controller_url, username, password, site, poll_interval_min, enabled } = req.body;
|
||||||
|
// If password is '***', keep existing
|
||||||
|
const existing = unifi.getConfig();
|
||||||
|
const pwd = (password && password !== '***') ? password : (existing?.password || '');
|
||||||
|
const cfg = unifi.saveConfig({
|
||||||
|
controller_url: controller_url?.trim() || '',
|
||||||
|
username: username?.trim() || '',
|
||||||
|
password: pwd,
|
||||||
|
site: site?.trim() || 'default',
|
||||||
|
poll_interval_min: parseInt(poll_interval_min) || 5,
|
||||||
|
enabled: enabled ? 1 : 0,
|
||||||
|
});
|
||||||
|
unifi.restartUnifiPoller();
|
||||||
|
res.json({ status: 'success', data: { ...cfg, password: cfg.password ? '***' : '' } });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDevices = (req, res) => {
|
||||||
|
try {
|
||||||
|
const devices = unifi.getAllDevices().map(d => ({
|
||||||
|
...d,
|
||||||
|
ports: JSON.parse(d.ports_json || '[]'),
|
||||||
|
radios: JSON.parse(d.radio_json || '[]'),
|
||||||
|
}));
|
||||||
|
res.json({ status: 'success', data: devices });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await unifi.syncUnifi();
|
||||||
|
res.json({ status: 'success', data: result });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { getConfig, saveConfig, getDevices, sync };
|
||||||
420
backend/src/controllers/user.controller.js
Normal file
420
backend/src/controllers/user.controller.js
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
const UserService = require('../services/user.service');
|
||||||
|
const Role = require('../models/Role');
|
||||||
|
const AuditLog = require('../models/AuditLog');
|
||||||
|
const User = require('../models/User');
|
||||||
|
const { asyncHandler } = require('../middleware/errorHandler');
|
||||||
|
const { getAzureGroups, getAzureGroupMembers, invalidateUserSessions } = require('../services/graph.service');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const DB_PATH = process.env.DATABASE_PATH || './database.sqlite';
|
||||||
|
|
||||||
|
class UserController {
|
||||||
|
/**
|
||||||
|
* Get all users
|
||||||
|
* GET /api/users
|
||||||
|
*/
|
||||||
|
static getAllUsers = asyncHandler(async (req, res) => {
|
||||||
|
const users = UserService.getAllUsers();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: users
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by ID
|
||||||
|
* GET /api/users/:id
|
||||||
|
*/
|
||||||
|
static getUserById = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const user = UserService.getUserById(parseInt(id));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new user
|
||||||
|
* POST /api/users
|
||||||
|
*/
|
||||||
|
static createUser = asyncHandler(async (req, res) => {
|
||||||
|
const user = await UserService.createUser(
|
||||||
|
req.body,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
status: 'success',
|
||||||
|
data: user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user
|
||||||
|
* PUT /api/users/:id
|
||||||
|
*/
|
||||||
|
static updateUser = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const user = await UserService.updateUser(
|
||||||
|
parseInt(id),
|
||||||
|
req.body,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete user
|
||||||
|
* DELETE /api/users/:id
|
||||||
|
*/
|
||||||
|
static deleteUser = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const deleted = UserService.deleteUser(
|
||||||
|
parseInt(id),
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
return res.status(404).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'User not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'User deleted successfully'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign role to user
|
||||||
|
* PUT /api/users/:id/role
|
||||||
|
*/
|
||||||
|
static assignRole = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { role_id } = req.body;
|
||||||
|
|
||||||
|
if (!role_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'role_id is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = UserService.assignRole(
|
||||||
|
parseInt(id),
|
||||||
|
role_id,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate/Deactivate user
|
||||||
|
* PUT /api/users/:id/activate
|
||||||
|
*/
|
||||||
|
static toggleUserStatus = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { is_active } = req.body;
|
||||||
|
|
||||||
|
if (is_active === undefined) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'is_active is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = UserService.toggleUserStatus(
|
||||||
|
parseInt(id),
|
||||||
|
is_active,
|
||||||
|
req.user.id,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all roles
|
||||||
|
* GET /api/roles
|
||||||
|
*/
|
||||||
|
static getAllRoles = asyncHandler(async (req, res) => {
|
||||||
|
const roles = Role.getAll();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: roles
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs
|
||||||
|
* GET /api/audit-logs
|
||||||
|
*/
|
||||||
|
static getAuditLogs = asyncHandler(async (req, res) => {
|
||||||
|
const limit = parseInt(req.query.limit) || 100;
|
||||||
|
const offset = parseInt(req.query.offset) || 0;
|
||||||
|
|
||||||
|
const logs = AuditLog.getAll(limit, offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: logs
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs for specific user
|
||||||
|
* GET /api/audit-logs/user/:userId
|
||||||
|
*/
|
||||||
|
static getAuditLogsByUser = asyncHandler(async (req, res) => {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 100;
|
||||||
|
const offset = parseInt(req.query.offset) || 0;
|
||||||
|
|
||||||
|
const logs = AuditLog.getByUserId(parseInt(userId), limit, offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: logs
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs for specific entity
|
||||||
|
* GET /api/audit-logs/entity/:type/:id
|
||||||
|
*/
|
||||||
|
static getAuditLogsByEntity = asyncHandler(async (req, res) => {
|
||||||
|
const { type, id } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 100;
|
||||||
|
const offset = parseInt(req.query.offset) || 0;
|
||||||
|
|
||||||
|
const logs = AuditLog.getByEntity(type, parseInt(id), limit, offset);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: logs
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available Azure AD groups for import selection
|
||||||
|
* GET /api/users/import/azure/groups
|
||||||
|
*/
|
||||||
|
static getAzureGroupsList = asyncHandler(async (req, res) => {
|
||||||
|
let groups;
|
||||||
|
try {
|
||||||
|
groups = await getAzureGroups();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(502).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
res.json({ status: 'success', data: groups });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import users from an Azure AD group
|
||||||
|
* POST /api/users/import/azure
|
||||||
|
* Body: { group_id, role_id }
|
||||||
|
*/
|
||||||
|
static importFromAzure = asyncHandler(async (req, res) => {
|
||||||
|
const { group_id, role_id } = req.body;
|
||||||
|
|
||||||
|
if (!group_id) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'group_id ist erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!role_id) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'role_id ist erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let members;
|
||||||
|
try {
|
||||||
|
members = await getAzureGroupMembers(group_id);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(502).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = { imported: 0, skipped: 0, errors: [] };
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
try {
|
||||||
|
const email = (member.mail || member.userPrincipalName || '').toLowerCase().trim();
|
||||||
|
if (!email) { results.skipped++; continue; }
|
||||||
|
|
||||||
|
// Skip if already exists (by azure_id or email)
|
||||||
|
const byAzureId = member.id ? User.getByAzureId(member.id) : null;
|
||||||
|
if (byAzureId) { results.skipped++; continue; }
|
||||||
|
|
||||||
|
const byEmail = User.getByEmail(email);
|
||||||
|
if (byEmail) {
|
||||||
|
// Link azure_id if not yet set
|
||||||
|
if (!byEmail.azure_id && member.id) {
|
||||||
|
User.update(byEmail.id, { azure_id: member.id });
|
||||||
|
}
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive username from email prefix, ensure uniqueness by appending number if needed
|
||||||
|
let baseUsername = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase();
|
||||||
|
let username = baseUsername;
|
||||||
|
let suffix = 1;
|
||||||
|
while (User.getByUsername(username)) {
|
||||||
|
username = `${baseUsername}${suffix++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = User.create({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password_hash: 'AZURE_SSO_NO_PASSWORD',
|
||||||
|
role_id: parseInt(role_id),
|
||||||
|
first_name: member.givenName || null,
|
||||||
|
last_name: member.surname || null,
|
||||||
|
is_active: true,
|
||||||
|
must_change_password: false,
|
||||||
|
});
|
||||||
|
if (member.id) {
|
||||||
|
User.update(newUser.id, { azure_id: member.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
results.imported++;
|
||||||
|
} catch (err) {
|
||||||
|
results.errors.push({ user: member.mail || member.userPrincipalName, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'success', data: results });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full user profile: user + asset count + fido count + lifecycle status
|
||||||
|
* GET /api/users/:id/full
|
||||||
|
*/
|
||||||
|
static getUserFull = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
const user = db.prepare(`
|
||||||
|
SELECT u.*, r.name as role_name,
|
||||||
|
m.username as manager_username,
|
||||||
|
m.first_name as manager_first_name,
|
||||||
|
m.last_name as manager_last_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN roles r ON u.role_id = r.id
|
||||||
|
LEFT JOIN users m ON u.manager_id = m.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
`).get(parseInt(id));
|
||||||
|
|
||||||
|
if (!user) return res.status(404).json({ status: 'error', message: 'User not found' });
|
||||||
|
|
||||||
|
const assetCount = db.prepare(`
|
||||||
|
SELECT COUNT(*) as cnt FROM assets
|
||||||
|
WHERE assigned_to_user_id = ? OR assigned_to_username = ?
|
||||||
|
`).get(parseInt(id), user.username);
|
||||||
|
|
||||||
|
const assetStats = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
COALESCE(SUM(purchase_price), 0) as total_value,
|
||||||
|
MIN(purchase_date) as oldest_date
|
||||||
|
FROM assets
|
||||||
|
WHERE assigned_to_user_id = ? OR assigned_to_username = ?
|
||||||
|
`).get(parseInt(id), user.username);
|
||||||
|
|
||||||
|
const fidoCount = db.prepare(
|
||||||
|
`SELECT COUNT(*) as cnt FROM fido_keys WHERE assigned_to_user_id = ? AND status = 'aktiv'`
|
||||||
|
).get(parseInt(id));
|
||||||
|
|
||||||
|
const onboarding = db.prepare(
|
||||||
|
`SELECT * FROM onboarding_protocols WHERE employee_user_id = ? ORDER BY created_at DESC LIMIT 1`
|
||||||
|
).get(parseInt(id));
|
||||||
|
|
||||||
|
const offboarding = db.prepare(
|
||||||
|
`SELECT * FROM offboarding_protocols WHERE employee_user_id = ? ORDER BY created_at DESC LIMIT 1`
|
||||||
|
).get(parseInt(id));
|
||||||
|
|
||||||
|
let lifecycleStage = 3; // Aktiv (default for existing staff)
|
||||||
|
if (offboarding) lifecycleStage = 5;
|
||||||
|
else if (onboarding?.status === 'in_progress') lifecycleStage = 2;
|
||||||
|
else if (onboarding?.status === 'pending') lifecycleStage = 1;
|
||||||
|
|
||||||
|
delete user.password_hash;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'success',
|
||||||
|
data: {
|
||||||
|
...user,
|
||||||
|
asset_count: assetCount?.cnt || 0,
|
||||||
|
asset_total_value: assetStats?.total_value || 0,
|
||||||
|
asset_oldest_date: assetStats?.oldest_date || null,
|
||||||
|
fido_count: fidoCount?.cnt || 0,
|
||||||
|
lifecycle_stage: lifecycleStage,
|
||||||
|
onboarding_protocol: onboarding || null,
|
||||||
|
offboarding_protocol: offboarding || null,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate Entra sessions (password reset via Entra SSPR)
|
||||||
|
* POST /api/users/:id/reset-entra
|
||||||
|
*/
|
||||||
|
static resetPasswordEntra = asyncHandler(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const user = UserService.getUserById(parseInt(id));
|
||||||
|
|
||||||
|
if (!user?.azure_id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Kein Azure-Konto verknüpft. Passwort-Reset nur für Entra-Benutzer möglich.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invalidateUserSessions(user.azure_id);
|
||||||
|
res.json({ status: 'success', message: 'Entra-Sessions invalidiert. Benutzer wird beim nächsten Login zu Passwort-Reset aufgefordert.' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export all users as CSV
|
||||||
|
* GET /api/users/export/csv
|
||||||
|
*/
|
||||||
|
static exportCsv = asyncHandler(async (req, res) => {
|
||||||
|
const users = UserService.getAllUsers();
|
||||||
|
const header = 'ID,Username,Email,Vorname,Nachname,Rolle,Abteilung,Position,Telefon,Standort,Eintrittsdatum,Aktiv';
|
||||||
|
const rows = users.map(u => [
|
||||||
|
u.id, u.username, u.email, u.first_name || '', u.last_name || '',
|
||||||
|
u.role_name || '', u.department || '', u.position || '',
|
||||||
|
u.phone || '', u.location || '', u.joined_date || '', u.is_active ? 'Ja' : 'Nein'
|
||||||
|
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="benutzer.csv"');
|
||||||
|
res.send('' + header + '\n' + rows.join('\n'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = UserController;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user