Repo-Cleanup: nur noch backend/frontend/agent-cs + Root-Configs versioniert

Entfernt aus der Versionierung (bleibt lokal auf der Platte, nicht Teil der IT-Nexus-App):
domain-join-tool/, nexus-scanner/ (separates Go-Projekt), playwright-tests/,
alte PS1-Agent-Generation (agent/), .claude/, .github/workflows/, diverse
Demo-HTMLs/.docx/.zip-Altlasten im Root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 13:29:55 +02:00
parent 81b1c326fc
commit 9a5ed02a8c
111 changed files with 12 additions and 20503 deletions

View File

@@ -1,27 +0,0 @@
{
"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
}
}

View File

@@ -1,22 +0,0 @@
{
"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:*)"
]
}
}

View File

@@ -1,64 +0,0 @@
# GitHub Actions — Deploy Workflow
## Übersicht
Der Workflow `deploy.yml` läuft automatisch bei jedem Push auf `main`.
- **Job 1 `build`**: Installiert Dependencies und baut das Frontend — rein zum Kompilierungs-Check.
- **Job 2 `deploy`**: Kopiert den Quellcode per SCP auf den Server und startet Docker neu. **Erfordert manuellen Approval** über das GitHub Environment `production`.
---
## 1. SSH Key als Secret hinterlegen
### SSH Key generieren (falls noch kein dedizierter Key vorhanden)
```bash
ssh-keygen -t ed25519 -C "github-actions-itnexus" -f ~/.ssh/github_actions_itnexus
```
Den Public Key auf dem Server hinterlegen:
```bash
cat ~/.ssh/github_actions_itnexus.pub | ssh root@192.168.0.194 "cat >> ~/.ssh/authorized_keys"
```
### Secrets in GitHub eintragen
Gehe zu: **Repository → Settings → Secrets and variables → Actions → New repository secret**
| Secret Name | Wert |
|---|---|
| `SSH_PRIVATE_KEY` | Inhalt von `~/.ssh/github_actions_itnexus` (Private Key, beginnt mit `-----BEGIN OPENSSH PRIVATE KEY-----`) |
| `SERVER_IP` | `192.168.0.194` |
---
## 2. Production Environment mit Required Reviewer einrichten
### Environment erstellen
1. Gehe zu: **Repository → Settings → Environments**
2. Klicke auf **New environment**
3. Name: `production` (exakt so, wie im Workflow hinterlegt)
4. Klicke auf **Configure environment**
### Required Reviewers setzen
1. Aktiviere **Required reviewers**
2. Füge `Simon Grüssing` (GitHub-Username) als Reviewer hinzu
3. Optional: **Prevent self-review** aktivieren wenn ein weiterer Reviewer vorhanden ist
4. Speichern mit **Save protection rules**
---
## 3. Approval-Prozess
1. Push auf `main` → Job `build` startet automatisch und läuft durch.
2. Nach erfolgreichem Build: Job `deploy` wartet auf Approval.
3. GitHub schickt eine **E-Mail-Benachrichtigung** an alle eingetragenen Reviewer.
4. Reviewer klickt in der E-Mail oder direkt im GitHub Actions Tab auf den Workflow-Run.
5. Unter **"This workflow run is waiting for a required review"** → **Review deployments** → Haken bei `production` setzen → **Approve and deploy**.
6. Erst dann startet der Deployment-Job.
> Tipp: Den Workflow-Status siehst du unter **Repository → Actions**.

View File

@@ -1,68 +0,0 @@
name: IT Nexus Deploy
on:
push:
branches:
- main
workflow_dispatch:
inputs:
deploy:
description: 'Manuell deployen?'
required: true
default: 'yes'
jobs:
build:
name: Build Frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm install
- name: Build frontend
working-directory: frontend
run: npm run build
env:
CI: false
deploy:
name: Deploy to Production
runs-on: ubuntu-latest
needs: build
environment: production
if: github.event_name == 'workflow_dispatch'
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
- name: Deploy frontend/src to server
run: |
scp -r frontend/src root@${{ secrets.SERVER_IP }}:/opt/it-nexus/frontend/src
- name: Deploy backend/src to server
run: |
scp -r backend/src root@${{ secrets.SERVER_IP }}:/opt/it-nexus/backend/src
- name: Rebuild and restart Docker containers
run: |
ssh root@${{ secrets.SERVER_IP }} "cd /opt/it-nexus && docker compose build frontend backend && docker compose up -d frontend backend"

16
.gitignore vendored
View File

@@ -48,7 +48,15 @@ installer/
*.intunewin *.intunewin
*.ova *.ova
*.exe *.exe
installer/
*.intunewin # Nicht Teil der IT-Nexus-App (separate Tools/Demos/Scratch — nur lokal)
*.ova .claude/
*.exe .github/
agent/
domain-join-tool/
nexus-scanner/
playwright-tests/
*.docx
*.zip
*-Demo.html
demo-*.html

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,190 +0,0 @@
# 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.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,29 +0,0 @@
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

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,216 +0,0 @@
# 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.*

File diff suppressed because it is too large Load Diff

View File

@@ -1,79 +0,0 @@
# 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*

View File

@@ -1,163 +0,0 @@
# 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
View File

@@ -1 +0,0 @@
mkdir: cannot create directory C:\\gradle-home\\it_nexus_build: File exists

View File

@@ -1,524 +0,0 @@
<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 &amp; 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 &amp; Uptime</li>
<li class="feature-item"><span class="feature-dot"></span> Netzwerk &amp; 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">&#10003;</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 &#8594; Administration &#8594; 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="&#10003;"; }
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>

View File

@@ -1,74 +0,0 @@
# 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 {}

View File

@@ -1,4 +0,0 @@
{
"server_url": "https://it-nexus.cereda-systems.de",
"agent_key": "DEIN-AGENT-KEY-HIER"
}

View File

@@ -1,4 +0,0 @@
{
"server_url": "https://it-nexus.cereda-systems.de",
"agent_key": "cereda-agent-2024-secure-key"
}

View File

@@ -1,44 +0,0 @@
# 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

View File

@@ -1,606 +0,0 @@
# 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)"
}

View File

@@ -1,103 +0,0 @@
#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;

View File

@@ -1,8 +0,0 @@
# 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

View File

@@ -1,749 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Benutzerverwaltung Design Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #3fa3a3;
--primary-dark: #008487;
--bg: #0f172a;
--bg2: #1e293b;
--bg3: #334155;
--text: #f1f5f9;
--text2: #94a3b8;
--text3: #64748b;
--border: #334155;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--info: #3b82f6;
--radius: 10px;
--shadow: 0 4px 12px rgba(0,0,0,0.4);
}
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
/* ── Demo Selector Bar ── */
.demo-bar {
background: #0a1628;
border-bottom: 1px solid var(--border);
padding: 12px 24px;
display: flex; align-items: center; gap: 16px;
position: sticky; top: 0; z-index: 100;
}
.demo-bar-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--text3); }
.demo-tabs { display: flex; gap: 6px; }
.demo-tab {
padding: 6px 16px; border-radius: 20px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border); background: transparent; color: var(--text2);
cursor: pointer; transition: all 0.15s;
}
.demo-tab:hover { border-color: var(--primary); color: var(--primary); }
.demo-tab.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.demo-tab.recommended::after { content: ' ★'; font-size: 11px; }
/* ── Page Container ── */
.page { display: none; padding: 28px 32px; max-width: 1300px; margin: 0 auto; }
.page.visible { display: block; }
/* ── Page Header ── */
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.page-title { font-size: 22px; font-weight: 700; }
.btn-group { display: flex; gap: 8px; }
.btn {
padding: 9px 18px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: none; cursor: pointer; transition: all 0.15s; font-family: inherit;
}
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover { background: var(--bg2); border-color: var(--primary); }
/* ── Stats Cards ── */
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 20px; }
.stat-card {
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px 20px; display: flex; align-items: center; gap: 14px;
transition: border-color 0.15s;
}
.stat-card:hover { border-color: var(--primary); }
.stat-icon {
width: 42px; height: 42px; border-radius: 10px; display: flex; align-items: center;
justify-content: center; font-size: 18px; flex-shrink: 0;
}
.stat-icon.blue { background: rgba(59,130,246,0.15); }
.stat-icon.green { background: rgba(16,185,129,0.15); }
.stat-icon.red { background: rgba(239,68,68,0.15); }
.stat-icon.yellow { background: rgba(245,158,11,0.15); }
.stat-num { font-size: 24px; font-weight: 700; line-height: 1; }
.stat-label { font-size: 12px; color: var(--text2); margin-top: 3px; }
/* ── Filter Bar ── */
.filter-bar { display: flex; gap: 10px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.search-input {
background: var(--bg2); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 9px 14px; font-size: 13px; font-family: inherit;
outline: none; width: 280px; transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--primary); }
.filter-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: 500;
border: 1px solid var(--border); background: var(--bg2); color: var(--text2);
cursor: pointer; transition: all 0.12s; white-space: nowrap;
}
.chip:hover { border-color: var(--primary); color: var(--primary); }
.chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.chip.active-red { background: var(--danger); border-color: var(--danger); color: #fff; }
.chip.active-green { background: var(--success); border-color: var(--success); color: #fff; }
.chip-sep { width: 1px; height: 24px; background: var(--border); margin: 0 2px; }
/* ── Table ── */
.card { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
thead th {
background: var(--bg3); text-align: left; padding: 11px 16px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px;
color: var(--text3); border-bottom: 1px solid var(--border); white-space: nowrap;
}
tbody tr { border-bottom: 1px solid rgba(51,65,85,0.5); transition: background 0.12s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: rgba(63,163,163,0.04); }
td { padding: 12px 16px; font-size: 13px; vertical-align: middle; }
/* ── Avatar ── */
.user-cell { display: flex; align-items: center; gap: 10px; }
.avatar {
width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center;
justify-content: center; font-size: 12px; font-weight: 700; flex-shrink: 0;
color: #fff;
}
.user-name { font-weight: 500; font-size: 13px; }
.user-username { font-size: 11px; color: var(--text3); }
/* ── Badges ── */
.badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px;
}
.badge-dot { width: 6px; height: 6px; border-radius: 50%; }
.badge-active { background: rgba(16,185,129,0.15); color: var(--success); }
.badge-inactive { background: rgba(239,68,68,0.1); color: var(--danger); }
.role-badge {
padding: 3px 9px; border-radius: 6px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 4px;
}
/* ── Action buttons (old style for Option A) ── */
.actions-old { display: flex; gap: 6px; }
.btn-sm { padding: 5px 12px; border-radius: 6px; font-size: 11px; font-weight: 600; border: none; cursor: pointer; font-family: inherit; }
.btn-sm-edit { background: rgba(63,163,163,0.15); color: var(--primary); }
.btn-sm-edit:hover { background: rgba(63,163,163,0.25); }
.btn-sm-toggle { background: rgba(148,163,184,0.1); color: var(--text2); }
.btn-sm-toggle:hover { background: rgba(148,163,184,0.2); }
.btn-sm-del { background: rgba(239,68,68,0.1); color: var(--danger); }
.btn-sm-del:hover { background: rgba(239,68,68,0.2); }
/* ── 3-dot menu (Option B+C) ── */
.menu-wrap { position: relative; display: inline-block; }
.menu-btn {
width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border);
background: transparent; color: var(--text2); cursor: pointer; font-size: 18px;
display: flex; align-items: center; justify-content: center; transition: all 0.12s;
line-height: 1;
}
.menu-btn:hover { background: var(--bg3); color: var(--text); border-color: var(--primary); }
.dropdown {
position: absolute; right: 0; top: calc(100% + 4px); background: var(--bg2);
border: 1px solid var(--border); border-radius: 8px; min-width: 160px;
box-shadow: var(--shadow); z-index: 50; overflow: hidden; display: none;
}
.dropdown.open { display: block; }
.dropdown-item {
padding: 9px 14px; font-size: 13px; cursor: pointer; display: flex; align-items: center; gap: 8px;
transition: background 0.1s; color: var(--text);
}
.dropdown-item:hover { background: var(--bg3); }
.dropdown-item.danger { color: var(--danger); }
.dropdown-item.danger:hover { background: rgba(239,68,68,0.1); }
.dropdown-sep { height: 1px; background: var(--border); margin: 3px 0; }
/* Inline role dropdown */
.role-select {
background: var(--bg3); border: 1px solid var(--border); color: var(--text);
border-radius: 6px; padding: 4px 8px; font-size: 12px; font-family: inherit;
cursor: pointer; outline: none; transition: border-color 0.12s;
}
.role-select:focus { border-color: var(--primary); }
/* ── Relative time ── */
.time-abs { font-size: 12px; color: var(--text2); }
.time-rel { font-size: 11px; color: var(--text3); }
.time-never { color: var(--text3); font-style: italic; }
/* ── Slideover (Option C) ── */
.slideover-layout { display: flex; gap: 0; position: relative; }
.table-area { flex: 1; min-width: 0; transition: margin-right 0.3s; }
.table-area.panel-open { margin-right: 0; }
.slideover {
width: 360px; flex-shrink: 0; background: var(--bg2); border: 1px solid var(--border);
border-radius: var(--radius); margin-left: 16px;
animation: slideIn 0.25s ease;
overflow-y: auto; max-height: calc(100vh - 200px);
}
@keyframes slideIn { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
.panel-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
.panel-avatar { width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 700; color: #fff; flex-shrink: 0; }
.panel-name { font-weight: 700; font-size: 15px; }
.panel-email { font-size: 12px; color: var(--text2); }
.panel-close { margin-left: auto; background: none; border: none; color: var(--text2); font-size: 20px; cursor: pointer; padding: 4px; border-radius: 6px; }
.panel-close:hover { background: var(--bg3); color: var(--text); }
.panel-section { padding: 16px 20px; border-bottom: 1px solid var(--border); }
.panel-section:last-child { border-bottom: none; }
.panel-section-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--text3); margin-bottom: 12px; }
.panel-field { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-size: 13px; }
.panel-field:last-child { margin-bottom: 0; }
.panel-field-label { color: var(--text2); }
.panel-actions { padding: 14px 20px; display: flex; gap: 8px; flex-direction: column; }
.btn-full { width: 100%; padding: 9px; text-align: center; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; border: none; font-family: inherit; transition: all 0.12s; }
.btn-full-primary { background: var(--primary); color: #fff; }
.btn-full-primary:hover { background: var(--primary-dark); }
.btn-full-warn { background: rgba(245,158,11,0.12); color: var(--warning); border: 1px solid rgba(245,158,11,0.3); }
.btn-full-warn:hover { background: rgba(245,158,11,0.2); }
.btn-full-danger { background: rgba(239,68,68,0.1); color: var(--danger); border: 1px solid rgba(239,68,68,0.25); }
.btn-full-danger:hover { background: rgba(239,68,68,0.18); }
/* Row clickable for C */
.clickable-row { cursor: pointer; }
.clickable-row.selected-row { background: rgba(63,163,163,0.08) !important; }
.clickable-row.selected-row td { border-left: 2px solid var(--primary); }
.clickable-row.selected-row td:not(:first-child) { border-left: none; }
/* ── Last login ── */
td .last-login { display: flex; flex-direction: column; }
/* ── Tooltip style note ── */
.design-note {
background: rgba(63,163,163,0.08); border: 1px solid rgba(63,163,163,0.2);
border-radius: 8px; padding: 10px 16px; margin-bottom: 16px;
font-size: 12px; color: var(--text2); line-height: 1.6;
}
.design-note strong { color: var(--primary); }
.combo-badge {
background: var(--primary); color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 8px; border-radius: 20px; margin-left: 8px; text-transform: uppercase;
}
</style>
</head>
<body>
<!-- Demo Switcher Bar -->
<div class="demo-bar">
<span class="demo-bar-label">Design-Option:</span>
<div class="demo-tabs">
<button class="demo-tab" onclick="show('a')">A Stats + Filter-Chips</button>
<button class="demo-tab" onclick="show('b')">B Avatar + 3-Punkte-Menü</button>
<button class="demo-tab" onclick="show('c')">C Slideover-Panel</button>
<button class="demo-tab recommended active" onclick="show('combo')">Kombiniert (Empfehlung)</button>
</div>
</div>
<!-- ════════════════════════════════════════════════════════
OPTION A Stats + Filter Chips
════════════════════════════════════════════════════════ -->
<div id="page-a" class="page">
<div class="page-header">
<h1 class="page-title">Benutzerverwaltung</h1>
<div class="btn-group">
<button class="btn btn-secondary">☁️ Aus Azure importieren</button>
<button class="btn btn-primary">+ Neuer Benutzer</button>
</div>
</div>
<div class="design-note">
<strong>Option A:</strong> Statistik-Kacheln oben + Filter-Chips statt nur Textsuche. Tabelle bleibt gleich. Kleinster Aufwand, sofortiger Informationsgewinn.
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon blue">👥</div>
<div><div class="stat-num">14</div><div class="stat-label">Benutzer gesamt</div></div>
</div>
<div class="stat-card">
<div class="stat-icon green"></div>
<div><div class="stat-num">11</div><div class="stat-label">Aktiv</div></div>
</div>
<div class="stat-card">
<div class="stat-icon red">🚫</div>
<div><div class="stat-num">3</div><div class="stat-label">Deaktiviert</div></div>
</div>
<div class="stat-card">
<div class="stat-icon yellow">⏱️</div>
<div><div class="stat-num">4</div><div class="stat-label">Noch nie eingeloggt</div></div>
</div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<input class="search-input" type="text" placeholder="🔍 Suche nach Name, E-Mail, Rolle…" oninput="filterA(this.value)">
<div class="chip-sep"></div>
<div class="filter-chips" id="role-chips-a">
<span class="chip active" onclick="chipClick(this,'a')">Alle Rollen</span>
<span class="chip" onclick="chipClick(this,'a')">👑 Super Admin</span>
<span class="chip" onclick="chipClick(this,'a')">🛡️ Admin</span>
<span class="chip" onclick="chipClick(this,'a')">🎧 Support</span>
<span class="chip" onclick="chipClick(this,'a')">🔧 Bearbeiter</span>
<span class="chip" onclick="chipClick(this,'a')">👤 Benutzer</span>
</div>
<div class="chip-sep"></div>
<span class="chip active-green active" onclick="statusChip(this,'aktiv')">● Aktiv</span>
<span class="chip" onclick="statusChip(this,'inaktiv')">● Inaktiv</span>
</div>
<!-- Table (same as current, just with search) -->
<div class="card">
<table>
<thead>
<tr>
<th>Benutzername</th>
<th>E-Mail</th>
<th>Name</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzter Login</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="tbody-a"></tbody>
</table>
</div>
</div>
<!-- ════════════════════════════════════════════════════════
OPTION B Avatar + 3-Punkte-Menü
════════════════════════════════════════════════════════ -->
<div id="page-b" class="page">
<div class="page-header">
<h1 class="page-title">Benutzerverwaltung</h1>
<div class="btn-group">
<button class="btn btn-secondary">☁️ Aus Azure importieren</button>
<button class="btn btn-primary">+ Neuer Benutzer</button>
</div>
</div>
<div class="design-note">
<strong>Option B:</strong> Avatar-Initialen in der ersten Spalte + Aktionen als 3-Punkte-Menü (spart viel Platz, besonders bei langen Buttons). Rolle inline per Dropdown änderbar.
</div>
<div class="filter-bar">
<input class="search-input" type="text" placeholder="🔍 Suche nach Name, E-Mail, Rolle…">
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Benutzer</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzter Login</th>
<th style="width:50px">···</th>
</tr>
</thead>
<tbody id="tbody-b"></tbody>
</table>
</div>
</div>
<!-- ════════════════════════════════════════════════════════
OPTION C Slideover-Panel
════════════════════════════════════════════════════════ -->
<div id="page-c" class="page">
<div class="page-header">
<h1 class="page-title">Benutzerverwaltung</h1>
<div class="btn-group">
<button class="btn btn-secondary">☁️ Aus Azure importieren</button>
<button class="btn btn-primary">+ Neuer Benutzer</button>
</div>
</div>
<div class="design-note">
<strong>Option C:</strong> Klick auf eine Zeile öffnet ein Detailpanel rechts. Tabelle bleibt links sichtbar. Kein Modal-Overlay. → <em>Klick auf eine Zeile zum Testen!</em>
</div>
<div class="filter-bar">
<input class="search-input" type="text" placeholder="🔍 Suche nach Name, E-Mail, Rolle…">
</div>
<div class="slideover-layout" id="c-layout">
<div class="table-area card" id="c-table-area">
<table>
<thead>
<tr>
<th>Benutzer</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzter Login</th>
</tr>
</thead>
<tbody id="tbody-c"></tbody>
</table>
</div>
<div class="slideover" id="c-panel" style="display:none"></div>
</div>
</div>
<!-- ════════════════════════════════════════════════════════
KOMBINATION (Empfehlung): Stats + Filter + Avatar + 3-Punkte + Slideover
════════════════════════════════════════════════════════ -->
<div id="page-combo" class="page visible">
<div class="page-header">
<div>
<h1 class="page-title">Benutzerverwaltung <span class="combo-badge">Empfehlung</span></h1>
</div>
<div class="btn-group">
<button class="btn btn-secondary">☁️ Aus Azure importieren</button>
<button class="btn btn-primary">+ Neuer Benutzer</button>
</div>
</div>
<div class="design-note">
<strong>Kombination aus A + B + C:</strong> Stats-Kacheln · Filter-Chips · Avatar-Initialen · 3-Punkte-Menü · Slideover-Panel bei Klick auf eine Zeile. → <em>Klick auf eine Zeile öffnet das Detail-Panel!</em>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon blue">👥</div>
<div><div class="stat-num">14</div><div class="stat-label">Benutzer gesamt</div></div>
</div>
<div class="stat-card">
<div class="stat-icon green"></div>
<div><div class="stat-num">11</div><div class="stat-label">Aktiv</div></div>
</div>
<div class="stat-card">
<div class="stat-icon red">🚫</div>
<div><div class="stat-num">3</div><div class="stat-label">Deaktiviert</div></div>
</div>
<div class="stat-card">
<div class="stat-icon yellow">⏱️</div>
<div><div class="stat-num">4</div><div class="stat-label">Noch nie eingeloggt</div></div>
</div>
</div>
<!-- Filter -->
<div class="filter-bar">
<input class="search-input" type="text" placeholder="🔍 Suche nach Name, E-Mail, Rolle…">
<div class="chip-sep"></div>
<div class="filter-chips">
<span class="chip active" onclick="chipClick(this,'combo')">Alle Rollen</span>
<span class="chip" onclick="chipClick(this,'combo')">👑 Super Admin</span>
<span class="chip" onclick="chipClick(this,'combo')">🛡️ Admin</span>
<span class="chip" onclick="chipClick(this,'combo')">🎧 Support</span>
<span class="chip" onclick="chipClick(this,'combo')">👤 Benutzer</span>
</div>
<div class="chip-sep"></div>
<span class="chip active-green active">● Aktiv</span>
<span class="chip">● Inaktiv</span>
</div>
<div class="slideover-layout" id="combo-layout">
<div class="table-area card" id="combo-table-area">
<table>
<thead>
<tr>
<th>Benutzer</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Status</th>
<th>Letzter Login</th>
<th style="width:50px">···</th>
</tr>
</thead>
<tbody id="tbody-combo"></tbody>
</table>
</div>
<div class="slideover" id="combo-panel" style="display:none"></div>
</div>
</div>
<script>
// ── Mock Data ──
const ROLE_META = {
super_admin: { icon: '👑', color: '#ef4444', label: 'Super Admin', avatarBg: '#7f1d1d' },
admin: { icon: '🛡️', color: '#f97316', label: 'Admin', avatarBg: '#7c2d12' },
support: { icon: '🎧', color: '#3b82f6', label: 'IT-Support', avatarBg: '#1e3a8a' },
bearbeiter: { icon: '🔧', color: '#8b5cf6', label: 'Bearbeiter', avatarBg: '#4c1d95' },
benutzer: { icon: '👤', color: '#64748b', label: 'Benutzer', avatarBg: '#1e293b' },
hr_personal: { icon: '🧑‍💼', color: '#10b981', label: 'HR', avatarBg: '#064e3b' },
buchhaltung: { icon: '💶', color: '#a78bfa', label: 'Buchhaltung', avatarBg: '#3b0764' },
};
const AVATAR_COLORS = ['#1d4ed8','#0f766e','#b45309','#be185d','#7c3aed','#0369a1','#15803d','#c2410c'];
function avatarColor(name) {
let h = 0; for (let c of name) h = (h * 31 + c.charCodeAt(0)) & 0xffff;
return AVATAR_COLORS[h % AVATAR_COLORS.length];
}
function initials(u) {
if (u.first_name && u.last_name) return (u.first_name[0]+u.last_name[0]).toUpperCase();
return u.username.slice(0,2).toUpperCase();
}
const USERS = [
{ id:1, username:'gruessing', email:'gruessing@cereda-systems.de', first_name:'Simon', last_name:'Grüssing', role:'super_admin', active:true, last_login:'2026-05-27T14:32:00' },
{ id:2, username:'smueller', email:'smueller@cereda-systems.de', first_name:'Stefan', last_name:'Müller', role:'admin', active:true, last_login:'2026-05-26T09:11:00' },
{ id:3, username:'jschmidt', email:'jschmidt@cereda-systems.de', first_name:'Julia', last_name:'Schmidt', role:'support', active:true, last_login:'2026-05-27T08:45:00' },
{ id:4, username:'tbauer', email:'tbauer@cereda-systems.de', first_name:'Thomas', last_name:'Bauer', role:'support', active:true, last_login:'2026-05-25T16:20:00' },
{ id:5, username:'mweber', email:'mweber@cereda-systems.de', first_name:'Maria', last_name:'Weber', role:'bearbeiter', active:true, last_login:'2026-05-22T11:00:00' },
{ id:6, username:'fkoch', email:'fkoch@cereda-systems.de', first_name:'Felix', last_name:'Koch', role:'benutzer', active:true, last_login:'2026-05-20T09:00:00' },
{ id:7, username:'krichter', email:'krichter@cereda-systems.de', first_name:'Klaus', last_name:'Richter', role:'benutzer', active:true, last_login:'2026-05-15T14:00:00' },
{ id:8, username:'aschneider', email:'aschneider@cereda-systems.de', first_name:'Anna', last_name:'Schneider', role:'hr_personal', active:true, last_login:'2026-05-24T10:30:00' },
{ id:9, username:'benders', email:'benders@cereda-systems.de', first_name:'Björn', last_name:'Enders', role:'benutzer', active:true, last_login:'2026-05-19T07:55:00' },
{ id:10, username:'mfischer', email:'mfischer@cereda-systems.de', first_name:'Markus', last_name:'Fischer', role:'buchhaltung', active:true, last_login:'2026-05-23T13:00:00' },
{ id:11, username:'lbraun', email:'lbraun@cereda-systems.de', first_name:'Lena', last_name:'Braun', role:'benutzer', active:true, last_login:null },
{ id:12, username:'konferenzraum', email:'konferenzraum@cereda-systems.de', first_name:null, last_name:null, role:'benutzer', active:true, last_login:null },
{ id:13, username:'poolfahrzeug', email:'poolfahrzeug@cereda-systems.de', first_name:null, last_name:null, role:'benutzer', active:false, last_login:null },
{ id:14, username:'einkauf', email:'einkauf@cereda-systems.de', first_name:null, last_name:null, role:'benutzer', active:false, last_login:null },
];
function relativeTime(iso) {
if (!iso) return '<span class="time-never">Nie</span>';
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff/60000), hrs = Math.floor(mins/60), days = Math.floor(hrs/24);
let rel = days > 0 ? `vor ${days} Tag${days>1?'en':''}` : hrs > 0 ? `vor ${hrs} Std.` : mins > 0 ? `vor ${mins} Min.` : 'gerade eben';
const abs = new Date(iso).toLocaleDateString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric'});
return `<div class="last-login"><span class="time-abs">${abs}</span><span class="time-rel">${rel}</span></div>`;
}
function roleBadge(role) {
const m = ROLE_META[role] || { icon:'🔵', color:'#64748b', label: role };
return `<span class="role-badge" style="background:${m.color}18;color:${m.color};">${m.icon} ${m.label}</span>`;
}
function statusBadge(active) {
return active
? `<span class="badge badge-active"><span class="badge-dot" style="background:var(--success)"></span>Aktiv</span>`
: `<span class="badge badge-inactive"><span class="badge-dot" style="background:var(--danger)"></span>Inaktiv</span>`;
}
function avatarHtml(u, size=34, fontSize=12) {
const bg = avatarColor(u.username);
const ini = initials(u);
return `<div class="avatar" style="width:${size}px;height:${size}px;background:${bg};font-size:${fontSize}px">${ini}</div>`;
}
// ── Option A ──
function renderA() {
const rows = USERS.map(u => `
<tr>
<td>${u.username}</td>
<td style="color:var(--text2);font-size:12px">${u.email}</td>
<td>${u.first_name || u.last_name ? (u.first_name||'') + ' ' + (u.last_name||'') : ''}</td>
<td>${roleBadge(u.role)}</td>
<td>${statusBadge(u.active)}</td>
<td>${relativeTime(u.last_login)}</td>
<td>
<div class="actions-old">
<button class="btn-sm btn-sm-edit">Bearbeiten</button>
<button class="btn-sm btn-sm-toggle">${u.active?'Deaktivieren':'Aktivieren'}</button>
<button class="btn-sm btn-sm-del">Löschen</button>
</div>
</td>
</tr>
`).join('');
document.getElementById('tbody-a').innerHTML = rows;
}
// ── Option B ──
function renderB() {
const rows = USERS.map(u => `
<tr>
<td>
<div class="user-cell">
${avatarHtml(u)}
<div>
<div class="user-name">${u.first_name || u.last_name ? (u.first_name||'')+' '+(u.last_name||'') : u.username}</div>
<div class="user-username">@${u.username}</div>
</div>
</div>
</td>
<td style="color:var(--text2);font-size:12px">${u.email}</td>
<td>
<select class="role-select" title="Rolle direkt ändern">
${Object.entries(ROLE_META).map(([k,m]) => `<option value="${k}" ${k===u.role?'selected':''}>${m.icon} ${m.label}</option>`).join('')}
</select>
</td>
<td>${statusBadge(u.active)}</td>
<td>${relativeTime(u.last_login)}</td>
<td>
<div class="menu-wrap" onclick="event.stopPropagation()">
<button class="menu-btn" onclick="toggleMenu(this)">⋯</button>
<div class="dropdown">
<div class="dropdown-item">✏️ Bearbeiten</div>
<div class="dropdown-item">${u.active ? '🚫 Deaktivieren' : '✅ Aktivieren'}</div>
<div class="dropdown-item">🔑 Passwort zurücksetzen</div>
<div class="dropdown-sep"></div>
<div class="dropdown-item danger">🗑️ Löschen</div>
</div>
</div>
</td>
</tr>
`).join('');
document.getElementById('tbody-b').innerHTML = rows;
}
// ── Option C Slideover ──
let selectedC = null;
function renderC() {
const rows = USERS.map((u,i) => `
<tr class="clickable-row" id="crow-${i}" onclick="openPanel('c', ${i})">
<td>
<div class="user-cell">
${avatarHtml(u)}
<div>
<div class="user-name">${u.first_name || u.last_name ? (u.first_name||'')+' '+(u.last_name||'') : u.username}</div>
<div class="user-username">@${u.username}</div>
</div>
</div>
</td>
<td style="color:var(--text2);font-size:12px">${u.email}</td>
<td>${roleBadge(u.role)}</td>
<td>${statusBadge(u.active)}</td>
<td>${relativeTime(u.last_login)}</td>
</tr>
`).join('');
document.getElementById('tbody-c').innerHTML = rows;
}
// ── Combo ──
let selectedCombo = null;
function renderCombo() {
const rows = USERS.map((u,i) => `
<tr class="clickable-row" id="combrow-${i}" onclick="openPanel('combo', ${i})">
<td>
<div class="user-cell">
${avatarHtml(u)}
<div>
<div class="user-name">${u.first_name || u.last_name ? (u.first_name||'')+' '+(u.last_name||'') : u.username}</div>
<div class="user-username">@${u.username}</div>
</div>
</div>
</td>
<td style="color:var(--text2);font-size:12px">${u.email}</td>
<td>
<select class="role-select" onclick="event.stopPropagation()" title="Rolle direkt ändern">
${Object.entries(ROLE_META).map(([k,m]) => `<option value="${k}" ${k===u.role?'selected':''}>${m.icon} ${m.label}</option>`).join('')}
</select>
</td>
<td>${statusBadge(u.active)}</td>
<td>${relativeTime(u.last_login)}</td>
<td>
<div class="menu-wrap" onclick="event.stopPropagation()">
<button class="menu-btn" onclick="toggleMenu(this)">⋯</button>
<div class="dropdown">
<div class="dropdown-item">✏️ Bearbeiten</div>
<div class="dropdown-item">${u.active ? '🚫 Deaktivieren' : '✅ Aktivieren'}</div>
<div class="dropdown-item">🔑 Passwort zurücksetzen</div>
<div class="dropdown-sep"></div>
<div class="dropdown-item danger">🗑️ Löschen</div>
</div>
</div>
</td>
</tr>
`).join('');
document.getElementById('tbody-combo').innerHTML = rows;
}
// ── Panel Content ──
function panelHtml(u, prefix) {
const m = ROLE_META[u.role] || {};
const displayName = u.first_name || u.last_name ? `${u.first_name||''} ${u.last_name||''}`.trim() : u.username;
return `
<div class="panel-header">
${avatarHtml(u, 48, 18)}
<div>
<div class="panel-name">${displayName}</div>
<div class="panel-email">${u.email}</div>
</div>
<button class="panel-close" onclick="closePanel('${prefix}')">×</button>
</div>
<div class="panel-section">
<div class="panel-section-label">Account</div>
<div class="panel-field"><span class="panel-field-label">Benutzername</span><span>@${u.username}</span></div>
<div class="panel-field"><span class="panel-field-label">Rolle</span>${roleBadge(u.role)}</div>
<div class="panel-field"><span class="panel-field-label">Status</span>${statusBadge(u.active)}</div>
</div>
<div class="panel-section">
<div class="panel-section-label">Aktivität</div>
<div class="panel-field"><span class="panel-field-label">Letzter Login</span>${u.last_login ? new Date(u.last_login).toLocaleString('de-DE') : '<span style="color:var(--text3);font-style:italic">Noch nie</span>'}</div>
<div class="panel-field"><span class="panel-field-label">Konto-ID</span><span style="color:var(--text3)">#${u.id}</span></div>
</div>
<div class="panel-actions">
<button class="btn-full btn-full-primary">✏️ Bearbeiten</button>
<button class="btn-full btn-full-warn">${u.active ? '🚫 Deaktivieren' : '✅ Aktivieren'}</button>
<button class="btn-full btn-full-danger">🗑️ Löschen</button>
</div>
`;
}
function openPanel(prefix, idx) {
const u = USERS[idx];
const panel = document.getElementById(`${prefix}-panel`);
panel.innerHTML = panelHtml(u, prefix);
panel.style.display = 'block';
// highlight row
document.querySelectorAll(`#tbody-${prefix} tr`).forEach((r,i) => r.classList.toggle('selected-row', i===idx));
}
function closePanel(prefix) {
document.getElementById(`${prefix}-panel`).style.display = 'none';
document.querySelectorAll(`#tbody-${prefix} tr`).forEach(r => r.classList.remove('selected-row'));
}
// ── Menu toggle ──
function toggleMenu(btn) {
const dd = btn.nextElementSibling;
const wasOpen = dd.classList.contains('open');
// close all
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
if (!wasOpen) dd.classList.add('open');
}
document.addEventListener('click', () => {
document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
});
// ── Filter chips ──
function chipClick(el, page) {
el.closest('.filter-chips').querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
el.classList.add('active');
}
// ── Page switcher ──
function show(id) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('visible'));
document.getElementById('page-' + id).classList.add('visible');
document.querySelectorAll('.demo-tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
}
// ── Init ──
renderA();
renderB();
renderC();
renderCombo();
</script>
</body>
</html>

View File

@@ -1,997 +0,0 @@
// IT Nexus Dev Team — Lokaler KI-Entwickler-Agent
// Start: node devteam.js
// Dann: http://localhost:4242
const http = require('http');
const https = require('https');
const API_KEY = process.env.ANTHROPIC_API_KEY || '';
const PORT = 4242;
// ─── IT Nexus Kontext für alle Agenten ────────────────────────────────────────
const IT_NEXUS_CONTEXT = `
# IT Nexus Vollständiger Stack-Kontext
## Tech Stack
- Backend: Node.js 18 + Express 4 + SQLite (better-sqlite3, SYNCHRON - kein await!)
- Frontend: React 18 (Create React App) + Custom CSS (Glassmorphism)
- Auth: JWT (jsonwebtoken) + bcryptjs
- KI: @anthropic-ai/sdk (Claude claude-sonnet-4-6)
- Deployment: Docker Compose + nginx auf LXC CT 102 (192.168.0.194)
## Dateistruktur Backend
\`\`\`
backend/src/
server.js Express Setup, alle app.use() Registrierungen
db/seed.js Alle DB-Migrations (jede in try/catch, idempotent!)
config/database.js SQLite Verbindung via better-sqlite3
middleware/
auth.js authenticateToken (JWT prüfen)
roleCheck.js requireAdmin, requireStaff Middleware
errorHandler.js asyncHandler(fn) Wrapper für alle Controller
controllers/ Business Logic, eine Datei pro Feature
models/ SQLite Queries, eine Datei pro Entity
routes/ Express Router, eine Datei pro Feature
services/ Externe APIs (Graph API, Proxmox, Anthropic)
\`\`\`
## Dateistruktur Frontend
\`\`\`
frontend/src/
pages/ Alle Seiten (NamingConvention: XxxPage.jsx)
components/common/
AppLayout.jsx Wrapper mit Sidebar + Topbar
Sidebar.jsx Navigation
Topbar.jsx Header
context/AuthContext.jsx Auth State, user Objekt
services/api.js Axios Instance mit Interceptors (IMMER verwenden!)
\`\`\`
## Kritische Konventionen Backend
1. DB-Migration: Jedes ALTER TABLE / CREATE TABLE einzeln in try/catch in seed.js
2. Controller immer mit asyncHandler wrappen: const foo = asyncHandler(async (req, res) => {...})
3. Statische Routen VOR /:id definieren (z.B. /statistics vor /:id)
4. better-sqlite3 ist SYNCHRON: db.prepare('...').get() KEIN await!
5. Exports am Ende: module.exports = { foo, bar }
## Kritische Konventionen Frontend
1. IMMER import api from '../../services/api' NIEMALS axios direkt importieren
2. IMMER user.role_name NIEMALS user.role (falsches Feld!)
3. CSS Variablen verwenden:
- Text: var(--text-primary), var(--text-secondary), var(--text-muted)
- Hintergrund: var(--bg-card), var(--bg-secondary), var(--border-color)
- Glassmorphism: backdrop-filter: blur(20px), rgba() Hintergründe
4. Rollen: super_admin, admin, support, bearbeiter, benutzer, hr_personal, buchhaltung
5. Neue Seite in AppLayout einbinden (Route in App.jsx)
## Deploy-Workflow
\`\`\`
scp datei.js root@192.168.0.194:/opt/it-nexus/backend/src/...
docker cp /opt/it-nexus/backend/src/.../datei.js fido-backend:/app/src/.../
docker restart fido-backend
# Frontend rebuild nur nötig wenn neue Seite/Komponente:
docker compose build frontend && docker compose up -d frontend
\`\`\`
## Beispiel: Neue Feature-Struktur
Backend: controller + model + route + seed.js Migration + server.js Route registrieren
Frontend: XxxPage.jsx + xxxService.js + Route in App.jsx + Link in Sidebar.jsx
`;
// ─── Agenten Definitionen ──────────────────────────────────────────────────────
const AGENTS = {
pm: {
name: 'Project Manager',
emoji: '📋',
color: '#6366f1',
system: `Du bist der Project Manager des IT Nexus Entwicklerteams. Du koordinierst alle anderen Agenten.
${IT_NEXUS_CONTEXT}
Deine Aufgabe:
1. Analysiere die Anfrage des Benutzers
2. Erstelle einen klaren Implementierungsplan
3. Bestimme welche Spezialisten benötigt werden
4. Fasse am Ende alles zusammen
Antworte auf Deutsch. Sei konkret und strukturiert. Nutze Markdown.
Erkläre kurz was getan werden muss, dann liste die benötigten Schritte auf.`
},
architect: {
name: 'Architect',
emoji: '🏗️',
color: '#f59e0b',
system: `Du bist der System-Architekt des IT Nexus Entwicklerteams.
${IT_NEXUS_CONTEXT}
Deine Aufgabe:
- Entscheide über DB-Schema (SQLite Spalten, Typen, Constraints)
- Entscheide über API-Struktur (Endpoints, HTTP-Methoden, Auth-Level)
- Entscheide über Datenfluss zwischen Frontend und Backend
- Identifiziere Abhängigkeiten zu bestehenden Features
Antworte auf Deutsch. Gib konkrete technische Entscheidungen.
Format: DB-Schema → API-Endpoints → Abhängigkeiten → Besonderheiten`
},
backend: {
name: 'Backend Dev',
emoji: '⚙️',
color: '#10b981',
system: `Du bist der Backend-Entwickler des IT Nexus Entwicklerteams. Du schreibst den kompletten Backend-Code.
${IT_NEXUS_CONTEXT}
Deine Aufgabe:
- Schreibe vollständige, lauffähige Node.js Dateien
- Folge exakt den Konventionen (asyncHandler, better-sqlite3 synchron, etc.)
- Jede Datei vollständig mit korrekten Imports und Exports
- DB-Migrations in seed.js Format
Format pro Datei:
### Datei: \`backend/src/pfad/dateiname.js\`
\`\`\`javascript
// kompletter Code
\`\`\`
Antworte auf Deutsch für Erklärungen, Code auf Englisch.`
},
frontend: {
name: 'Frontend Dev',
emoji: '🎨',
color: '#3b82f6',
system: `Du bist der Frontend-Entwickler des IT Nexus Entwicklerteams. Du schreibst den kompletten Frontend-Code.
${IT_NEXUS_CONTEXT}
Deine Aufgabe:
- Schreibe vollständige React Komponenten (.jsx Dateien)
- Glassmorphism Design (konsistent mit bestehendem IT Nexus Style)
- IMMER api.get/post aus services/api.js verwenden
- IMMER user.role_name verwenden
- CSS inline mit var(--*) Variablen
Format pro Datei:
### Datei: \`frontend/src/pfad/DateiName.jsx\`
\`\`\`jsx
// kompletter Code
\`\`\`
Antworte auf Deutsch für Erklärungen, Code auf Englisch.`
},
senior: {
name: 'Senior Dev',
emoji: '🔍',
color: '#ef4444',
system: `Du bist der Senior Developer und Code Reviewer des IT Nexus Entwicklerteams.
${IT_NEXUS_CONTEXT}
Deine Aufgabe:
- Reviewe den generierten Code auf Bugs, Sicherheitsprobleme, Konventionsverletzungen
- Prüfe ob alle Konventionen eingehalten wurden (user.role_name, api.js, asyncHandler, etc.)
- Prüfe auf fehlende Error Handling
- Prüfe auf fehlende Route-Registrierungen in server.js
- Prüfe auf fehlende Sidebar-Links
- Gib konkrete Fixes wenn nötig
Format:
✅ Was gut ist
⚠️ Was fehlt / falsch ist + Fix
📝 Deploy-Befehl am Ende
Antworte auf Deutsch.`
},
qa: {
name: 'QA Engineer',
emoji: '🧪',
color: '#8b5cf6',
system: `Du bist der QA Engineer des IT Nexus Entwicklerteams.
${IT_NEXUS_CONTEXT}
Deine Aufgabe:
- Identifiziere potenzielle Edge Cases
- Schreibe manuelle Testschritte die der Entwickler ausführen soll
- Prüfe ob alle API-Endpoints abgesichert sind (Auth)
- Prüfe Fehlerszenarien (leere Daten, falscher Input, fehlende Rechte)
- Schreibe wenn möglich einen Playwright-Test
Format:
📋 Manuelle Tests (nummeriert)
🔒 Security-Checks
⚡ Edge Cases
(Optional) Playwright Test Code
Antworte auf Deutsch.`
}
};
// ─── Anthropic API Aufruf ──────────────────────────────────────────────────────
function callClaude(systemPrompt, messages, onChunk) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 8096,
system: systemPrompt,
messages,
stream: true
});
const req = https.request({
hostname: 'api.anthropic.com',
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
'anthropic-version': '2023-06-01',
'Content-Length': Buffer.byteLength(body)
}
}, (res) => {
let full = '';
res.on('data', chunk => {
const lines = chunk.toString().split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const d = JSON.parse(line.slice(6));
if (d.type === 'content_block_delta' && d.delta?.text) {
full += d.delta.text;
onChunk(d.delta.text);
}
} catch {}
}
}
});
res.on('end', () => resolve(full));
res.on('error', reject);
});
req.on('error', reject);
req.write(body);
req.end();
});
}
// ─── Orchestrierung ────────────────────────────────────────────────────────────
async function runTeam(task, send) {
const context = [];
const runAgent = async (agentId, userMsg) => {
const agent = AGENTS[agentId];
send({ type: 'agent_start', agent: agentId, name: agent.name, emoji: agent.emoji, color: agent.color });
const msgs = [...context, { role: 'user', content: userMsg }];
let full = '';
await callClaude(agent.system, msgs, chunk => {
full += chunk;
send({ type: 'chunk', agent: agentId, text: chunk });
});
send({ type: 'agent_done', agent: agentId });
context.push({ role: 'user', content: userMsg });
context.push({ role: 'assistant', content: full });
return full;
};
// 1. PM analysiert
const pmResult = await runAgent('pm',
`Neue Aufgabe für das IT Nexus Entwicklerteam:\n\n"${task}"\n\nErstelle einen kurzen Implementierungsplan.`
);
// 2. Architect entscheidet
await runAgent('architect',
`Aufgabe: "${task}"\n\nPM-Plan:\n${pmResult}\n\nGib deine technischen Entscheidungen für DB-Schema und API-Struktur.`
);
// 3. Backend Dev schreibt Code
const backendResult = await runAgent('backend',
`Aufgabe: "${task}"\n\nSchreibe den vollständigen Backend-Code (Controller, Model, Route, seed.js Migration). Alle Dateien komplett.`
);
// 4. Frontend Dev schreibt Code
const frontendResult = await runAgent('frontend',
`Aufgabe: "${task}"\n\nSchreibe den vollständigen Frontend-Code (Page, Service). Alle Dateien komplett.`
);
// 5. Senior Dev reviewt
await runAgent('senior',
`Reviewe diesen Code:\n\n**Backend:**\n${backendResult}\n\n**Frontend:**\n${frontendResult}\n\nFinde Bugs, Konventionsverletzungen, fehlende Teile.`
);
// 6. QA testet
await runAgent('qa',
`Aufgabe: "${task}"\n\nErstelle Testschritte und prüfe Edge Cases für die implementierte Funktion.`
);
send({ type: 'done' });
}
// ─── HTTP Server ───────────────────────────────────────────────────────────────
const HTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IT Nexus Dev Team</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0a0f1e;
color: #e2e8f0;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.header {
background: rgba(15,23,42,0.95);
border-bottom: 1px solid rgba(99,102,241,0.3);
padding: 14px 24px;
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.header-logo {
width: 36px; height: 36px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
}
.header h1 { font-size: 18px; font-weight: 700; color: #fff; }
.header p { font-size: 12px; color: #64748b; margin-top: 1px; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #10b981; margin-left: auto;
box-shadow: 0 0 8px #10b981;
animation: pulse 2s infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
/* Layout */
.main { display: flex; flex: 1; overflow: hidden; }
/* Sidebar: Team */
.sidebar {
width: 200px;
background: rgba(15,23,42,0.8);
border-right: 1px solid rgba(255,255,255,0.06);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.sidebar-title {
font-size: 10px;
font-weight: 700;
color: #475569;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
padding: 0 4px;
}
.agent-card {
padding: 8px 10px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: #94a3b8;
border: 1px solid transparent;
transition: all 0.2s;
}
.agent-card.active {
background: rgba(255,255,255,0.06);
color: #fff;
border-color: rgba(255,255,255,0.1);
}
.agent-card.thinking {
animation: agent-pulse 0.8s infinite;
}
@keyframes agent-pulse {
0%,100% { opacity: 1; }
50% { opacity: 0.5; }
}
.agent-emoji { font-size: 16px; }
.agent-status {
width: 6px; height: 6px; border-radius: 50%;
background: #1e293b;
margin-left: auto;
flex-shrink: 0;
}
.agent-status.active { background: #10b981; box-shadow: 0 0 6px #10b981; }
.agent-status.done { background: #6366f1; }
/* Chat Area */
.chat { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.chat::-webkit-scrollbar { width: 4px; }
.chat::-webkit-scrollbar-track { background: transparent; }
.chat::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
.msg { display: flex; gap: 12px; }
.msg-avatar {
width: 36px; height: 36px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.1);
}
.msg-body { flex: 1; min-width: 0; }
.msg-header {
display: flex; align-items: center; gap: 8px;
margin-bottom: 6px;
}
.msg-name { font-size: 13px; font-weight: 700; }
.msg-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.msg-content {
font-size: 13px;
line-height: 1.7;
color: #cbd5e1;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
padding: 14px 16px;
white-space: pre-wrap;
word-break: break-word;
}
/* Code Blocks */
.msg-content pre {
background: rgba(0,0,0,0.5);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 14px;
margin: 10px 0;
overflow-x: auto;
font-family: 'Cascadia Code', 'Fira Code', Consolas, monospace;
font-size: 12px;
line-height: 1.6;
}
.msg-content code {
background: rgba(0,0,0,0.4);
padding: 1px 5px;
border-radius: 3px;
font-family: 'Cascadia Code', Consolas, monospace;
font-size: 12px;
color: #7dd3fc;
}
.msg-content pre code {
background: none; padding: 0; color: #e2e8f0;
}
.msg-content h1, .msg-content h2, .msg-content h3 {
color: #f1f5f9; margin: 14px 0 6px;
}
.msg-content ul, .msg-content ol { padding-left: 20px; margin: 6px 0; }
.msg-content li { margin: 3px 0; }
.msg-content strong { color: #f1f5f9; }
/* User message */
.msg.user .msg-content {
background: rgba(99,102,241,0.1);
border-color: rgba(99,102,241,0.3);
color: #e2e8f0;
}
/* Input Area */
.input-area {
border-top: 1px solid rgba(255,255,255,0.06);
background: rgba(15,23,42,0.95);
padding: 16px 20px;
flex-shrink: 0;
}
.api-key-bar {
display: flex; gap: 8px; margin-bottom: 10px; align-items: center;
}
.api-key-bar label { font-size: 11px; color: #475569; white-space: nowrap; }
.api-key-bar input {
flex: 1;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
padding: 6px 10px;
color: #94a3b8;
font-size: 12px;
font-family: monospace;
}
.input-row { display: flex; gap: 10px; }
textarea {
flex: 1;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
padding: 12px 14px;
color: #e2e8f0;
font-size: 13px;
resize: none;
font-family: inherit;
line-height: 1.5;
transition: border-color 0.2s;
}
textarea:focus { outline: none; border-color: rgba(99,102,241,0.5); }
textarea::placeholder { color: #334155; }
.send-btn {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border: none;
border-radius: 10px;
color: #fff;
font-size: 13px;
font-weight: 700;
padding: 0 20px;
cursor: pointer;
transition: opacity 0.2s;
white-space: nowrap;
}
.send-btn:hover { opacity: 0.85; }
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.hint { font-size: 11px; color: #334155; margin-top: 8px; }
/* Welcome */
.welcome {
margin: auto;
text-align: center;
padding: 40px;
max-width: 500px;
}
.welcome .big-emoji { font-size: 60px; margin-bottom: 16px; }
.welcome h2 { font-size: 22px; font-weight: 700; color: #f1f5f9; margin-bottom: 8px; }
.welcome p { font-size: 14px; color: #475569; line-height: 1.6; }
.examples { margin-top: 24px; display: flex; flex-direction: column; gap: 8px; }
.example-btn {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 10px 14px;
color: #94a3b8;
font-size: 12px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.example-btn:hover { background: rgba(255,255,255,0.08); color: #e2e8f0; }
</style>
</head>
<body>
<div class="header">
<div class="header-logo">👨‍💻</div>
<div>
<h1>IT Nexus Dev Team</h1>
<p>6 KI-Agenten · Lokal · Nur für dich</p>
</div>
<div class="status-dot"></div>
</div>
<div class="main">
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-title">Dev Team</div>
<div class="agent-card" id="card-pm">
<span class="agent-emoji">📋</span>
<span>Project Manager</span>
<div class="agent-status" id="status-pm"></div>
</div>
<div class="agent-card" id="card-architect">
<span class="agent-emoji">🏗️</span>
<span>Architect</span>
<div class="agent-status" id="status-architect"></div>
</div>
<div class="agent-card" id="card-backend">
<span class="agent-emoji">⚙️</span>
<span>Backend Dev</span>
<div class="agent-status" id="status-backend"></div>
</div>
<div class="agent-card" id="card-frontend">
<span class="agent-emoji">🎨</span>
<span>Frontend Dev</span>
<div class="agent-status" id="status-frontend"></div>
</div>
<div class="agent-card" id="card-senior">
<span class="agent-emoji">🔍</span>
<span>Senior Dev</span>
<div class="agent-status" id="status-senior"></div>
</div>
<div class="agent-card" id="card-qa">
<span class="agent-emoji">🧪</span>
<span>QA Engineer</span>
<div class="agent-status" id="status-qa"></div>
</div>
</div>
<!-- Chat -->
<div class="chat" id="chat">
<div class="welcome" id="welcome">
<div class="big-emoji">🚀</div>
<h2>Dein persönliches Dev Team</h2>
<p>Beschreibe was du bauen möchtest. Das Team analysiert, plant, schreibt Code und reviewt alles automatisch.</p>
<div class="examples">
<button class="example-btn" onclick="setExample(this)">📦 Neue Seite für Lizenzmanagement (CRUD, DB, API, React)</button>
<button class="example-btn" onclick="setExample(this)">🔔 E-Mail-Benachrichtigung wenn Agent offline ist</button>
<button class="example-btn" onclick="setExample(this)">📊 Dashboard-Widget mit Top 5 offenen Tickets</button>
<button class="example-btn" onclick="setExample(this)">🔐 Zwei-Faktor-Authentifizierung für Admin-Login</button>
</div>
</div>
</div>
</div>
<!-- Input -->
<div class="input-area">
<div class="api-key-bar">
<label>🔑 API Key:</label>
<input type="password" id="apiKey" placeholder="sk-ant-..." oninput="saveKey(this.value)" />
</div>
<div class="input-row">
<textarea id="taskInput" rows="2" placeholder="Was soll das Team bauen? (Enter = Senden, Shift+Enter = Neue Zeile)"></textarea>
<button class="send-btn" id="sendBtn" onclick="sendTask()">Team starten ▶</button>
</div>
<div class="hint">⚡ PM → Architect → Backend Dev → Frontend Dev → Senior Dev → QA Engineer</div>
</div>
<script>
const COLORS = {
pm: '#6366f1', architect: '#f59e0b', backend: '#10b981',
frontend: '#3b82f6', senior: '#ef4444', qa: '#8b5cf6'
};
const NAMES = {
pm: 'Project Manager', architect: 'Architect', backend: 'Backend Dev',
frontend: 'Frontend Dev', senior: 'Senior Dev', qa: 'QA Engineer'
};
const EMOJIS = {
pm: '📋', architect: '🏗️', backend: '⚙️',
frontend: '🎨', senior: '🔍', qa: '🧪'
};
// API Key aus localStorage laden
window.onload = () => {
const saved = localStorage.getItem('devteam_apikey');
if (saved) document.getElementById('apiKey').value = saved;
};
function saveKey(v) { localStorage.setItem('devteam_apikey', v); }
function setExample(btn) {
document.getElementById('taskInput').value = btn.textContent.replace(/^[^\s]+\s/, '').trim();
document.getElementById('taskInput').focus();
}
document.getElementById('taskInput').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendTask(); }
});
let currentMsgEl = null;
let currentRaw = '';
function addUserMsg(text) {
document.getElementById('welcome')?.remove();
const el = document.createElement('div');
el.className = 'msg user';
el.innerHTML = \`
<div class="msg-avatar" style="background:rgba(99,102,241,0.2)">👤</div>
<div class="msg-body">
<div class="msg-header">
<span class="msg-name" style="color:#6366f1">Du</span>
</div>
<div class="msg-content">\${escHtml(text)}</div>
</div>\`;
document.getElementById('chat').appendChild(el);
scrollChat();
}
function startAgentMsg(agentId) {
// Sidebar aktualisieren
document.querySelectorAll('.agent-card').forEach(c => c.classList.remove('active','thinking'));
document.querySelectorAll('.agent-status').forEach(s => { s.classList.remove('active'); });
const card = document.getElementById('card-' + agentId);
const status = document.getElementById('status-' + agentId);
if (card) { card.classList.add('active','thinking'); }
if (status) { status.classList.add('active'); }
const color = COLORS[agentId];
const name = NAMES[agentId];
const emoji = EMOJIS[agentId];
const el = document.createElement('div');
el.className = 'msg';
el.id = 'msg-' + agentId;
el.innerHTML = \`
<div class="msg-avatar" style="background:\${color}20; border-color:\${color}40">\${emoji}</div>
<div class="msg-body">
<div class="msg-header">
<span class="msg-name" style="color:\${color}">\${name}</span>
<span class="msg-badge" style="background:\${color}20;color:\${color}">schreibt...</span>
</div>
<div class="msg-content" id="content-\${agentId}"><span class="cursor">▌</span></div>
</div>\`;
document.getElementById('chat').appendChild(el);
currentMsgEl = document.getElementById('content-' + agentId);
currentRaw = '';
scrollChat();
}
function appendChunk(agentId, text) {
currentRaw += text;
if (currentMsgEl) {
currentMsgEl.innerHTML = renderMarkdown(currentRaw) + '<span class="cursor">▌</span>';
scrollChat();
}
}
function doneAgentMsg(agentId) {
if (currentMsgEl) {
currentMsgEl.innerHTML = renderMarkdown(currentRaw);
}
const card = document.getElementById('card-' + agentId);
const status = document.getElementById('status-' + agentId);
if (card) card.classList.remove('thinking');
if (status) { status.classList.remove('active'); status.classList.add('done'); }
// Badge aktualisieren
const msg = document.getElementById('msg-' + agentId);
if (msg) {
const badge = msg.querySelector('.msg-badge');
if (badge) { badge.textContent = 'fertig ✓'; badge.style.background = COLORS[agentId]+'30'; }
}
currentMsgEl = null;
}
function renderMarkdown(text) {
return text
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/\`\`\`(\w+)?\n([\s\S]*?)\`\`\`/g, '<pre><code>$2</code></pre>')
.replace(/\`([^\`]+)\`/g, '<code>$1</code>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/^(\d+)\. (.+)$/gm, '<li>$1. $2</li>')
.replace(/\n\n/g, '<br><br>')
.replace(/\n/g, '<br>');
}
function escHtml(t) {
return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function scrollChat() {
const chat = document.getElementById('chat');
chat.scrollTop = chat.scrollHeight;
}
async function sendTask() {
const task = document.getElementById('taskInput').value.trim();
const apiKey = document.getElementById('apiKey').value.trim();
if (!task) return;
if (!apiKey) { alert('Bitte zuerst den Anthropic API Key eingeben!'); return; }
const btn = document.getElementById('sendBtn');
btn.disabled = true;
btn.textContent = '⏳ Team arbeitet...';
addUserMsg(task);
document.getElementById('taskInput').value = '';
// Alle Status zurücksetzen
document.querySelectorAll('.agent-status').forEach(s => s.classList.remove('active','done'));
document.querySelectorAll('.agent-card').forEach(c => c.classList.remove('active','thinking'));
try {
const res = await fetch('/api/task', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task, apiKey })
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const d = JSON.parse(line.slice(6));
if (d.type === 'agent_start') startAgentMsg(d.agent);
else if (d.type === 'chunk') appendChunk(d.agent, d.text);
else if (d.type === 'agent_done') doneAgentMsg(d.agent);
else if (d.type === 'done') {
document.querySelectorAll('.agent-card').forEach(c => c.classList.remove('active','thinking'));
}
} catch {}
}
}
}
} catch (e) {
const el = document.createElement('div');
el.style.cssText = 'background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:8px;padding:12px;color:#fca5a5;font-size:13px;';
el.textContent = '❌ Fehler: ' + e.message;
document.getElementById('chat').appendChild(el);
}
btn.disabled = false;
btn.textContent = 'Team starten ▶';
scrollChat();
}
</script>
</body>
</html>`;
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
// HTML
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(HTML);
return;
}
// Task API
if (req.method === 'POST' && url.pathname === '/api/task') {
let body = '';
req.on('data', c => body += c);
req.on('end', async () => {
try {
const { task, apiKey } = JSON.parse(body);
// API Key aus Request oder Umgebungsvariable
const key = apiKey || API_KEY;
if (!key) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Kein API Key' }));
return;
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
const send = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
// Agenten mit aktuellem Key aufrufen
const callAgent = (systemPrompt, messages) => new Promise((resolve, reject) => {
const reqBody = JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 8096,
system: systemPrompt,
messages,
stream: true
});
const apiReq = https.request({
hostname: 'api.anthropic.com',
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': key,
'anthropic-version': '2023-06-01',
'Content-Length': Buffer.byteLength(reqBody)
}
}, (apiRes) => {
let full = '';
let buf = '';
apiRes.on('data', chunk => {
buf += chunk.toString();
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const d = JSON.parse(line.slice(6));
if (d.type === 'content_block_delta' && d.delta?.text) {
full += d.delta.text;
send({ type: 'chunk', agent: currentAgent, text: d.delta.text });
}
} catch {}
}
}
});
apiRes.on('end', () => resolve(full));
apiRes.on('error', reject);
});
apiReq.on('error', reject);
apiReq.write(reqBody);
apiReq.end();
});
let currentAgent = '';
const context = [];
const runAgent = async (agentId, userMsg) => {
currentAgent = agentId;
send({ type: 'agent_start', agent: agentId });
const msgs = [...context, { role: 'user', content: userMsg }];
const full = await callAgent(AGENTS[agentId].system, msgs);
send({ type: 'agent_done', agent: agentId });
context.push({ role: 'user', content: userMsg });
context.push({ role: 'assistant', content: full });
return full;
};
// Team ausführen
const pmResult = await runAgent('pm',
`Neue Aufgabe:\n\n"${task}"\n\nErstelle einen kurzen klaren Implementierungsplan für das IT Nexus Entwicklerteam.`
);
await runAgent('architect',
`Aufgabe: "${task}"\n\nPM-Plan:\n${pmResult}\n\nEntscheide über DB-Schema, API-Endpoints und technische Architektur.`
);
const backendResult = await runAgent('backend',
`Aufgabe: "${task}"\n\nSchreibe jetzt den vollständigen Backend-Code. Alle Dateien komplett und lauffähig. Folge exakt den IT Nexus Konventionen.`
);
const frontendResult = await runAgent('frontend',
`Aufgabe: "${task}"\n\nSchreibe jetzt den vollständigen Frontend-Code. Alle Dateien komplett. Folge exakt den IT Nexus Konventionen.`
);
await runAgent('senior',
`Reviewe diesen Code:\n\nBACKEND:\n${backendResult}\n\nFRONTEND:\n${frontendResult}\n\nFinde alle Bugs, Konventionsverletzungen und fehlende Teile. Gib Fixes.`
);
await runAgent('qa',
`Aufgabe: "${task}"\n\nErstelle konkrete Testschritte, Security-Checks und Edge Cases.`
);
send({ type: 'done' });
res.end();
} catch (e) {
try {
res.write(`data: ${JSON.stringify({ type: 'error', text: e.message })}\n\n`);
res.end();
} catch {}
}
});
return;
}
res.writeHead(404);
res.end();
});
server.listen(PORT, '127.0.0.1', () => {
console.log('');
console.log('╔═══════════════════════════════════════╗');
console.log('║ IT Nexus Dev Team — Bereit! ║');
console.log('╠═══════════════════════════════════════╣');
console.log(`║ → http://localhost:${PORT}`);
console.log('║ ║');
console.log('║ 6 Agenten: PM, Architect, ║');
console.log('║ Backend, Frontend, Senior, QA ║');
console.log('╚═══════════════════════════════════════╝');
console.log('');
if (!API_KEY) {
console.log('⚠️ Kein ANTHROPIC_API_KEY gesetzt — im Browser eingeben');
console.log(' Oder: set ANTHROPIC_API_KEY=sk-ant-... && node devteam.js');
}
console.log('');
});

View File

@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net472</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<AssemblyName>DomainJoinTool</AssemblyName>
<RootNamespace>DomainJoinTool</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>icon.ico</ApplicationIcon>
<LangVersion>9.0</LangVersion>
<PlatformTarget>x64</PlatformTarget>
<Optimize>true</Optimize>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<Reference Include="System.Management.Automation" Condition="Exists('$(PSHOME)\System.Management.Automation.dll')">
<HintPath>$(PSHOME)\System.Management.Automation.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@@ -1,642 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Text;
using System.IO;
using System.Threading.Tasks;
using System.Windows.Forms;
using Microsoft.Win32;
namespace DomainJoinTool
{
public class MainForm : Form
{
// ── Cereda/Zelo Colors ────────────────────────────────────────────────
static readonly Color CTeal = ColorTranslator.FromHtml("#0D9488");
static readonly Color CTealD = ColorTranslator.FromHtml("#0F766E");
static readonly Color CGreen = ColorTranslator.FromHtml("#059669");
static readonly Color CRed = ColorTranslator.FromHtml("#DC2626");
static readonly Color CAmber = ColorTranslator.FromHtml("#D97706");
static readonly Color CGray = ColorTranslator.FromHtml("#6B7280");
static readonly Color CBG = ColorTranslator.FromHtml("#F8FAFC");
static readonly Color CBorder= ColorTranslator.FromHtml("#E2E8F0");
static readonly Color CText = ColorTranslator.FromHtml("#0F172A");
static readonly Color CText2 = ColorTranslator.FromHtml("#64748B");
// ── State ─────────────────────────────────────────────────────────────
bool _entra, _domain; string _dn = ""; int _step;
Timer _animTimer; float _phase, _spin;
// ── Controls ──────────────────────────────────────────────────────────
StatusBadge _badge;
Panel _body1, _body2;
ModernInput _txUser, _txPass;
RoundButton _btnJoin, _btnLeave, _btnMig;
Label _lnkBack, _lnkTab1, _lnkTab2;
ComboBox _cbSrc, _cbDst;
ProgressBar _pb; Label _lbPb;
RichTextBox _log;
Label _lblStat;
Label _lblMigStatus;
ProgressBar _pbMig;
Label _lblMigCount;
RichTextBox _logMig;
const string DOM = "winkel.local";
const string LOG = @"C:\ProgramData\DomainJoinTool\join.log";
const string FLG = @"C:\ProgramData\DomainJoinTool\pending-migration.txt";
const string EXE = @"C:\ProgramData\DomainJoinTool\DomainJoinTool.exe";
public MainForm()
{
Text = "Cereda Systems · Domain Join Tool";
ClientSize = new Size(620, 680);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false; StartPosition = FormStartPosition.CenterScreen;
BackColor = CBG; Font = new Font("Segoe UI", 9f); Icon = MakeIcon();
Build();
_animTimer = new Timer { Interval = 16 };
_animTimer.Tick += (s, e) => { _phase = (_phase + 0.025f) % 1f; _spin = (_spin + 9f) % 360f; _badge?.Tick(_phase, _spin); };
_animTimer.Start();
DetectStatus();
LoadProfiles();
}
void Build()
{
// ── Header ────────────────────────────────────────────────────────
var hdr = new Panel { Dock = DockStyle.Top, Height = 64, BackColor = CTeal };
hdr.Paint += (s, e) =>
{
var g = e.Graphics;
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
// Subtle darker stripe at bottom
using (var br = new SolidBrush(Color.FromArgb(30, 0, 0, 0)))
g.FillRectangle(br, 0, hdr.Height - 3, hdr.Width, 3);
// Logo circle
using (var br = new SolidBrush(Color.FromArgb(40, 255, 255, 255)))
g.FillEllipse(br, 14, 10, 36, 36);
using (var f = new Font("Segoe UI", 11f, FontStyle.Bold))
using (var br = new SolidBrush(Color.White))
{ var sf = new System.Drawing.StringFormat { Alignment = System.Drawing.StringAlignment.Center, LineAlignment = System.Drawing.StringAlignment.Center }; g.DrawString("IT", f, br, new RectangleF(14, 10, 36, 36), sf); }
// Text
using (var br = new SolidBrush(Color.FromArgb(200, 255, 255, 255)))
g.DrawString("CEREDA SYSTEMS", new Font("Segoe UI", 7.5f, FontStyle.Bold), br, 60, 12);
g.DrawString("Domain Join Tool", new Font("Segoe UI", 13f, FontStyle.Bold), Brushes.White, 58, 28);
// Version badge
using (var br = new SolidBrush(Color.FromArgb(40, 255, 255, 255)))
g.FillRectangle(br, hdr.Width - 68, 20, 56, 20);
using (var br = new SolidBrush(Color.FromArgb(200, 255, 255, 255)))
g.DrawString("v2.5.0", new Font("Segoe UI", 8f), br, new RectangleF(hdr.Width - 68, 20, 56, 20),
new System.Drawing.StringFormat { Alignment = System.Drawing.StringAlignment.Center, LineAlignment = System.Drawing.StringAlignment.Center });
};
Controls.Add(hdr);
// ── Tab bar ───────────────────────────────────────────────────────
var tabBar = new Panel { Dock = DockStyle.Top, Height = 38, BackColor = Color.White };
tabBar.Paint += (s, e) => {
e.Graphics.DrawLine(new Pen(CBorder), 0, 37, 600, 37);
// Active indicator
if (_step == 0) e.Graphics.FillRectangle(new SolidBrush(CTeal), 16, 35, 120, 3);
else e.Graphics.FillRectangle(new SolidBrush(CTeal), 148, 35, 140, 3);
};
_lnkTab1 = TabLabel("1 · Domain Join", new Point(16, 8), true);
_lnkTab2 = TabLabel("2 · Profil Migration", new Point(148, 8), false);
_lnkTab1.Click += (s, e) => GoTo(0, tabBar);
_lnkTab2.Click += (s, e) => GoTo(1, tabBar);
tabBar.Controls.Add(_lnkTab1); tabBar.Controls.Add(_lnkTab2);
Controls.Add(tabBar);
// ── Bodies (explicit position to avoid Dock=Fill overlap bug) ────
// hdr=64 + tabBar=38 = 102px
_body1 = new Panel { Location = new Point(0, 102), Size = new Size(620, 578), BackColor = CBG };
_body2 = new Panel { Location = new Point(0, 102), Size = new Size(620, 578), BackColor = CBG, Visible = false };
BuildBody1(); BuildBody2();
Controls.Add(_body1); Controls.Add(_body2);
}
Label TabLabel(string t, Point loc, bool active)
{
var l = new Label { Text = t, Font = new Font("Segoe UI", 9f, active ? FontStyle.Bold : FontStyle.Regular), ForeColor = active ? CTeal : CText2, Location = loc, AutoSize = true, Cursor = Cursors.Hand };
l.MouseEnter += (s, e) => l.ForeColor = CTeal;
l.MouseLeave += (s, e) => l.ForeColor = (_step == (l == _lnkTab1 ? 0 : 1)) ? CTeal : CText2;
return l;
}
void GoTo(int step, Panel tabBar = null)
{
_step = step; _body1.Visible = step == 0; _body2.Visible = step == 1;
_lnkTab1.Font = new Font("Segoe UI", 9f, step == 0 ? FontStyle.Bold : FontStyle.Regular);
_lnkTab2.Font = new Font("Segoe UI", 9f, step == 1 ? FontStyle.Bold : FontStyle.Regular);
_lnkTab1.ForeColor = step == 0 ? CTeal : CText2;
_lnkTab2.ForeColor = step == 1 ? CTeal : CText2;
(tabBar ?? _body1.Parent?.Parent as Panel)?.Invalidate();
// Find and invalidate the tabBar
foreach (Control c in Controls) if (c is Panel p && p.Height == 38) { p.Invalidate(); break; }
}
// ── Body 1: Domain Join ───────────────────────────────────────────────
void BuildBody1()
{
int m = 16, y = 14, w = 588;
// ── Status Card ───────────────────────────────────────────────────
var statCard = MkCard(m, y, w, 86); y += 98;
_lblStat = new Label { Text = "Status", Font = new Font("Segoe UI", 9f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(20, 10), AutoSize = true };
statCard.Controls.Add(_lblStat);
statCard.Controls.Add(new Panel { Location = new Point(20, 30), Size = new Size(w - 26, 1), BackColor = CBorder });
_badge = new StatusBadge { Location = new Point(20, 40) };
statCard.Controls.Add(_badge);
// Right: Entra → AD
statCard.Controls.Add(new Label { Text = "Entra ID", Font = new Font("Segoe UI", 8.5f, FontStyle.Bold), ForeColor = CText2, Location = new Point(280, 38), AutoSize = true });
statCard.Controls.Add(new Label { Text = "→", Font = new Font("Segoe UI", 14f), ForeColor = Color.FromArgb(180, 180, 200), Location = new Point(358, 32), AutoSize = true });
statCard.Controls.Add(new Label { Text = DOM, Font = new Font("Segoe UI", 8.5f, FontStyle.Bold), ForeColor = CGreen, Location = new Point(382, 38), AutoSize = true });
statCard.Controls.Add(new Label { Text = Environment.MachineName, Font = new Font("Cascadia Mono", 7.5f), ForeColor = CText2, Location = new Point(280, 57), AutoSize = true });
_body1.Controls.Add(statCard);
// ── Credentials Card ──────────────────────────────────────────────
var credCard = MkCard(m, y, w, 168); y += 180;
credCard.Controls.Add(new Label { Text = "Domain-Zugangsdaten", Font = new Font("Segoe UI", 10f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(20, 12), AutoSize = true });
credCard.Controls.Add(new Label { Text = "Ziel: " + DOM, Font = new Font("Cascadia Mono", 8f, FontStyle.Bold), ForeColor = CGreen, Location = new Point(220, 15), AutoSize = true });
credCard.Controls.Add(new Panel { Location = new Point(20, 36), Size = new Size(w - 26, 1), BackColor = CBorder });
_txUser = Inp(new Point(20, 44), new Size(w - 40, 52), "Benutzername"); _txUser.Val = "Administrator";
_txPass = Inp(new Point(20, 104), new Size(w - 40, 52), "Passwort", true);
credCard.Controls.Add(_txUser); credCard.Controls.Add(_txPass);
_body1.Controls.Add(credCard);
// ── Action Buttons ────────────────────────────────────────────────
_btnJoin = new RoundButton("▶ Entra leaven + Domain joinen", CTeal, 10) { Location = new Point(m, y), Size = new Size(420, 44) };
_btnLeave = new RoundButton("■ Nur Entra verlassen", CRed, 10) { Location = new Point(m + 428, y), Size = new Size(160, 44) };
_btnJoin.Click += DoJoin; _btnLeave.Click += DoLeave;
_body1.Controls.Add(_btnJoin); _body1.Controls.Add(_btnLeave); y += 52;
// ── Progress ─────────────────────────────────────────────────────
_pb = new ProgressBar { Location = new Point(m, y), Size = new Size(w, 4), Style = ProgressBarStyle.Marquee, MarqueeAnimationSpeed = 25, Visible = false }; y += 8;
_lbPb = Lbl("Bitte warten…", new Point(m, y)); _lbPb.Visible = false; y += 22;
_body1.Controls.Add(_pb); _body1.Controls.Add(_lbPb);
// ── Log ───────────────────────────────────────────────────────────
_body1.Controls.Add(Lbl("Log · " + LOG, new Point(m, y), new Font("Cascadia Mono", 7.5f), CText2)); y += 16;
_log = new RichTextBox { Location = new Point(m, y), Size = new Size(w, 100), BackColor = Color.FromArgb(13, 17, 23), ForeColor = Color.FromArgb(100, 210, 130), Font = new Font("Cascadia Mono", 8f), ReadOnly = true, BorderStyle = BorderStyle.None };
_body1.Controls.Add(_log);
}
// ── Body 2: Migration ─────────────────────────────────────────────────
void BuildBody2()
{
int m = 16, y = 14, w = 588;
var infoCard = MkCard(m, y, w, 140); y += 152;
infoCard.Controls.Add(new Label { Text = "Was wird migriert", Font = new Font("Segoe UI", 10f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(20, 12), AutoSize = true });
infoCard.Controls.Add(new Panel { Location = new Point(20, 36), Size = new Size(w - 26, 1), BackColor = CBorder });
int iy = 46;
foreach (var (t, ok) in new[] {
("Dokumente, Desktop, Downloads, Bilder, Videos, Musik", true),
("AppData\\Roaming — Outlook, Edge, Chrome, App-Einstellungen", true),
("Hintergrundbild, Taskbar-Pins, Startmenü-Layout", true),
("Temp-Cache, NTUSER.DAT — nicht übertragen", false),
}) { infoCard.Controls.Add(Lbl((ok ? "✓ " : "✗ ") + t, new Point(20, iy), null, ok ? CGreen : CAmber)); iy += 24; }
_body2.Controls.Add(infoCard);
var selCard = MkCard(m, y, w, 108); y += 120;
selCard.Controls.Add(new Label { Text = "Profile auswählen", Font = new Font("Segoe UI", 10f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(20, 12), AutoSize = true });
selCard.Controls.Add(new Panel { Location = new Point(20, 36), Size = new Size(w - 26, 1), BackColor = CBorder });
selCard.Controls.Add(Lbl("Von (altes Entra-Profil):", new Point(20, 46)));
selCard.Controls.Add(Lbl("Nach (neues Domain-Profil):", new Point(20, 76)));
_cbSrc = Cmb(new Point(210, 43), new Size(w - 230, 24));
_cbDst = Cmb(new Point(210, 73), new Size(w - 230, 24));
selCard.Controls.Add(_cbSrc); selCard.Controls.Add(_cbDst);
_body2.Controls.Add(selCard);
_btnMig = new RoundButton("▶ Profil jetzt migrieren", CGreen, 10) { Location = new Point(m, y), Size = new Size(440, 44) }; y += 52;
_btnMig.Click += DoMig;
_lnkBack = new Label { Text = "← Zurück zu Domain Join", Font = new Font("Segoe UI", 9f), ForeColor = CText2, Location = new Point(m + 448, y - 40), AutoSize = true, Cursor = Cursors.Hand };
_lnkBack.Click += (s, e) => GoTo(0);
_lnkBack.MouseEnter += (s, e) => _lnkBack.ForeColor = CTeal;
_lnkBack.MouseLeave += (s, e) => _lnkBack.ForeColor = CText2;
_body2.Controls.Add(_btnMig); _body2.Controls.Add(_lnkBack);
// ── Status + Progress + Log (Tab 2) ──────────────────────────────
_lblMigStatus = new Label { Text = "", Font = new Font("Segoe UI", 9f, FontStyle.Bold), ForeColor = CTealD, Location = new Point(m, y), AutoSize = true };
_lblMigCount = new Label { Text = "", Font = new Font("Cascadia Mono", 8f), ForeColor = CText2, Location = new Point(m + 300, y), AutoSize = true };
y += 22;
_pbMig = new ProgressBar { Location = new Point(m, y), Size = new Size(w, 6), Style = ProgressBarStyle.Marquee, MarqueeAnimationSpeed = 20, Visible = false }; y += 14;
_logMig = new RichTextBox { Location = new Point(m, y), Size = new Size(w, 106), BackColor = Color.FromArgb(13, 17, 23), ForeColor = Color.FromArgb(100, 210, 130), Font = new Font("Cascadia Mono", 8f), ReadOnly = true, BorderStyle = BorderStyle.None, Visible = false };
_body2.Controls.Add(_lblMigStatus); _body2.Controls.Add(_lblMigCount);
_body2.Controls.Add(_pbMig); _body2.Controls.Add(_logMig);
}
// ── State ─────────────────────────────────────────────────────────────
void DetectStatus()
{
Task.Run(new Action(() => {
try { var r = Run("dsregcmd", "/status", 10000); _entra = r.Contains("AzureAdJoined : YES"); _domain = r.Contains("DomainJoined : YES"); foreach (var ln in r.Split('\n')) if (ln.Trim().StartsWith("DomainName :")) { _dn = ln.Split(':')[1].Trim(); break; } } catch { }
Invoke(new Action(() => { RefreshStat(); CheckAutoMig(); }));
}));
}
void RefreshStat()
{
if (_domain) { _badge.Mode = StatusBadge.BMode.Joined; _lblStat.Text = "Domain joined: " + _dn; _lblStat.ForeColor = CGreen; _btnJoin.Text = "↺ Domain wechseln"; _btnLeave.Text = "■ Verlassen"; }
else if (_entra) { _badge.Mode = StatusBadge.BMode.Entra; _lblStat.Text = "Entra ID joined — bereit für Domain-Join"; _lblStat.ForeColor = CTealD; }
else { _badge.Mode = StatusBadge.BMode.None; _lblStat.Text = "Workgroup — kein Domain-Join"; _lblStat.ForeColor = CAmber; _btnLeave.Enabled = false; }
}
void CheckAutoMig()
{
if (!File.Exists(FLG) || !_domain) return;
try {
var old = File.ReadAllText(FLG).Trim(); LoadProfiles();
for (int i = 0; i < _cbSrc.Items.Count; i++) if (_cbSrc.Items[i].ToString().Equals(old, StringComparison.OrdinalIgnoreCase)) { _cbSrc.SelectedIndex = i; break; }
var cur = Environment.UserName;
for (int i = 0; i < _cbDst.Items.Count; i++) if (_cbDst.Items[i].ToString().Equals(cur, StringComparison.OrdinalIgnoreCase)) { _cbDst.SelectedIndex = i; break; }
GoTo(1);
if (MessageBox.Show("Domain-Join erfolgreich!\n\nProfil migrieren?\n Von: C:\\Users\\" + old + "\n Nach: C:\\Users\\" + cur, "Profil Migration", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
DoMig(null, EventArgs.Empty);
else File.Delete(FLG);
} catch { }
}
void LoadProfiles()
{
if (_cbSrc == null) return;
var list = new List<string>();
if (Directory.Exists(@"C:\Users")) foreach (var d in Directory.GetDirectories(@"C:\Users")) { var n = Path.GetFileName(d); if (n != "Public" && n != "Default" && n != "Default User" && n != "All Users") list.Add(n); }
_cbSrc.Items.Clear(); _cbDst.Items.Clear();
foreach (var p in list) { _cbSrc.Items.Add(p); _cbDst.Items.Add(p); }
if (_cbSrc.Items.Count > 0) _cbSrc.SelectedIndex = 0;
if (_cbDst.Items.Count > 1) _cbDst.SelectedIndex = 1;
}
// ── Actions ───────────────────────────────────────────────────────────
async void DoJoin(object s, EventArgs e)
{
if (string.IsNullOrWhiteSpace(_txUser.Val) || string.IsNullOrWhiteSpace(_txPass.Val)) { MessageBox.Show("Bitte Benutzername und Passwort eingeben.", "Eingabe fehlt", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
if (MessageBox.Show("Computer wird der Domain '" + DOM + "' beigetreten.\n\nFortfahren?", "Bestätigung", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return;
Busy(true, "Domain-Wechsel läuft…"); _badge.Mode = StatusBadge.BMode.Working;
var user = _txUser.Val.Trim(); var pass = _txPass.Val;
await Task.Run(new Action(async () => {
if (_entra) { Prog("Entra ID verlassen…"); Log(Run("dsregcmd", "/leave", 30000)); await Task.Delay(2000); }
Prog("Domain joinen…");
if (!user.Contains("\\") && !user.Contains("@")) user = DOM + "\\" + user;
var res = PS("$pw=ConvertTo-SecureString '" + Esc(pass) + "' -AsPlainText -Force; $cr=New-Object System.Management.Automation.PSCredential('" + Esc(user) + "',$pw); Add-Computer -DomainName '" + DOM + "' -Credential $cr -Force 2>&1");
Log(res);
var ok = !res.ToLower().Contains("fehler") && !res.ToLower().Contains("error") && !res.ToLower().Contains("failed") && !res.ToLower().Contains("denied");
if (ok) SetupAutoMig();
Invoke(new Action(() => {
Busy(false, "");
if (ok) { if (MessageBox.Show("Erfolgreich!\n\nJetzt neu starten?", "✓ Fertig", MessageBoxButtons.YesNo, MessageBoxIcon.Information) == DialogResult.Yes) Run("shutdown", "/r /t 5 /c \"Domain Join\""); }
else MessageBox.Show("Fehlgeschlagen — Log prüfen.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error);
DetectStatus();
}));
}));
}
async void DoLeave(object s, EventArgs e)
{
if (MessageBox.Show("Computer verlässt " + (_domain ? "Domain '" + _dn + "'" : "Entra ID") + ".\n\nFortfahren?", "Bestätigung", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return;
Busy(true, "Verlasse…"); _badge.Mode = StatusBadge.BMode.Working;
await Task.Run(new Action(() => { Log(Run("dsregcmd", "/leave", 30000)); Invoke(new Action(() => { Busy(false, ""); MessageBox.Show("Fertig. Bitte neu starten.", "Erledigt", MessageBoxButtons.OK, MessageBoxIcon.Information); DetectStatus(); })); }));
}
void SetupAutoMig()
{
try { Directory.CreateDirectory(Path.GetDirectoryName(FLG)); File.WriteAllText(FLG, Environment.UserName); File.Copy(System.Reflection.Assembly.GetExecutingAssembly().Location, EXE, true); using (var k = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce", true)) k.SetValue("DomainJoinMigration", "\"" + EXE + "\""); Log("[AUTO] Migration startet nach nächstem Login automatisch"); } catch (Exception ex) { Log("[WARN] " + ex.Message); }
}
async void DoMig(object s, EventArgs e)
{
if (_cbSrc.SelectedItem == null || _cbDst.SelectedItem == null)
{
MessageBox.Show("Bitte Quell- und Zielprofil auswählen.", "Profil fehlt", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
var src = _cbSrc.SelectedItem.ToString();
var dst = _cbDst.SelectedItem.ToString();
if (src == dst) { MessageBox.Show("Quelle und Ziel sind identisch.", "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
var srcPath = @"C:\Users\" + src;
var dstPath = @"C:\Users\" + dst;
if (!Directory.Exists(srcPath)) { MessageBox.Show("Quellprofil nicht gefunden:\n" + srcPath, "Fehler", MessageBoxButtons.OK, MessageBoxIcon.Error); return; }
if (MessageBox.Show("Profil kopieren?\n\nVon: " + srcPath + "\nNach: " + dstPath + "\n\nDas kann mehrere Minuten dauern.", "Bestätigung", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return;
_btnMig.Enabled = false;
_logMig.Visible = true;
_logMig.Clear();
_pbMig.Visible = true;
_lblMigCount.Text = "";
MigLog("[START] " + src + " → " + dst);
MigStatus("⏳ Migration läuft…");
Log("[START] Profil-Migration: " + src + " → " + dst);
await Task.Run(new Action(() =>
{
try
{
if (!Directory.Exists(dstPath))
{
Directory.CreateDirectory(dstPath);
MigLog("[INFO] Zielordner angelegt: " + dstPath);
}
var args = "\"" + srcPath + "\" \"" + dstPath + "\""
+ " /E /XJ /XC /XN /XO"
+ " /XA:O"
+ " /XD Temp INetCache WebCache \"AppData\\Local\\Temp\" OneDrive \"Cereda Systems GmbH\" \"OneDrive - Cereda Systems GmbH\""
+ " /XF NTUSER.DAT ntuser.dat ntuser.ini \"*.regtrans-ms\" \"*.blf\""
+ " /R:0 /W:0 /NP";
MigLog("[CMD] robocopy " + args);
var psi = new System.Diagnostics.ProcessStartInfo(
@"C:\Windows\System32\robocopy.exe", args)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
int exitCode = -1;
int copiedFiles = 0, skippedFiles = 0;
using (var proc = System.Diagnostics.Process.Start(psi))
{
string line;
while ((line = proc.StandardOutput.ReadLine()) != null)
{
MigLog(line);
// Count copied files ("Neue Datei" or "New File")
if (line.Contains("Neue Datei") || line.Contains("New File"))
{
copiedFiles++;
MigCount("Kopiert: " + copiedFiles + " Dateien" + (skippedFiles > 0 ? " • Übersprungen: " + skippedFiles : ""));
}
else if (line.Contains("FEHLER") || line.Contains("ERROR"))
{
skippedFiles++;
MigCount("Kopiert: " + copiedFiles + " Dateien • Übersprungen: " + skippedFiles);
}
}
proc.WaitForExit(1800000);
exitCode = proc.ExitCode;
}
Invoke(new Action(() => _pbMig.Visible = false));
// robocopy: 0-7 = OK (0=nichts zu tun, 1=kopiert, 2=extra, etc.), 8+ = Fehler
if (exitCode >= 8)
{
MigLog("[FEHLER] ExitCode=" + exitCode);
MigStatus("✗ Fehler (ExitCode " + exitCode + ")");
}
else
{
MigLog("[OK] Abgeschlossen. ExitCode=" + exitCode);
MigStatus("✓ Migration abgeschlossen — " + copiedFiles + " Dateien kopiert");
}
try { if (File.Exists(FLG)) File.Delete(FLG); } catch { }
Invoke(new Action(() =>
{
_btnMig.Enabled = true;
MessageBox.Show("Migration abgeschlossen!\n\nDateien sind in:\n" + dstPath,
"✓ Fertig", MessageBoxButtons.OK, MessageBoxIcon.Information);
}));
}
catch (Exception ex)
{
MigLog("[EXCEPTION] " + ex.Message);
MigStatus("✗ Fehler aufgetreten");
Log("[EXCEPTION] Migration: " + ex.Message);
Invoke(new Action(() =>
{
_btnMig.Enabled = true;
MessageBox.Show("Fehler bei Migration:\n\n" + ex.Message, "Fehler",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}));
}
}));
}
// ── Helpers ───────────────────────────────────────────────────────────
void Busy(bool b, string m) { if (InvokeRequired) { Invoke(new Action(() => Busy(b, m))); return; } _btnJoin.Enabled = _btnLeave.Enabled = !b; _pb.Visible = _lbPb.Visible = b; if (!string.IsNullOrEmpty(m)) _lbPb.Text = m; }
void Prog(string m) { if (InvokeRequired) { Invoke(new Action(() => Prog(m))); return; } _lbPb.Text = m; Log(m); }
void Log(string t) { if (InvokeRequired) { Invoke(new Action(() => Log(t))); return; } if (string.IsNullOrWhiteSpace(t)) return; var ln = "[" + DateTime.Now.ToString("HH:mm:ss") + "] " + t.Trim(); _log?.AppendText(ln + "\n"); _log?.ScrollToCaret(); try { Directory.CreateDirectory(Path.GetDirectoryName(LOG)); File.AppendAllText(LOG, DateTime.Now.ToString("yyyy-MM-dd") + " " + ln + Environment.NewLine); } catch { } }
void MigLog(string t) { if (InvokeRequired) { Invoke(new Action(() => MigLog(t))); return; } if (string.IsNullOrWhiteSpace(t)) return; _logMig?.AppendText(t.TrimEnd() + "\n"); _logMig?.ScrollToCaret(); }
void MigStatus(string t) { if (InvokeRequired) { Invoke(new Action(() => MigStatus(t))); return; } if (_lblMigStatus != null) { _lblMigStatus.Text = t; _lblMigStatus.ForeColor = t.StartsWith("✓") ? CGreen : t.StartsWith("✗") ? CRed : CTealD; } }
void MigCount(string t) { if (InvokeRequired) { Invoke(new Action(() => MigCount(t))); return; } if (_lblMigCount != null) _lblMigCount.Text = t; }
// Same pattern as ZeloServerVerwaltung
Panel MkCard(int x, int y, int w, int h)
{
var p = new Panel { Location = new Point(x, y), Size = new Size(w, h), BackColor = Color.White };
var accent = new Panel { Location = Point.Empty, Size = new Size(5, h), BackColor = CTeal };
p.Controls.Add(accent);
p.Paint += (s, e) =>
{
var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias;
// Border
using (var path = RR(1, 1, w - 2, h - 2, 10))
using (var pen = new Pen(Color.FromArgb(215, 225, 235), 1f)) g.DrawPath(pen, path);
// Subtle bottom shadow line
using (var pen = new Pen(Color.FromArgb(50, 0, 100, 150), 1.5f))
g.DrawLine(pen, 10, h - 1, w - 4, h - 1);
};
using (var path = RR(0, 0, w, h, 10))
p.Region = new Region(path);
return p;
}
Label Lbl(string t, Point loc, Font f = null, Color? c = null) => new Label { Text = t, Font = f ?? new Font("Segoe UI", 9f), ForeColor = c ?? CText2, Location = loc, AutoSize = true };
ModernInput Inp(Point loc, Size sz, string placeholder = "", bool pw = false)
{
return new ModernInput(placeholder, pw) { Location = loc, Size = sz };
}
ComboBox Cmb(Point loc, Size sz) => new ComboBox { Location = loc, Size = sz, DropDownStyle = ComboBoxStyle.DropDownList, FlatStyle = FlatStyle.Flat, Font = new Font("Segoe UI", 9f) };
static GraphicsPath RR(int x, int y, int w, int h, int r) { var p = new GraphicsPath(); p.AddArc(x, y, r * 2, r * 2, 180, 90); p.AddArc(x + w - r * 2, y, r * 2, r * 2, 270, 90); p.AddArc(x + w - r * 2, y + h - r * 2, r * 2, r * 2, 0, 90); p.AddArc(x, y + h - r * 2, r * 2, r * 2, 90, 90); p.CloseFigure(); return p; }
static Icon MakeIcon()
{
var bmp = new Bitmap(32, 32);
using (var g = Graphics.FromImage(bmp))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
// Teal rounded square
using (var path = RR(0, 0, 32, 32, 6))
using (var br = new SolidBrush(ColorTranslator.FromHtml("#0D9488")))
g.FillPath(br, path);
// White "C" letter
var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
g.DrawString("C", new Font("Segoe UI", 16f, FontStyle.Bold), Brushes.White, new RectangleF(0, 0, 32, 32), sf);
}
IntPtr hIcon = bmp.GetHicon();
return Icon.FromHandle(hIcon);
}
string Run(string exe, string args, int ms = 60000) { var psi = new ProcessStartInfo(exe, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using (var p = Process.Start(psi)) { var o = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); p.WaitForExit(ms); return o; } }
string PS(string sc) => Run("powershell.exe", "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command \"" + sc.Replace("\"", "\\\"") + "\"", 120000);
static string Esc(string s) => s.Replace("'", "''");
protected override void OnFormClosed(FormClosedEventArgs e) { _animTimer?.Stop(); _animTimer?.Dispose(); base.OnFormClosed(e); }
// ── ModernInput — Floating-Label + Fokus-Effekt + Validierung ─────────
class ModernInput : Panel
{
readonly Label _lbl;
readonly TextBox _txt;
readonly Label _icon;
bool _focused, _ready;
static readonly Color Teal = ColorTranslator.FromHtml("#0D9488");
static readonly Color Gray = ColorTranslator.FromHtml("#94A3B8");
static readonly Color Border = ColorTranslator.FromHtml("#E2E8F0");
static readonly Color Green = ColorTranslator.FromHtml("#059669");
static readonly Color Red = ColorTranslator.FromHtml("#DC2626");
public string Val { get => _txt.Text; set { _txt.Text = value; Update_(); } }
public ModernInput(string placeholder, bool pw = false)
{
DoubleBuffered = true;
SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint |
ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true);
BackColor = Color.Transparent; Height = 52;
_lbl = new Label {
Text = placeholder,
Font = new Font("Segoe UI", 9.5f),
ForeColor = Gray,
Location = new Point(13, 15),
AutoSize = true
};
_txt = new TextBox {
BorderStyle = BorderStyle.None,
Location = new Point(13, 28),
Font = new Font("Segoe UI", 10.5f),
BackColor = Color.White
};
if (pw) _txt.PasswordChar = '●';
// Validation icon
_icon = new Label {
Font = new Font("Segoe UI", 12f),
Text = "",
AutoSize = true,
ForeColor = Gray
};
_txt.GotFocus += (s, e) => { _focused = true; Update_(); Invalidate(); };
_txt.LostFocus += (s, e) => { _focused = false; Update_(); Invalidate(); };
_txt.TextChanged += (s, e) => { Update_(); Invalidate(); };
Controls.Add(_lbl);
Controls.Add(_txt);
Controls.Add(_icon);
_ready = true;
}
void Update_()
{
bool fl = _focused || !string.IsNullOrEmpty(_txt.Text);
_lbl.Font = new Font("Segoe UI", fl ? 7.5f : 9.5f);
_lbl.Location = new Point(13, fl ? 6 : 15);
_lbl.ForeColor = _focused ? Teal : Gray;
bool filled = !string.IsNullOrWhiteSpace(_txt.Text);
_icon.Text = filled ? "✓" : "";
_icon.ForeColor = filled ? Green : Gray;
if (_icon.Width > 0)
_icon.Location = new Point(Width - _icon.Width - 12, (Height - _icon.Height) / 2);
}
protected override void OnResize(EventArgs e)
{
base.OnResize(e);
if (!_ready || _txt == null) return;
_txt.Width = Width - 32;
Update_();
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.SmoothingMode = SmoothingMode.AntiAlias;
// White background
using (var br = new SolidBrush(Color.White))
g.FillRectangle(br, 0, 0, Width, Height);
// Border
var bc = _focused ? Teal : Border;
using (var pen = new Pen(bc, _focused ? 2f : 1f))
g.DrawRectangle(pen, 0, 0, Width - 1, Height - 1);
// Bottom accent line on focus
if (_focused)
using (var pen = new Pen(Teal, 2.5f))
g.DrawLine(pen, 2, Height - 1, Width - 3, Height - 1);
}
}
// ── RoundButton (same as ZeloServerVerwaltung) ────────────────────────
class RoundButton : Control
{
bool _hov, _press; readonly Color _base, _hov2, _press2; readonly int _r;
public RoundButton(string text, Color baseColor, int radius = 8)
{
_base = baseColor; _hov2 = ControlPaint.Dark(baseColor, 0.10f); _press2 = ControlPaint.Dark(baseColor, 0.20f); _r = radius;
Text = text; Font = new Font("Segoe UI", 9f, FontStyle.Bold); Cursor = Cursors.Hand;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);
}
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias;
g.Clear(Parent?.BackColor ?? Color.White);
var fill = _press ? _press2 : (_hov && Enabled) ? _hov2 : _base;
using (var path = RR(0, 0, Width, Height, _r)) using (var br = new SolidBrush(fill)) g.FillPath(br, path);
if (!Enabled) using (var path = RR(0, 0, Width, Height, _r)) using (var br = new SolidBrush(Color.FromArgb(110, 255, 255, 255))) g.FillPath(br, path);
TextRenderer.DrawText(e.Graphics, Text, Font, new Rectangle(0, 0, Width, Height), Color.White, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
protected override void OnMouseEnter(EventArgs e) { _hov = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { _hov = false; _press = false; Invalidate(); base.OnMouseLeave(e); }
protected override void OnMouseDown(MouseEventArgs e) { if (e.Button == MouseButtons.Left) { _press = true; Invalidate(); } base.OnMouseDown(e); }
protected override void OnMouseUp(MouseEventArgs e) { _press = false; Invalidate(); base.OnMouseUp(e); }
protected override void OnEnabledChanged(EventArgs e) { Cursor = Enabled ? Cursors.Hand : Cursors.Default; Invalidate(); base.OnEnabledChanged(e); }
}
// ── StatusBadge (adapted from ZeloServerVerwaltung) ───────────────────
class StatusBadge : Control
{
public enum BMode { Entra, Joined, None, Working }
BMode _mode; float _phase, _spin;
public BMode Mode { get => _mode; set { _mode = value; Invalidate(); } }
public StatusBadge() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); Size = new Size(230, 28); BackColor = Color.White; }
public void Tick(float ph, float sp) { _phase = ph; _spin = sp; if (_mode != BMode.None) Invalidate(); }
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; g.Clear(Color.White);
Color bg; string txt;
switch (_mode) {
case BMode.Joined: bg = ColorTranslator.FromHtml("#059669"); txt = "DOMAIN JOINED"; break;
case BMode.Entra: bg = ColorTranslator.FromHtml("#0D9488"); txt = "ENTRA ID JOINED"; break;
case BMode.Working: bg = ColorTranslator.FromHtml("#D97706"); txt = "BITTE WARTEN..."; break;
default: bg = ColorTranslator.FromHtml("#6B7280"); txt = "WORKGROUP"; break;
}
int r = Height / 2;
using (var path = new GraphicsPath()) { path.AddArc(0, 0, r * 2, r * 2, 90, 180); path.AddArc(Width - r * 2, 0, r * 2, r * 2, 270, 180); path.CloseFigure(); using (var br = new SolidBrush(bg)) g.FillPath(br, path); }
// Dot
int dx = r, dy = r, dr = 5;
if (_mode == BMode.Working) { using (var p2 = new Pen(Color.FromArgb(60, Color.White), 2f)) g.DrawEllipse(p2, dx - dr, dy - dr, dr * 2, dr * 2); using (var p2 = new Pen(Color.White, 2f) { StartCap = LineCap.Round, EndCap = LineCap.Round }) g.DrawArc(p2, dx - dr, dy - dr, dr * 2, dr * 2, _spin, 100f); }
else { double pv = Math.Sin(_phase * Math.PI * 2) * 0.5 + 0.5; int gr = dr + (int)(pv * 2); using (var b = new SolidBrush(Color.FromArgb(60, Color.White))) g.FillEllipse(b, dx - gr, dy - gr, gr * 2, gr * 2); using (var b = new SolidBrush(Color.White)) g.FillEllipse(b, dx - dr, dy - dr, dr * 2, dr * 2); }
// Text
var rect = new RectangleF(r * 2 + 6, 0, Width - r * 2 - 6 - r, Height);
var sf = new StringFormat { Alignment = StringAlignment.Near, LineAlignment = StringAlignment.Center };
using (var f = new Font("Segoe UI", 8.5f, FontStyle.Bold)) using (var br = new SolidBrush(Color.White)) g.DrawString(txt, f, br, rect, sf);
}
}
}
}

View File

@@ -1,30 +0,0 @@
using System;
using System.Windows.Forms;
namespace DomainJoinTool
{
internal static class Program
{
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
Application.ThreadException += (s, e) =>
System.IO.File.AppendAllText(@"C:\ProgramData\DomainJoinTool\crash.log",
DateTime.Now + ": " + e.Exception + Environment.NewLine);
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
System.IO.File.AppendAllText(@"C:\ProgramData\DomainJoinTool\crash.log",
DateTime.Now + ": " + e.ExceptionObject + Environment.NewLine);
try
{
Application.Run(new MainForm());
}
catch (Exception ex)
{
MessageBox.Show("Startfehler: " + ex.Message + "\n\n" + ex.StackTrace, "Fehler");
}
}
}
}

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="DomainJoinTool.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- Braucht Admin-Rechte für dsregcmd und Add-Computer -->
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,63 +0,0 @@
{
"format": 1,
"restore": {
"C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj": {}
},
"projects": {
"C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
"projectName": "DomainJoinTool",
"projectPath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
"packagesPath": "C:\\Users\\gruessing\\.nuget\\packages\\",
"outputPath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\obj\\",
"projectStyle": "PackageReference",
"configFilePaths": [
"C:\\Users\\gruessing\\AppData\\Roaming\\NuGet\\NuGet.Config"
],
"originalTargetFrameworks": [
"net472"
],
"sources": {
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net472": {
"targetAlias": "net472",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
}
},
"frameworks": {
"net472": {
"targetAlias": "net472",
"dependencies": {
"Microsoft.NETFramework.ReferenceAssemblies": {
"suppressParent": "All",
"target": "Package",
"version": "[1.0.3, )",
"autoReferenced": true
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\8.0.421\\RuntimeIdentifierGraph.json"
}
},
"runtimes": {
"win7-x64": {
"#import": []
}
}
}
}
}

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<RestoreSuccess Condition=" '$(RestoreSuccess)' == '' ">True</RestoreSuccess>
<RestoreTool Condition=" '$(RestoreTool)' == '' ">NuGet</RestoreTool>
<ProjectAssetsFile Condition=" '$(ProjectAssetsFile)' == '' ">$(MSBuildThisFileDirectory)project.assets.json</ProjectAssetsFile>
<NuGetPackageRoot Condition=" '$(NuGetPackageRoot)' == '' ">$(UserProfile)\.nuget\packages\</NuGetPackageRoot>
<NuGetPackageFolders Condition=" '$(NuGetPackageFolders)' == '' ">C:\Users\gruessing\.nuget\packages\</NuGetPackageFolders>
<NuGetProjectStyle Condition=" '$(NuGetProjectStyle)' == '' ">PackageReference</NuGetProjectStyle>
<NuGetToolVersion Condition=" '$(NuGetToolVersion)' == '' ">6.11.2</NuGetToolVersion>
</PropertyGroup>
<ItemGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<SourceRoot Include="C:\Users\gruessing\.nuget\packages\" />
</ItemGroup>
</Project>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<Project ToolsVersion="14.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ImportGroup Condition=" '$(ExcludeRestorePackageImports)' != 'true' ">
<Import Project="$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net472\1.0.3\build\Microsoft.NETFramework.ReferenceAssemblies.net472.targets" Condition="Exists('$(NuGetPackageRoot)microsoft.netframework.referenceassemblies.net472\1.0.3\build\Microsoft.NETFramework.ReferenceAssemblies.net472.targets')" />
</ImportGroup>
</Project>

View File

@@ -1,4 +0,0 @@
// <autogenerated />
using System;
using System.Reflection;
[assembly: global::System.Runtime.Versioning.TargetFrameworkAttribute(".NETFramework,Version=v4.7.2", FrameworkDisplayName = ".NET Framework 4.7.2")]

View File

@@ -1,22 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("DomainJoinTool")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Release")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("DomainJoinTool")]
[assembly: System.Reflection.AssemblyTitleAttribute("DomainJoinTool")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
// Von der MSBuild WriteCodeFragment-Klasse generiert.

View File

@@ -1 +0,0 @@
d0c739f07a28fc8609fc4a098983a9d7bd13f38d051a4df2bc76519c63de7a08

View File

@@ -1,11 +0,0 @@
is_global = true
build_property.ApplicationManifest = app.manifest
build_property.StartupObject =
build_property.ApplicationDefaultFont =
build_property.ApplicationHighDpiMode =
build_property.ApplicationUseCompatibleTextRendering =
build_property.ApplicationVisualStyles =
build_property.RootNamespace = DomainJoinTool
build_property.ProjectDir = C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =

View File

@@ -1 +0,0 @@
074cd1570727f0b97007ce35e18f8b37cb149bef28991a72fe6b298b163c25a9

View File

@@ -1,8 +0,0 @@
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.csproj.AssemblyReference.cache
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.GeneratedMSBuildEditorConfig.editorconfig
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.AssemblyInfoInputs.cache
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.AssemblyInfo.cs
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.csproj.CoreCompileInputs.cache
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\bin\Release\net472\DomainJoinTool.exe.config
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\bin\Release\net472\DomainJoinTool.exe
C:\Users\gruessing\OneDrive - Cereda Systems GmbH\Desktop\IT Tool\domain-join-tool\obj\Release\net472\DomainJoinTool.exe

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2" />
</startup>
</configuration>

View File

@@ -1,489 +0,0 @@
{
"version": 3,
"targets": {
".NETFramework,Version=v4.7.2": {
"Microsoft.NETFramework.ReferenceAssemblies/1.0.3": {
"type": "package",
"dependencies": {
"Microsoft.NETFramework.ReferenceAssemblies.net472": "1.0.3"
}
},
"Microsoft.NETFramework.ReferenceAssemblies.net472/1.0.3": {
"type": "package",
"build": {
"build/Microsoft.NETFramework.ReferenceAssemblies.net472.targets": {}
}
}
},
".NETFramework,Version=v4.7.2/win7-x64": {
"Microsoft.NETFramework.ReferenceAssemblies/1.0.3": {
"type": "package",
"dependencies": {
"Microsoft.NETFramework.ReferenceAssemblies.net472": "1.0.3"
}
},
"Microsoft.NETFramework.ReferenceAssemblies.net472/1.0.3": {
"type": "package",
"build": {
"build/Microsoft.NETFramework.ReferenceAssemblies.net472.targets": {}
}
}
}
},
"libraries": {
"Microsoft.NETFramework.ReferenceAssemblies/1.0.3": {
"sha512": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==",
"type": "package",
"path": "microsoft.netframework.referenceassemblies/1.0.3",
"files": [
".nupkg.metadata",
".signature.p7s",
"microsoft.netframework.referenceassemblies.1.0.3.nupkg.sha512",
"microsoft.netframework.referenceassemblies.nuspec"
]
},
"Microsoft.NETFramework.ReferenceAssemblies.net472/1.0.3": {
"sha512": "0E7evZXHXaDYYiLRfpyXvCh+yzM2rNTyuZDI+ZO7UUqSc6GfjePiXTdqJGtgIKUwdI81tzQKmaWprnUiPj9hAw==",
"type": "package",
"path": "microsoft.netframework.referenceassemblies.net472/1.0.3",
"files": [
".nupkg.metadata",
".signature.p7s",
"build/.NETFramework/v4.7.2/Accessibility.dll",
"build/.NETFramework/v4.7.2/Accessibility.xml",
"build/.NETFramework/v4.7.2/CustomMarshalers.dll",
"build/.NETFramework/v4.7.2/CustomMarshalers.xml",
"build/.NETFramework/v4.7.2/Facades/Microsoft.Win32.Primitives.dll",
"build/.NETFramework/v4.7.2/Facades/System.AppContext.dll",
"build/.NETFramework/v4.7.2/Facades/System.Collections.Concurrent.dll",
"build/.NETFramework/v4.7.2/Facades/System.Collections.NonGeneric.dll",
"build/.NETFramework/v4.7.2/Facades/System.Collections.Specialized.dll",
"build/.NETFramework/v4.7.2/Facades/System.Collections.dll",
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.Annotations.dll",
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.EventBasedAsync.dll",
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.Primitives.dll",
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.TypeConverter.dll",
"build/.NETFramework/v4.7.2/Facades/System.ComponentModel.dll",
"build/.NETFramework/v4.7.2/Facades/System.Console.dll",
"build/.NETFramework/v4.7.2/Facades/System.Data.Common.dll",
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.Contracts.dll",
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.Debug.dll",
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.FileVersionInfo.dll",
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.Process.dll",
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.StackTrace.dll",
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.TextWriterTraceListener.dll",
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.Tools.dll",
"build/.NETFramework/v4.7.2/Facades/System.Diagnostics.TraceSource.dll",
"build/.NETFramework/v4.7.2/Facades/System.Drawing.Primitives.dll",
"build/.NETFramework/v4.7.2/Facades/System.Dynamic.Runtime.dll",
"build/.NETFramework/v4.7.2/Facades/System.Globalization.Calendars.dll",
"build/.NETFramework/v4.7.2/Facades/System.Globalization.Extensions.dll",
"build/.NETFramework/v4.7.2/Facades/System.Globalization.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.Compression.ZipFile.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.FileSystem.DriveInfo.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.FileSystem.Primitives.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.FileSystem.Watcher.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.FileSystem.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.IsolatedStorage.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.MemoryMappedFiles.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.Pipes.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.UnmanagedMemoryStream.dll",
"build/.NETFramework/v4.7.2/Facades/System.IO.dll",
"build/.NETFramework/v4.7.2/Facades/System.Linq.Expressions.dll",
"build/.NETFramework/v4.7.2/Facades/System.Linq.Parallel.dll",
"build/.NETFramework/v4.7.2/Facades/System.Linq.Queryable.dll",
"build/.NETFramework/v4.7.2/Facades/System.Linq.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.Http.Rtc.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.NameResolution.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.NetworkInformation.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.Ping.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.Primitives.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.Requests.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.Security.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.Sockets.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.WebHeaderCollection.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.WebSockets.Client.dll",
"build/.NETFramework/v4.7.2/Facades/System.Net.WebSockets.dll",
"build/.NETFramework/v4.7.2/Facades/System.ObjectModel.dll",
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Emit.ILGeneration.dll",
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Emit.Lightweight.dll",
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Emit.dll",
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Extensions.dll",
"build/.NETFramework/v4.7.2/Facades/System.Reflection.Primitives.dll",
"build/.NETFramework/v4.7.2/Facades/System.Reflection.dll",
"build/.NETFramework/v4.7.2/Facades/System.Resources.Reader.dll",
"build/.NETFramework/v4.7.2/Facades/System.Resources.ResourceManager.dll",
"build/.NETFramework/v4.7.2/Facades/System.Resources.Writer.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.CompilerServices.VisualC.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Extensions.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Handles.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.InteropServices.RuntimeInformation.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.InteropServices.WindowsRuntime.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.InteropServices.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Numerics.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Serialization.Formatters.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Serialization.Json.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Serialization.Primitives.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.Serialization.Xml.dll",
"build/.NETFramework/v4.7.2/Facades/System.Runtime.dll",
"build/.NETFramework/v4.7.2/Facades/System.Security.Claims.dll",
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.Algorithms.dll",
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.Csp.dll",
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.Encoding.dll",
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.Primitives.dll",
"build/.NETFramework/v4.7.2/Facades/System.Security.Cryptography.X509Certificates.dll",
"build/.NETFramework/v4.7.2/Facades/System.Security.Principal.dll",
"build/.NETFramework/v4.7.2/Facades/System.Security.SecureString.dll",
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.Duplex.dll",
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.Http.dll",
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.NetTcp.dll",
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.Primitives.dll",
"build/.NETFramework/v4.7.2/Facades/System.ServiceModel.Security.dll",
"build/.NETFramework/v4.7.2/Facades/System.Text.Encoding.Extensions.dll",
"build/.NETFramework/v4.7.2/Facades/System.Text.Encoding.dll",
"build/.NETFramework/v4.7.2/Facades/System.Text.RegularExpressions.dll",
"build/.NETFramework/v4.7.2/Facades/System.Threading.Overlapped.dll",
"build/.NETFramework/v4.7.2/Facades/System.Threading.Tasks.Parallel.dll",
"build/.NETFramework/v4.7.2/Facades/System.Threading.Tasks.dll",
"build/.NETFramework/v4.7.2/Facades/System.Threading.Thread.dll",
"build/.NETFramework/v4.7.2/Facades/System.Threading.ThreadPool.dll",
"build/.NETFramework/v4.7.2/Facades/System.Threading.Timer.dll",
"build/.NETFramework/v4.7.2/Facades/System.Threading.dll",
"build/.NETFramework/v4.7.2/Facades/System.ValueTuple.dll",
"build/.NETFramework/v4.7.2/Facades/System.Xml.ReaderWriter.dll",
"build/.NETFramework/v4.7.2/Facades/System.Xml.XDocument.dll",
"build/.NETFramework/v4.7.2/Facades/System.Xml.XPath.XDocument.dll",
"build/.NETFramework/v4.7.2/Facades/System.Xml.XPath.dll",
"build/.NETFramework/v4.7.2/Facades/System.Xml.XmlDocument.dll",
"build/.NETFramework/v4.7.2/Facades/System.Xml.XmlSerializer.dll",
"build/.NETFramework/v4.7.2/Facades/netstandard.dll",
"build/.NETFramework/v4.7.2/ISymWrapper.dll",
"build/.NETFramework/v4.7.2/ISymWrapper.xml",
"build/.NETFramework/v4.7.2/Microsoft.Activities.Build.dll",
"build/.NETFramework/v4.7.2/Microsoft.Activities.Build.xml",
"build/.NETFramework/v4.7.2/Microsoft.Build.Conversion.v4.0.dll",
"build/.NETFramework/v4.7.2/Microsoft.Build.Conversion.v4.0.xml",
"build/.NETFramework/v4.7.2/Microsoft.Build.Engine.dll",
"build/.NETFramework/v4.7.2/Microsoft.Build.Engine.xml",
"build/.NETFramework/v4.7.2/Microsoft.Build.Framework.dll",
"build/.NETFramework/v4.7.2/Microsoft.Build.Framework.xml",
"build/.NETFramework/v4.7.2/Microsoft.Build.Tasks.v4.0.dll",
"build/.NETFramework/v4.7.2/Microsoft.Build.Tasks.v4.0.xml",
"build/.NETFramework/v4.7.2/Microsoft.Build.Utilities.v4.0.dll",
"build/.NETFramework/v4.7.2/Microsoft.Build.Utilities.v4.0.xml",
"build/.NETFramework/v4.7.2/Microsoft.Build.dll",
"build/.NETFramework/v4.7.2/Microsoft.Build.xml",
"build/.NETFramework/v4.7.2/Microsoft.CSharp.dll",
"build/.NETFramework/v4.7.2/Microsoft.CSharp.xml",
"build/.NETFramework/v4.7.2/Microsoft.JScript.dll",
"build/.NETFramework/v4.7.2/Microsoft.JScript.xml",
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.Compatibility.Data.dll",
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.Compatibility.Data.xml",
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.Compatibility.dll",
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.Compatibility.xml",
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.dll",
"build/.NETFramework/v4.7.2/Microsoft.VisualBasic.xml",
"build/.NETFramework/v4.7.2/Microsoft.VisualC.STLCLR.dll",
"build/.NETFramework/v4.7.2/Microsoft.VisualC.STLCLR.xml",
"build/.NETFramework/v4.7.2/Microsoft.VisualC.dll",
"build/.NETFramework/v4.7.2/Microsoft.VisualC.xml",
"build/.NETFramework/v4.7.2/PermissionSets/FullTrust.xml",
"build/.NETFramework/v4.7.2/PermissionSets/Internet.xml",
"build/.NETFramework/v4.7.2/PermissionSets/LocalIntranet.xml",
"build/.NETFramework/v4.7.2/PresentationBuildTasks.dll",
"build/.NETFramework/v4.7.2/PresentationBuildTasks.xml",
"build/.NETFramework/v4.7.2/PresentationCore.dll",
"build/.NETFramework/v4.7.2/PresentationCore.xml",
"build/.NETFramework/v4.7.2/PresentationFramework.Aero.dll",
"build/.NETFramework/v4.7.2/PresentationFramework.Aero.xml",
"build/.NETFramework/v4.7.2/PresentationFramework.Aero2.dll",
"build/.NETFramework/v4.7.2/PresentationFramework.Aero2.xml",
"build/.NETFramework/v4.7.2/PresentationFramework.AeroLite.dll",
"build/.NETFramework/v4.7.2/PresentationFramework.AeroLite.xml",
"build/.NETFramework/v4.7.2/PresentationFramework.Classic.dll",
"build/.NETFramework/v4.7.2/PresentationFramework.Classic.xml",
"build/.NETFramework/v4.7.2/PresentationFramework.Luna.dll",
"build/.NETFramework/v4.7.2/PresentationFramework.Luna.xml",
"build/.NETFramework/v4.7.2/PresentationFramework.Royale.dll",
"build/.NETFramework/v4.7.2/PresentationFramework.Royale.xml",
"build/.NETFramework/v4.7.2/PresentationFramework.dll",
"build/.NETFramework/v4.7.2/PresentationFramework.xml",
"build/.NETFramework/v4.7.2/ReachFramework.dll",
"build/.NETFramework/v4.7.2/ReachFramework.xml",
"build/.NETFramework/v4.7.2/RedistList/FrameworkList.xml",
"build/.NETFramework/v4.7.2/System.Activities.Core.Presentation.dll",
"build/.NETFramework/v4.7.2/System.Activities.Core.Presentation.xml",
"build/.NETFramework/v4.7.2/System.Activities.DurableInstancing.dll",
"build/.NETFramework/v4.7.2/System.Activities.DurableInstancing.xml",
"build/.NETFramework/v4.7.2/System.Activities.Presentation.dll",
"build/.NETFramework/v4.7.2/System.Activities.Presentation.xml",
"build/.NETFramework/v4.7.2/System.Activities.dll",
"build/.NETFramework/v4.7.2/System.Activities.xml",
"build/.NETFramework/v4.7.2/System.AddIn.Contract.dll",
"build/.NETFramework/v4.7.2/System.AddIn.Contract.xml",
"build/.NETFramework/v4.7.2/System.AddIn.dll",
"build/.NETFramework/v4.7.2/System.AddIn.xml",
"build/.NETFramework/v4.7.2/System.ComponentModel.Composition.Registration.dll",
"build/.NETFramework/v4.7.2/System.ComponentModel.Composition.Registration.xml",
"build/.NETFramework/v4.7.2/System.ComponentModel.Composition.dll",
"build/.NETFramework/v4.7.2/System.ComponentModel.Composition.xml",
"build/.NETFramework/v4.7.2/System.ComponentModel.DataAnnotations.dll",
"build/.NETFramework/v4.7.2/System.ComponentModel.DataAnnotations.xml",
"build/.NETFramework/v4.7.2/System.Configuration.Install.dll",
"build/.NETFramework/v4.7.2/System.Configuration.Install.xml",
"build/.NETFramework/v4.7.2/System.Configuration.dll",
"build/.NETFramework/v4.7.2/System.Configuration.xml",
"build/.NETFramework/v4.7.2/System.Core.dll",
"build/.NETFramework/v4.7.2/System.Core.xml",
"build/.NETFramework/v4.7.2/System.Data.DataSetExtensions.dll",
"build/.NETFramework/v4.7.2/System.Data.DataSetExtensions.xml",
"build/.NETFramework/v4.7.2/System.Data.Entity.Design.dll",
"build/.NETFramework/v4.7.2/System.Data.Entity.Design.xml",
"build/.NETFramework/v4.7.2/System.Data.Entity.dll",
"build/.NETFramework/v4.7.2/System.Data.Entity.xml",
"build/.NETFramework/v4.7.2/System.Data.Linq.dll",
"build/.NETFramework/v4.7.2/System.Data.Linq.xml",
"build/.NETFramework/v4.7.2/System.Data.OracleClient.dll",
"build/.NETFramework/v4.7.2/System.Data.OracleClient.xml",
"build/.NETFramework/v4.7.2/System.Data.Services.Client.dll",
"build/.NETFramework/v4.7.2/System.Data.Services.Client.xml",
"build/.NETFramework/v4.7.2/System.Data.Services.Design.dll",
"build/.NETFramework/v4.7.2/System.Data.Services.Design.xml",
"build/.NETFramework/v4.7.2/System.Data.Services.dll",
"build/.NETFramework/v4.7.2/System.Data.Services.xml",
"build/.NETFramework/v4.7.2/System.Data.SqlXml.dll",
"build/.NETFramework/v4.7.2/System.Data.SqlXml.xml",
"build/.NETFramework/v4.7.2/System.Data.dll",
"build/.NETFramework/v4.7.2/System.Data.xml",
"build/.NETFramework/v4.7.2/System.Deployment.dll",
"build/.NETFramework/v4.7.2/System.Deployment.xml",
"build/.NETFramework/v4.7.2/System.Design.dll",
"build/.NETFramework/v4.7.2/System.Design.xml",
"build/.NETFramework/v4.7.2/System.Device.dll",
"build/.NETFramework/v4.7.2/System.Device.xml",
"build/.NETFramework/v4.7.2/System.Diagnostics.Tracing.dll",
"build/.NETFramework/v4.7.2/System.Diagnostics.Tracing.xml",
"build/.NETFramework/v4.7.2/System.DirectoryServices.AccountManagement.dll",
"build/.NETFramework/v4.7.2/System.DirectoryServices.AccountManagement.xml",
"build/.NETFramework/v4.7.2/System.DirectoryServices.Protocols.dll",
"build/.NETFramework/v4.7.2/System.DirectoryServices.Protocols.xml",
"build/.NETFramework/v4.7.2/System.DirectoryServices.dll",
"build/.NETFramework/v4.7.2/System.DirectoryServices.xml",
"build/.NETFramework/v4.7.2/System.Drawing.Design.dll",
"build/.NETFramework/v4.7.2/System.Drawing.Design.xml",
"build/.NETFramework/v4.7.2/System.Drawing.dll",
"build/.NETFramework/v4.7.2/System.Drawing.xml",
"build/.NETFramework/v4.7.2/System.Dynamic.dll",
"build/.NETFramework/v4.7.2/System.EnterpriseServices.Thunk.dll",
"build/.NETFramework/v4.7.2/System.EnterpriseServices.Wrapper.dll",
"build/.NETFramework/v4.7.2/System.EnterpriseServices.dll",
"build/.NETFramework/v4.7.2/System.EnterpriseServices.xml",
"build/.NETFramework/v4.7.2/System.IO.Compression.FileSystem.dll",
"build/.NETFramework/v4.7.2/System.IO.Compression.FileSystem.xml",
"build/.NETFramework/v4.7.2/System.IO.Compression.dll",
"build/.NETFramework/v4.7.2/System.IO.Compression.xml",
"build/.NETFramework/v4.7.2/System.IO.Log.dll",
"build/.NETFramework/v4.7.2/System.IO.Log.xml",
"build/.NETFramework/v4.7.2/System.IdentityModel.Selectors.dll",
"build/.NETFramework/v4.7.2/System.IdentityModel.Selectors.xml",
"build/.NETFramework/v4.7.2/System.IdentityModel.Services.dll",
"build/.NETFramework/v4.7.2/System.IdentityModel.Services.xml",
"build/.NETFramework/v4.7.2/System.IdentityModel.dll",
"build/.NETFramework/v4.7.2/System.IdentityModel.xml",
"build/.NETFramework/v4.7.2/System.Linq.xml",
"build/.NETFramework/v4.7.2/System.Management.Instrumentation.dll",
"build/.NETFramework/v4.7.2/System.Management.Instrumentation.xml",
"build/.NETFramework/v4.7.2/System.Management.dll",
"build/.NETFramework/v4.7.2/System.Management.xml",
"build/.NETFramework/v4.7.2/System.Messaging.dll",
"build/.NETFramework/v4.7.2/System.Messaging.xml",
"build/.NETFramework/v4.7.2/System.Net.Http.WebRequest.dll",
"build/.NETFramework/v4.7.2/System.Net.Http.WebRequest.xml",
"build/.NETFramework/v4.7.2/System.Net.Http.dll",
"build/.NETFramework/v4.7.2/System.Net.Http.xml",
"build/.NETFramework/v4.7.2/System.Net.dll",
"build/.NETFramework/v4.7.2/System.Net.xml",
"build/.NETFramework/v4.7.2/System.Numerics.dll",
"build/.NETFramework/v4.7.2/System.Numerics.xml",
"build/.NETFramework/v4.7.2/System.Printing.dll",
"build/.NETFramework/v4.7.2/System.Printing.xml",
"build/.NETFramework/v4.7.2/System.Reflection.Context.dll",
"build/.NETFramework/v4.7.2/System.Reflection.Context.xml",
"build/.NETFramework/v4.7.2/System.Runtime.Caching.dll",
"build/.NETFramework/v4.7.2/System.Runtime.Caching.xml",
"build/.NETFramework/v4.7.2/System.Runtime.DurableInstancing.dll",
"build/.NETFramework/v4.7.2/System.Runtime.DurableInstancing.xml",
"build/.NETFramework/v4.7.2/System.Runtime.Remoting.dll",
"build/.NETFramework/v4.7.2/System.Runtime.Remoting.xml",
"build/.NETFramework/v4.7.2/System.Runtime.Serialization.Formatters.Soap.dll",
"build/.NETFramework/v4.7.2/System.Runtime.Serialization.Formatters.Soap.xml",
"build/.NETFramework/v4.7.2/System.Runtime.Serialization.dll",
"build/.NETFramework/v4.7.2/System.Runtime.Serialization.xml",
"build/.NETFramework/v4.7.2/System.Security.dll",
"build/.NETFramework/v4.7.2/System.Security.xml",
"build/.NETFramework/v4.7.2/System.ServiceModel.Activation.dll",
"build/.NETFramework/v4.7.2/System.ServiceModel.Activation.xml",
"build/.NETFramework/v4.7.2/System.ServiceModel.Activities.dll",
"build/.NETFramework/v4.7.2/System.ServiceModel.Activities.xml",
"build/.NETFramework/v4.7.2/System.ServiceModel.Channels.dll",
"build/.NETFramework/v4.7.2/System.ServiceModel.Channels.xml",
"build/.NETFramework/v4.7.2/System.ServiceModel.Discovery.dll",
"build/.NETFramework/v4.7.2/System.ServiceModel.Discovery.xml",
"build/.NETFramework/v4.7.2/System.ServiceModel.Routing.dll",
"build/.NETFramework/v4.7.2/System.ServiceModel.Routing.xml",
"build/.NETFramework/v4.7.2/System.ServiceModel.Web.dll",
"build/.NETFramework/v4.7.2/System.ServiceModel.Web.xml",
"build/.NETFramework/v4.7.2/System.ServiceModel.dll",
"build/.NETFramework/v4.7.2/System.ServiceModel.xml",
"build/.NETFramework/v4.7.2/System.ServiceProcess.dll",
"build/.NETFramework/v4.7.2/System.ServiceProcess.xml",
"build/.NETFramework/v4.7.2/System.Speech.dll",
"build/.NETFramework/v4.7.2/System.Speech.xml",
"build/.NETFramework/v4.7.2/System.Threading.Tasks.Dataflow.xml",
"build/.NETFramework/v4.7.2/System.Transactions.dll",
"build/.NETFramework/v4.7.2/System.Transactions.xml",
"build/.NETFramework/v4.7.2/System.Web.Abstractions.dll",
"build/.NETFramework/v4.7.2/System.Web.ApplicationServices.dll",
"build/.NETFramework/v4.7.2/System.Web.ApplicationServices.xml",
"build/.NETFramework/v4.7.2/System.Web.DataVisualization.Design.dll",
"build/.NETFramework/v4.7.2/System.Web.DataVisualization.dll",
"build/.NETFramework/v4.7.2/System.Web.DataVisualization.xml",
"build/.NETFramework/v4.7.2/System.Web.DynamicData.Design.dll",
"build/.NETFramework/v4.7.2/System.Web.DynamicData.Design.xml",
"build/.NETFramework/v4.7.2/System.Web.DynamicData.dll",
"build/.NETFramework/v4.7.2/System.Web.DynamicData.xml",
"build/.NETFramework/v4.7.2/System.Web.Entity.Design.dll",
"build/.NETFramework/v4.7.2/System.Web.Entity.Design.xml",
"build/.NETFramework/v4.7.2/System.Web.Entity.dll",
"build/.NETFramework/v4.7.2/System.Web.Entity.xml",
"build/.NETFramework/v4.7.2/System.Web.Extensions.Design.dll",
"build/.NETFramework/v4.7.2/System.Web.Extensions.Design.xml",
"build/.NETFramework/v4.7.2/System.Web.Extensions.dll",
"build/.NETFramework/v4.7.2/System.Web.Extensions.xml",
"build/.NETFramework/v4.7.2/System.Web.Mobile.dll",
"build/.NETFramework/v4.7.2/System.Web.Mobile.xml",
"build/.NETFramework/v4.7.2/System.Web.RegularExpressions.dll",
"build/.NETFramework/v4.7.2/System.Web.RegularExpressions.xml",
"build/.NETFramework/v4.7.2/System.Web.Routing.dll",
"build/.NETFramework/v4.7.2/System.Web.Services.dll",
"build/.NETFramework/v4.7.2/System.Web.Services.xml",
"build/.NETFramework/v4.7.2/System.Web.dll",
"build/.NETFramework/v4.7.2/System.Web.xml",
"build/.NETFramework/v4.7.2/System.Windows.Controls.Ribbon.dll",
"build/.NETFramework/v4.7.2/System.Windows.Controls.Ribbon.xml",
"build/.NETFramework/v4.7.2/System.Windows.Forms.DataVisualization.Design.dll",
"build/.NETFramework/v4.7.2/System.Windows.Forms.DataVisualization.dll",
"build/.NETFramework/v4.7.2/System.Windows.Forms.DataVisualization.xml",
"build/.NETFramework/v4.7.2/System.Windows.Forms.dll",
"build/.NETFramework/v4.7.2/System.Windows.Forms.xml",
"build/.NETFramework/v4.7.2/System.Windows.Input.Manipulations.dll",
"build/.NETFramework/v4.7.2/System.Windows.Input.Manipulations.xml",
"build/.NETFramework/v4.7.2/System.Windows.Presentation.dll",
"build/.NETFramework/v4.7.2/System.Windows.Presentation.xml",
"build/.NETFramework/v4.7.2/System.Windows.dll",
"build/.NETFramework/v4.7.2/System.Workflow.Activities.dll",
"build/.NETFramework/v4.7.2/System.Workflow.Activities.xml",
"build/.NETFramework/v4.7.2/System.Workflow.ComponentModel.dll",
"build/.NETFramework/v4.7.2/System.Workflow.ComponentModel.xml",
"build/.NETFramework/v4.7.2/System.Workflow.Runtime.dll",
"build/.NETFramework/v4.7.2/System.Workflow.Runtime.xml",
"build/.NETFramework/v4.7.2/System.WorkflowServices.dll",
"build/.NETFramework/v4.7.2/System.WorkflowServices.xml",
"build/.NETFramework/v4.7.2/System.Xaml.dll",
"build/.NETFramework/v4.7.2/System.Xaml.xml",
"build/.NETFramework/v4.7.2/System.Xml.Linq.dll",
"build/.NETFramework/v4.7.2/System.Xml.Linq.xml",
"build/.NETFramework/v4.7.2/System.Xml.Serialization.dll",
"build/.NETFramework/v4.7.2/System.Xml.dll",
"build/.NETFramework/v4.7.2/System.Xml.xml",
"build/.NETFramework/v4.7.2/System.dll",
"build/.NETFramework/v4.7.2/System.xml",
"build/.NETFramework/v4.7.2/UIAutomationClient.dll",
"build/.NETFramework/v4.7.2/UIAutomationClient.xml",
"build/.NETFramework/v4.7.2/UIAutomationClientsideProviders.dll",
"build/.NETFramework/v4.7.2/UIAutomationClientsideProviders.xml",
"build/.NETFramework/v4.7.2/UIAutomationProvider.dll",
"build/.NETFramework/v4.7.2/UIAutomationProvider.xml",
"build/.NETFramework/v4.7.2/UIAutomationTypes.dll",
"build/.NETFramework/v4.7.2/UIAutomationTypes.xml",
"build/.NETFramework/v4.7.2/WindowsBase.dll",
"build/.NETFramework/v4.7.2/WindowsBase.xml",
"build/.NETFramework/v4.7.2/WindowsFormsIntegration.dll",
"build/.NETFramework/v4.7.2/WindowsFormsIntegration.xml",
"build/.NETFramework/v4.7.2/XamlBuildTask.dll",
"build/.NETFramework/v4.7.2/XamlBuildTask.xml",
"build/.NETFramework/v4.7.2/mscorlib.dll",
"build/.NETFramework/v4.7.2/mscorlib.xml",
"build/.NETFramework/v4.7.2/namespaces.xml",
"build/.NETFramework/v4.7.2/sysglobl.dll",
"build/.NETFramework/v4.7.2/sysglobl.xml",
"build/Microsoft.NETFramework.ReferenceAssemblies.net472.targets",
"microsoft.netframework.referenceassemblies.net472.1.0.3.nupkg.sha512",
"microsoft.netframework.referenceassemblies.net472.nuspec"
]
}
},
"projectFileDependencyGroups": {
".NETFramework,Version=v4.7.2": [
"Microsoft.NETFramework.ReferenceAssemblies >= 1.0.3"
]
},
"packageFolders": {
"C:\\Users\\gruessing\\.nuget\\packages\\": {}
},
"project": {
"version": "1.0.0",
"restore": {
"projectUniqueName": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
"projectName": "DomainJoinTool",
"projectPath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
"packagesPath": "C:\\Users\\gruessing\\.nuget\\packages\\",
"outputPath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\obj\\",
"projectStyle": "PackageReference",
"configFilePaths": [
"C:\\Users\\gruessing\\AppData\\Roaming\\NuGet\\NuGet.Config"
],
"originalTargetFrameworks": [
"net472"
],
"sources": {
"https://api.nuget.org/v3/index.json": {}
},
"frameworks": {
"net472": {
"targetAlias": "net472",
"projectReferences": {}
}
},
"warningProperties": {
"warnAsError": [
"NU1605"
]
},
"restoreAuditProperties": {
"enableAudit": "true",
"auditLevel": "low",
"auditMode": "direct"
}
},
"frameworks": {
"net472": {
"targetAlias": "net472",
"dependencies": {
"Microsoft.NETFramework.ReferenceAssemblies": {
"suppressParent": "All",
"target": "Package",
"version": "[1.0.3, )",
"autoReferenced": true
}
},
"runtimeIdentifierGraphPath": "C:\\Program Files\\dotnet\\sdk\\8.0.421\\RuntimeIdentifierGraph.json"
}
},
"runtimes": {
"win7-x64": {
"#import": []
}
}
}
}

View File

@@ -1,11 +0,0 @@
{
"version": 2,
"dgSpecHash": "nUWqqbOYUIM=",
"success": true,
"projectFilePath": "C:\\Users\\gruessing\\OneDrive - Cereda Systems GmbH\\Desktop\\IT Tool\\domain-join-tool\\DomainJoinTool.csproj",
"expectedPackageFiles": [
"C:\\Users\\gruessing\\.nuget\\packages\\microsoft.netframework.referenceassemblies\\1.0.3\\microsoft.netframework.referenceassemblies.1.0.3.nupkg.sha512",
"C:\\Users\\gruessing\\.nuget\\packages\\microsoft.netframework.referenceassemblies.net472\\1.0.3\\microsoft.netframework.referenceassemblies.net472.1.0.3.nupkg.sha512"
],
"logs": []
}

BIN
files.zip

Binary file not shown.

View File

@@ -1,49 +0,0 @@
BINARY := nexus-scanner
CMD := ./cmd/nexus-scanner
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION)"
.PHONY: build run test fmt vet clean install
## build: compile for the local OS/arch
build:
go build $(LDFLAGS) -o $(BINARY) $(CMD)
## build-linux: cross-compile for Linux amd64 + arm64
build-linux:
GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BINARY)-linux-amd64 $(CMD)
GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(BINARY)-linux-arm64 $(CMD)
## run: build and run with the example config (local testing only)
run: build
./$(BINARY) config.example.yaml
## test: run all tests
test:
go test -v -race ./...
## fmt: format all Go source files
fmt:
gofmt -w ./...
## vet: run go vet
vet:
go vet ./...
## tidy: update go.sum and remove unused dependencies
tidy:
go mod tidy
## clean: remove built binaries
clean:
rm -f $(BINARY) $(BINARY)-linux-amd64 $(BINARY)-linux-arm64
## install: deploy to a scanner VM (set SCANNER_HOST env var)
## Usage: SCANNER_HOST=root@192.168.0.x make install
install: build-linux
scp $(BINARY)-linux-amd64 $(SCANNER_HOST):/usr/local/bin/$(BINARY)
ssh $(SCANNER_HOST) "systemctl restart $(BINARY)"
## help: print this help
help:
@grep -E '^## ' Makefile | sed 's/## / /'

View File

@@ -1,156 +0,0 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
"github.com/cereda-systems/nexus-scanner/internal/module"
arpmod "github.com/cereda-systems/nexus-scanner/internal/modules/arp"
admod "github.com/cereda-systems/nexus-scanner/internal/modules/adsync"
dnsmod "github.com/cereda-systems/nexus-scanner/internal/modules/dnsreverse"
vendormod "github.com/cereda-systems/nexus-scanner/internal/modules/macvendor"
reportermod "github.com/cereda-systems/nexus-scanner/internal/modules/nexusreporter"
portscanmod "github.com/cereda-systems/nexus-scanner/internal/modules/portscan"
sitemon "github.com/cereda-systems/nexus-scanner/internal/modules/sitemon"
snmpmod "github.com/cereda-systems/nexus-scanner/internal/modules/snmpmod"
sysarpmod "github.com/cereda-systems/nexus-scanner/internal/modules/sysarp"
"github.com/cereda-systems/nexus-scanner/internal/scheduler"
"github.com/cereda-systems/nexus-scanner/internal/web"
)
var version = "dev"
func main() {
cfgPath := "/etc/nexus-scanner/config.yaml"
if len(os.Args) > 1 {
cfgPath = os.Args[1]
}
cfg, err := config.Load(cfgPath)
if err != nil {
slog.Error("failed to load config", "path", cfgPath, "err", err)
os.Exit(1)
}
// Log buffer captures entries for the /logs web page.
logBuf := web.NewLogBuffer(500)
level := slog.LevelInfo
if cfg.LogLevel == "debug" {
level = slog.LevelDebug
}
inner := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
handler := web.NewSlogHandler(inner, logBuf)
slog.SetDefault(slog.New(handler))
slog.Info("nexus-scanner starting", "version", version, "site", cfg.Site)
store, err := db.Open(cfg.DB.Path)
if err != nil {
slog.Error("failed to open database", "path", cfg.DB.Path, "err", err)
os.Exit(1)
}
defer store.Close()
registry := module.NewRegistry()
// sys_arp: reads the OS ARP cache — works on Windows, Linux and macOS
// without raw-socket privileges. Always enabled.
registry.Register(sysarpmod.New(cfg.Site, store))
slog.Info("module registered", "module", "sys_arp")
// dns_reverse: enriches known hosts with reverse-DNS hostnames.
registry.Register(dnsmod.New(cfg.Site, store))
slog.Info("module registered", "module", "dns_reverse")
// mac_vendor: assigns OUI-based vendor names to known hosts (runs once at startup).
registry.Register(vendormod.New(cfg.Site, store))
slog.Info("module registered", "module", "mac_vendor")
// ad_sync: queries Active Directory for all computer objects.
if cfg.Modules.ADSync.Enabled {
registry.Register(admod.New(cfg.Site, cfg.Modules.ADSync, store))
slog.Info("module registered", "module", "ad_sync",
"server", cfg.Modules.ADSync.Server,
"search_base", cfg.Modules.ADSync.SearchBase,
)
}
// site_monitoring: availability checks (ping, http, tcp) — always enabled,
// checks are managed via the web UI.
cfg.Modules.SiteMonitoring.Enabled = true
registry.Register(sitemon.New(cfg.Site, cfg.Modules.SiteMonitoring, cfg.Alert, *cfg, store))
slog.Info("module registered", "module", "site_monitoring",
"interval", cfg.Modules.SiteMonitoring.Interval)
// port_scan: TCP port scanner for all online hosts.
cfg.Modules.PortScan.Enabled = true
registry.Register(portscanmod.New(cfg.Site, cfg.Modules.PortScan, store))
slog.Info("module registered", "module", "port_scan",
"interval", cfg.Modules.PortScan.Interval)
// snmp: polls SNMP targets for device info (managed via web UI).
cfg.Modules.SNMP.Enabled = true
registry.Register(snmpmod.New(cfg.Site, cfg.Modules.SNMP, store))
slog.Info("module registered", "module", "snmp",
"interval", cfg.Modules.SNMP.Interval)
// nexus_reporter: pushes changed assets to IT Nexus API.
if cfg.Nexus.URL != "" && cfg.Nexus.APIKey != "" {
cfg.Modules.NexusReporter.Enabled = true
registry.Register(reportermod.New(cfg.Site, cfg.Modules.NexusReporter, *cfg, store))
slog.Info("module registered", "module", "nexus_reporter")
}
// arp_discovery: full ARP sweep via raw sockets (Linux only).
if cfg.Modules.ARPDiscovery.Enabled {
registry.Register(arpmod.New(cfg.Site, cfg.Modules.ARPDiscovery, store))
slog.Info("module registered", "module", "arp_discovery",
"interval", cfg.Modules.ARPDiscovery.Interval,
"subnets", cfg.Modules.ARPDiscovery.Subnets,
)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Offline-Sweeper: marks hosts as offline when not seen for OfflineAfter duration.
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n, err := store.MarkStaleHostsOffline(cfg.OfflineAfter)
if err != nil {
slog.Error("offline sweep", "err", err)
} else if n > 0 {
slog.Info("hosts marked offline", "count", n, "threshold", cfg.OfflineAfter)
}
}
}
}()
sched := scheduler.New(registry)
go sched.Run(ctx)
srv := web.NewServer(cfgPath, cfg, store, registry, logBuf)
go func() {
if err := srv.ListenAndServe(ctx); err != nil {
slog.Error("web server stopped", "err", err)
stop()
}
}()
slog.Info("nexus-scanner ready", "addr", cfg.Web.Addr)
<-ctx.Done()
slog.Info("nexus-scanner shutting down")
}

View File

@@ -1,53 +0,0 @@
site: LUD
log_level: debug
setup_complete: true
db:
path: C:/temp/nexus-scanner/scanner.db
web:
addr: :8090
token: demo1234
nexus:
url: https://it-nexus.cereda-systems.de
api_key: nsx-aa0193d719c2be765441e1dd24bf7480
offline_after: 15m0s
alert:
smtp:
enabled: false
host: ""
port: 587
username: ""
password: ""
from: ""
to: ""
nexus_enabled: true
modules:
arp_discovery:
enabled: false
interval: 5m0s
subnets:
- 192.168.0.0/24
interface: eth0
ad_sync:
enabled: true
interval: 30m0s
server: 192.168.0.12
port: 389
bind_dn: svc-scanner@winkel.local
bind_password: 6ApvVf6X9hvHBxYUqQ3y
search_base: DC=winkel,DC=local
tls: false
site_monitoring:
enabled: true
interval: 1m0s
port_scan:
enabled: true
interval: 15m0s
ports: []
timeout: 500ms
snmp:
enabled: true
interval: 5m0s
community: public
nexus_reporter:
enabled: true
interval: 10m0s

View File

@@ -1,35 +0,0 @@
# Nexus Scanner — example configuration
# Copy to /etc/nexus-scanner/config.yaml and adjust.
site: "LUD" # LUD = Lüdenscheid | BAR = Barleben
log_level: "info" # debug | info | warn | error
db:
path: "/var/lib/nexus-scanner/scanner.db"
web:
addr: ":8080"
token: "change-me-generate-with-openssl-rand-hex-32"
nexus:
url: "https://it-nexus.cereda-systems.de"
api_key: "" # will be filled once IT Nexus wires up the scanner endpoint
modules:
ad_sync:
enabled: true
interval: 30m
server: "192.168.0.1" # IP des Domain Controllers
port: 389 # 389 = LDAP, 636 = LDAPS
bind_dn: "svc-scanner@winkel.local"
bind_password: "" # Passwort des Service-Accounts
search_base: "DC=winkel,DC=local"
tls: false
arp_discovery:
enabled: true
interval: 5m # how often to scan (Go duration: 30s, 5m, 1h …)
interface: "eth0" # network interface to send ARP requests on
subnets:
- "192.168.0.0/24" # add more subnets as needed
# - "10.0.0.0/24"

View File

@@ -1,22 +0,0 @@
# Minimale Startkonfiguration — wird nach dem Setup-Wizard überschrieben.
site: "LUD"
log_level: "info"
setup_complete: false
db:
path: "/var/lib/nexus-scanner/scanner.db"
web:
addr: ":8080"
token: "" # wird im Setup-Wizard gesetzt
nexus:
url: ""
api_key: ""
modules:
arp_discovery:
enabled: false # wird im Setup-Wizard aktiviert
interval: 5m
interface: "eth0"
subnets: []

View File

@@ -1,62 +0,0 @@
site: "BAR"
log_level: "info"
setup_complete: false
db:
path: "/var/lib/nexus-scanner/scanner.db"
web:
addr: ":8090"
token: "" # wird beim Setup-Wizard gesetzt
nexus:
url: "https://it-nexus.cereda-systems.de"
api_key: "nsx-aa0193d719c2be765441e1dd24bf7480"
offline_after: 15m
alert:
nexus_enabled: true
smtp:
enabled: false
host: ""
port: 587
username: ""
password: ""
from: ""
to: ""
modules:
arp_discovery:
enabled: true
interval: 5m
subnets:
- "192.168.10.0/24" # Barleben-Netz anpassen!
interface: "eth0"
ad_sync:
enabled: false # DC-Daten für BAR eintragen und auf true setzen
interval: 30m
server: ""
port: 389
bind_dn: "svc-scanner@winkel.local"
bind_password: ""
search_base: "DC=winkel,DC=local"
tls: false
site_monitoring:
enabled: true
interval: 1m
port_scan:
enabled: true
interval: 15m
snmp:
enabled: true
interval: 5m
community: "public"
nexus_reporter:
enabled: true
interval: 10m

View File

@@ -1,62 +0,0 @@
site: "LUD"
log_level: "info"
setup_complete: false
db:
path: "/var/lib/nexus-scanner/scanner.db"
web:
addr: ":8090"
token: "" # wird beim Setup-Wizard gesetzt
nexus:
url: "https://it-nexus.cereda-systems.de"
api_key: "nsx-aa0193d719c2be765441e1dd24bf7480"
offline_after: 15m
alert:
nexus_enabled: true
smtp:
enabled: false
host: ""
port: 587
username: ""
password: ""
from: ""
to: ""
modules:
arp_discovery:
enabled: true
interval: 5m
subnets:
- "192.168.0.0/24"
interface: "eth0"
ad_sync:
enabled: true
interval: 30m
server: "192.168.0.12"
port: 389
bind_dn: "svc-scanner@winkel.local"
bind_password: "6ApvVf6X9hvHBxYUqQ3y"
search_base: "DC=winkel,DC=local"
tls: false
site_monitoring:
enabled: true
interval: 1m
port_scan:
enabled: true
interval: 15m
snmp:
enabled: true
interval: 5m
community: "public"
nexus_reporter:
enabled: true
interval: 10m

View File

@@ -1,133 +0,0 @@
#!/bin/bash
# Nexus Scanner — Installations-Skript
# Läuft auf Debian 12 / Ubuntu 22.04+
# Aufruf: sudo bash install.sh [LUD|BAR]
set -e
SITE="${1:-LUD}"
BINARY_URL="https://it-nexus.cereda-systems.de/downloads/nexus-scanner-linux-amd64"
BINARY="/usr/local/bin/nexus-scanner"
CONFIG_DIR="/etc/nexus-scanner"
DATA_DIR="/var/lib/nexus-scanner"
SERVICE_FILE="/etc/systemd/system/nexus-scanner.service"
USER="nexus-scanner"
echo "=============================="
echo " Nexus Scanner Installation"
echo " Standort: $SITE"
echo "=============================="
# Abhängigkeiten
apt-get update -qq
apt-get install -y -qq curl iputils-ping nmap snmp 2>/dev/null || true
# Benutzer anlegen
if ! id "$USER" &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
echo "Benutzer $USER angelegt."
fi
# Verzeichnisse
mkdir -p "$CONFIG_DIR" "$DATA_DIR"
chown "$USER:$USER" "$DATA_DIR"
# Binary installieren (aus deploy/-Verzeichnis oder per Download)
if [ -f "./nexus-scanner-linux-amd64" ]; then
cp ./nexus-scanner-linux-amd64 "$BINARY"
echo "Binary aus lokalem Verzeichnis installiert."
else
echo "Binary wird heruntergeladen..."
curl -fsSL "$BINARY_URL" -o "$BINARY"
fi
chmod 755 "$BINARY"
# Config installieren
if [ ! -f "$CONFIG_DIR/config.yaml" ]; then
if [ -f "./config.${SITE,,}.yaml" ]; then
cp "./config.${SITE,,}.yaml" "$CONFIG_DIR/config.yaml"
else
# Minimal-Config erzeugen
cat > "$CONFIG_DIR/config.yaml" << EOF
site: "$SITE"
log_level: "info"
setup_complete: false
db:
path: "$DATA_DIR/scanner.db"
web:
addr: ":8090"
token: ""
nexus:
url: "https://it-nexus.cereda-systems.de"
api_key: "nsx-aa0193d719c2be765441e1dd24bf7480"
offline_after: 15m
modules:
arp_discovery:
enabled: true
interval: 5m
subnets: ["192.168.0.0/24"]
interface: "eth0"
site_monitoring:
enabled: true
interval: 1m
port_scan:
enabled: true
interval: 15m
snmp:
enabled: true
interval: 5m
community: "public"
nexus_reporter:
enabled: true
interval: 10m
EOF
fi
echo "Config nach $CONFIG_DIR/config.yaml installiert."
fi
chown "$USER":"$USER" "$CONFIG_DIR/config.yaml"
chmod 640 "$CONFIG_DIR/config.yaml"
# systemd Service installieren
cat > "$SERVICE_FILE" << 'EOF'
[Unit]
Description=Nexus Scanner — Netzwerk-Discovery Agent
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nexus-scanner
Group=nexus-scanner
ExecStart=/usr/local/bin/nexus-scanner /etc/nexus-scanner/config.yaml
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nexus-scanner
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/nexus-scanner /etc/nexus-scanner
PrivateTmp=yes
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable nexus-scanner
systemctl restart nexus-scanner
IP=$(hostname -I | awk '{print $1}')
echo ""
echo "=============================="
echo " Installation abgeschlossen!"
echo "=============================="
echo ""
echo " Web-UI: http://$IP:8090"
echo " Status: systemctl status nexus-scanner"
echo " Logs: journalctl -u nexus-scanner -f"
echo ""
echo " Öffne http://$IP:8090 im Browser"
echo " um den Setup-Wizard abzuschließen."
echo ""

View File

@@ -1,27 +0,0 @@
[Unit]
Description=Nexus Scanner — Netzwerk-Discovery Agent
Documentation=https://it-nexus.cereda-systems.de
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=nexus-scanner
Group=nexus-scanner
ExecStart=/usr/local/bin/nexus-scanner /etc/nexus-scanner/config.yaml
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nexus-scanner
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/nexus-scanner
PrivateTmp=yes
CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
[Install]
WantedBy=multi-user.target

View File

@@ -1,36 +0,0 @@
module github.com/cereda-systems/nexus-scanner
go 1.24.0
require (
github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.29.10
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gosnmp/gosnmp v1.43.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/josharian/native v1.0.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 // indirect
github.com/mdlayher/packet v1.0.0 // indirect
github.com/mdlayher/socket v0.2.1 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
golang.org/x/sys v0.41.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.49.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

View File

@@ -1,92 +0,0 @@
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gosnmp/gosnmp v1.43.2 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw=
github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875 h1:ql8x//rJsHMjS+qqEag8n3i4azw1QneKh5PieH9UEbY=
github.com/mdlayher/arp v0.0.0-20220512170110-6706a2966875/go.mod h1:kfOoFJuHWp76v1RgZCb9/gVUc7XdY877S2uVYbNliGc=
github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118 h1:2oDp6OOhLxQ9JBoUuysVz9UZ9uI6oLUbvAZu0x8o+vE=
github.com/mdlayher/ethernet v0.0.0-20220221185849-529eae5b6118/go.mod h1:ZFUnHIVchZ9lJoWoEGUg8Q3M4U8aNNWA3CVSUTkW4og=
github.com/mdlayher/packet v1.0.0 h1:InhZJbdShQYt6XV2GPj5XHxChzOfhJJOMbvnGAmOfQ8=
github.com/mdlayher/packet v1.0.0/go.mod h1:eE7/ctqDhoiRhQ44ko5JZU2zxB88g+JH/6jmnjzPjOU=
github.com/mdlayher/socket v0.2.1 h1:F2aaOwb53VsBE+ebRS9bLd7yPOfYUMC8lOODdCBDY6w=
github.com/mdlayher/socket v0.2.1/go.mod h1:QLlNPkFR88mRUNQIzRBMfXxwKal8H7u1h3bL1CV+f0E=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.20.0 h1:45Or8mQfbUqJOG9WaxvlFYOAQO0lQ5RvqBcFCXngjxk=
modernc.org/cc/v4 v4.20.0/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.16.0 h1:ofwORa6vx2FMm0916/CkZjpFPSR70VwTjUCe2Eg5BnA=
modernc.org/ccgo/v4 v4.16.0/go.mod h1:dkNyWIjFrVIZ68DTo36vHK+6/ShBn4ysU61So6PIqCI=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.49.3 h1:j2MRCRdwJI2ls/sGbeSk0t2bypOG/uvPZUsGQFDulqg=
modernc.org/libc v1.49.3/go.mod h1:yMZuGkn7pXbKfoT/M35gFJOAEdSKdxL0q64sF7KqCDo=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg=
modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -1,195 +0,0 @@
package config
import (
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
// Config holds the full agent configuration loaded from config.yaml.
type Config struct {
Site string `yaml:"site"` // LUD or BAR
LogLevel string `yaml:"log_level"` // debug | info | warn | error
SetupComplete bool `yaml:"setup_complete"` // false → show setup wizard on first run
DB struct {
Path string `yaml:"path"`
} `yaml:"db"`
Web struct {
Addr string `yaml:"addr"`
Token string `yaml:"token"`
} `yaml:"web"`
Nexus struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
} `yaml:"nexus"`
OfflineAfter time.Duration `yaml:"offline_after"` // mark hosts offline after this duration
Alert AlertConfig `yaml:"alert"`
Modules struct {
ARPDiscovery ARPConfig `yaml:"arp_discovery"`
ADSync ADConfig `yaml:"ad_sync"`
SiteMonitoring SiteMonConfig `yaml:"site_monitoring"`
PortScan PortScanConfig `yaml:"port_scan"`
SNMP SNMPConfig `yaml:"snmp"`
NexusReporter ReporterConfig `yaml:"nexus_reporter"`
} `yaml:"modules"`
}
// ADConfig holds settings for the ad_sync module.
type ADConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
Server string `yaml:"server"` // DC hostname or IP
Port int `yaml:"port"` // default 389
BindDN string `yaml:"bind_dn"` // svc-scanner@winkel.local
BindPassword string `yaml:"bind_password"`
SearchBase string `yaml:"search_base"` // DC=winkel,DC=local
TLS bool `yaml:"tls"` // use LDAPS
}
// SiteMonConfig holds settings for the site_monitoring module.
type SiteMonConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
}
// PortScanConfig holds settings for the port_scan module.
type PortScanConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
Ports []int `yaml:"ports"` // empty = use defaults
Timeout time.Duration `yaml:"timeout"` // per-port connect timeout
}
// SNMPConfig holds settings for the snmp module.
type SNMPConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
Community string `yaml:"community"` // default community string
}
// ReporterConfig holds settings for the nexus_reporter module.
type ReporterConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
}
// AlertConfig holds notification settings.
type AlertConfig struct {
SMTP struct {
Enabled bool `yaml:"enabled"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Username string `yaml:"username"`
Password string `yaml:"password"`
From string `yaml:"from"`
To string `yaml:"to"`
} `yaml:"smtp"`
NexusEnabled bool `yaml:"nexus_enabled"`
}
// ARPConfig holds settings for the arp_discovery module.
type ARPConfig struct {
Enabled bool `yaml:"enabled"`
Interval time.Duration `yaml:"interval"`
Subnets []string `yaml:"subnets"`
Interface string `yaml:"interface"`
}
// Load reads and validates the YAML config at path.
func Load(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open config %q: %w", path, err)
}
defer f.Close()
var cfg Config
if err := yaml.NewDecoder(f).Decode(&cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
if err := cfg.applyDefaults(); err != nil {
return nil, fmt.Errorf("config validation: %w", err)
}
return &cfg, nil
}
// Save writes the config back to path in YAML format.
func Save(path string, cfg *Config) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create config %q: %w", path, err)
}
defer f.Close()
if err := yaml.NewEncoder(f).Encode(cfg); err != nil {
return fmt.Errorf("encode config: %w", err)
}
return nil
}
func (c *Config) applyDefaults() error {
if c.Site == "" {
return fmt.Errorf("site is required (LUD or BAR)")
}
if c.Site != "LUD" && c.Site != "BAR" {
return fmt.Errorf("site must be LUD or BAR, got %q", c.Site)
}
if c.LogLevel == "" {
c.LogLevel = "info"
}
if c.DB.Path == "" {
c.DB.Path = "/var/lib/nexus-scanner/scanner.db"
}
if c.Web.Addr == "" {
c.Web.Addr = ":8080"
}
if c.Modules.ARPDiscovery.Interval == 0 {
c.Modules.ARPDiscovery.Interval = 5 * time.Minute
}
if c.Modules.ARPDiscovery.Interface == "" {
c.Modules.ARPDiscovery.Interface = "eth0"
}
if c.Modules.ADSync.Port == 0 {
c.Modules.ADSync.Port = 389
}
if c.Modules.ADSync.Interval == 0 {
c.Modules.ADSync.Interval = 30 * time.Minute
}
if c.Modules.ADSync.SearchBase == "" && c.Modules.ADSync.Server != "" {
c.Modules.ADSync.SearchBase = "DC=winkel,DC=local"
}
if c.Modules.SiteMonitoring.Interval == 0 {
c.Modules.SiteMonitoring.Interval = time.Minute
}
if c.Modules.PortScan.Interval == 0 {
c.Modules.PortScan.Interval = 15 * time.Minute
}
if c.Modules.PortScan.Timeout == 0 {
c.Modules.PortScan.Timeout = 500 * time.Millisecond
}
if c.Modules.SNMP.Interval == 0 {
c.Modules.SNMP.Interval = 5 * time.Minute
}
if c.Modules.SNMP.Community == "" {
c.Modules.SNMP.Community = "public"
}
if c.Modules.NexusReporter.Interval == 0 {
c.Modules.NexusReporter.Interval = 10 * time.Minute
}
if c.OfflineAfter == 0 {
c.OfflineAfter = 15 * time.Minute
}
if c.Alert.SMTP.Port == 0 {
c.Alert.SMTP.Port = 587
}
return nil
}

View File

@@ -1,659 +0,0 @@
package db
import (
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// Store wraps the SQLite database.
type Store struct {
db *sql.DB
}
// Open opens (or creates) the SQLite database at path and runs migrations.
func Open(path string) (*Store, error) {
conn, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite at %q: %w", path, err)
}
// SQLite works best with a single writer connection.
conn.SetMaxOpenConns(1)
// Recommended pragmas for reliability and performance.
for _, pragma := range []string{
"PRAGMA journal_mode=WAL",
"PRAGMA busy_timeout=5000",
"PRAGMA foreign_keys=ON",
} {
if _, err := conn.Exec(pragma); err != nil {
conn.Close()
return nil, fmt.Errorf("pragma %q: %w", pragma, err)
}
}
s := &Store{db: conn}
if err := s.migrate(); err != nil {
conn.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
// Close closes the underlying database connection.
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS host_ports (
ip TEXT NOT NULL,
port INTEGER NOT NULL,
scanned_at DATETIME NOT NULL,
PRIMARY KEY (ip, port)
);
CREATE TABLE IF NOT EXISTS snmp_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
ip TEXT NOT NULL,
community TEXT NOT NULL DEFAULT 'public',
version TEXT NOT NULL DEFAULT 'v2c',
type TEXT NOT NULL DEFAULT 'generic',
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS snmp_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
oid_name TEXT NOT NULL,
value TEXT NOT NULL DEFAULT '',
scanned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(target_id, oid_name)
);
CREATE TABLE IF NOT EXISTS module_states (
name TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS monitor_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
target TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS monitor_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
check_id INTEGER NOT NULL,
status TEXT NOT NULL,
latency_ms INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_monitor_results_check
ON monitor_results(check_id, checked_at DESC);
CREATE TABLE IF NOT EXISTS ad_computers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cn TEXT NOT NULL UNIQUE,
dn TEXT NOT NULL DEFAULT '',
os TEXT NOT NULL DEFAULT '',
os_version TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL DEFAULT '',
department TEXT NOT NULL DEFAULT '',
last_logon DATETIME,
ad_created DATETIME,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ip TEXT NOT NULL UNIQUE,
mac TEXT NOT NULL,
vendor TEXT NOT NULL DEFAULT '',
hostname TEXT NOT NULL DEFAULT '',
site TEXT NOT NULL,
first_seen DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
status TEXT NOT NULL DEFAULT 'online'
);
CREATE TABLE IF NOT EXISTS scan_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module TEXT NOT NULL,
started_at DATETIME NOT NULL,
ended_at DATETIME,
status TEXT NOT NULL DEFAULT 'running',
error TEXT NOT NULL DEFAULT ''
);
`)
return err
}
// Host represents a discovered network device.
type Host struct {
ID int64
IP string
MAC string
Vendor string
Hostname string
Site string
FirstSeen time.Time
LastSeen time.Time
Status string
}
// UpsertHost inserts a new host or updates MAC, vendor and last_seen if the
// IP already exists. first_seen is never overwritten on update.
func (s *Store) UpsertHost(h Host) error {
_, err := s.db.Exec(`
INSERT INTO hosts (ip, mac, vendor, hostname, site, first_seen, last_seen, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'online')
ON CONFLICT(ip) DO UPDATE SET
mac = excluded.mac,
vendor = CASE WHEN excluded.vendor != '' THEN excluded.vendor ELSE vendor END,
hostname = CASE WHEN excluded.hostname != '' THEN excluded.hostname ELSE hostname END,
last_seen = excluded.last_seen,
status = 'online'
`, h.IP, h.MAC, h.Vendor, h.Hostname, h.Site, h.FirstSeen.UTC(), h.LastSeen.UTC())
if err != nil {
return fmt.Errorf("upsert host %s: %w", h.IP, err)
}
return nil
}
// ListHosts returns all hosts ordered by last_seen desc.
// Pass an empty site to return all sites.
func (s *Store) ListHosts(site string) ([]Host, error) {
query := `
SELECT id, ip, mac, vendor, hostname, site, first_seen, last_seen, status
FROM hosts`
var args []any
if site != "" {
query += ` WHERE site = ?`
args = append(args, site)
}
query += ` ORDER BY last_seen DESC`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("list hosts: %w", err)
}
defer rows.Close()
var hosts []Host
for rows.Next() {
var h Host
if err := rows.Scan(
&h.ID, &h.IP, &h.MAC, &h.Vendor, &h.Hostname,
&h.Site, &h.FirstSeen, &h.LastSeen, &h.Status,
); err != nil {
return nil, fmt.Errorf("scan host row: %w", err)
}
hosts = append(hosts, h)
}
return hosts, rows.Err()
}
// CountHosts returns the total number of hosts in the database.
func (s *Store) CountHosts() (int, error) {
var n int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM hosts`).Scan(&n); err != nil {
return 0, fmt.Errorf("count hosts: %w", err)
}
return n, nil
}
// BeginScan records the start of a module scan run and returns the run ID.
func (s *Store) BeginScan(module string) (int64, error) {
res, err := s.db.Exec(`
INSERT INTO scan_runs (module, started_at) VALUES (?, ?)
`, module, time.Now().UTC())
if err != nil {
return 0, fmt.Errorf("begin scan: %w", err)
}
return res.LastInsertId()
}
// EndScan marks a scan run as completed (status ok or error).
func (s *Store) EndScan(id int64, scanErr error) error {
status, errMsg := "ok", ""
if scanErr != nil {
status, errMsg = "error", scanErr.Error()
}
_, err := s.db.Exec(`
UPDATE scan_runs SET ended_at = ?, status = ?, error = ? WHERE id = ?
`, time.Now().UTC(), status, errMsg, id)
if err != nil {
return fmt.Errorf("end scan %d: %w", id, err)
}
return nil
}
// ADComputer represents a computer object from Active Directory.
type ADComputer struct {
ID int64
CN string
DN string
OS string
OSVersion string
Owner string
Department string
LastLogon time.Time
ADCreated time.Time
UpdatedAt time.Time
}
// UpsertADComputer inserts or updates an AD computer record by CN.
func (s *Store) UpsertADComputer(c ADComputer) error {
_, err := s.db.Exec(`
INSERT INTO ad_computers (cn, dn, os, os_version, owner, department, last_logon, ad_created, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(cn) DO UPDATE SET
dn = excluded.dn,
os = excluded.os,
os_version = excluded.os_version,
owner = excluded.owner,
department = excluded.department,
last_logon = excluded.last_logon,
updated_at = excluded.updated_at
`, c.CN, c.DN, c.OS, c.OSVersion, c.Owner, c.Department,
nullTime(c.LastLogon), nullTime(c.ADCreated), time.Now().UTC())
if err != nil {
return fmt.Errorf("upsert ad computer %s: %w", c.CN, err)
}
return nil
}
// ListADComputers returns all known AD computers.
func (s *Store) ListADComputers() ([]ADComputer, error) {
rows, err := s.db.Query(`
SELECT id, cn, dn, os, os_version, owner, department, last_logon, ad_created, updated_at
FROM ad_computers ORDER BY cn
`)
if err != nil {
return nil, fmt.Errorf("list ad computers: %w", err)
}
defer rows.Close()
var out []ADComputer
for rows.Next() {
var c ADComputer
var lastLogon, adCreated *time.Time
if err := rows.Scan(&c.ID, &c.CN, &c.DN, &c.OS, &c.OSVersion,
&c.Owner, &c.Department, &lastLogon, &adCreated, &c.UpdatedAt); err != nil {
return nil, err
}
if lastLogon != nil {
c.LastLogon = *lastLogon
}
if adCreated != nil {
c.ADCreated = *adCreated
}
out = append(out, c)
}
return out, rows.Err()
}
// UpdateHostnameFromAD sets the hostname of a host matching the given IP.
func (s *Store) UpdateHostnameFromAD(hostname string) error {
_, err := s.db.Exec(`
UPDATE hosts SET hostname = ? WHERE hostname = '' AND ip IN (
SELECT ip FROM hosts WHERE LOWER(hostname) = LOWER(?)
)
`, hostname, hostname)
return err
}
func nullTime(t time.Time) any {
if t.IsZero() {
return nil
}
return t.UTC()
}
// LastScanTime returns when the given module last ran successfully.
func (s *Store) LastScanTime(module string) (time.Time, error) {
var t time.Time
err := s.db.QueryRow(`
SELECT ended_at FROM scan_runs
WHERE module = ? AND status = 'ok'
ORDER BY ended_at DESC LIMIT 1
`, module).Scan(&t)
if err == sql.ErrNoRows {
return time.Time{}, nil
}
return t, err
}
/* ── Site Monitoring ──────────────────────────────────────────────── */
// MonitorCheck is a configured availability check (ping/http/tcp).
type MonitorCheck struct {
ID int64
Name string
Type string // ping | http | tcp
Target string
Enabled bool
CreatedAt time.Time
}
// MonitorResult is a single check execution result.
type MonitorResult struct {
ID int64
CheckID int64
Status string // online | offline
LatencyMS int
Error string
CheckedAt time.Time
}
// ListMonitorChecks returns all configured checks ordered by creation time.
func (s *Store) ListMonitorChecks() ([]MonitorCheck, error) {
rows, err := s.db.Query(`
SELECT id, name, type, target, enabled, created_at
FROM monitor_checks ORDER BY id
`)
if err != nil {
return nil, fmt.Errorf("list monitor checks: %w", err)
}
defer rows.Close()
var out []MonitorCheck
for rows.Next() {
var c MonitorCheck
var ena int
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.Target, &ena, &c.CreatedAt); err != nil {
return nil, err
}
c.Enabled = ena == 1
out = append(out, c)
}
return out, rows.Err()
}
// AddMonitorCheck inserts a new check definition.
func (s *Store) AddMonitorCheck(name, typ, target string) (int64, error) {
res, err := s.db.Exec(`
INSERT INTO monitor_checks (name, type, target) VALUES (?, ?, ?)
`, name, typ, target)
if err != nil {
return 0, fmt.Errorf("add monitor check: %w", err)
}
return res.LastInsertId()
}
// DeleteMonitorCheck removes a check and all its results.
func (s *Store) DeleteMonitorCheck(id int64) error {
if _, err := s.db.Exec(`DELETE FROM monitor_results WHERE check_id = ?`, id); err != nil {
return fmt.Errorf("delete monitor results: %w", err)
}
if _, err := s.db.Exec(`DELETE FROM monitor_checks WHERE id = ?`, id); err != nil {
return fmt.Errorf("delete monitor check: %w", err)
}
return nil
}
// InsertMonitorResult stores one check execution result.
func (s *Store) InsertMonitorResult(r MonitorResult) error {
_, err := s.db.Exec(`
INSERT INTO monitor_results (check_id, status, latency_ms, error, checked_at)
VALUES (?, ?, ?, ?, ?)
`, r.CheckID, r.Status, r.LatencyMS, r.Error, r.CheckedAt.UTC())
if err != nil {
return fmt.Errorf("insert monitor result: %w", err)
}
return nil
}
// LatestMonitorResults returns the most recent result for each check_id.
func (s *Store) LatestMonitorResults() (map[int64]MonitorResult, error) {
rows, err := s.db.Query(`
SELECT r.id, r.check_id, r.status, r.latency_ms, r.error, r.checked_at
FROM monitor_results r
INNER JOIN (
SELECT check_id, MAX(id) as max_id FROM monitor_results GROUP BY check_id
) latest ON r.id = latest.max_id
`)
if err != nil {
return nil, fmt.Errorf("latest monitor results: %w", err)
}
defer rows.Close()
out := make(map[int64]MonitorResult)
for rows.Next() {
var r MonitorResult
if err := rows.Scan(&r.ID, &r.CheckID, &r.Status, &r.LatencyMS, &r.Error, &r.CheckedAt); err != nil {
return nil, err
}
out[r.CheckID] = r
}
return out, rows.Err()
}
// MonitorResultHistory returns the last limit results for a given check.
func (s *Store) MonitorResultHistory(checkID int64, limit int) ([]MonitorResult, error) {
rows, err := s.db.Query(`
SELECT id, check_id, status, latency_ms, error, checked_at
FROM monitor_results WHERE check_id = ?
ORDER BY checked_at DESC LIMIT ?
`, checkID, limit)
if err != nil {
return nil, fmt.Errorf("monitor history: %w", err)
}
defer rows.Close()
var out []MonitorResult
for rows.Next() {
var r MonitorResult
if err := rows.Scan(&r.ID, &r.CheckID, &r.Status, &r.LatencyMS, &r.Error, &r.CheckedAt); err != nil {
return nil, err
}
out = append(out, r)
}
// Reverse so oldest-first for sparkline rendering.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, rows.Err()
}
/* ── Module States ────────────────────────────────────────────────── */
// SetModuleEnabled stores the enabled state for a module.
func (s *Store) SetModuleEnabled(name string, enabled bool) error {
v := 0
if enabled {
v = 1
}
_, err := s.db.Exec(`
INSERT INTO module_states (name, enabled) VALUES (?,?)
ON CONFLICT(name) DO UPDATE SET enabled=excluded.enabled
`, name, v)
return err
}
// GetModuleEnabled returns whether a module is enabled (default true if not set).
func (s *Store) GetModuleEnabled(name string) bool {
var v int
err := s.db.QueryRow(`SELECT enabled FROM module_states WHERE name=?`, name).Scan(&v)
if err != nil {
return true // default: enabled
}
return v == 1
}
/* ── Port Scan ────────────────────────────────────────────────────── */
// UpsertHostPorts replaces all open ports for an IP.
func (s *Store) UpsertHostPorts(ip string, ports []int) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM host_ports WHERE ip = ?`, ip); err != nil {
return err
}
now := time.Now().UTC()
for _, p := range ports {
if _, err := tx.Exec(`INSERT INTO host_ports (ip, port, scanned_at) VALUES (?,?,?)`, ip, p, now); err != nil {
return err
}
}
return tx.Commit()
}
// GetHostPorts returns all open ports for an IP.
func (s *Store) GetHostPorts(ip string) ([]int, error) {
rows, err := s.db.Query(`SELECT port FROM host_ports WHERE ip = ? ORDER BY port`, ip)
if err != nil {
return nil, err
}
defer rows.Close()
var ports []int
for rows.Next() {
var p int
rows.Scan(&p)
ports = append(ports, p)
}
return ports, rows.Err()
}
// ListAllHostPorts returns a map of IP → open ports.
func (s *Store) ListAllHostPorts() (map[string][]int, error) {
rows, err := s.db.Query(`SELECT ip, port FROM host_ports ORDER BY ip, port`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]int)
for rows.Next() {
var ip string
var port int
rows.Scan(&ip, &port)
out[ip] = append(out[ip], port)
}
return out, rows.Err()
}
// MarkStaleHostsOffline marks hosts as offline if last_seen is older than threshold.
func (s *Store) MarkStaleHostsOffline(olderThan time.Duration) (int, error) {
threshold := time.Now().UTC().Add(-olderThan)
res, err := s.db.Exec(`UPDATE hosts SET status='offline' WHERE status='online' AND last_seen < ?`, threshold)
if err != nil {
return 0, fmt.Errorf("mark stale offline: %w", err)
}
n, _ := res.RowsAffected()
return int(n), nil
}
/* ── SNMP ─────────────────────────────────────────────────────────── */
// SNMPTarget is a configured SNMP polling target.
type SNMPTarget struct {
ID int64
Name string
IP string
Community string
Version string
Type string // generic | printer | switch
Enabled bool
CreatedAt time.Time
}
// SNMPResult is the latest value for one OID on one target.
type SNMPResult struct {
ID int64
TargetID int64
OIDName string
Value string
ScannedAt time.Time
}
// ListSNMPTargets returns all SNMP targets.
func (s *Store) ListSNMPTargets() ([]SNMPTarget, error) {
rows, err := s.db.Query(`SELECT id,name,ip,community,version,type,enabled,created_at FROM snmp_targets ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SNMPTarget
for rows.Next() {
var t SNMPTarget
var ena int
rows.Scan(&t.ID, &t.Name, &t.IP, &t.Community, &t.Version, &t.Type, &ena, &t.CreatedAt)
t.Enabled = ena == 1
out = append(out, t)
}
return out, rows.Err()
}
// AddSNMPTarget inserts a new SNMP target.
func (s *Store) AddSNMPTarget(name, ip, community, version, typ string) (int64, error) {
res, err := s.db.Exec(`INSERT INTO snmp_targets (name,ip,community,version,type) VALUES (?,?,?,?,?)`,
name, ip, community, version, typ)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// DeleteSNMPTarget removes a target and its results.
func (s *Store) DeleteSNMPTarget(id int64) error {
s.db.Exec(`DELETE FROM snmp_results WHERE target_id = ?`, id)
_, err := s.db.Exec(`DELETE FROM snmp_targets WHERE id = ?`, id)
return err
}
// UpsertSNMPResult inserts or updates one OID result.
func (s *Store) UpsertSNMPResult(r SNMPResult) error {
_, err := s.db.Exec(`
INSERT INTO snmp_results (target_id, oid_name, value, scanned_at)
VALUES (?,?,?,?)
ON CONFLICT(target_id, oid_name) DO UPDATE SET value=excluded.value, scanned_at=excluded.scanned_at
`, r.TargetID, r.OIDName, r.Value, r.ScannedAt.UTC())
return err
}
// LatestSNMPResults returns all latest OID values keyed by target_id.
func (s *Store) LatestSNMPResults() (map[int64]map[string]string, error) {
rows, err := s.db.Query(`SELECT target_id, oid_name, value FROM snmp_results ORDER BY target_id, oid_name`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[int64]map[string]string)
for rows.Next() {
var tid int64
var k, v string
rows.Scan(&tid, &k, &v)
if out[tid] == nil {
out[tid] = make(map[string]string)
}
out[tid][k] = v
}
return out, rows.Err()
}
// CleanupMonitorResults deletes old results, keeping the last keepPerCheck per check.
func (s *Store) CleanupMonitorResults(keepPerCheck int) error {
_, err := s.db.Exec(`
DELETE FROM monitor_results WHERE id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY check_id ORDER BY checked_at DESC
) AS rn FROM monitor_results
) WHERE rn <= ?
)
`, keepPerCheck)
return err
}

View File

@@ -1,50 +0,0 @@
package module
import (
"context"
"fmt"
"time"
)
// Module is the interface every scanner module must implement.
type Module interface {
// Name returns the unique identifier for this module (e.g. "arp_discovery").
Name() string
// Interval returns how often the scheduler should run this module.
// A zero duration means the module runs once on startup only.
Interval() time.Duration
// Run executes one scan cycle. It must respect ctx cancellation.
Run(ctx context.Context) error
}
// Registry holds all registered modules.
type Registry struct {
modules []Module
}
// NewRegistry returns an empty module registry.
func NewRegistry() *Registry {
return &Registry{}
}
// Register adds a module to the registry.
func (r *Registry) Register(m Module) {
r.modules = append(r.modules, m)
}
// All returns all registered modules.
func (r *Registry) All() []Module {
return r.modules
}
// Get returns the module with the given name.
func (r *Registry) Get(name string) (Module, error) {
for _, m := range r.modules {
if m.Name() == name {
return m, nil
}
}
return nil, fmt.Errorf("module %q not registered", name)
}

View File

@@ -1,182 +0,0 @@
// Package adsync queries Active Directory for computer objects and stores
// them in the local database. It enriches existing hosts with their AD
// computer name and department, and records computers that are in AD but
// not yet seen on the network (useful for finding offline/missing devices).
package adsync
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module queries Active Directory for all computer objects.
type Module struct {
site string
cfg config.ADConfig
store *db.Store
log *slog.Logger
}
// New returns a new AD sync module.
func New(site string, cfg config.ADConfig, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
store: store,
log: slog.With("module", "ad_sync"),
}
}
func (m *Module) Name() string { return "ad_sync" }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run executes one AD sync cycle.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
scanErr := m.sync(ctx)
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
m.log.Error("record scan end", "err", endErr)
}
return scanErr
}
func (m *Module) sync(ctx context.Context) error {
addr := fmt.Sprintf("%s:%d", m.cfg.Server, m.cfg.Port)
var conn *ldap.Conn
var err error
if m.cfg.TLS {
conn, err = ldap.DialURL("ldaps://" + addr)
} else {
conn, err = ldap.DialURL("ldap://" + addr)
}
if err != nil {
return fmt.Errorf("ldap dial %s: %w", addr, err)
}
defer conn.Close()
if err := conn.Bind(m.cfg.BindDN, m.cfg.BindPassword); err != nil {
return fmt.Errorf("ldap bind as %s: %w", m.cfg.BindDN, err)
}
computers, err := m.searchComputers(conn)
if err != nil {
return fmt.Errorf("ldap search: %w", err)
}
m.log.Info("computers found in AD", "count", len(computers))
saved := 0
for _, c := range computers {
if err := m.store.UpsertADComputer(c); err != nil {
m.log.Error("upsert ad computer", "cn", c.CN, "err", err)
continue
}
saved++
m.log.Debug("ad computer synced", "cn", c.CN, "os", c.OS, "dept", c.Department)
}
m.log.Info("ad sync complete", "synced", saved, "total", len(computers))
return nil
}
func (m *Module) searchComputers(conn *ldap.Conn) ([]db.ADComputer, error) {
req := ldap.NewSearchRequest(
m.cfg.SearchBase,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0, 0, false,
"(&(objectClass=computer)(objectCategory=Computer))",
[]string{
"cn",
"distinguishedName",
"operatingSystem",
"operatingSystemVersion",
"lastLogonTimestamp",
"whenCreated",
"description",
"managedBy",
},
nil,
)
result, err := conn.SearchWithPaging(req, 500)
if err != nil {
return nil, fmt.Errorf("ldap search computers: %w", err)
}
var computers []db.ADComputer
for _, entry := range result.Entries {
c := db.ADComputer{
CN: entry.GetAttributeValue("cn"),
DN: entry.GetAttributeValue("distinguishedName"),
OS: entry.GetAttributeValue("operatingSystem"),
OSVersion: entry.GetAttributeValue("operatingSystemVersion"),
Owner: entry.GetAttributeValue("description"),
Department: ouFromDN(entry.GetAttributeValue("distinguishedName")),
}
if raw := entry.GetAttributeValue("lastLogonTimestamp"); raw != "" {
c.LastLogon = windowsTimeToGoTime(raw)
}
if raw := entry.GetAttributeValue("whenCreated"); raw != "" {
c.ADCreated = parseLDAPGeneralizedTime(raw)
}
computers = append(computers, c)
}
return computers, nil
}
// ouFromDN extracts the first OU component from a distinguished name.
// "CN=IT-NB-02,OU=Notebooks,OU=IT,DC=winkel,DC=local" → "Notebooks"
func ouFromDN(dn string) string {
for _, part := range strings.Split(dn, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToUpper(part), "OU=") {
return part[3:]
}
}
return ""
}
// windowsTimeToGoTime converts a Windows LDAP timestamp string (100-ns intervals
// since 1601-01-01) to a Go time.Time.
func windowsTimeToGoTime(raw string) time.Time {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n == 0 {
return time.Time{}
}
// Offset between Windows epoch (1601-01-01) and Unix epoch (1970-01-01)
// in 100-nanosecond intervals.
const windowsToUnix int64 = 116444736000000000
unixNano := (n - windowsToUnix) * 100
return time.Unix(0, unixNano).UTC()
}
// parseLDAPGeneralizedTime parses LDAP generalizedTime format: "20240101120000.0Z"
func parseLDAPGeneralizedTime(raw string) time.Time {
// Trim trailing sub-second and timezone info for simple parsing.
raw = strings.TrimSuffix(raw, "Z")
if idx := strings.Index(raw, "."); idx != -1 {
raw = raw[:idx]
}
t, err := time.Parse("20060102150405", raw)
if err != nil {
return time.Time{}
}
return t.UTC()
}

View File

@@ -1,167 +0,0 @@
// Package arp implements the arp_discovery scanner module.
// It sends ARP requests across configured subnets and records
// responding hosts (IP + MAC) in the shared SQLite store.
package arp
import (
"context"
"fmt"
"log/slog"
"net"
"net/netip"
"time"
"github.com/mdlayher/arp"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module performs ARP discovery on configured subnets.
type Module struct {
site string
cfg config.ARPConfig
store *db.Store
log *slog.Logger
}
// New returns a new ARP discovery module.
func New(site string, cfg config.ARPConfig, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
store: store,
log: slog.With("module", "arp_discovery"),
}
}
func (m *Module) Name() string { return "arp_discovery" }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run executes one full ARP scan cycle across all configured subnets.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan record: %w", err)
}
scanErr := m.scanAllSubnets(ctx)
if err := m.store.EndScan(scanID, scanErr); err != nil {
m.log.Error("failed to record scan end", "err", err)
}
return scanErr
}
func (m *Module) scanAllSubnets(ctx context.Context) error {
iface, err := net.InterfaceByName(m.cfg.Interface)
if err != nil {
return fmt.Errorf("interface %q: %w", m.cfg.Interface, err)
}
client, err := arp.Dial(iface)
if err != nil {
return fmt.Errorf("arp dial on %s: %w", m.cfg.Interface, err)
}
defer client.Close()
for _, subnet := range m.cfg.Subnets {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := m.scanSubnet(ctx, client, subnet); err != nil {
// Log and continue — one failed subnet should not abort others.
m.log.Error("subnet scan failed", "subnet", subnet, "err", err)
}
}
return nil
}
func (m *Module) scanSubnet(ctx context.Context, client *arp.Client, cidr string) error {
prefix, err := netip.ParsePrefix(cidr)
if err != nil {
return fmt.Errorf("parse prefix %q: %w", cidr, err)
}
prefix = prefix.Masked()
targets := hostsInPrefix(prefix)
m.log.Info("scanning subnet", "subnet", cidr, "targets", len(targets))
// Send all ARP requests first, then collect responses.
// Set a deadline so Read() returns after silence.
if err := client.SetDeadline(time.Now().Add(3 * time.Second)); err != nil {
return fmt.Errorf("set deadline: %w", err)
}
for _, ip := range targets {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := client.Request(ip); err != nil {
m.log.Debug("arp request failed", "ip", ip, "err", err)
}
// Small delay to avoid flooding the network.
time.Sleep(2 * time.Millisecond)
}
// Collect all responses until the deadline fires.
found := 0
for {
pkt, _, err := client.Read()
if err != nil {
// Deadline reached — done reading.
break
}
now := time.Now().UTC()
host := db.Host{
IP: pkt.SenderIP.String(),
MAC: pkt.SenderHardwareAddr.String(),
Site: m.site,
FirstSeen: now,
LastSeen: now,
}
if err := m.store.UpsertHost(host); err != nil {
m.log.Error("upsert host", "ip", host.IP, "err", err)
continue
}
m.log.Info("host discovered", "ip", host.IP, "mac", host.MAC)
found++
}
m.log.Info("subnet scan done", "subnet", cidr, "found", found)
return nil
}
// hostsInPrefix returns all usable host addresses in a prefix,
// excluding the network address and the broadcast address.
//
// Example: 192.168.0.0/24 → 192.168.0.1 … 192.168.0.254
func hostsInPrefix(prefix netip.Prefix) []netip.Addr {
var addrs []netip.Addr
// Start at network address + 1 (skip network address itself).
addr := prefix.Masked().Addr().Next()
for prefix.Contains(addr) {
next := addr.Next()
// Stop before the broadcast address (last address in prefix).
if !prefix.Contains(next) {
break
}
addrs = append(addrs, addr)
addr = next
}
return addrs
}

View File

@@ -1,230 +0,0 @@
package arp_test
import (
"fmt"
"net/netip"
"os"
"testing"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// helper creates a temp SQLite store and registers cleanup.
func newTestStore(t *testing.T) *db.Store {
t.Helper()
f, err := os.CreateTemp("", "nexus-test-*.db")
if err != nil {
t.Fatalf("create temp db: %v", err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
store, err := db.Open(f.Name())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
func TestUpsertAndListHost(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC().Truncate(time.Second)
original := db.Host{
IP: "192.168.0.10",
MAC: "aa:bb:cc:dd:ee:ff",
Vendor: "Acme Corp",
Site: "LUD",
FirstSeen: now,
LastSeen: now,
}
if err := store.UpsertHost(original); err != nil {
t.Fatalf("UpsertHost (insert): %v", err)
}
hosts, err := store.ListHosts("")
if err != nil {
t.Fatalf("ListHosts: %v", err)
}
if len(hosts) != 1 {
t.Fatalf("expected 1 host, got %d", len(hosts))
}
h := hosts[0]
if h.IP != original.IP {
t.Errorf("IP: got %q, want %q", h.IP, original.IP)
}
if h.MAC != original.MAC {
t.Errorf("MAC: got %q, want %q", h.MAC, original.MAC)
}
if h.Status != "online" {
t.Errorf("Status: got %q, want %q", h.Status, "online")
}
// Update the same IP — last_seen and MAC should change, first_seen should not.
updated := original
updated.MAC = "11:22:33:44:55:66"
updated.LastSeen = now.Add(time.Minute)
if err := store.UpsertHost(updated); err != nil {
t.Fatalf("UpsertHost (update): %v", err)
}
hosts, err = store.ListHosts("")
if err != nil {
t.Fatalf("ListHosts after update: %v", err)
}
if len(hosts) != 1 {
t.Fatalf("expected 1 host after upsert, got %d", len(hosts))
}
if hosts[0].MAC != "11:22:33:44:55:66" {
t.Errorf("MAC after update: got %q, want %q", hosts[0].MAC, "11:22:33:44:55:66")
}
}
func TestListHostsFilterBySite(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
hosts := []db.Host{
{IP: "10.0.0.1", MAC: "aa:aa:aa:aa:aa:01", Site: "LUD", FirstSeen: now, LastSeen: now},
{IP: "10.0.0.2", MAC: "aa:aa:aa:aa:aa:02", Site: "LUD", FirstSeen: now, LastSeen: now},
{IP: "10.0.1.1", MAC: "aa:aa:aa:aa:bb:01", Site: "BAR", FirstSeen: now, LastSeen: now},
}
for _, h := range hosts {
if err := store.UpsertHost(h); err != nil {
t.Fatalf("UpsertHost %s: %v", h.IP, err)
}
}
lud, err := store.ListHosts("LUD")
if err != nil {
t.Fatal(err)
}
if len(lud) != 2 {
t.Errorf("LUD: expected 2, got %d", len(lud))
}
bar, err := store.ListHosts("BAR")
if err != nil {
t.Fatal(err)
}
if len(bar) != 1 {
t.Errorf("BAR: expected 1, got %d", len(bar))
}
all, err := store.ListHosts("")
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Errorf("all: expected 3, got %d", len(all))
}
}
func TestCountHosts(t *testing.T) {
store := newTestStore(t)
now := time.Now().UTC()
n, err := store.CountHosts()
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("expected 0 initially, got %d", n)
}
for i := range 5 {
err := store.UpsertHost(db.Host{
IP: fmt.Sprintf("10.0.0.%d", i+1),
MAC: fmt.Sprintf("aa:bb:cc:dd:ee:%02x", i),
Site: "LUD",
FirstSeen: now,
LastSeen: now,
})
if err != nil {
t.Fatalf("UpsertHost %d: %v", i, err)
}
}
n, err = store.CountHosts()
if err != nil {
t.Fatal(err)
}
if n != 5 {
t.Errorf("expected 5, got %d", n)
}
}
func TestScanRunLifecycle(t *testing.T) {
store := newTestStore(t)
id, err := store.BeginScan("arp_discovery")
if err != nil {
t.Fatalf("BeginScan: %v", err)
}
if id == 0 {
t.Error("expected non-zero scan ID")
}
if err := store.EndScan(id, nil); err != nil {
t.Fatalf("EndScan (ok): %v", err)
}
last, err := store.LastScanTime("arp_discovery")
if err != nil {
t.Fatal(err)
}
if last.IsZero() {
t.Error("expected non-zero last scan time after successful run")
}
}
// TestHostsInPrefix validates the subnet host enumeration logic.
// This test lives here because hostsInPrefix is package-internal;
// in a real scenario you would export it for testing or white-box test it.
func TestHostsInPrefixCount(t *testing.T) {
cases := []struct {
cidr string
count int
}{
{"192.168.0.0/24", 254}, // .1 .254
{"10.0.0.0/30", 2}, // .1 and .2 only
{"10.0.0.0/29", 6}, // .1 .6
}
for _, tc := range cases {
t.Run(tc.cidr, func(t *testing.T) {
prefix, err := netip.ParsePrefix(tc.cidr)
if err != nil {
t.Fatal(err)
}
got := hostsInPrefix(prefix.Masked())
if len(got) != tc.count {
t.Errorf("cidr %s: got %d hosts, want %d", tc.cidr, len(got), tc.count)
}
})
}
}
// hostsInPrefix is a copy of the unexported function for white-box testing.
func hostsInPrefix(prefix netip.Prefix) []netip.Addr {
var addrs []netip.Addr
addr := prefix.Masked().Addr().Next()
for prefix.Contains(addr) {
next := addr.Next()
if !prefix.Contains(next) {
break
}
addrs = append(addrs, addr)
addr = next
}
return addrs
}

View File

@@ -1,94 +0,0 @@
// Package dnsreverse implements the dns_reverse module.
// It enriches already-known hosts by performing reverse-DNS lookups
// for every host that has no hostname recorded yet.
package dnsreverse
import (
"context"
"fmt"
"log/slog"
"net"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module enriches hosts in the database with reverse-DNS hostnames.
type Module struct {
site string
store *db.Store
log *slog.Logger
}
// New returns a new dns_reverse module.
func New(site string, store *db.Store) *Module {
return &Module{
site: site,
store: store,
log: slog.With("module", "dns_reverse"),
}
}
func (m *Module) Name() string { return "dns_reverse" }
func (m *Module) Interval() time.Duration { return 10 * time.Minute }
// Run looks up hostnames for all hosts that have none recorded yet.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
scanErr := m.scan(ctx)
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
m.log.Error("record scan end", "err", endErr)
}
return scanErr
}
func (m *Module) scan(ctx context.Context) error {
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
resolved := 0
for _, h := range hosts {
// Skip hosts that already have a hostname.
if h.Hostname != "" {
continue
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
names, err := net.DefaultResolver.LookupAddr(ctx, h.IP)
if err != nil {
// A lookup failure is not fatal — continue with the next host.
m.log.Debug("reverse lookup failed", "ip", h.IP, "err", err)
continue
}
if len(names) == 0 {
continue
}
// LookupAddr returns FQDNs with a trailing dot — strip it.
hostname := strings.TrimSuffix(names[0], ".")
h.Hostname = hostname
if err := m.store.UpsertHost(h); err != nil {
m.log.Error("upsert host", "ip", h.IP, "err", err)
continue
}
m.log.Info("hostname resolved", "ip", h.IP, "hostname", hostname)
resolved++
}
m.log.Info("scan complete", "checked", len(hosts), "resolved", resolved)
return nil
}

View File

@@ -1,129 +0,0 @@
// Package macvendor implements the mac_vendor module.
// It maps the OUI (first 3 bytes) of every known host's MAC address to a
// human-readable vendor name and persists the result in the database.
// The mapping is based on a built-in table; no external files are required.
package macvendor
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// ouiTable maps lowercase OUI prefixes (aa:bb:cc) to vendor names.
// Broadcast / multicast entries are intentionally kept with an empty string
// so they are recognised but not written to the database.
var ouiTable = map[string]string{
"00:0c:29": "VMware",
"00:50:56": "VMware",
"bc:24:11": "Proxmox/Ceph",
"90:1b:0e": "Supermicro",
"90:09:d0": "Synology",
"00:17:c8": "Hewlett-Packard",
"00:be:43": "Ubiquiti",
"e4:43:4b": "Ubiquiti",
"24:6a:0e": "Ubiquiti",
"40:86:cb": "Intel",
"68:c6:ac": "Intel",
"1c:af:4a": "Dell",
"50:81:40": "Dell",
"4c:5f:70": "Lenovo",
"dc:58:bc": "Apple",
"c8:4b:d6": "Kyocera",
"38:d5:7a": "Samsung",
"00:04:a5": "Barco",
"7c:5a:1c": "LANCOM",
"00:0a:b3": "Cisco",
"00:e0:67": "Aten",
"ff:ff:ff": "", // broadcast — leave vendor empty
}
// LookupVendor returns the vendor name for a MAC address.
// The MAC must be in the format aa:bb:cc:dd:ee:ff (colon-separated).
// An empty string is returned when no match is found.
func LookupVendor(mac string) string {
if len(mac) < 8 {
return ""
}
oui := strings.ToLower(mac[:8])
return ouiTable[oui]
}
// Module enriches hosts in the database with OUI-based vendor names.
type Module struct {
site string
store *db.Store
log *slog.Logger
}
// New returns a new mac_vendor module.
func New(site string, store *db.Store) *Module {
return &Module{
site: site,
store: store,
log: slog.With("module", "mac_vendor"),
}
}
func (m *Module) Name() string { return "mac_vendor" }
// Interval returns 0, which causes the scheduler to run this module once at
// startup and never repeat it automatically.
func (m *Module) Interval() time.Duration { return 0 }
// Run assigns vendor names to all hosts that have none recorded yet.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
scanErr := m.scan(ctx)
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
m.log.Error("record scan end", "err", endErr)
}
return scanErr
}
func (m *Module) scan(ctx context.Context) error {
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
enriched := 0
for _, h := range hosts {
// Skip hosts that already have a vendor assigned.
if h.Vendor != "" {
continue
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
vendor := LookupVendor(h.MAC)
if vendor == "" {
// No entry in our table — leave vendor empty.
continue
}
h.Vendor = vendor
if err := m.store.UpsertHost(h); err != nil {
m.log.Error("upsert host", "ip", h.IP, "err", err)
continue
}
m.log.Info("vendor resolved", "ip", h.IP, "mac", h.MAC, "vendor", vendor)
enriched++
}
m.log.Info("scan complete", "checked", len(hosts), "enriched", enriched)
return nil
}

View File

@@ -1,54 +0,0 @@
package macvendor
import "testing"
func TestLookupVendor(t *testing.T) {
tests := []struct {
mac string
want string
}{
// Known OUIs from the built-in table.
{"00:0c:29:1a:2b:3c", "VMware"},
{"00:50:56:ab:cd:ef", "VMware"},
{"bc:24:11:00:00:01", "Proxmox/Ceph"},
{"90:1b:0e:ff:ee:dd", "Supermicro"},
{"90:09:d0:11:22:33", "Synology"},
{"00:17:c8:44:55:66", "Hewlett-Packard"},
{"00:be:43:77:88:99", "Ubiquiti"},
{"e4:43:4b:aa:bb:cc", "Ubiquiti"},
{"24:6a:0e:dd:ee:ff", "Ubiquiti"},
{"40:86:cb:00:11:22", "Intel"},
{"68:c6:ac:33:44:55", "Intel"},
{"1c:af:4a:66:77:88", "Dell"},
{"50:81:40:99:aa:bb", "Dell"},
{"4c:5f:70:cc:dd:ee", "Lenovo"},
{"dc:58:bc:ff:00:11", "Apple"},
{"c8:4b:d6:22:33:44", "Kyocera"},
{"38:d5:7a:55:66:77", "Samsung"},
{"00:04:a5:88:99:aa", "Barco"},
{"7c:5a:1c:bb:cc:dd", "LANCOM"},
{"00:0a:b3:ee:ff:00", "Cisco"},
{"00:e0:67:11:22:33", "Aten"},
// Broadcast — vendor must be empty.
{"ff:ff:ff:ff:ff:ff", ""},
// Unknown OUI — no vendor.
{"de:ad:be:ef:00:01", ""},
// MAC shorter than 8 characters — must not panic.
{"aa:bb", ""},
{"", ""},
// Upper-case input — must be normalised.
{"00:0C:29:1A:2B:3C", "VMware"},
{"BC:24:11:FF:EE:DD", "Proxmox/Ceph"},
}
for _, tc := range tests {
got := LookupVendor(tc.mac)
if got != tc.want {
t.Errorf("LookupVendor(%q) = %q, want %q", tc.mac, got, tc.want)
}
}
}

View File

@@ -1,130 +0,0 @@
// Package nexusreporter pushes newly discovered or changed assets to IT Nexus.
package nexusreporter
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "nexus_reporter"
type Module struct {
site string
cfg config.ReporterConfig
nexus config.Config // full config for URL + APIKey
store *db.Store
client *http.Client
// last hash per IP to detect changes
lastHash map[string][32]byte
}
// New creates a new IT Nexus reporter module.
func New(site string, cfg config.ReporterConfig, nexusCfg config.Config, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
nexus: nexusCfg,
store: store,
client: &http.Client{Timeout: 15 * time.Second},
lastHash: make(map[string][32]byte),
}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
type assetPayload struct {
Site string `json:"site"`
Version string `json:"scanner_version"`
ReportedAt time.Time `json:"reported_at"`
Assets []assetItem `json:"assets"`
}
type assetItem struct {
IP string `json:"ip"`
MAC string `json:"mac"`
Hostname string `json:"hostname"`
Vendor string `json:"vendor"`
Status string `json:"status"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
}
// Run reports changed/new hosts to IT Nexus.
func (m *Module) Run(ctx context.Context) error {
if m.nexus.Nexus.URL == "" || m.nexus.Nexus.APIKey == "" {
slog.Debug("nexus reporter skipped — URL or APIKey not configured")
return nil
}
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
var changed []assetItem
for _, h := range hosts {
hash := hostHash(h)
if prev, seen := m.lastHash[h.IP]; seen && prev == hash {
continue // unchanged
}
m.lastHash[h.IP] = hash
changed = append(changed, assetItem{
IP: h.IP,
MAC: h.MAC,
Hostname: h.Hostname,
Vendor: h.Vendor,
Status: h.Status,
FirstSeen: h.FirstSeen,
LastSeen: h.LastSeen,
})
}
if len(changed) == 0 {
slog.Debug("nexus reporter: no changes")
return nil
}
payload := assetPayload{
Site: m.site,
Version: "1.0",
ReportedAt: time.Now(),
Assets: changed,
}
body, _ := json.Marshal(payload)
url := m.nexus.Nexus.URL + "/api/scanner/assets"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Scanner-Key", m.nexus.Nexus.APIKey)
resp, err := m.client.Do(req)
if err != nil {
return fmt.Errorf("post to nexus: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("nexus returned HTTP %d", resp.StatusCode)
}
slog.Info("nexus reporter: assets reported", "count", len(changed), "url", url)
return nil
}
func hostHash(h db.Host) [32]byte {
s := h.IP + "|" + h.MAC + "|" + h.Hostname + "|" + h.Vendor + "|" + h.Status
return sha256.Sum256([]byte(s))
}

View File

@@ -1,106 +0,0 @@
// Package portscan performs concurrent TCP port scanning on all online hosts.
package portscan
import (
"context"
"fmt"
"log/slog"
"net"
"sort"
"sync"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "port_scan"
// Common ports to scan when none are configured.
var defaultPorts = []int{21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 3389, 5985, 8080, 8443, 9100}
// Module scans TCP ports on all online hosts.
type Module struct {
site string
cfg config.PortScanConfig
store *db.Store
}
// New creates a new port scan module.
func New(site string, cfg config.PortScanConfig, store *db.Store) *Module {
return &Module{site: site, cfg: cfg, store: store}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run scans all online hosts and stores their open ports.
func (m *Module) Run(ctx context.Context) error {
hosts, err := m.store.ListHosts("")
if err != nil {
return fmt.Errorf("list hosts: %w", err)
}
ports := m.cfg.Ports
if len(ports) == 0 {
ports = defaultPorts
}
timeout := m.cfg.Timeout
if timeout == 0 {
timeout = 500 * time.Millisecond
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
found := 0
for _, h := range hosts {
if h.Status != "online" {
continue
}
open := scanHost(ctx, h.IP, ports, timeout)
sort.Ints(open)
if err := m.store.UpsertHostPorts(h.IP, open); err != nil {
slog.Error("save host ports", "ip", h.IP, "err", err)
}
if len(open) > 0 {
slog.Debug("port scan", "ip", h.IP, "open", open)
found++
}
}
_ = m.store.EndScan(scanID, nil)
slog.Info("port scan complete", "hosts_with_open_ports", found, "total_hosts", len(hosts))
return nil
}
// scanHost tries all ports concurrently (max 30 goroutines) and returns open ones.
func scanHost(ctx context.Context, ip string, ports []int, timeout time.Duration) []int {
var mu sync.Mutex
var open []int
var wg sync.WaitGroup
sem := make(chan struct{}, 30)
for _, port := range ports {
wg.Add(1)
go func(p int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
addr := fmt.Sprintf("%s:%d", ip, p)
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err == nil {
conn.Close()
mu.Lock()
open = append(open, p)
mu.Unlock()
}
}(port)
}
wg.Wait()
return open
}

View File

@@ -1,298 +0,0 @@
// Package sitemon checks the reachability of configured targets (ping, http, tcp).
// Each scanner instance monitors from its own site perspective, giving you
// per-site uptime visibility across all your locations.
package sitemon
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"log/slog"
"net"
"net/http"
"net/smtp"
"os/exec"
"runtime"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "site_monitoring"
// Module runs periodic availability checks and stores results in the DB.
type Module struct {
site string
cfg config.SiteMonConfig
alertCfg config.AlertConfig
nexusCfg config.Config
store *db.Store
client *http.Client
alertHTTP *http.Client
// track previous state per check ID to detect transitions
prevState map[int64]string
}
// New creates a new site monitoring module.
func New(site string, cfg config.SiteMonConfig, alertCfg config.AlertConfig, nexusCfg config.Config, store *db.Store) *Module {
return &Module{
site: site,
cfg: cfg,
alertCfg: alertCfg,
nexusCfg: nexusCfg,
store: store,
prevState: make(map[int64]string),
alertHTTP: &http.Client{Timeout: 10 * time.Second},
client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run executes all enabled checks once and stores results.
func (m *Module) Run(ctx context.Context) error {
checks, err := m.store.ListMonitorChecks()
if err != nil {
return fmt.Errorf("list checks: %w", err)
}
if len(checks) == 0 {
return nil
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
for _, check := range checks {
if !check.Enabled {
continue
}
result := m.runCheck(ctx, check)
if err := m.store.InsertMonitorResult(result); err != nil {
slog.Error("insert monitor result", "check", check.Name, "err", err)
}
slog.Debug("monitor check done",
"name", check.Name, "status", result.Status, "latency_ms", result.LatencyMS)
// Detect state transitions and fire alerts.
if prev, seen := m.prevState[check.ID]; seen && prev != result.Status {
go m.sendAlert(check, result)
}
m.prevState[check.ID] = result.Status
}
_ = m.store.EndScan(scanID, nil)
_ = m.store.CleanupMonitorResults(100)
return nil
}
func (m *Module) runCheck(ctx context.Context, check db.MonitorCheck) db.MonitorResult {
result := db.MonitorResult{
CheckID: check.ID,
CheckedAt: time.Now(),
}
start := time.Now()
var checkErr error
switch check.Type {
case "ping":
checkErr = doPing(ctx, check.Target)
case "http", "https":
checkErr = doHTTP(ctx, m.client, check.Target)
case "tcp":
checkErr = doTCP(ctx, check.Target)
default:
checkErr = fmt.Errorf("unknown type: %s", check.Type)
}
result.LatencyMS = int(time.Since(start).Milliseconds())
if checkErr != nil {
result.Status = "offline"
result.Error = checkErr.Error()
} else {
result.Status = "online"
}
return result
}
func doPing(ctx context.Context, target string) error {
var args []string
if runtime.GOOS == "windows" {
args = []string{"-n", "1", "-w", "2000", target}
} else {
args = []string{"-c", "1", "-W", "2", target}
}
cmd := exec.CommandContext(ctx, "ping", args...)
out, err := cmd.Output()
if err != nil {
return fmt.Errorf("ping: %w", err)
}
lower := strings.ToLower(string(out))
if strings.Contains(lower, "unreachable") ||
strings.Contains(lower, "timed out") ||
strings.Contains(lower, "100% loss") ||
strings.Contains(lower, "100% packet loss") {
return fmt.Errorf("host unreachable")
}
return nil
}
func doHTTP(ctx context.Context, client *http.Client, target string) error {
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
target = "https://" + target
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "NexusScanner/1.0")
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil
}
func doTCP(ctx context.Context, target string) error {
d := net.Dialer{Timeout: 5 * time.Second}
conn, err := d.DialContext(ctx, "tcp", target)
if err != nil {
return err
}
conn.Close()
return nil
}
/* ── Alerts ───────────────────────────────────────────────────────── */
func (m *Module) sendAlert(check db.MonitorCheck, result db.MonitorResult) {
emoji := "🔴"
word := "OFFLINE"
if result.Status == "online" {
emoji = "✅"
word = "WIEDER ONLINE"
}
subject := fmt.Sprintf("[Nexus Scanner %s] %s %s: %s", m.site, emoji, word, check.Name)
body := fmt.Sprintf(
"Standort: %s\nCheck: %s (%s)\nZiel: %s\nStatus: %s\nZeit: %s\n",
m.site, check.Name, check.Type, check.Target,
strings.ToUpper(result.Status),
result.CheckedAt.Format("02.01.2006 15:04:05"),
)
if result.Error != "" {
body += "Fehler: " + result.Error + "\n"
}
if m.alertCfg.SMTP.Enabled {
if err := m.sendMail(subject, body); err != nil {
slog.Error("alert mail failed", "err", err)
} else {
slog.Info("alert mail sent", "check", check.Name, "status", result.Status)
}
}
if m.alertCfg.NexusEnabled && m.nexusCfg.Nexus.URL != "" {
if err := m.sendNexusAlert(check, result, subject, body); err != nil {
slog.Error("nexus alert failed", "err", err)
} else {
slog.Info("nexus alert sent", "check", check.Name, "status", result.Status)
}
}
}
func (m *Module) sendMail(subject, body string) error {
sc := m.alertCfg.SMTP
addr := fmt.Sprintf("%s:%d", sc.Host, sc.Port)
msg := []byte("From: " + sc.From + "\r\n" +
"To: " + sc.To + "\r\n" +
"Subject: " + subject + "\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n\r\n" +
body)
var auth smtp.Auth
if sc.Username != "" {
auth = smtp.PlainAuth("", sc.Username, sc.Password, sc.Host)
}
// Try STARTTLS first, fall back to plain.
tlsCfg := &tls.Config{ServerName: sc.Host, InsecureSkipVerify: false}
c, err := smtp.Dial(addr)
if err != nil {
return fmt.Errorf("dial smtp: %w", err)
}
defer c.Close()
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(tlsCfg); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(sc.From); err != nil {
return err
}
if err := c.Rcpt(sc.To); err != nil {
return err
}
wc, err := c.Data()
if err != nil {
return err
}
defer wc.Close()
_, err = wc.Write(msg)
return err
}
func (m *Module) sendNexusAlert(check db.MonitorCheck, result db.MonitorResult, subject, body string) error {
payload := map[string]any{
"site": m.site,
"check": check.Name,
"type": check.Type,
"target": check.Target,
"status": result.Status,
"latency": result.LatencyMS,
"error": result.Error,
"timestamp": result.CheckedAt,
"subject": subject,
"body": body,
}
b, _ := json.Marshal(payload)
url := m.nexusCfg.Nexus.URL + "/api/scanner/alert"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Scanner-Key", m.nexusCfg.Nexus.APIKey)
resp, err := m.alertHTTP.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("nexus returned %d", resp.StatusCode)
}
return nil
}

View File

@@ -1,334 +0,0 @@
// Package snmpmod polls SNMP targets for device information.
// Supports generic devices, printers (toner/pages) and switches.
package snmpmod
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/gosnmp/gosnmp"
"github.com/cereda-systems/nexus-scanner/internal/config"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
const modName = "snmp"
// OIDs queried for every device.
var baseOIDs = map[string]string{
"sysDescr": "1.3.6.1.2.1.1.1.0",
"sysUpTime": "1.3.6.1.2.1.1.3.0",
"sysName": "1.3.6.1.2.1.1.5.0",
"sysLocation": "1.3.6.1.2.1.1.6.0",
"sysContact": "1.3.6.1.2.1.1.4.0",
"ifNumber": "1.3.6.1.2.1.2.1.0",
}
// Switch-specific scalar OIDs (queried via Get).
var switchScalarOIDs = map[string]string{
"cpuLoad": "1.3.6.1.2.1.25.3.3.1.2.1", // hrProcessorLoad (first CPU)
}
// Printer-specific OIDs (Kyocera / most RFC 3805 printers).
var printerOIDs = map[string]string{
"tonerLevel": "1.3.6.1.2.1.43.11.1.1.9.1.1",
"tonerMax": "1.3.6.1.2.1.43.11.1.1.8.1.1",
"pageCount": "1.3.6.1.2.1.43.10.2.1.4.1.1",
"printerStatus": "1.3.6.1.2.1.25.3.5.1.1.1",
}
// Walk base OIDs for interfaces and memory.
const (
oidIfOperStatus = "1.3.6.1.2.1.2.2.1.8" // 1=up, 2=down
oidIfHCInOctets = "1.3.6.1.2.1.31.1.1.1.6" // 64-bit in bytes
oidIfHCOutOctets = "1.3.6.1.2.1.31.1.1.1.10" // 64-bit out bytes
oidIfDescr = "1.3.6.1.2.1.2.2.1.2" // interface name
oidHrStorageDescr = "1.3.6.1.2.1.25.2.3.1.2"
oidHrStorageUsed = "1.3.6.1.2.1.25.2.3.1.6"
oidHrStorageSize = "1.3.6.1.2.1.25.2.3.1.5"
oidHrStorageAlloc = "1.3.6.1.2.1.25.2.3.1.4"
)
// Module polls SNMP targets and stores results.
type Module struct {
site string
cfg config.SNMPConfig
store *db.Store
}
// New creates a new SNMP module.
func New(site string, cfg config.SNMPConfig, store *db.Store) *Module {
return &Module{site: site, cfg: cfg, store: store}
}
func (m *Module) Name() string { return modName }
func (m *Module) Interval() time.Duration { return m.cfg.Interval }
// Run polls all enabled SNMP targets.
func (m *Module) Run(ctx context.Context) error {
targets, err := m.store.ListSNMPTargets()
if err != nil {
return fmt.Errorf("list snmp targets: %w", err)
}
if len(targets) == 0 {
return nil
}
scanID, err := m.store.BeginScan(modName)
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
ok := 0
for _, t := range targets {
if !t.Enabled {
continue
}
if err := m.pollTarget(ctx, t); err != nil {
slog.Warn("snmp poll failed", "target", t.Name, "ip", t.IP, "err", err)
} else {
ok++
}
}
_ = m.store.EndScan(scanID, nil)
slog.Info("snmp poll complete", "ok", ok, "total", len(targets))
return nil
}
func (m *Module) pollTarget(ctx context.Context, t db.SNMPTarget) error {
community := t.Community
if community == "" {
community = m.cfg.Community
}
gs := &gosnmp.GoSNMP{
Target: t.IP,
Port: 161,
Community: community,
Version: snmpVersion(t.Version),
Timeout: 10 * time.Second,
Retries: 1,
MaxOids: 60,
}
if err := gs.Connect(); err != nil {
return fmt.Errorf("connect: %w", err)
}
defer gs.Conn.Close()
now := time.Now()
// --- Base + type-specific scalar GET ---
oids := make(map[string]string)
for k, v := range baseOIDs {
oids[k] = v
}
switch t.Type {
case "printer":
for k, v := range printerOIDs {
oids[k] = v
}
case "switch":
for k, v := range switchScalarOIDs {
oids[k] = v
}
}
oidList := make([]string, 0, len(oids))
reverseMap := make(map[string]string)
for name, oid := range oids {
oidList = append(oidList, oid)
reverseMap[oid] = name
}
result, err := gs.Get(oidList)
if err != nil {
return fmt.Errorf("get: %w", err)
}
for _, pdu := range result.Variables {
name := resolveOID(pdu.Name, reverseMap)
if name == "" {
continue
}
value := pduToString(pdu)
if value == "" || value == "<nil>" || strings.HasPrefix(value, "<nil>") {
continue
}
_ = m.store.UpsertSNMPResult(db.SNMPResult{
TargetID: t.ID,
OIDName: name,
Value: value,
ScannedAt: now,
})
}
// --- Switch: walk interface table + memory ---
if t.Type == "switch" {
m.walkSwitch(gs, t, now)
}
slog.Debug("snmp polled", "target", t.Name, "ip", t.IP, "type", t.Type)
return nil
}
// walkSwitch collects interface stats and memory for switch-type targets.
func (m *Module) walkSwitch(gs *gosnmp.GoSNMP, t db.SNMPTarget, now time.Time) {
// Interface status walk → portsUp / portsTotal
var portsUp, portsTotal int
var trafficIn, trafficOut uint64
ifStatus := walkOID(gs, oidIfOperStatus)
for _, v := range ifStatus {
portsTotal++
if v == "1" {
portsUp++
}
}
// Traffic counters (64-bit)
for _, v := range walkOID(gs, oidIfHCInOctets) {
n, _ := strconv.ParseUint(v, 10, 64)
trafficIn += n
}
for _, v := range walkOID(gs, oidIfHCOutOctets) {
n, _ := strconv.ParseUint(v, 10, 64)
trafficOut += n
}
save := func(name, value string) {
_ = m.store.UpsertSNMPResult(db.SNMPResult{
TargetID: t.ID,
OIDName: name,
Value: value,
ScannedAt: now,
})
}
if portsTotal > 0 {
save("portsTotal", strconv.Itoa(portsTotal))
save("portsUp", strconv.Itoa(portsUp))
}
if trafficIn > 0 {
save("trafficIn", strconv.FormatUint(trafficIn, 10))
}
if trafficOut > 0 {
save("trafficOut", strconv.FormatUint(trafficOut, 10))
}
// Memory: find "Physical Memory" or "Real Memory" row
descrByIdx := walkOID(gs, oidHrStorageDescr)
usedByIdx := walkOID(gs, oidHrStorageUsed)
sizeByIdx := walkOID(gs, oidHrStorageSize)
allocByIdx := walkOID(gs, oidHrStorageAlloc)
for idx, descr := range descrByIdx {
d := strings.ToLower(descr)
if strings.Contains(d, "physical") || strings.Contains(d, "real") || strings.Contains(d, "ram") {
used, _ := strconv.ParseInt(usedByIdx[idx], 10, 64)
size, _ := strconv.ParseInt(sizeByIdx[idx], 10, 64)
alloc, _ := strconv.ParseInt(allocByIdx[idx], 10, 64)
if alloc <= 0 {
alloc = 1024
}
usedBytes := used * alloc
totalBytes := size * alloc
if totalBytes > 0 {
save("memUsed", strconv.FormatInt(usedBytes, 10))
save("memTotal", strconv.FormatInt(totalBytes, 10))
}
break
}
}
}
// walkOID performs an SNMP walk and returns a map of last-OID-index → value string.
func walkOID(gs *gosnmp.GoSNMP, baseOID string) map[string]string {
out := make(map[string]string)
err := gs.Walk(baseOID, func(pdu gosnmp.SnmpPDU) error {
// Extract the index (last component of OID)
name := strings.TrimPrefix(pdu.Name, ".")
parts := strings.Split(name, ".")
idx := parts[len(parts)-1]
val := pduRawToString(pdu)
if val != "" {
out[idx] = val
}
return nil
})
if err != nil {
slog.Debug("snmp walk failed", "oid", baseOID, "err", err)
}
return out
}
func resolveOID(oidName string, reverseMap map[string]string) string {
key := strings.TrimPrefix(oidName, ".")
if name := reverseMap[key]; name != "" {
return name
}
// Try without trailing .0
if len(key) > 2 && key[len(key)-2:] == ".0" {
return reverseMap[key[:len(key)-2]]
}
return ""
}
func snmpVersion(v string) gosnmp.SnmpVersion {
switch v {
case "v1":
return gosnmp.Version1
case "v3":
return gosnmp.Version3
default:
return gosnmp.Version2c
}
}
// pduToString converts a PDU value to a display string (formatted).
func pduToString(pdu gosnmp.SnmpPDU) string {
switch pdu.Type {
case gosnmp.OctetString:
if b, ok := pdu.Value.([]byte); ok {
return strings.TrimSpace(string(b))
}
case gosnmp.TimeTicks:
ticks, _ := pdu.Value.(uint32)
d := time.Duration(ticks) * 10 * time.Millisecond
h := int(d.Hours())
mn := int(d.Minutes()) % 60
return strconv.Itoa(h) + "h " + strconv.Itoa(mn) + "m"
case gosnmp.ObjectIdentifier:
if s, ok := pdu.Value.(string); ok {
return s
}
default:
return fmt.Sprintf("%v", pdu.Value)
}
return fmt.Sprintf("%v", pdu.Value)
}
// pduRawToString converts a PDU value to a raw numeric/string value (for walks).
func pduRawToString(pdu gosnmp.SnmpPDU) string {
switch pdu.Type {
case gosnmp.OctetString:
if b, ok := pdu.Value.([]byte); ok {
return strings.TrimSpace(string(b))
}
case gosnmp.Integer:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter32, gosnmp.Gauge32, gosnmp.Uinteger32:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.Counter64:
return fmt.Sprintf("%d", pdu.Value)
case gosnmp.TimeTicks:
if t, ok := pdu.Value.(uint32); ok {
return fmt.Sprintf("%d", t)
}
}
return fmt.Sprintf("%v", pdu.Value)
}

View File

@@ -1,142 +0,0 @@
// Package sysarp implements a cross-platform ARP-table reader.
// It runs "arp -a" (available on Windows, Linux and macOS) and
// writes every discovered host into the shared SQLite store.
// Unlike the arp_discovery module it does NOT send packets —
// it reads what the OS already knows, so no raw-socket privilege is needed.
package sysarp
import (
"bufio"
"bytes"
"context"
"fmt"
"log/slog"
"os/exec"
"regexp"
"runtime"
"strings"
"time"
"github.com/cereda-systems/nexus-scanner/internal/db"
)
// Module reads the OS ARP cache and persists discovered hosts.
type Module struct {
site string
store *db.Store
log *slog.Logger
}
// New returns a new sys_arp module.
func New(site string, store *db.Store) *Module {
return &Module{
site: site,
store: store,
log: slog.With("module", "sys_arp"),
}
}
func (m *Module) Name() string { return "sys_arp" }
func (m *Module) Interval() time.Duration { return 2 * time.Minute }
// Run executes one read of the ARP cache.
func (m *Module) Run(ctx context.Context) error {
scanID, err := m.store.BeginScan(m.Name())
if err != nil {
return fmt.Errorf("begin scan: %w", err)
}
scanErr := m.scan(ctx)
if endErr := m.store.EndScan(scanID, scanErr); endErr != nil {
m.log.Error("record scan end", "err", endErr)
}
return scanErr
}
func (m *Module) scan(ctx context.Context) error {
out, err := exec.CommandContext(ctx, "arp", "-a").Output()
if err != nil {
return fmt.Errorf("arp -a failed: %w", err)
}
entries := parseARPOutput(out)
m.log.Info("ARP cache read", "entries", len(entries))
now := time.Now().UTC()
saved := 0
for _, e := range entries {
if err := m.store.UpsertHost(db.Host{
IP: e.ip,
MAC: e.mac,
Site: m.site,
FirstSeen: now,
LastSeen: now,
}); err != nil {
m.log.Error("upsert host", "ip", e.ip, "err", err)
continue
}
m.log.Info("host found", "ip", e.ip, "mac", e.mac)
saved++
}
m.log.Info("scan complete", "found", len(entries), "saved", saved)
return nil
}
type arpEntry struct{ ip, mac string }
// Windows: " 192.168.0.1 aa-bb-cc-dd-ee-ff dynamic"
var reWindows = regexp.MustCompile(`^\s+([\d.]+)\s+([0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5})\s+\S+`)
// Linux/macOS: "? (192.168.0.1) at aa:bb:cc:dd:ee:ff [ether] on eth0"
var reUnix = regexp.MustCompile(`\(([\d.]+)\) at ([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})`)
// skipPrefixes are IPs that are multicast / broadcast — not real hosts.
var skipPrefixes = []string{"224.", "239.", "255.", "ff02"}
func parseARPOutput(data []byte) []arpEntry {
var entries []arpEntry
seen := make(map[string]struct{})
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := scanner.Text()
var ip, mac string
if runtime.GOOS == "windows" {
m := reWindows.FindStringSubmatch(line)
if m == nil {
continue
}
ip = m[1]
// Normalise Windows dashes to colons: aa-bb-cc → aa:bb:cc
mac = strings.ReplaceAll(strings.ToLower(m[2]), "-", ":")
} else {
m := reUnix.FindStringSubmatch(line)
if m == nil {
continue
}
ip = m[1]
mac = strings.ToLower(m[2])
}
// Skip multicast, broadcast and already-seen IPs.
skip := false
for _, pfx := range skipPrefixes {
if strings.HasPrefix(ip, pfx) || strings.HasPrefix(mac, pfx) {
skip = true
break
}
}
if skip {
continue
}
if _, dup := seen[ip]; dup {
continue
}
seen[ip] = struct{}{}
entries = append(entries, arpEntry{ip: ip, mac: mac})
}
return entries
}

View File

@@ -1,63 +0,0 @@
package scheduler
import (
"context"
"log/slog"
"time"
"github.com/cereda-systems/nexus-scanner/internal/module"
)
// Scheduler runs registered modules at their configured intervals.
type Scheduler struct {
registry *module.Registry
}
// New returns a new Scheduler backed by the given registry.
func New(registry *module.Registry) *Scheduler {
return &Scheduler{registry: registry}
}
// Run starts all modules in background goroutines and blocks until ctx is cancelled.
func (s *Scheduler) Run(ctx context.Context) {
for _, m := range s.registry.All() {
go s.runModule(ctx, m)
}
<-ctx.Done()
}
func (s *Scheduler) runModule(ctx context.Context, m module.Module) {
log := slog.With("module", m.Name())
// Run immediately on startup.
s.execute(ctx, m, log)
// If interval is zero, run once and stop.
if m.Interval() == 0 {
return
}
ticker := time.NewTicker(m.Interval())
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.execute(ctx, m, log)
}
}
}
func (s *Scheduler) execute(ctx context.Context, m module.Module, log *slog.Logger) {
log.Info("run started")
start := time.Now()
if err := m.Run(ctx); err != nil {
log.Error("run failed", "err", err, "elapsed", time.Since(start).Round(time.Millisecond))
return
}
log.Info("run completed", "elapsed", time.Since(start).Round(time.Millisecond))
}

View File

@@ -1,6 +0,0 @@
package web
import "embed"
//go:embed static templates
var webFS embed.FS

File diff suppressed because it is too large Load Diff

View File

@@ -1,510 +0,0 @@
/* ── Reset ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Design Tokens ── */
:root {
--teal: #008487;
--teal-600: #006e71;
--teal-700: #005a5c;
--teal-50: #e6f3f3;
--bg: #f5f5f7;
--surface: #ffffff;
--surface-2: #fbfbfd;
--surface-3: #f2f2f4;
--text: #1d1d1f;
--text-2: #515154;
--text-3: #86868b;
--border: rgba(0,0,0,0.08);
--border-strong: rgba(0,0,0,0.14);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04), 0 1px 1px rgba(0,0,0,0.03);
--shadow-md: 0 4px 16px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-lg: 0 24px 64px -16px rgba(0,0,0,0.18), 0 6px 18px rgba(0,0,0,0.08);
--success: #34a853;
--warning: #e8a23a;
--danger: #e5484d;
--info: #2f7df1;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--r-xl: 20px;
--sidebar-w: 232px;
--topbar-h: 52px;
}
[data-theme="dark"] {
color-scheme: dark;
--bg: #000000;
--surface: #1c1c1e;
--surface-2: #161618;
--surface-3: #2c2c2e;
--text: #f5f5f7;
--text-2: #aeaeb2;
--text-3: #636366;
--border: rgba(255,255,255,0.08);
--border-strong: rgba(255,255,255,0.14);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.6);
--shadow-md: 0 6px 20px rgba(0,0,0,0.45);
--teal-50: rgba(0,132,135,0.15);
}
/* ── Base ── */
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
letter-spacing: -0.005em;
}
a { text-decoration: none; color: inherit; }
button { font-family: inherit; }
input, select { font-family: inherit; font-size: inherit; color: inherit; }
::selection { background: rgba(0,132,135,0.2); }
/* ── Layout ── */
.app { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
.main { display: flex; flex-direction: column; min-width: 0; }
/* ── Sidebar ── */
.sidebar {
position: sticky; top: 0; height: 100vh;
background: var(--surface-2);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
padding: 14px 10px;
overflow-y: auto;
}
.sidebar-brand {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px 16px;
}
.brand-mark {
width: 30px; height: 30px; border-radius: var(--r-sm);
background: var(--teal); display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.brand-mark svg { color: #fff; width: 17px; height: 17px; }
.brand-name { font-size: 13.5px; font-weight: 700; color: var(--text); }
.brand-sub { font-size: 11px; color: var(--text-3); }
.sidebar-section {
font-size: 10.5px; font-weight: 600; color: var(--text-3);
letter-spacing: 0.05em; text-transform: uppercase;
padding: 12px 8px 5px;
}
.nav-item {
display: flex; align-items: center; gap: 9px;
padding: 7px 8px; border-radius: var(--r-sm);
color: var(--text-2); font-size: 13.5px; font-weight: 500;
transition: background 0.12s, color 0.12s;
cursor: pointer; border: none; background: none; width: 100%; text-align: left;
}
.nav-item:hover { background: var(--surface-3); color: var(--text); }
.nav-item.active { background: var(--teal-50); color: var(--teal); }
.nav-item svg { width: 17px; height: 17px; flex-shrink: 0; stroke-width: 1.75; }
.nav-badge {
margin-left: auto; background: var(--surface-3); color: var(--text-2);
border-radius: 999px; padding: 1px 7px; font-size: 11px; font-weight: 600;
}
.nav-item.active .nav-badge { background: rgba(0,132,135,.18); color: var(--teal); }
.nav-dot { margin-left: auto; width: 6px; height: 6px; border-radius: 50%; background: var(--success); }
.sidebar-footer {
margin-top: auto; padding: 10px 8px 6px;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.avatar {
width: 27px; height: 27px; border-radius: 50%;
background: linear-gradient(135deg, var(--teal), var(--teal-700));
color: #fff; display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; flex-shrink: 0;
}
.user-name { font-size: 12.5px; font-weight: 600; color: var(--text); }
.user-sub { font-size: 11px; color: var(--text-3); }
/* ── Topbar ── */
.topbar {
height: var(--topbar-h); position: sticky; top: 0;
background: color-mix(in srgb, var(--bg) 85%, transparent);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 28px; gap: 8px; z-index: 50;
}
.breadcrumb { flex: 1; display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-3); }
.breadcrumb-sep { color: var(--text-3); }
.breadcrumb-current { font-weight: 600; color: var(--text); }
.topbar-actions { display: flex; align-items: center; gap: 8px; }
/* ── Content area ── */
.content { flex: 1; padding: 24px 28px; }
/* ── Page header ── */
.page-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; }
.page-header h2, .page-title { font-size: 21px; font-weight: 700; letter-spacing: -0.3px; }
.page-header p, .page-sub { font-size: 13px; color: var(--text-3); margin-top: 2px; }
.page-actions { display: flex; align-items: center; gap: 8px; }
/* ── Scan status pill (in page header) ── */
.scan-pill {
display: inline-flex; align-items: center; gap: 7px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 999px; padding: 5px 12px 5px 10px;
font-size: 12.5px;
}
.scan-pill .label { font-weight: 500; color: var(--text); }
.scan-pill .sep { color: var(--text-3); }
.scan-pill .site { color: var(--text-3); }
/* ── Status pill (topbar) ── */
.status-pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 500;
background: var(--surface-3); color: var(--text-2);
}
.status-pill.online { background: rgba(52,168,83,.1); color: var(--success); }
.status-pill.scanning { background: rgba(0,132,135,.1); color: var(--teal); }
/* ── Buttons ── */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: var(--r-sm);
font-size: 13px; font-weight: 500;
cursor: pointer; border: 1px solid var(--border);
background: var(--surface); color: var(--text);
transition: background 0.12s, border-color 0.12s; white-space: nowrap;
}
.btn:hover { background: var(--surface-3); }
.btn svg { width: 14px; height: 14px; stroke-width: 1.75; }
.btn-primary { background: var(--teal); color: #fff; border-color: var(--teal); }
.btn-primary:hover { background: var(--teal-600); border-color: var(--teal-600); }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
.btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-3); }
.btn:disabled { opacity: .55; cursor: not-allowed; }
/* ── Icon button ── */
.icon-btn {
width: 30px; height: 30px; border-radius: var(--r-sm);
border: 1px solid var(--border); background: var(--surface); color: var(--text-2);
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: background 0.12s;
}
.icon-btn:hover { background: var(--surface-3); color: var(--text); }
.icon-btn svg { width: 14px; height: 14px; stroke-width: 1.75; }
/* ── Inputs ── */
.input, .select {
width: 100%; padding: 7.5px 12px;
border: 1px solid var(--border-strong); border-radius: var(--r-sm);
background: var(--surface); color: var(--text); font-size: 13.5px;
outline: none; transition: border-color .14s, box-shadow .14s;
}
.input:focus, .select:focus {
border-color: var(--teal);
box-shadow: 0 0 0 3px rgba(0,132,135,.14);
}
.input::placeholder { color: var(--text-3); }
.input-label { font-size: 12.5px; font-weight: 500; color: var(--text-2); margin-bottom: 5px; display: block; }
/* ── Toggle ── */
.toggle { position: relative; display: inline-block; width: 36px; height: 22px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
.toggle-track {
position: absolute; inset: 0; background: var(--surface-3);
border: 1px solid var(--border); border-radius: 20px;
cursor: pointer; transition: background .18s;
}
.toggle input:checked + .toggle-track { background: var(--teal); border-color: var(--teal); }
.toggle-track::after {
content: ''; position: absolute; left: 2px; top: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2);
transition: transform .18s;
}
.toggle input:checked + .toggle-track::after { transform: translateX(14px); }
/* ── Dot ── */
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
.dot.green { background: var(--success); box-shadow: 0 0 0 2px rgba(52,168,83,.18); }
.dot.red { background: var(--danger); }
.dot.yellow { background: var(--warning); }
.dot.gray { background: var(--text-3); }
.dot.teal { background: var(--teal); }
.dot.pulse { animation: dotpulse 1.6s ease-out infinite; }
@keyframes dotpulse {
0% { box-shadow: 0 0 0 0 rgba(52,168,83,0.5); }
70% { box-shadow: 0 0 0 6px rgba(52,168,83,0); }
100% { box-shadow: 0 0 0 0 rgba(52,168,83,0); }
}
/* ── Badge ── */
.badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 2px 8px; border-radius: 999px; font-size: 11.5px; font-weight: 500;
background: var(--surface-3); color: var(--text-2);
}
.badge-green { background: rgba(52,168,83,.12); color: #2c8a44; }
.badge-red { background: rgba(229,72,77,.12); color: #c4393d; }
.badge-yellow { background: rgba(232,162,58,.14); color: #b07820; }
.badge-teal { background: var(--teal-50); color: var(--teal); }
.badge-gray { background: var(--surface-3); color: var(--text-3); }
[data-theme="dark"] .badge-green { color: #4cc471; }
[data-theme="dark"] .badge-red { color: #ff6b6f; }
[data-theme="dark"] .badge-yellow { color: #ffc46b; }
[data-theme="dark"] .badge-teal { color: #4ec6c9; }
/* ── Card ── */
.card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-lg); overflow: hidden;
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid var(--border);
}
.card-title { font-size: 14px; font-weight: 600; }
.card-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.card-action { font-size: 12.5px; color: var(--teal); display: flex; align-items: center; gap: 4px; }
.card-action svg { width: 12px; height: 12px; }
/* ── Stats grid ── */
.stats-grid { display: grid; gap: 16px; margin-bottom: 20px; }
.stats-grid-4 { grid-template-columns: repeat(4, 1fr); }
.stat {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 18px 20px;
box-shadow: var(--shadow-sm);
}
.stat-icon { width: 14px; height: 14px; color: var(--text-3); stroke-width: 1.75; }
.stat-label { font-size: 12.5px; color: var(--text-3); font-weight: 500; margin-bottom: 6px; display: flex; align-items: center; gap: 7px; }
.stat-value { font-size: 30px; font-weight: 600; letter-spacing: -0.02em; margin-top: 2px; }
.stat-delta { font-size: 12.5px; color: var(--text-3); margin-top: 4px; }
.stat-delta.up { color: var(--success); }
.stat-delta.down { color: var(--danger); }
/* ── Sparkline ── */
.sparkline { display: flex; align-items: flex-end; gap: 2px; height: 24px; margin-top: 8px; }
.sparkline span { flex: 1; background: var(--teal); border-radius: 1.5px; opacity: 0.75; }
/* ── Dashboard 2-col grid ── */
.dash-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; }
/* ── Table ── */
table { width: 100%; border-collapse: collapse; }
thead th {
padding: 10px 16px; text-align: left; white-space: nowrap;
font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em;
color: var(--text-3); border-bottom: 1px solid var(--border);
background: var(--surface-2); position: sticky; top: var(--topbar-h);
}
tbody td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border); }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--surface-2); }
.mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12.5px; }
/* ── Device icon cell ── */
.device-icon {
width: 28px; height: 28px; border-radius: 7px;
background: var(--surface-3); color: var(--text-2);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.device-icon svg { width: 14px; height: 14px; stroke-width: 1.75; }
.device-row { display: flex; align-items: center; gap: 10px; }
.device-name { font-size: 13px; font-weight: 600; }
.device-vendor { font-size: 11.5px; color: var(--text-3); }
/* ── Filter chips ── */
.filter-bar { display: flex; align-items: center; gap: 10px; padding: 12px 16px; flex-wrap: wrap; border-bottom: 1px solid var(--border); background: var(--surface); }
.filter-chips { display: flex; gap: 5px; }
.chip {
padding: 4px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 500;
background: transparent; border: 1px solid var(--border-strong); color: var(--text-2);
cursor: pointer; transition: all .12s;
}
.chip:hover { background: var(--surface-3); }
.chip.active { background: var(--teal-50); border-color: transparent; color: var(--teal); }
.search-wrap { position: relative; flex: 1; min-width: 200px; max-width: 300px; }
.search-wrap svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 14px; height: 14px; color: var(--text-3); pointer-events: none; }
.search-wrap .input { padding-left: 32px; }
/* ── Module row ── */
.module-row {
display: flex; align-items: center; gap: 14px;
padding: 14px 20px; border-bottom: 1px solid var(--border); transition: background .12s;
}
.module-row:last-child { border-bottom: none; }
.module-row:hover { background: var(--surface-2); }
.module-icon {
width: 34px; height: 34px; border-radius: var(--r-sm);
background: var(--surface-3); color: var(--text-3);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.module-icon.active { background: var(--teal-50); color: var(--teal); }
.module-icon svg { width: 17px; height: 17px; stroke-width: 1.75; }
.module-info { flex: 1; min-width: 0; }
.module-name { font-size: 13.5px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.module-desc { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.module-stats { display: flex; gap: 16px; font-size: 12px; color: var(--text-3); margin-top: 3px; }
.module-stats strong { color: var(--text-2); font-weight: 500; }
.module-config {
border-top: 1px solid var(--border); padding: 18px 20px;
background: var(--surface-2);
display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
}
/* ── Dashboard module list row ── */
.dash-module-row {
display: flex; align-items: center; gap: 12px;
padding: 11px 20px; border-top: 1px solid var(--border);
}
.dash-module-icon {
width: 30px; height: 30px; border-radius: var(--r-sm);
background: var(--teal-50); color: var(--teal);
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.dash-module-icon.off { background: var(--surface-3); color: var(--text-3); }
.dash-module-icon svg { width: 15px; height: 15px; stroke-width: 1.75; }
.dash-module-name { font-size: 13px; font-weight: 600; }
.dash-module-sub { font-size: 11.5px; color: var(--text-3); }
/* ── Logs ── */
.log-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
.log-container {
background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-lg);
height: 500px; overflow-y: auto;
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12px;
}
.log-row {
display: grid; grid-template-columns: 80px 48px 1fr;
gap: 14px; padding: 6px 16px;
border-bottom: 1px solid var(--border); align-items: start; line-height: 1.5;
}
.log-row:last-child { border-bottom: none; }
.log-row:hover { background: var(--surface-3); }
.log-time { color: var(--text-3); font-size: 11px; white-space: nowrap; padding-top: 1px; }
.log-level { font-size: 10.5px; font-weight: 700; padding: 2px 6px; border-radius: 4px; text-align: center; text-transform: uppercase; letter-spacing: 0.04em; }
.log-level.ok { background: rgba(52,168,83,.14); color: #2c8a44; }
.log-level.info { background: rgba(47,125,241,.14); color: #2766c4; }
.log-level.warn { background: rgba(232,162,58,.18); color: #b07820; }
.log-level.err { background: rgba(229,72,77,.14); color: #c4393d; }
[data-theme="dark"] .log-level.ok { color: #4cc471; }
[data-theme="dark"] .log-level.info { color: #71aaff; }
[data-theme="dark"] .log-level.warn { color: #ffc46b; }
[data-theme="dark"] .log-level.err { color: #ff7679; }
.log-msg { color: var(--text); word-break: break-word; }
/* ── Settings ── */
.settings-section { margin-bottom: 24px; }
.settings-title { font-size: 15px; font-weight: 700; margin-bottom: 14px; }
.setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-bottom: 1px solid var(--border); gap: 20px;
}
.setting-row:last-child { border-bottom: none; }
.setting-label { font-size: 13.5px; font-weight: 500; }
.setting-sub { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.setting-body { padding: 18px 20px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.setting-body.full { grid-template-columns: 1fr; }
.form-group { margin-bottom: 14px; }
/* ── Password wrap ── */
.password-wrap { position: relative; }
.password-wrap .input { padding-right: 40px; }
.eye-btn {
position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer; color: var(--text-3); padding: 2px;
}
.eye-btn:hover { color: var(--text); }
.eye-btn svg { width: 16px; height: 16px; stroke-width: 1.75; }
/* ── Auth / Login ── */
.auth-wrap {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: var(--bg); padding: 48px 24px;
}
.auth-card {
width: 100%; max-width: 380px; background: var(--surface);
border: 1px solid var(--border); border-radius: 18px; padding: 36px 30px;
box-shadow: var(--shadow-lg);
display: flex; flex-direction: column; align-items: center; gap: 20px;
}
.auth-logo { height: 36px; }
.auth-title { font-size: 20px; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
.auth-sub { font-size: 13px; color: var(--text-3); text-align: center; margin-top: -10px; }
.auth-card form { width: 100%; display: flex; flex-direction: column; gap: 12px; }
/* ── Setup wizard ── */
.wizard-wrap {
min-height: 100vh; display: flex;
align-items: safe center; justify-content: safe center;
background: var(--bg); padding: 24px;
}
.wizard {
width: 100%; max-width: 540px; background: var(--surface);
border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow-lg); overflow: hidden;
}
.wizard-header { padding: 26px 30px 18px; border-bottom: 1px solid var(--border); }
.wizard-steps { display: flex; gap: 6px; margin-top: 16px; }
.wizard-step { flex: 1; height: 3px; border-radius: 999px; background: var(--surface-3); }
.wizard-step.done, .wizard-step.active { background: var(--teal); }
.wizard-body { padding: 26px 30px; min-height: 220px; }
.wizard-footer {
padding: 14px 30px; border-top: 1px solid var(--border);
background: var(--surface-2);
display: flex; align-items: center; justify-content: space-between;
}
.wizard-icon {
width: 48px; height: 48px; border-radius: var(--r-md);
background: var(--teal-50); color: var(--teal);
display: flex; align-items: center; justify-content: center;
margin-bottom: 16px;
}
.wizard-icon svg { width: 24px; height: 24px; stroke-width: 1.75; }
.wizard-title { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
.wizard-sub { font-size: 13px; color: var(--text-3); margin-bottom: 20px; }
.site-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.site-card {
border: 2px solid var(--border); border-radius: var(--r-md); padding: 16px;
cursor: pointer; transition: border-color .15s, background .15s;
}
.site-card:hover { border-color: var(--teal-50); background: var(--surface-2); }
.site-card.selected { border-color: var(--teal); background: var(--teal-50); }
.site-card-name { font-weight: 600; font-size: 14px; }
.site-card-sub { font-size: 12px; color: var(--text-3); margin-top: 3px; }
/* ── Pagination ── */
.pagination { display: flex; align-items: center; gap: 3px; }
.page-btn {
min-width: 28px; height: 28px; border-radius: 6px;
border: 1px solid transparent; background: transparent;
color: var(--text-2); font-size: 12.5px; font-weight: 500; cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0 8px;
}
.page-btn:hover { background: var(--surface-3); color: var(--text); }
.page-btn.active { background: var(--teal); color: #fff; }
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
/* ── Error / Success msgs ── */
.error-msg {
background: rgba(229,72,77,.08); border: 1px solid rgba(229,72,77,.2);
border-radius: var(--r-sm); padding: 10px 14px; font-size: 13px; color: var(--danger);
}
.success-msg {
background: rgba(52,168,83,.08); border: 1px solid rgba(52,168,83,.2);
border-radius: var(--r-sm); padding: 10px 14px; font-size: 13px; color: var(--success);
}
/* ── Spinner ── */
.spinner { width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.3); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── HTMX ── */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline-flex; }
/* ── Utilities ── */
.spacer { flex: 1; }
.mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; font-size: 12.5px; }

View File

@@ -1,71 +0,0 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Active Directory</h1>
<p class="page-sub">{{len .Computers}} Computer-Objekte aus {{.Domain}}</p>
</div>
</div>
{{if not .ADEnabled}}
<div style="background:rgba(232,162,58,.08);border:1px solid rgba(232,162,58,.3);border-radius:var(--r-lg);padding:20px 24px;margin-bottom:20px;">
<div style="font-weight:600;color:var(--warning);margin-bottom:6px;">AD Sync nicht konfiguriert</div>
<div style="font-size:13px;color:var(--text-2);">Trage Server, Bind-DN und Passwort in der <a href="/settings" style="color:var(--teal);">config.yaml</a> ein und aktiviere das Modul.</div>
</div>
{{end}}
<!-- Stats -->
<div class="stats-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:20px;">
<div class="stat">
<div class="stat-label">Computer in AD</div>
<div class="stat-value">{{len .Computers}}</div>
<div class="stat-delta neutral">{{.Domain}}</div>
</div>
<div class="stat">
<div class="stat-label">Im Netzwerk gesehen</div>
<div class="stat-value" style="color:var(--success);">{{.SeenCount}}</div>
<div class="stat-delta up">↑ aktiv erreichbar</div>
</div>
<div class="stat">
<div class="stat-label">Nicht gesehen</div>
<div class="stat-value" style="{{if gt .MissingCount 0}}color:var(--warning);{{end}}">{{.MissingCount}}</div>
<div class="stat-delta {{if gt .MissingCount 0}}down{{else}}neutral{{end}}">offline oder abwesend</div>
</div>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Computer-Name</th>
<th>Abteilung / OU</th>
<th>Betriebssystem</th>
<th>Letzter Login (AD)</th>
<th>Im Netzwerk</th>
</tr>
</thead>
<tbody>
{{range .Computers}}
<tr>
<td style="font-weight:600;">{{.CN}}</td>
<td style="color:var(--text-2);">{{if .Department}}{{.Department}}{{else}}<span style="color:var(--text-3);"></span>{{end}}</td>
<td style="color:var(--text-2);font-size:12px;">{{if .OS}}{{.OS}}{{else}}<span style="color:var(--text-3);"></span>{{end}}</td>
<td style="color:var(--text-3);font-size:12px;">
{{if not .LastLogon.IsZero}}{{formatTime .LastLogon}}{{else}}—{{end}}
</td>
<td>
{{if .SeenInNetwork}}
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;"></span>Online</span>
{{else}}
<span class="badge badge-yellow">Nicht gesehen</span>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="5" style="text-align:center;padding:48px;color:var(--text-3);">
Noch keine AD-Daten — AD Sync ausführen.
</td></tr>
{{end}}
</tbody>
</table>
</div>
{{end}}

View File

@@ -1,126 +0,0 @@
{{define "content"}}
<div class="page-header">
<div>
<h2 class="page-title">Geräte-Inventar</h2>
<p class="page-sub">{{.TotalHosts}} Einträge · zuletzt aktualisiert {{.LastScan}}</p>
</div>
<div style="display:flex;gap:8px;">
<a href="/assets/export.csv" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
CSV Export
</a>
</div>
</div>
<!-- Filter bar -->
<div class="card" style="margin-bottom:16px;overflow:visible;">
<form method="GET" action="/assets">
<div class="filter-bar">
<div class="search-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input class="input" type="search" name="q" value="{{.Query}}" placeholder="IP, Hostname oder MAC…">
</div>
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:12px;color:var(--text-3);white-space:nowrap;">Standort:</span>
<div class="filter-chips">
<button type="submit" name="site" value="" class="chip{{if eq .SiteFilter ""}} active{{end}}">Alle</button>
<button type="submit" name="site" value="LUD" class="chip{{if eq .SiteFilter "LUD"}} active{{end}}">Lüdenscheid</button>
<button type="submit" name="site" value="BAR" class="chip{{if eq .SiteFilter "BAR"}} active{{end}}">Barleben</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-size:12px;color:var(--text-3);white-space:nowrap;">Status:</span>
<div class="filter-chips">
<button type="submit" name="status" value="" class="chip{{if eq .StatusFilter ""}} active{{end}}">Alle</button>
<button type="submit" name="status" value="online" class="chip{{if eq .StatusFilter "online"}} active{{end}}">Online</button>
<button type="submit" name="status" value="offline" class="chip{{if eq .StatusFilter "offline"}} active{{end}}">Offline</button>
</div>
</div>
<input type="hidden" name="site" value="{{.SiteFilter}}">
<input type="hidden" name="status" value="{{.StatusFilter}}">
</div>
</form>
</div>
<!-- Table -->
<div class="card">
<table>
<thead>
<tr>
<th style="padding-left:20px;">IP-Adresse</th>
<th>MAC-Adresse</th>
<th>Hersteller</th>
<th>Hostname</th>
<th>Offene Ports</th>
<th>Standort</th>
<th>Zuletzt gesehen</th>
<th style="padding-right:20px;">Status</th>
</tr>
</thead>
<tbody>
{{range .Hosts}}
<tr>
<td style="padding-left:20px;" class="mono" style="font-weight:600;">{{.IP}}</td>
<td class="mono" style="color:var(--text-3);">{{.MAC}}</td>
<td>
<div class="device-row">
<div class="device-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/>
</svg>
</div>
<span style="color:var(--text-2);">{{if .Vendor}}{{.Vendor}}{{else}}<span style="color:var(--text-3);"></span>{{end}}</span>
</div>
</td>
<td style="color:var(--text-2);font-size:12.5px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{if .Hostname}}{{.Hostname}}{{else}}<span style="color:var(--text-3);"></span>{{end}}</td>
<td>
{{if .OpenPorts}}
<div style="display:flex;flex-wrap:wrap;gap:3px;">
{{range .OpenPorts}}<span style="background:var(--teal-50);color:var(--teal);border-radius:3px;padding:1px 5px;font-size:10px;font-family:monospace;font-weight:600;">{{.}}</span>{{end}}
</div>
{{else}}<span style="color:var(--text-3);"></span>{{end}}
</td>
<td><span class="badge badge-gray">{{.Site}}</span></td>
<td style="color:var(--text-3);font-size:12px;">{{.LastSeenFmt}}</td>
<td style="padding-right:20px;">
{{if eq .Status "online"}}
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;"></span>Online</span>
{{else}}
<span class="badge badge-gray"><span class="dot gray" style="width:5px;height:5px;margin-right:4px;"></span>Offline</span>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="8" style="text-align:center;padding:48px;color:var(--text-3);">
Keine Geräte gefunden.
</td></tr>
{{end}}
</tbody>
</table>
{{if gt .TotalPages 1}}
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-top:1px solid var(--border);background:var(--surface-2);">
<span style="font-size:12.5px;color:var(--text-3);">
Zeige {{if gt .TotalHosts 0}}{{inc (mul (dec .PageNum) 20)}}{{end}}{{.TotalHosts}} Einträge
</span>
<div class="pagination">
{{if gt .PageNum 1}}
<a href="/assets?{{.QueryString}}&page={{dec .PageNum}}" class="page-btn"></a>
{{else}}
<button class="page-btn" disabled></button>
{{end}}
{{range .Pages}}
<a href="/assets?{{$.QueryString}}&page={{.}}" class="page-btn{{if eq . $.PageNum}} active{{end}}">{{.}}</a>
{{end}}
{{if lt .PageNum .TotalPages}}
<a href="/assets?{{.QueryString}}&page={{inc .PageNum}}" class="page-btn"></a>
{{else}}
<button class="page-btn" disabled></button>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}

View File

@@ -1,166 +0,0 @@
{{define "content"}}
<div class="page-header">
<div>
<h2 class="page-title">Übersicht</h2>
<p class="page-sub">Status deines lokalen Scanners und der gefundenen Assets.</p>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<div class="scan-pill">
{{if .Scanning}}<span class="dot green" style="animation:dotpulse 1.6s ease-out infinite;"></span>
<span class="label">Scanner läuft</span>
{{else}}<span class="dot yellow"></span>
<span class="label">Bereit</span>{{end}}
<span class="sep">·</span>
<span class="site">Standort {{.SiteID}}</span>
</div>
<form method="POST" action="/scan/start" style="margin:0;">
<button type="submit" class="btn btn-primary" {{if .Scanning}}disabled{{end}}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px;">
{{if .Scanning}}<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>
{{else}}<polygon points="6 4 20 12 6 20 6 4" fill="currentColor" stroke="none"/>{{end}}
</svg>
{{if .Scanning}}Scan läuft…{{else}}Jetzt scannen{{end}}
</button>
</form>
</div>
</div>
<!-- Stats -->
<div class="stats-grid stats-grid-4">
<div class="stat">
<div class="stat-label">
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/></svg>
Gefundene Geräte
</div>
<div class="stat-value">{{.HostCount}}</div>
<div class="stat-delta up">+{{.RecentCount}} seit letztem Scan</div>
<div class="sparkline">
<span style="height:35%"></span><span style="height:42%"></span><span style="height:50%"></span><span style="height:58%"></span><span style="height:62%"></span><span style="height:70%"></span><span style="height:78%"></span><span style="height:82%"></span><span style="height:88%"></span><span style="height:92%"></span><span style="height:95%"></span><span style="height:100%"></span>
</div>
</div>
<div class="stat">
<div class="stat-label">
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
Neu seit letztem Scan
</div>
<div class="stat-value">{{.RecentCount}}</div>
<div class="stat-delta">In den letzten 60 Min</div>
<div class="sparkline">
<span style="height:20%"></span><span style="height:30%"></span><span style="height:25%"></span><span style="height:45%"></span><span style="height:55%"></span><span style="height:60%"></span><span style="height:70%"></span><span style="height:65%"></span><span style="height:80%"></span><span style="height:75%"></span><span style="height:85%"></span><span style="height:90%"></span>
</div>
</div>
<div class="stat">
<div class="stat-label">
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4M12 17h.01"/></svg>
Offline-Geräte
</div>
<div class="stat-value" style="{{if gt .OfflineCount 5}}color:var(--danger);{{end}}">{{.OfflineCount}}</div>
<div class="stat-delta {{if gt .OfflineCount 5}}down{{end}}">{{if gt .OfflineCount 5}}Erhöht ggü. Vorwoche{{else}}Im Normalbereich{{end}}</div>
<div class="sparkline">
<span style="height:60%"></span><span style="height:70%"></span><span style="height:55%"></span><span style="height:65%"></span><span style="height:80%"></span><span style="height:60%"></span><span style="height:75%"></span><span style="height:65%"></span><span style="height:70%"></span><span style="height:85%"></span><span style="height:90%"></span><span style="height:70%"></span>
</div>
</div>
<div class="stat">
<div class="stat-label">
<svg class="stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Letzter Scan
</div>
<div class="stat-value" style="font-size:26px;margin-top:4px;">{{.LastScan}}</div>
<div class="stat-delta">Intervall: {{.ARPInterval}}</div>
</div>
</div>
<!-- 2-col content -->
<div class="dash-grid">
<!-- Zuletzt gefundene Geräte -->
<div class="card">
<div class="card-header">
<div>
<div class="card-title">Zuletzt gefundene Geräte</div>
<div class="card-sub">Die 5 jüngsten Einträge im Inventar</div>
</div>
<a href="/assets" class="card-action">
Alle anzeigen
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</a>
</div>
<table>
<thead>
<tr>
<th style="padding-left:20px;">Gerät</th>
<th>IP-Adresse</th>
<th>Standort</th>
<th>Status</th>
<th style="padding-right:20px;text-align:right;">Gesehen</th>
</tr>
</thead>
<tbody>
{{range .RecentHosts}}
<tr>
<td style="padding-left:20px;">
<div class="device-row">
<div class="device-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/><path d="M6 7h.01M6 17h.01"/></svg>
</div>
<div>
<div class="device-name">{{if .Hostname}}{{.Hostname}}{{else}}{{.IP}}{{end}}</div>
<div class="device-vendor">{{if .Vendor}}{{.Vendor}}{{else}}Unbekannt{{end}}</div>
</div>
</div>
</td>
<td class="mono">{{.IP}}</td>
<td><span class="badge badge-gray">{{.Site}}</span></td>
<td>
{{if eq .Status "online"}}
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;"></span>Online</span>
{{else}}
<span class="badge badge-red"><span class="dot red" style="width:5px;height:5px;margin-right:4px;"></span>Offline</span>
{{end}}
</td>
<td style="padding-right:20px;text-align:right;color:var(--text-3);font-size:12px;">{{.LastSeenFmt}}</td>
</tr>
{{else}}
<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-3);">
Noch keine Geräte — Scan starten.
</td></tr>
{{end}}
</tbody>
</table>
</div>
<!-- Aktive Module -->
<div class="card">
<div class="card-header">
<div>
<div class="card-title">Aktive Module</div>
<div class="card-sub">Status der Scan-Quellen</div>
</div>
<a href="/modules" class="card-action">
Verwalten
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</a>
</div>
{{range $i, $m := .Modules}}
<div class="dash-module-row" style="{{if eq $i 0}}border-top:none;{{end}}">
<div class="dash-module-icon{{if not $m.Enabled}} off{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px;">
{{if eq $m.Name "arp_discovery"}}<path d="M5 12.55a11 11 0 0 1 14 0M1.42 9a16 16 0 0 1 21.16 0M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01"/>
{{else if eq $m.Name "dns_reverse"}}<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>
{{else if eq $m.Name "ad_sync"}}<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
{{else if eq $m.Name "site_monitoring"}}<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
{{else if eq $m.Name "port_scan"}}<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
{{else if eq $m.Name "snmp"}}<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
{{else}}<rect x="2" y="3" width="20" height="8" rx="2"/><rect x="2" y="13" width="20" height="8" rx="2"/>{{end}}
</svg>
</div>
<div style="flex:1;min-width:0;">
<div class="dash-module-name">{{$m.DisplayName}}</div>
<div class="dash-module-sub">{{if $m.LastRun}}Letzter Lauf {{$m.LastRun}}{{else}}Noch nicht gelaufen{{end}}</div>
</div>
{{if $m.Enabled}}<span class="badge badge-green" style="font-size:11px;flex-shrink:0;">OK</span>
{{else}}<span class="badge badge-gray" style="font-size:11px;flex-shrink:0;">Aus</span>{{end}}
</div>
{{end}}
</div>
</div>
{{end}}

View File

@@ -1,145 +0,0 @@
{{define "layout"}}
<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — Nexus Scanner</title>
<link rel="stylesheet" href="/static/style.css">
<script>
(function(){
var t = localStorage.getItem('nx-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head>
<body>
<div class="app">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div>
<div class="brand-name">Nexus Scanner</div>
<div class="brand-sub">{{.SiteID}} · {{.Addr}}</div>
</div>
</div>
<div class="sidebar-section">Scanner</div>
<nav>
<a href="/dashboard" class="nav-item{{if eq .Page "dashboard"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
<span>Übersicht</span>
</a>
<a href="/assets" class="nav-item{{if eq .Page "assets"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18M9 9v11"/></svg>
<span>Geräte</span>
<span class="nav-badge">{{.HostCount}}</span>
</a>
<a href="/modules" class="nav-item{{if eq .Page "modules"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
<span>Module</span>
</a>
<a href="/snmp" class="nav-item{{if eq .Page "snmp"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
<span>SNMP</span>
</a>
<a href="/monitoring" class="nav-item{{if eq .Page "monitoring"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
<span>Monitoring</span>
{{if .Scanning}}<span class="nav-dot"></span>{{end}}
</a>
<a href="/ad" class="nav-item{{if eq .Page "ad"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span>Active Directory</span>
</a>
<a href="/logs" class="nav-item{{if eq .Page "logs"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
<span>Logs</span>
<span class="nav-dot"></span>
</a>
</nav>
<div class="sidebar-section">System</div>
<nav>
<a href="/settings" class="nav-item{{if eq .Page "settings"}} active{{end}}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/></svg>
<span>Einstellungen</span>
</a>
<button class="nav-item" onclick="toggleTheme()">
<svg id="theme-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<span id="theme-label">Dunkler Modus</span>
</button>
</nav>
<div class="sidebar-footer">
<div class="avatar">A</div>
<div style="flex:1;min-width:0;">
<div class="user-name">Administrator</div>
<div class="user-sub">Eingeloggt</div>
</div>
<a href="/logout" class="icon-btn" title="Abmelden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
</a>
</div>
</aside>
<!-- Main -->
<div class="main">
<header class="topbar">
<div class="breadcrumb">
<span>Nexus Scanner</span>
<span class="breadcrumb-sep">/</span>
<span class="breadcrumb-current">{{.Title}}</span>
</div>
<div class="topbar-actions">
{{if .Scanning}}
<div class="status-pill scanning"><span class="dot teal" style="animation:dotpulse 1.6s ease-out infinite;"></span> Scan läuft…</div>
{{else}}
<div class="status-pill online"><span class="dot green"></span> Online · {{.SiteID}}</div>
{{end}}
<a href="/logout" class="icon-btn" title="Abmelden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
</a>
</div>
</header>
<main class="content">
{{template "content" .}}
</main>
</div>
</div>
<script>
function toggleTheme() {
var cur = document.documentElement.getAttribute('data-theme') || 'light';
var next = cur === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('nx-theme', next);
updateThemeUI(next);
}
function updateThemeUI(t) {
var icon = document.getElementById('theme-icon');
var label = document.getElementById('theme-label');
if (!icon || !label) return;
if (t === 'dark') {
icon.innerHTML = '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>';
label.textContent = 'Heller Modus';
} else {
icon.innerHTML = '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
label.textContent = 'Dunkler Modus';
}
}
(function(){
updateThemeUI(document.documentElement.getAttribute('data-theme') || 'light');
})();
</script>
</body>
</html>
{{end}}

View File

@@ -1,71 +0,0 @@
{{define "login"}}
<!DOCTYPE html>
<html data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anmelden — Nexus Scanner</title>
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
</head>
<body>
<div class="auth-wrap">
<div class="auth-card">
<div class="auth-logo">
<div class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div>
<div class="brand-name">Nexus Scanner</div>
<div class="brand-sub">Netzwerk-Inventar Agent</div>
</div>
</div>
<h1 class="auth-title">Willkommen zurück.</h1>
<p class="auth-sub">Passwort eingeben um fortzufahren.</p>
{{if .Error}}
<div class="error-msg">{{.Error}}</div>
{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label class="input-label" for="password">Passwort</label>
<div class="password-wrap">
<input class="input" type="password" id="password" name="password"
placeholder="••••••••" autocomplete="current-password" autofocus required>
<button type="button" class="eye-btn" onclick="togglePw()" title="Anzeigen">
<svg id="eye-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-full" style="justify-content:center;">
Anmelden
</button>
</form>
<p style="font-size:12px;color:var(--text-3);text-align:center;margin-top:20px;">
Nexus Scanner · Cereda Systems GmbH
</p>
</div>
</div>
<script>
function togglePw() {
const f = document.getElementById('password');
const i = document.getElementById('eye-icon');
if (f.type === 'password') {
f.type = 'text';
i.innerHTML = '<path d="M17.94 17.94A10.94 10.94 0 0 1 12 20c-6.5 0-10-7-10-7a18.4 18.4 0 0 1 4.06-4.94"/><path d="M9.9 4.24A10 10 0 0 1 12 4c6.5 0 10 7 10 7a18.4 18.4 0 0 1-3.16 4.19"/><path d="m2 2 20 20"/>';
} else {
f.type = 'password';
i.innerHTML = '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>';
}
}
</script>
</body>
</html>
{{end}}

View File

@@ -1,57 +0,0 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Logs</h1>
<p class="page-sub">Strukturierte Ausgabe des Scanners in Echtzeit.</p>
</div>
<div class="page-actions">
<form method="GET" action="/logs" style="display:flex;gap:8px;align-items:center;">
<select class="select" name="level" style="width:auto;" onchange="this.form.submit()">
<option value="">Alle Level</option>
<option value="INFO"{{if eq .LevelFilter "INFO"}} selected{{end}}>INFO</option>
<option value="WARN"{{if eq .LevelFilter "WARN"}} selected{{end}}>WARN</option>
<option value="ERROR"{{if eq .LevelFilter "ERROR"}} selected{{end}}>ERROR</option>
</select>
</form>
<button class="btn" onclick="clearLogs()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
Leeren
</button>
</div>
</div>
<div class="log-container" id="log-container"
hx-get="/logs/rows?level={{.LevelFilter}}"
hx-trigger="every 2s"
hx-swap="innerHTML"
hx-target="this">
{{template "log-rows" .}}
</div>
<div style="margin-top:10px;font-size:12px;color:var(--text-3);text-align:right;">
Aktualisiert automatisch alle 2 Sekunden
<span id="log-count"> · {{len .Logs}} Einträge</span>
</div>
<script>
function clearLogs() {
fetch('/logs/clear', {method:'POST'}).then(() => {
document.getElementById('log-container').innerHTML = '';
});
}
</script>
{{end}}
{{define "log-rows"}}
{{range .Logs}}
<div class="log-row">
<span class="log-time">{{.Time}}</span>
<span class="log-level {{.LevelClass}}">{{.Level}}</span>
<span class="log-msg">{{.Message}}</span>
</div>
{{else}}
<div style="padding:48px;text-align:center;color:var(--text-3);font-size:13px;">
Noch keine Log-Einträge.
</div>
{{end}}
{{end}}

View File

@@ -1,69 +0,0 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Module</h1>
<p class="page-sub">Scanner-Module aktivieren, deaktivieren und konfigurieren.</p>
</div>
</div>
<div class="card">
{{range .Modules}}
<div class="module-row">
<div class="module-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
</div>
<div class="module-info">
<div class="module-name">{{.DisplayName}}</div>
<div class="module-desc">{{.Description}} · alle {{.Interval}}</div>
</div>
<div class="module-status">
{{if .LastRun}}
<span style="font-size:12px;color:var(--text-3);">Letzter Lauf: {{.LastRun}}</span>
{{end}}
{{if .Enabled}}
<span class="badge badge-green">Aktiv</span>
{{else}}
<span class="badge badge-gray">Inaktiv</span>
{{end}}
<form method="POST" action="/modules/{{.Name}}/toggle" style="display:inline;">
<label class="toggle" title="{{if .Enabled}}Deaktivieren{{else}}Aktivieren{{end}}">
<input type="checkbox"{{if .Enabled}} checked{{end}} onchange="this.form.submit()">
<span class="toggle-track"></span>
</label>
</form>
</div>
</div>
{{else}}
<div style="padding:48px;text-align:center;color:var(--text-3);">
Keine Module registriert. Konfiguriere Module in config.yaml.
</div>
{{end}}
</div>
<div class="card" style="margin-top:16px;">
<div class="card-header">
<span class="card-title">ARP-Discovery Konfiguration</span>
</div>
<form method="POST" action="/modules/arp_discovery/config" class="setting-body full">
<div class="form-group">
<label class="input-label">Subnetz (CIDR)</label>
{{range .ARPSubnets}}
<input class="input mb-3" type="text" name="subnet" value="{{.}}" placeholder="192.168.0.0/24">
{{else}}
<input class="input mb-3" type="text" name="subnet" value="" placeholder="192.168.0.0/24">
{{end}}
</div>
<div class="form-group">
<label class="input-label">Netzwerk-Interface</label>
<input class="input" type="text" name="interface" value="{{.ARPInterface}}" placeholder="eth0">
</div>
<div>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
{{end}}

View File

@@ -1,158 +0,0 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Standort-Monitoring</h1>
<p class="page-sub">Erreichbarkeit von Internet, Diensten und Netzwerkgeräten.</p>
</div>
<div style="display:flex;gap:8px;align-items:center;">
{{if .AllOnline}}
<div class="status-pill online"><span class="dot green"></span> Alle Systeme Online</div>
{{else if eq .TotalChecks 0}}
<div class="status-pill"><span class="dot" style="background:var(--text-3);"></span> Noch keine Checks</div>
{{else}}
<div class="status-pill" style="background:rgba(255,59,48,.1);border-color:rgba(255,59,48,.3);color:#ff3b30;">
<span class="dot" style="background:#ff3b30;"></span> Probleme erkannt
</div>
{{end}}
<form method="POST" action="/monitoring/scan" style="margin:0;">
<button type="submit" class="btn btn-primary" style="height:32px;font-size:13px;">Jetzt prüfen</button>
</form>
</div>
</div>
<!-- Stats -->
<div class="stats-grid" style="grid-template-columns:repeat(3,1fr);margin-bottom:20px;">
<div class="stat">
<div class="stat-label">Checks gesamt</div>
<div class="stat-value">{{.TotalChecks}}</div>
<div class="stat-delta neutral">konfiguriert</div>
</div>
<div class="stat">
<div class="stat-label">Online</div>
<div class="stat-value" style="{{if gt .OnlineCount 0}}color:var(--success);{{end}}">{{.OnlineCount}}</div>
<div class="stat-delta up">erreichbar</div>
</div>
<div class="stat">
<div class="stat-label">Offline</div>
<div class="stat-value" style="{{if gt .OfflineCount 0}}color:#ff3b30;{{end}}">{{.OfflineCount}}</div>
<div class="stat-delta {{if gt .OfflineCount 0}}down{{else}}neutral{{end}}">nicht erreichbar</div>
</div>
</div>
<!-- Checks Table -->
<div class="card" style="margin-bottom:20px;">
{{if .Checks}}
<table>
<thead>
<tr>
<th>Name</th>
<th>Typ</th>
<th>Ziel</th>
<th>Status</th>
<th>Latenz</th>
<th style="width:180px;">Verlauf (letzte 20)</th>
<th>Geprüft</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Checks}}
<tr>
<td style="font-weight:600;">{{.Check.Name}}</td>
<td>
<span style="background:var(--bg-2);border:1px solid var(--border);border-radius:4px;padding:1px 7px;font-size:11px;font-family:monospace;text-transform:uppercase;color:var(--text-2);">{{.Check.Type}}</span>
</td>
<td style="color:var(--text-2);font-size:12px;font-family:monospace;">{{.Check.Target}}</td>
<td>
{{if not .HasResult}}
<span class="badge" style="color:var(--text-3);">Ausstehend</span>
{{else if eq .Latest.Status "online"}}
<span class="badge badge-green"><span class="dot green" style="width:5px;height:5px;margin-right:4px;display:inline-block;"></span>Online</span>
{{else}}
<span class="badge" style="background:rgba(255,59,48,.1);border-color:rgba(255,59,48,.3);color:#ff3b30;"><span class="dot" style="background:#ff3b30;width:5px;height:5px;margin-right:4px;display:inline-block;border-radius:50%;"></span>Offline</span>
{{end}}
</td>
<td style="color:var(--text-2);font-size:12px;">
{{if and .HasResult (gt .Latest.LatencyMS 0)}}{{.Latest.LatencyMS}} ms{{else}}—{{end}}
</td>
<td>
<div style="display:flex;gap:2px;align-items:center;">
{{range .History}}
<div style="width:8px;height:20px;border-radius:2px;flex-shrink:0;
background:{{if eq .Status "online"}}var(--success){{else}}#ff3b30{{end}};
opacity:0.8;"
title="{{formatTime .CheckedAt}} · {{.Status}}{{if gt .LatencyMS 0}} · {{.LatencyMS}}ms{{end}}{{if .Error}} · {{.Error}}{{end}}">
</div>
{{end}}
{{if eq (len .History) 0}}
<span style="color:var(--text-3);font-size:11px;">noch keine Daten</span>
{{end}}
</div>
</td>
<td style="color:var(--text-3);font-size:12px;">
{{if .HasResult}}{{formatTime .Latest.CheckedAt}}{{else}}—{{end}}
</td>
<td>
<form method="POST" action="/monitoring/checks/{{.Check.ID}}/delete" style="margin:0;">
<button type="submit" class="icon-btn" title="Löschen"
onclick="return confirm('Check \'{{.Check.Name}}\' löschen?')"
style="color:var(--text-3);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div style="text-align:center;padding:48px;color:var(--text-3);">
<div style="font-size:32px;margin-bottom:8px;">📡</div>
<div style="font-weight:600;margin-bottom:4px;">Noch keine Checks konfiguriert</div>
<div style="font-size:13px;">Füge unten deinen ersten Check hinzu.</div>
</div>
{{end}}
</div>
<!-- Add Check Form -->
<div class="card" style="margin-top:4px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;padding-bottom:14px;border-bottom:1px solid var(--border);">
<div>
<div style="font-weight:600;font-size:14px;color:var(--text-1);">Check hinzufügen</div>
<div style="font-size:12px;color:var(--text-3);margin-top:2px;">Jeder Standort prüft unabhängig — Ping, HTTP oder TCP Port.</div>
</div>
</div>
{{if .AddErr}}<div class="error-msg" style="margin-bottom:14px;">{{.AddErr}}</div>{{end}}
<form method="POST" action="/monitoring/checks/add">
<div style="display:grid;grid-template-columns:1fr 110px 1fr;gap:12px;margin-bottom:14px;">
<div>
<label class="input-label">Name</label>
<input class="input" type="text" name="name" placeholder="z.B. Internet (Google)" required>
</div>
<div>
<label class="input-label">Typ</label>
<select class="select" name="type" style="width:100%;">
<option value="ping">Ping</option>
<option value="http">HTTP/S</option>
<option value="tcp">TCP Port</option>
</select>
</div>
<div>
<label class="input-label">Ziel</label>
<input class="input" type="text" name="target"
placeholder="8.8.8.8 · https://… · host:443" required>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;color:var(--text-3);">
<span style="color:var(--text-2);font-weight:600;">Ping:</span> IP-Adresse &nbsp;&nbsp;
<span style="color:var(--text-2);font-weight:600;">HTTP/S:</span> https://hostname &nbsp;&nbsp;
<span style="color:var(--text-2);font-weight:600;">TCP:</span> hostname:port
</span>
<button type="submit" class="btn btn-primary">+ Hinzufügen</button>
</div>
</form>
</div>
{{end}}

View File

@@ -1,252 +0,0 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">Einstellungen</h1>
<p class="page-sub">Standort, IT Nexus API und Scanner-Optionen.</p>
</div>
</div>
{{if .Saved}}<div class="success-msg" style="margin-bottom:16px;">Einstellungen gespeichert.</div>{{end}}
{{if .SaveErr}}<div class="error-msg" style="margin-bottom:16px;">{{.SaveErr}}</div>{{end}}
<form method="POST" action="/settings">
<!-- Standort -->
<div class="settings-section">
<div class="settings-title">Standort</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">Standort-ID</div>
<div class="setting-sub">Wird in allen gemeldeten Assets verwendet.</div>
</div>
<div class="setting-control">
<select class="select" name="site" style="width:200px;">
<option value="LUD"{{if eq .Site "LUD"}} selected{{end}}>Lüdenscheid (LUD)</option>
<option value="BAR"{{if eq .Site "BAR"}} selected{{end}}>Barleben (BAR)</option>
</select>
</div>
</div>
</div>
</div>
<!-- IT Nexus API -->
<div class="settings-section">
<div class="settings-title">IT Nexus Integration</div>
<div class="card">
<div class="setting-body full">
<div class="form-group">
<label class="input-label">API URL</label>
<input class="input" type="url" name="nexus_url" value="{{.NexusURL}}"
placeholder="https://it-nexus.cereda-systems.de">
</div>
<div class="form-group">
<label class="input-label">API Key</label>
<div class="password-wrap">
<input class="input" type="password" id="api-key" name="nexus_api_key" value="{{.NexusAPIKey}}"
placeholder="itx-…">
<button type="button" class="eye-btn" onclick="toggleApiKey()" title="Anzeigen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Scanner -->
<div class="settings-section">
<div class="settings-title">Scanner-Optionen</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">Scan-Intervall</div>
<div class="setting-sub">Wie oft der ARP-Scan automatisch ausgeführt wird.</div>
</div>
<div class="setting-control">
<select class="select" name="arp_interval" style="width:160px;">
<option value="1m"{{if eq .ARPInterval "1m"}} selected{{end}}>1 Minute</option>
<option value="5m"{{if eq .ARPInterval "5m"}} selected{{end}}>5 Minuten</option>
<option value="15m"{{if eq .ARPInterval "15m"}} selected{{end}}>15 Minuten</option>
<option value="1h"{{if eq .ARPInterval "1h"}} selected{{end}}>1 Stunde</option>
</select>
</div>
</div>
</div>
</div>
<!-- Scanner-Verhalten -->
<div class="settings-section">
<div class="settings-title">Scanner-Verhalten</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">Offline-Schwellwert</div>
<div class="setting-sub">Host gilt als Offline wenn er seit dieser Zeit nicht mehr gesehen wurde.</div>
</div>
<div class="setting-control">
<select class="select" name="offline_after" style="width:140px;">
<option value="5m"{{if eq .OfflineAfter "5m0s"}} selected{{end}}>5 Minuten</option>
<option value="10m"{{if eq .OfflineAfter "10m0s"}} selected{{end}}>10 Minuten</option>
<option value="15m"{{if eq .OfflineAfter "15m0s"}} selected{{end}}>15 Minuten</option>
<option value="30m"{{if eq .OfflineAfter "30m0s"}} selected{{end}}>30 Minuten</option>
<option value="1h"{{if eq .OfflineAfter "1h0m0s"}} selected{{end}}>1 Stunde</option>
</select>
</div>
</div>
</div>
</div>
<!-- Active Directory -->
<div class="settings-section">
<div class="settings-title">Active Directory</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">AD Sync aktivieren</div>
<div class="setting-sub">Computer-Objekte aus winkel.local importieren.</div>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" name="ad_enabled"{{if .ADEnabled}} checked{{end}}>
<span class="toggle-track"></span>
</label>
</div>
</div>
<div class="setting-body">
<div class="form-group">
<label class="input-label">Domain Controller (IP oder Hostname)</label>
<input class="input" type="text" name="ad_server" value="{{.ADServer}}" placeholder="192.168.0.12">
</div>
<div class="form-group">
<label class="input-label">Port</label>
<input class="input" type="number" name="ad_port" value="{{.ADPort}}" placeholder="389">
</div>
<div class="form-group">
<label class="input-label">Bind-DN (Service Account)</label>
<input class="input" type="text" name="ad_bind_dn" value="{{.ADBindDN}}" placeholder="svc-scanner@winkel.local">
</div>
<div class="form-group">
<label class="input-label">Passwort</label>
<div class="password-wrap">
<input class="input" type="password" id="ad-pw" name="ad_bind_password" value="{{.ADBindPW}}" placeholder="Service-Account Passwort">
<button type="button" class="eye-btn" onclick="toggleAdPw()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<div class="form-group">
<label class="input-label">Search Base</label>
<input class="input" type="text" name="ad_search_base" value="{{.ADSearchBase}}" placeholder="DC=winkel,DC=local">
</div>
<div class="form-group">
<label class="input-label">Sync-Intervall</label>
<select class="select" name="ad_interval">
<option value="15m"{{if eq .ADInterval "15m0s"}} selected{{end}}>15 Minuten</option>
<option value="30m"{{if eq .ADInterval "30m0s"}} selected{{end}}>30 Minuten</option>
<option value="1h"{{if eq .ADInterval "1h0m0s"}} selected{{end}}>1 Stunde</option>
<option value="6h"{{if eq .ADInterval "6h0m0s"}} selected{{end}}>6 Stunden</option>
</select>
</div>
</div>
</div>
</div>
<!-- Alerts -->
<div class="settings-section">
<div class="settings-title">Benachrichtigungen</div>
<div class="card">
<div class="setting-row">
<div>
<div class="setting-label">IT Nexus Alert</div>
<div class="setting-sub">Bei Ausfall an IT Nexus API melden (POST /api/scanner/alert).</div>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" name="alert_nexus"{{if .AlertNexus}} checked{{end}}>
<span class="toggle-track"></span>
</label>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">E-Mail (SMTP)</div>
<div class="setting-sub">E-Mail bei Status-Wechsel senden.</div>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" name="smtp_enabled"{{if .SMTPEnabled}} checked{{end}}>
<span class="toggle-track"></span>
</label>
</div>
</div>
<div class="setting-body" style="grid-template-columns:1fr 1fr;">
<div class="form-group">
<label class="input-label">SMTP-Host</label>
<input class="input" type="text" name="smtp_host" value="{{.SMTPHost}}" placeholder="smtp.office365.com">
</div>
<div class="form-group">
<label class="input-label">Port</label>
<input class="input" type="number" name="smtp_port" value="{{.SMTPPort}}" placeholder="587">
</div>
<div class="form-group">
<label class="input-label">Benutzername</label>
<input class="input" type="text" name="smtp_user" value="{{.SMTPUser}}" placeholder="scanner@cereda-systems.de">
</div>
<div class="form-group">
<label class="input-label">Passwort</label>
<div class="password-wrap">
<input class="input" type="password" name="smtp_pass" value="{{.SMTPPass}}" placeholder="SMTP-Passwort">
<button type="button" class="eye-btn" onclick="this.previousElementSibling.type=this.previousElementSibling.type==='password'?'text':'password'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
</div>
<div class="form-group">
<label class="input-label">Absender (From)</label>
<input class="input" type="email" name="smtp_from" value="{{.SMTPFrom}}" placeholder="nexus-scanner@cereda-systems.de">
</div>
<div class="form-group">
<label class="input-label">Empfänger (To)</label>
<input class="input" type="email" name="smtp_to" value="{{.SMTPTo}}" placeholder="it@cereda-systems.de">
</div>
</div>
</div>
</div>
<!-- Passwort -->
<div class="settings-section">
<div class="settings-title">Passwort ändern</div>
<div class="card">
<div class="setting-body">
<div class="form-group">
<label class="input-label">Neues Passwort</label>
<input class="input" type="password" name="new_password" placeholder="Mindestens 6 Zeichen" autocomplete="new-password">
</div>
<div class="form-group">
<label class="input-label">Bestätigung</label>
<input class="input" type="password" name="confirm_password" placeholder="Passwort wiederholen" autocomplete="new-password">
</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;">
<button type="reset" class="btn">Zurücksetzen</button>
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
</div>
</form>
<script>
function togglePw(id) {
const f = document.getElementById(id);
f.type = f.type === 'password' ? 'text' : 'password';
}
function toggleApiKey() { togglePw('api-key'); }
function toggleAdPw() { togglePw('ad-pw'); }
</script>
{{end}}

View File

@@ -1,219 +0,0 @@
{{define "setup"}}
<!DOCTYPE html>
<html data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einrichtung — Nexus Scanner</title>
<link rel="stylesheet" href="/static/style.css">
<script>(function(){ var t=localStorage.getItem('nx-theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
</head>
<body>
<div class="wizard-wrap">
<div class="wizard">
<!-- Header -->
<div class="wizard-header">
<div style="display:flex;align-items:center;gap:10px;">
<div class="brand-mark" style="width:28px;height:28px;border-radius:7px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px;color:#fff;"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<span style="font-size:13px;font-weight:600;color:var(--text-2);">Nexus Scanner</span>
</div>
<div class="wizard-steps">
{{range $i, $_ := .Steps}}
<div class="wizard-step{{if lt $i $.Step}} done{{else if eq $i $.Step}} active{{end}}"></div>
{{end}}
</div>
</div>
{{if eq .Step 0}}
<!-- Schritt 1: Standort -->
<div class="wizard-body">
<div class="wizard-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<h2 class="wizard-title">Willkommen beim Nexus Scanner.</h2>
<p class="wizard-sub">Wähle zuerst deinen Standort.</p>
<form method="POST" action="/setup" id="f">
<input type="hidden" name="step" value="0">
<div class="site-cards">
<label class="site-card{{if eq .Site "LUD"}} selected{{end}}" onclick="sel(this,'LUD')">
<input type="radio" name="site" value="LUD" style="display:none;"{{if eq .Site "LUD"}} checked{{end}}>
<div class="site-card-name">Lüdenscheid</div>
<div class="site-card-sub">LUD · 192.168.0.0/24</div>
</label>
<label class="site-card{{if eq .Site "BAR"}} selected{{end}}" onclick="sel(this,'BAR')">
<input type="radio" name="site" value="BAR" style="display:none;"{{if eq .Site "BAR"}} checked{{end}}>
<div class="site-card-name">Barleben</div>
<div class="site-card-sub">BAR · 192.168.10.0/24</div>
</label>
</div>
</form>
</div>
<div class="wizard-footer">
<span style="font-size:12.5px;color:var(--text-3);">Schritt 1 von {{len .Steps}}</span>
<button form="f" type="submit" name="next" value="1" class="btn btn-primary">
Weiter <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
{{else if eq .Step 1}}
<!-- Schritt 2: Netzwerk -->
<div class="wizard-body">
<div class="wizard-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<h2 class="wizard-title">Netzwerk konfigurieren.</h2>
<p class="wizard-sub">Welches Subnetz soll gescannt werden?</p>
{{if .Error}}<div class="error-msg" style="margin-bottom:14px;">{{.Error}}</div>{{end}}
<form method="POST" action="/setup" id="f">
<input type="hidden" name="step" value="1">
<input type="hidden" name="site" value="{{.Site}}">
<div class="form-group">
<label class="input-label">Subnetz (CIDR)</label>
<input class="input" type="text" name="subnet" value="{{.Subnet}}" placeholder="192.168.0.0/24" required autofocus>
<p style="font-size:12px;color:var(--text-3);margin-top:5px;">Beispiel: 192.168.0.0/24 scannt 254 Adressen.</p>
</div>
<div class="form-group">
<label class="input-label">Netzwerk-Interface</label>
<select class="select" name="interface">
{{range .Interfaces}}
<option value="{{.}}"{{if eq . $.Interface}} selected{{end}}>{{.}}</option>
{{end}}
{{if not .Interfaces}}<option value="eth0">eth0</option>{{end}}
</select>
<p style="font-size:12px;color:var(--text-3);margin-top:5px;">Automatisch erkannt — wähle das Interface zum LAN.</p>
</div>
</form>
</div>
<div class="wizard-footer">
<a href="/setup?step=0" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> Zurück
</a>
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-size:12.5px;color:var(--text-3);">Schritt 2 von {{len .Steps}}</span>
<button form="f" type="submit" name="next" value="2" class="btn btn-primary">
Weiter <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
{{else if eq .Step 2}}
<!-- Schritt 3: IT Nexus API -->
<div class="wizard-body">
<div class="wizard-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</div>
<h2 class="wizard-title">IT Nexus verbinden.</h2>
<p class="wizard-sub">Optional — kann auch später konfiguriert werden.</p>
<form method="POST" action="/setup" id="f">
<input type="hidden" name="step" value="2">
<input type="hidden" name="site" value="{{.Site}}">
<input type="hidden" name="subnet" value="{{.Subnet}}">
<input type="hidden" name="interface" value="{{.Interface}}">
<div class="form-group">
<label class="input-label">IT Nexus API URL</label>
<input class="input" type="url" name="nexus_url" value="{{.NexusURL}}" placeholder="https://it-nexus.cereda-systems.de">
</div>
<div class="form-group">
<label class="input-label">API Key</label>
<input class="input" type="password" name="nexus_api_key" value="{{.NexusAPIKey}}" placeholder="nsx-…">
</div>
</form>
</div>
<div class="wizard-footer">
<a href="/setup?step=1" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> Zurück
</a>
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-size:12.5px;color:var(--text-3);">Schritt 3 von {{len .Steps}}</span>
<button form="f" type="submit" name="next" value="3" class="btn btn-primary">
Weiter <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
{{else if eq .Step 3}}
<!-- Schritt 4: Passwort -->
<div class="wizard-body">
<div class="wizard-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<h2 class="wizard-title">Passwort festlegen.</h2>
<p class="wizard-sub">Mindestens 6 Zeichen für den Web-Login.</p>
{{if .Error}}<div class="error-msg" style="margin-bottom:14px;">{{.Error}}</div>{{end}}
<form method="POST" action="/setup" id="f">
<input type="hidden" name="step" value="3">
<input type="hidden" name="site" value="{{.Site}}">
<input type="hidden" name="subnet" value="{{.Subnet}}">
<input type="hidden" name="interface" value="{{.Interface}}">
<input type="hidden" name="nexus_url" value="{{.NexusURL}}">
<input type="hidden" name="nexus_api_key" value="{{.NexusAPIKey}}">
<div class="form-group">
<label class="input-label">Passwort</label>
<input class="input" type="password" name="password" placeholder="Mindestens 6 Zeichen" minlength="6" required autofocus>
</div>
<div class="form-group">
<label class="input-label">Bestätigung</label>
<input class="input" type="password" name="confirm" placeholder="Passwort wiederholen" required>
</div>
</form>
</div>
<div class="wizard-footer">
<a href="/setup?step=2" class="btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> Zurück
</a>
<div style="display:flex;align-items:center;gap:12px;">
<span style="font-size:12.5px;color:var(--text-3);">Schritt 4 von {{len .Steps}}</span>
<button form="f" type="submit" name="next" value="4" class="btn btn-primary">
Weiter <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
</button>
</div>
</div>
{{else}}
<!-- Schritt 5: Fertig -->
<div class="wizard-body">
<div class="wizard-icon" style="background:rgba(52,168,83,.12);color:var(--success);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<h2 class="wizard-title">Alles bereit.</h2>
<p class="wizard-sub">Der Scanner ist konfiguriert und startet jetzt.</p>
<div style="display:flex;flex-direction:column;gap:10px;font-size:13px;margin-top:16px;">
<div style="display:flex;align-items:center;gap:8px;"><span class="badge badge-teal">{{.Site}}</span><span style="color:var(--text-2);">Standort</span></div>
<div style="display:flex;align-items:center;gap:8px;"><span class="badge badge-green">{{.Subnet}}</span><span style="color:var(--text-2);">Subnetz</span></div>
{{if .NexusURL}}<div style="display:flex;align-items:center;gap:8px;"><span class="badge badge-green">Verbunden</span><span style="color:var(--text-2);">IT Nexus API</span></div>{{end}}
<div style="display:flex;align-items:center;gap:8px;"><span class="badge badge-green"></span><span style="color:var(--text-2);">Passwort gesetzt</span></div>
</div>
<form method="POST" action="/setup/complete" id="f">
<input type="hidden" name="step" value="4">
<input type="hidden" name="site" value="{{.Site}}">
<input type="hidden" name="subnet" value="{{.Subnet}}">
<input type="hidden" name="interface" value="{{.Interface}}">
<input type="hidden" name="nexus_url" value="{{.NexusURL}}">
<input type="hidden" name="nexus_api_key" value="{{.NexusAPIKey}}">
<input type="hidden" name="password" value="{{.Password}}">
</form>
</div>
<div class="wizard-footer" style="justify-content:center;">
<button form="f" type="submit" class="btn btn-primary" style="width:100%;justify-content:center;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
Scanner starten
</button>
</div>
{{end}}
</div><!-- .wizard -->
</div><!-- .wizard-wrap -->
<script>
function sel(card, site) {
document.querySelectorAll('.site-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
card.querySelector('input[type=radio]').checked = true;
}
</script>
</body>
</html>
{{end}}

View File

@@ -1,232 +0,0 @@
{{define "content"}}
<div class="page-header">
<div>
<h1 class="page-title">SNMP</h1>
<p class="page-sub">Geräte-Informationen via SNMP — Drucker, Switches, USVs.</p>
</div>
</div>
{{if .AddErr}}<div class="error-msg" style="margin-bottom:16px;">{{.AddErr}}</div>{{end}}
{{if .Targets}}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px;margin-bottom:24px;">
{{range .Targets}}
<div class="card" style="overflow:visible;">
<!-- Card Header -->
<div style="padding:16px 20px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:10px;">
<div style="width:32px;height:32px;border-radius:var(--r-sm);background:var(--teal-50);color:var(--teal);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
{{if eq .Target.Type "printer"}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
{{else if eq .Target.Type "switch"}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;"><rect x="2" y="7" width="20" height="10" rx="2"/><path d="M6 12h.01M10 12h.01M14 12h.01M18 12h.01"/></svg>
{{else}}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;"><rect x="4" y="4" width="16" height="16" rx="2"/><path d="M9 9h6M9 12h6M9 15h4"/></svg>
{{end}}
</div>
<div>
<div style="font-weight:600;font-size:14px;">{{.Target.Name}}</div>
<div style="font-size:12px;color:var(--text-3);font-family:monospace;">{{.Target.IP}}</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
{{if .HasData}}
<span class="badge badge-green" style="font-size:11px;">Online</span>
{{else}}
<span class="badge badge-gray" style="font-size:11px;">Kein Datum</span>
{{end}}
<form method="POST" action="/snmp/targets/{{.Target.ID}}/delete" style="margin:0;">
<button type="submit" class="icon-btn" onclick="return confirm('{{.Target.Name}} löschen?')" style="color:var(--text-3);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
</form>
</div>
</div>
<!-- Card Body -->
<div style="padding:16px 20px;">
{{if .HasData}}
{{if .SysName}}<div style="margin-bottom:8px;"><span style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;">Name</span><div style="font-size:13px;color:var(--text-1);margin-top:2px;">{{.SysName}}</div></div>{{end}}
{{if .SysDescr}}<div style="margin-bottom:8px;"><span style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;">Beschreibung</span><div style="font-size:12px;color:var(--text-2);margin-top:2px;">{{.SysDescr}}</div></div>{{end}}
{{if .SysUpTime}}<div style="margin-bottom:8px;"><span style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;">Uptime</span><div style="font-size:13px;color:var(--text-2);margin-top:2px;">{{.SysUpTime}}</div></div>{{end}}
{{if .SysLocation}}<div style="margin-bottom:8px;"><span style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;">Standort</span><div style="font-size:13px;color:var(--text-2);margin-top:2px;">{{.SysLocation}}</div></div>{{end}}
{{if eq .Target.Type "switch"}}
<!-- Switch Stats -->
<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border);">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
{{if .PortsTotal}}
<div style="background:var(--surface-2);border-radius:var(--r-sm);padding:10px 12px;">
<div style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:4px;">Ports</div>
<div style="font-size:20px;font-weight:600;color:var(--text-1);">{{.PortsUp}}<span style="font-size:13px;color:var(--text-3);font-weight:400;"> / {{.PortsTotal}}</span></div>
<div style="font-size:11px;color:var(--success);margin-top:2px;">● aktiv</div>
</div>
{{end}}
{{if .TrafficIn}}
<div style="background:var(--surface-2);border-radius:var(--r-sm);padding:10px 12px;">
<div style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:4px;">Traffic</div>
<div style="font-size:12px;color:var(--text-2);margin-top:2px;">↓ {{.TrafficIn}}</div>
<div style="font-size:12px;color:var(--text-2);margin-top:2px;">↑ {{.TrafficOut}}</div>
</div>
{{end}}
{{if .MemTotal}}
<div style="background:var(--surface-2);border-radius:var(--r-sm);padding:10px 12px;">
<div style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:4px;">RAM</div>
<div style="font-size:13px;color:var(--text-1);">{{.MemUsed}} / {{.MemTotal}}</div>
<div style="background:var(--surface-3);border-radius:3px;height:5px;margin-top:5px;overflow:hidden;">
<div style="height:100%;border-radius:3px;width:{{.MemPct}}%;background:{{if gt .MemPct 85}}var(--danger){{else if gt .MemPct 70}}var(--warning){{else}}var(--teal){{end}};"></div>
</div>
</div>
{{end}}
{{if .CpuLoad}}
<div style="background:var(--surface-2);border-radius:var(--r-sm);padding:10px 12px;">
<div style="font-size:11px;color:var(--text-3);font-weight:600;text-transform:uppercase;margin-bottom:4px;">CPU</div>
<div style="font-size:20px;font-weight:600;color:var(--text-1);">{{.CpuLoad}}<span style="font-size:13px;font-weight:400;color:var(--text-3);">%</span></div>
<div style="background:var(--surface-3);border-radius:3px;height:5px;margin-top:5px;overflow:hidden;">
<div style="height:100%;border-radius:3px;width:{{.CpuLoad}}%;background:var(--teal);"></div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{if eq .Target.Type "printer"}}
<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border);">
{{if .PrinterStatus}}
<div style="display:inline-flex;align-items:center;gap:5px;margin-bottom:12px;background:{{if eq .PrinterStatus "Druckt"}}rgba(0,132,135,.1){{else}}var(--surface-2){{end}};padding:4px 10px;border-radius:20px;">
<span style="width:6px;height:6px;border-radius:50%;background:{{if eq .PrinterStatus "Druckt"}}var(--teal){{else}}var(--success){{end}};display:inline-block;"></span>
<span style="font-size:12px;font-weight:500;color:var(--text-2);">{{.PrinterStatus}}</span>
</div>
{{end}}
{{if .TonerLevel}}
<div style="margin-bottom:4px;display:flex;justify-content:space-between;align-items:baseline;">
<span style="font-size:12px;font-weight:600;color:var(--text-2);">Toner</span>
<span style="font-size:13px;font-weight:600;color:{{if lt .TonerPct 20}}#ff3b30{{else if lt .TonerPct 40}}var(--warning){{else}}var(--text-1){{end}};">{{.TonerPct}}%</span>
</div>
<div style="background:var(--surface-3);border-radius:6px;height:10px;overflow:hidden;">
<div style="height:100%;border-radius:6px;width:{{.TonerPct}}%;background:{{if lt .TonerPct 20}}#ff3b30{{else if lt .TonerPct 40}}#ff9500{{else}}var(--success){{end}};transition:width .4s;"></div>
</div>
{{if lt .TonerPct 20}}<div style="font-size:11px;color:#ff3b30;margin-top:5px;font-weight:500;">⚠ Toner bald leer — bitte nachbestellen</div>{{end}}
{{end}}
{{if .PageCount}}
<div style="display:flex;align-items:center;gap:6px;margin-top:12px;padding-top:10px;border-top:1px solid var(--border);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;color:var(--text-3);flex-shrink:0;"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
<span style="font-size:12px;color:var(--text-3);">Gesamtseiten: <strong style="color:var(--text-2);">{{.PageCount}}</strong></span>
</div>
{{end}}
</div>
{{end}}
{{else}}
<div style="color:var(--text-3);font-size:13px;text-align:center;padding:16px 0;">Noch kein Scan — läuft alle {{.Interval}}.</div>
{{end}}
</div>
<!-- Card Footer -->
<div style="padding:8px 20px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span style="font-size:11px;color:var(--text-3);">Community: <code style="background:var(--surface-3);padding:1px 5px;border-radius:3px;">{{.Target.Community}}</code> · {{.Target.Version}}</span>
<div style="display:flex;gap:8px;align-items:center;">
{{if .HasData}}
<button onclick="openDetail('modal-{{.Target.ID}}')" class="btn" style="font-size:11px;padding:4px 10px;height:auto;">
Details
</button>
{{end}}
<span class="badge badge-gray" style="font-size:11px;text-transform:capitalize;">{{.Target.Type}}</span>
</div>
</div>
</div>
<!-- Detail Modal -->
{{if .HasData}}
<div id="modal-{{.Target.ID}}" style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.45);backdrop-filter:blur(2px);" onclick="if(event.target===this)closeDetail('modal-{{.Target.ID}}')">
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:min(560px,95vw);max-height:85vh;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);box-shadow:0 8px 40px rgba(0,0,0,.25);">
<div style="padding:18px 22px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;background:var(--surface);z-index:1;">
<div>
<div style="font-weight:600;font-size:15px;">{{.Target.Name}}</div>
<div style="font-size:12px;color:var(--text-3);font-family:monospace;margin-top:2px;">{{.Target.IP}} · {{.Target.Type}}</div>
</div>
<button onclick="closeDetail('modal-{{.Target.ID}}')" class="icon-btn" style="color:var(--text-3);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px;"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div style="padding:20px 22px;">
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr>
<th style="text-align:left;font-size:11px;font-weight:600;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;padding:0 0 10px;border-bottom:1px solid var(--border);width:40%;">Feld</th>
<th style="text-align:left;font-size:11px;font-weight:600;color:var(--text-3);text-transform:uppercase;letter-spacing:.06em;padding:0 0 10px 16px;border-bottom:1px solid var(--border);">Wert</th>
</tr>
</thead>
<tbody>
{{range $k, $v := .AllValues}}
<tr>
<td style="padding:9px 0;border-bottom:1px solid var(--border);color:var(--text-3);font-size:12px;vertical-align:top;font-weight:500;">{{$k}}</td>
<td style="padding:9px 0 9px 16px;border-bottom:1px solid var(--border);color:var(--text-1);font-family:{{if or (eq $k "sysDescr") (eq $k "sysName") (eq $k "sysLocation") (eq $k "sysContact")}}inherit{{else}}monospace{{end}};font-size:13px;word-break:break-all;">{{$v}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
{{end}}
{{end}}
</div>
{{else}}
<div class="card" style="margin-bottom:24px;">
<div style="text-align:center;padding:48px;color:var(--text-3);">
<div style="font-size:32px;margin-bottom:8px;">📡</div>
<div style="font-weight:600;margin-bottom:4px;">Noch keine SNMP-Geräte</div>
<div style="font-size:13px;">Füge unten dein erstes Gerät hinzu.</div>
</div>
</div>
{{end}}
<!-- Add Target Form -->
<div class="card">
<div style="padding:16px 20px;border-bottom:1px solid var(--border);">
<div style="font-weight:600;font-size:14px;">SNMP-Gerät hinzufügen</div>
<div style="font-size:12px;color:var(--text-3);margin-top:2px;">Drucker (Kyocera), Switches, USVs — Community string "public" ist Standard.</div>
</div>
<form method="POST" action="/snmp/targets/add" style="padding:16px 20px;">
<div style="display:grid;grid-template-columns:1fr 160px 110px 110px 110px;gap:12px;margin-bottom:14px;">
<div>
<label class="input-label">Name</label>
<input class="input" type="text" name="name" placeholder="z.B. Kyocera EcoSys" required>
</div>
<div>
<label class="input-label">IP-Adresse</label>
<input class="input" type="text" name="ip" placeholder="192.168.0.50" required>
</div>
<div>
<label class="input-label">Community</label>
<input class="input" type="text" name="community" placeholder="public" value="public">
</div>
<div>
<label class="input-label">Version</label>
<select class="select" name="version" style="width:100%;">
<option value="v2c">SNMPv2c</option>
<option value="v1">SNMPv1</option>
</select>
</div>
<div>
<label class="input-label">Typ</label>
<select class="select" name="type" style="width:100%;">
<option value="generic">Allgemein</option>
<option value="printer">Drucker</option>
<option value="switch">Switch</option>
</select>
</div>
</div>
<div style="display:flex;justify-content:flex-end;">
<button type="submit" class="btn btn-primary">+ Gerät hinzufügen</button>
</div>
</form>
</div>
<script>
function openDetail(id) { document.getElementById(id).style.display = 'block'; document.body.style.overflow = 'hidden'; }
function closeDetail(id) { document.getElementById(id).style.display = 'none'; document.body.style.overflow = ''; }
document.addEventListener('keydown', function(e) { if(e.key === 'Escape') { document.querySelectorAll('[id^="modal-"]').forEach(m => { m.style.display='none'; }); document.body.style.overflow=''; } });
</script>
{{end}}

Binary file not shown.

Binary file not shown.

View File

@@ -1,32 +0,0 @@
[Unit]
Description=Nexus Network Scanner Agent
Documentation=https://it-nexus.cereda-systems.de
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/nexus-scanner /etc/nexus-scanner/config.yaml
Restart=on-failure
RestartSec=10s
User=nexus-scanner
Group=nexus-scanner
# Allow raw socket access for ARP (runs as non-root).
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/nexus-scanner
# Logging — captured by journald.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=nexus-scanner
[Install]
WantedBy=multi-user.target

View File

@@ -1,881 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Onboarding Prozess Cereda Systems</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,300;0,400;0,500;0,700;1,300&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #22263a;
--border: #2e3348;
--hr-color: #3b82f6;
--hr-bg: #1e2d4a;
--it-color: #10b981;
--it-bg: #0d2e22;
--vg-color: #f59e0b;
--vg-bg: #2d2010;
--bk-color: #a78bfa;
--bk-bg: #1e1833;
--done-color: #34d399;
--text: #e2e8f0;
--text-muted: #64748b;
--text-dim: #94a3b8;
--accent: #3b82f6;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DM Sans', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 0;
}
/* HEADER */
.header {
background: linear-gradient(135deg, #0f1117 0%, #1a1d27 50%, #0f1117 100%);
border-bottom: 1px solid var(--border);
padding: 32px 40px 28px;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(12px);
}
.header-inner {
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.header-badge {
background: var(--accent);
color: white;
font-family: 'DM Mono', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.08em;
padding: 4px 10px;
border-radius: 4px;
}
.header h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
color: #f1f5f9;
}
.header p {
font-size: 13px;
color: var(--text-muted);
margin-top: 2px;
}
.header-meta {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-dim);
background: var(--surface);
border: 1px solid var(--border);
padding: 5px 10px;
border-radius: 6px;
}
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* MAIN */
.main {
max-width: 1400px;
margin: 0 auto;
padding: 32px 40px 60px;
}
/* PHASE HEADER */
.phase-header {
display: grid;
grid-template-columns: 220px 1fr 1fr 1fr 1fr;
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius) var(--radius) 0 0;
overflow: hidden;
margin-top: 32px;
}
.phase-header:first-child { margin-top: 0; }
.col-label {
padding: 14px 16px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.col-label.timeline { background: var(--surface); color: var(--text-muted); }
.col-label.hr { background: var(--hr-bg); color: var(--hr-color); border-left: 3px solid var(--hr-color); }
.col-label.it { background: var(--it-bg); color: var(--it-color); border-left: 3px solid var(--it-color); }
.col-label.vg { background: var(--vg-bg); color: var(--vg-color); border-left: 3px solid var(--vg-color); }
.col-label.bk { background: var(--bk-bg); color: var(--bk-color); border-left: 3px solid var(--bk-color); }
/* SWIMLANE */
.swimlane-wrapper {
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 var(--radius) var(--radius);
overflow: hidden;
margin-bottom: 40px;
}
.swimlane-row {
display: grid;
grid-template-columns: 220px 1fr 1fr 1fr 1fr;
gap: 1px;
background: var(--border);
min-height: 120px;
}
.swimlane-row:last-child > * { }
.swimlane-cell {
background: var(--surface);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.swimlane-cell.timeline-cell {
background: var(--bg);
justify-content: center;
}
.timeline-label {
font-family: 'DM Mono', monospace;
font-size: 12px;
font-weight: 500;
color: var(--text-dim);
line-height: 1.4;
}
.timeline-day {
font-size: 20px;
font-weight: 700;
color: #f1f5f9;
letter-spacing: -0.02em;
}
.swimlane-cell.hr-cell { background: color-mix(in srgb, var(--hr-bg) 60%, var(--surface)); border-left: 2px solid var(--hr-color); }
.swimlane-cell.it-cell { background: color-mix(in srgb, var(--it-bg) 60%, var(--surface)); border-left: 2px solid var(--it-color); }
.swimlane-cell.vg-cell { background: color-mix(in srgb, var(--vg-bg) 60%, var(--surface)); border-left: 2px solid var(--vg-color); }
.swimlane-cell.bk-cell { background: color-mix(in srgb, var(--bk-bg) 60%, var(--surface)); border-left: 2px solid var(--bk-color); }
/* TASK CARDS */
.task {
background: var(--surface2);
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.45;
color: var(--text-dim);
border: 1px solid var(--border);
cursor: pointer;
transition: all 0.15s ease;
position: relative;
}
.task:hover {
border-color: currentColor;
color: var(--text);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.task.hr-task { border-left: 3px solid var(--hr-color); }
.task.it-task { border-left: 3px solid var(--it-color); }
.task.vg-task { border-left: 3px solid var(--vg-color); }
.task.bk-task { border-left: 3px solid var(--bk-color); }
.task.critical {
border-top: 2px solid #ef4444;
}
.task-title {
font-weight: 600;
font-size: 12px;
margin-bottom: 2px;
}
.task-title.hr { color: var(--hr-color); }
.task-title.it { color: var(--it-color); }
.task-title.vg { color: var(--vg-color); }
.task-title.bk { color: var(--bk-color); }
.task-body { color: var(--text-dim); font-size: 11.5px; }
.task-tag {
display: inline-block;
margin-top: 5px;
font-size: 10px;
font-family: 'DM Mono', monospace;
font-weight: 500;
padding: 2px 6px;
border-radius: 3px;
letter-spacing: 0.04em;
}
.task-tag.critical { background: rgba(239,68,68,0.15); color: #ef4444; }
.task-tag.important { background: rgba(245,158,11,0.15); color: #f59e0b; }
.task-tag.normal { background: rgba(100,116,139,0.12); color: var(--text-muted); }
.empty-cell {
display: flex;
align-items: center;
justify-content: center;
color: var(--border);
font-size: 20px;
}
/* STATUS INDICATOR */
.status-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
font-size: 13px;
color: var(--text-dim);
}
.status-icon { font-size: 16px; }
.status-text { font-weight: 500; color: var(--text); }
.status-arrow {
color: var(--border);
font-size: 16px;
}
.trigger-badge {
background: #ef4444;
color: white;
font-size: 11px;
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
letter-spacing: 0.05em;
font-family: 'DM Mono', monospace;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* SECTION HEADING */
.section-title {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.section-title::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* COMPLETION CHECKLIST */
.checklist-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 40px;
}
.checklist-header {
padding: 16px 20px;
background: linear-gradient(90deg, #1a2744, var(--surface));
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 14px;
display: flex;
align-items: center;
gap: 10px;
}
.checklist-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--border);
}
.checklist-col {
background: var(--surface);
padding: 16px;
}
.checklist-col-header {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.checklist-col-header.hr { color: var(--hr-color); }
.checklist-col-header.it { color: var(--it-color); }
.checklist-col-header.vg { color: var(--vg-color); }
.checklist-col-header.bk { color: var(--bk-color); }
.check-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 0;
font-size: 12px;
color: var(--text-dim);
border-bottom: 1px solid rgba(255,255,255,0.04);
cursor: pointer;
transition: color 0.1s;
}
.check-item:last-child { border-bottom: none; }
.check-item:hover { color: var(--text); }
.check-box {
width: 16px;
height: 16px;
border: 1.5px solid var(--border);
border-radius: 3px;
flex-shrink: 0;
margin-top: 1px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
font-size: 10px;
cursor: pointer;
}
.check-item.checked .check-box {
background: var(--done-color);
border-color: var(--done-color);
color: #0f1117;
}
.check-item.checked .check-label {
text-decoration: line-through;
color: var(--text-muted);
opacity: 0.6;
}
.progress-bar-wrap {
height: 3px;
background: var(--border);
border-radius: 2px;
margin-bottom: 10px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
.hr .progress-bar-fill { background: var(--hr-color); }
.it .progress-bar-fill { background: var(--it-color); }
.vg .progress-bar-fill { background: var(--vg-color); }
.bk .progress-bar-fill { background: var(--bk-color); }
.progress-label {
font-family: 'DM Mono', monospace;
font-size: 10px;
color: var(--text-muted);
margin-bottom: 8px;
}
/* FOOTER */
.doc-footer {
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 12px;
border-top: 1px solid var(--border);
margin-top: 20px;
}
@media print {
body { background: white; color: #111; }
.header { position: static; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-inner">
<span class="header-badge">CEREDA SYSTEMS</span>
<div>
<h1>Mitarbeiter Onboarding Prozessablauf</h1>
<p>Von Vertragsunterzeichnung bis zum ersten Arbeitstag · Alle Abteilungen</p>
</div>
<div class="header-meta">
<div class="legend-item"><div class="legend-dot" style="background:var(--hr-color)"></div>HR / Personal</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--it-color)"></div>IT</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--vg-color)"></div>Vorgesetzter</div>
<div class="legend-item"><div class="legend-dot" style="background:var(--bk-color)"></div>Buchhaltung / Lohn</div>
</div>
</div>
</div>
<div class="main">
<!-- TRIGGER -->
<div class="status-row">
<span class="status-icon">📄</span>
<span class="trigger-badge">TRIGGER</span>
<span class="status-text">Arbeitsvertrag unterzeichnet</span>
<span class="status-arrow"></span>
<span>Onboarding-Prozess wird gestartet · Alle Abteilungen werden parallel angestoßen</span>
<span class="status-arrow" style="margin-left:auto">Ziel: Tag 1 = ✅ alles bereit</span>
</div>
<!-- ========== PHASE 1: SOFORT ========== -->
<p class="section-title">Phase 1 — Sofort nach Vertragsunterzeichnung</p>
<div class="phase-header">
<div class="col-label timeline">⏱ Zeitpunkt</div>
<div class="col-label hr">🧑‍💼 HR / Personal</div>
<div class="col-label it">💻 IT</div>
<div class="col-label vg">👔 Vorgesetzter</div>
<div class="col-label bk">💶 Buchhaltung / Lohn</div>
</div>
<div class="swimlane-wrapper">
<div class="swimlane-row">
<div class="swimlane-cell timeline-cell">
<div class="timeline-day">Tag 0</div>
<div class="timeline-label">Vertrag<br>unterzeichnet</div>
</div>
<div class="swimlane-cell hr-cell">
<div class="task hr-task">
<div class="task-title hr">Personalakte anlegen</div>
<div class="task-body">Digitale Akte erstellen · Vertragskopie ablegen · Eintrittsdatum eintragen</div>
<span class="task-tag critical">🔴 Sofort</span>
</div>
<div class="task hr-task">
<div class="task-title hr">Abteilungen informieren</div>
<div class="task-body">IT, Vorgesetzten & Buchhaltung per E-Mail/Ticket anstoßen mit: Name, Position, Startdatum, Abteilung</div>
<span class="task-tag critical">🔴 Sofort</span>
</div>
<div class="task hr-task">
<div class="task-title hr">Berufsgenossenschaft anmelden</div>
<div class="task-body">BG-Anmeldung vorbereiten</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
<div class="swimlane-cell it-cell">
<div class="task it-task">
<div class="task-title it">IT-Onboarding-Ticket anlegen</div>
<div class="task-body">Ticket im Helpdesk erstellen mit allen Infos. Startet alle IT-Aufgaben.</div>
<span class="task-tag critical">🔴 Sofort</span>
</div>
<div class="task it-task">
<div class="task-title it">Hardware prüfen & bestellen</div>
<div class="task-body">Verfügbaren Laptop/PC prüfen · ggf. Neubestellung auslösen (Lieferzeit beachten!)</div>
<span class="task-tag critical">🔴 Sofort</span>
</div>
</div>
<div class="swimlane-cell vg-cell">
<div class="task vg-task">
<div class="task-title vg">Buddy / Paten benennen</div>
<div class="task-body">Einen erfahrenen Kollegen als Begleitung für die ersten Wochen auswählen & informieren</div>
<span class="task-tag critical">🔴 Sofort</span>
</div>
<div class="task vg-task">
<div class="task-title vg">Einarbeitungsplan erstellen</div>
<div class="task-body">30-Tage-Plan ausarbeiten: Aufgaben, Ziele, erste Projekte, Schulungsbedarf</div>
<span class="task-tag important">⚡ Diese Woche</span>
</div>
</div>
<div class="swimlane-cell bk-cell">
<div class="task bk-task">
<div class="task-title bk">Mitarbeiter in DATEV anlegen</div>
<div class="task-body">Stammdaten anlegen · Eintrittsdatum · Kostenstelle · Abteilung</div>
<span class="task-tag critical">🔴 Sofort</span>
</div>
<div class="task bk-task">
<div class="task-title bk">Bankdaten & Steuer anfordern</div>
<div class="task-body">IBAN-Formular an MA senden · Steuerklasse erfragen · SV-Nummer anfordern</div>
<span class="task-tag important">⚡ Diese Woche</span>
</div>
</div>
</div>
</div>
<!-- ========== PHASE 2: 2 WOCHEN VOR START ========== -->
<p class="section-title">Phase 2 — 2 Wochen vor Arbeitsbeginn</p>
<div class="phase-header">
<div class="col-label timeline">⏱ Zeitpunkt</div>
<div class="col-label hr">🧑‍💼 HR / Personal</div>
<div class="col-label it">💻 IT</div>
<div class="col-label vg">👔 Vorgesetzter</div>
<div class="col-label bk">💶 Buchhaltung / Lohn</div>
</div>
<div class="swimlane-wrapper">
<div class="swimlane-row">
<div class="swimlane-cell timeline-cell">
<div class="timeline-day">14</div>
<div class="timeline-label">2 Wochen<br>vor Start</div>
</div>
<div class="swimlane-cell hr-cell">
<div class="task hr-task">
<div class="task-title hr">Willkommens-E-Mail senden</div>
<div class="task-body">Startzeit, Ansprechpartner, Parkplatz, Dresscode, was mitbringen (Ausweis!)</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
<div class="task hr-task">
<div class="task-title hr">Formulare vorbereiten</div>
<div class="task-body">Datenschutzerklärung · IT-Nutzungsordnung · Betriebsordnung · Schweigepflicht</div>
<span class="task-tag normal">Standard</span>
</div>
<div class="task hr-task">
<div class="task-title hr">Mitarbeiterausweis beantragen</div>
<div class="task-body">Foto anfordern oder Fototermin Tag 1 planen</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
<div class="swimlane-cell it-cell">
<div class="task it-task">
<div class="task-title it">AD-Account erstellen</div>
<div class="task-body">Benutzername nach Konvention · Temporäres Passwort · Gruppe / Abteilung zuordnen</div>
<span class="task-tag critical">🔴 Kritisch</span>
</div>
<div class="task it-task">
<div class="task-title it">E-Mail-Adresse einrichten</div>
<div class="task-body">Postfach anlegen · Signatur vorkonfigurieren · In Verteiler aufnehmen</div>
<span class="task-tag critical">🔴 Kritisch</span>
</div>
<div class="task it-task">
<div class="task-title it">Hardware aufsetzen</div>
<div class="task-body">OS-Image · Windows Updates · Antivirus · Endpoint-Agent · Gerät im Asset-Management erfassen</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
</div>
<div class="swimlane-cell vg-cell">
<div class="task vg-task">
<div class="task-title vg">Team informieren</div>
<div class="task-body">Teammitglieder über neuen Kollegen informieren · Buddy briefen · Mittagessen Tag 1 planen</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
<div class="task vg-task">
<div class="task-title vg">Arbeitsplatz vorbereiten</div>
<div class="task-body">Schreibtisch · Stuhl · Namensschild · Willkommensmappe am Platz bereitstellen</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
<div class="swimlane-cell bk-cell">
<div class="task bk-task">
<div class="task-title bk">Lohnkonto einrichten</div>
<div class="task-body">Gehaltsgruppe · Steuerklasse · Krankenkasse · Urlaubsanspruch in System hinterlegen</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
<div class="task bk-task">
<div class="task-title bk">Sozialversicherung anmelden</div>
<div class="task-body">SV-Anmeldung bei Krankenkasse einreichen</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
</div>
</div>
<!-- ========== PHASE 3: 1 WOCHE VOR START ========== -->
<p class="section-title">Phase 3 — 1 Woche vor Arbeitsbeginn</p>
<div class="phase-header">
<div class="col-label timeline">⏱ Zeitpunkt</div>
<div class="col-label hr">🧑‍💼 HR / Personal</div>
<div class="col-label it">💻 IT</div>
<div class="col-label vg">👔 Vorgesetzter</div>
<div class="col-label bk">💶 Buchhaltung / Lohn</div>
</div>
<div class="swimlane-wrapper">
<div class="swimlane-row">
<div class="swimlane-cell timeline-cell">
<div class="timeline-day">7</div>
<div class="timeline-label">1 Woche<br>vor Start</div>
</div>
<div class="swimlane-cell hr-cell">
<div class="task hr-task">
<div class="task-title hr">Vollständigkeits-Check</div>
<div class="task-body">Alle Dokumente vollständig? IBAN erhalten? SV-Nummer? Pflichtunterweisungen geplant?</div>
<span class="task-tag critical">🔴 Pflicht</span>
</div>
<div class="task hr-task">
<div class="task-title hr">Pflichtunterweisungen planen</div>
<div class="task-body">Termine für Arbeitssicherheit · Brandschutz · Datenschutz in KW 1 einplanen</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
</div>
<div class="swimlane-cell it-cell">
<div class="task it-task">
<div class="task-title it">Kompletttest aller Systeme</div>
<div class="task-body">Login testen · E-Mail senden/empfangen · VPN verbinden · Netzlaufwerke prüfen · Teams testen</div>
<span class="task-tag critical">🔴 Pflicht</span>
</div>
<div class="task it-task">
<div class="task-title it">Software installieren</div>
<div class="task-body">Office 365 · VPN-Client · Passwortmanager · Teams · ERP/CRM · abteilungsspezifische Tools</div>
<span class="task-tag critical">🔴 Pflicht</span>
</div>
<div class="task it-task">
<div class="task-title it">Telefon / Durchwahl einrichten</div>
<div class="task-body">IP-Telefon oder Softphone · Durchwahl zuweisen · Mailbox-Ansage konfigurieren</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
<div class="task it-task">
<div class="task-title it">IT-Begrüßungsmappe drucken</div>
<div class="task-body">Quick-Start-Guide: Login, Helpdesk-Kontakt, WLAN, Drucker, wichtige URLs</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
<div class="swimlane-cell vg-cell">
<div class="task vg-task">
<div class="task-title vg">Erste Aufgaben vorbereiten</div>
<div class="task-body">23 konkrete, überschaubare Aufgaben für Tag 1 und Woche 1 definieren</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
<div class="task vg-task">
<div class="task-title vg">Check-in-Termine planen</div>
<div class="task-body">Tägliche 15-Min-Calls Woche 1 · Wöchentliche 30-Min-Gespräche Monat 1 im Kalender blocken</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
<div class="swimlane-cell bk-cell">
<div class="task bk-task">
<div class="task-title bk">Gehaltsabrechnung vorbereiten</div>
<div class="task-body">Erstes Gehalt anteilig berechnen (falls Monatsmitte) · Auszahlungstermin prüfen</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
<div class="task bk-task">
<div class="task-title bk">Reisekostenformulare bereitstellen</div>
<div class="task-body">Spesenabrechnungsformulare und -richtlinien übergeben</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
</div>
</div>
<!-- ========== PHASE 4: TAG 1 ========== -->
<p class="section-title">Phase 4 — Der erste Arbeitstag</p>
<div class="phase-header">
<div class="col-label timeline">⏱ Zeitpunkt</div>
<div class="col-label hr">🧑‍💼 HR / Personal</div>
<div class="col-label it">💻 IT</div>
<div class="col-label vg">👔 Vorgesetzter</div>
<div class="col-label bk">💶 Buchhaltung / Lohn</div>
</div>
<div class="swimlane-wrapper">
<div class="swimlane-row">
<div class="swimlane-cell timeline-cell">
<div class="timeline-day">Tag 1</div>
<div class="timeline-label">Erster<br>Arbeitstag</div>
</div>
<div class="swimlane-cell hr-cell">
<div class="task hr-task">
<div class="task-title hr">Empfang & Begrüßung</div>
<div class="task-body">MA am Empfang abholen · Rundgang · Schlüssel/Badge übergeben · Parkausweis</div>
<span class="task-tag critical">🔴 08:00 Uhr</span>
</div>
<div class="task hr-task">
<div class="task-title hr">Alle Formulare unterzeichnen</div>
<div class="task-body">Betriebsordnung · Datenschutz · IT-Nutzungsordnung · Schweigepflicht · Bestätigungen</div>
<span class="task-tag critical">🔴 Pflicht</span>
</div>
<div class="task hr-task">
<div class="task-title hr">Willkommensmappe übergeben</div>
<div class="task-body">Unternehmensinfos · Organigramm · Notfallnummern · Kantinen-/Pauseninfos</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
<div class="swimlane-cell it-cell">
<div class="task it-task">
<div class="task-title it">Hardware-Übergabe</div>
<div class="task-body">Laptop + Zubehör übergeben · Übergabeprotokoll (Seriennummern) unterzeichnen lassen</div>
<span class="task-tag critical">🔴 Pflicht</span>
</div>
<div class="task it-task">
<div class="task-title it">Erster Login begleiten</div>
<div class="task-body">Passwort ändern · MFA einrichten · E-Mail testen · VPN testen · Teams einloggen</div>
<span class="task-tag critical">🔴 Pflicht</span>
</div>
<div class="task it-task">
<div class="task-title it">IT-Begrüßungsmappe übergeben</div>
<div class="task-body">Helpdesk-Kontakt · Passwortregeln · wichtige URLs · Verhaltensregeln bei IT-Vorfällen</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
</div>
<div class="swimlane-cell vg-cell">
<div class="task vg-task">
<div class="task-title vg">Persönliche Begrüßung</div>
<div class="task-body">Nicht delegieren! Vorgesetzter begrüßt persönlich · Teamvorstellung · Buddy vorstellen</div>
<span class="task-tag critical">🔴 Pflicht</span>
</div>
<div class="task vg-task">
<div class="task-title vg">Einarbeitungsplan besprechen</div>
<div class="task-body">Ziele Probezeit · erste Aufgaben · Erwartungen klar kommunizieren · Fragen beantworten</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
<div class="task vg-task">
<div class="task-title vg">Gemeinsames Mittagessen</div>
<div class="task-body">Mit dem Team essen gehen sozialer Anschluss ist entscheidend!</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
<div class="swimlane-cell bk-cell">
<div class="task bk-task">
<div class="task-title bk">Bankverbindung bestätigen</div>
<div class="task-body">IBAN im System prüfen · Erste Gehaltsabrechnung korrekt hinterlegt?</div>
<span class="task-tag important">⚡ Wichtig</span>
</div>
<div class="task bk-task">
<div class="task-title bk">Zeiterfassung erklären</div>
<div class="task-body">System vorstellen · ersten Einstempeln begleiten · Urlaubsantragsprozess erklären</div>
<span class="task-tag normal">Standard</span>
</div>
</div>
</div>
</div>
<!-- ========== COMPLETION CHECKLIST ========== -->
<p class="section-title">Abnahme-Checkliste — Alles bereit vor Tag 1?</p>
<div class="checklist-section">
<div class="checklist-header">
<span>Alles auf Grün bevor der Mitarbeiter anfängt</span>
<span style="margin-left:auto;font-size:12px;font-weight:400;color:var(--text-muted)">Klicken zum Abhaken</span>
</div>
<div class="checklist-grid">
<div class="checklist-col">
<div class="checklist-col-header hr">HR / Personal</div>
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="prog-hr" style="width:0%"></div></div>
<div class="progress-label" id="label-hr">0 / 6 erledigt</div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Personalakte vollständig angelegt</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Alle Abteilungen informiert</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Willkommens-E-Mail gesendet</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Alle Formulare vorbereitet</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Badge / Ausweis bestellt</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Pflichtunterweisungen geplant</span></div>
</div>
<div class="checklist-col">
<div class="checklist-col-header it">IT</div>
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="prog-it" style="width:0%"></div></div>
<div class="progress-label" id="label-it">0 / 8 erledigt</div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">AD-Account angelegt & getestet</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">E-Mail-Adresse aktiv</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Hardware aufgesetzt & getestet</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Alle Software installiert</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">VPN & Netzwerkzugänge OK</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Telefon / Teams eingerichtet</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Berechtigungen vergeben</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">IT-Begrüßungsmappe bereit</span></div>
</div>
<div class="checklist-col">
<div class="checklist-col-header vg">Vorgesetzter</div>
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="prog-vg" style="width:0%"></div></div>
<div class="progress-label" id="label-vg">0 / 5 erledigt</div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Buddy / Pate benannt & gebrieft</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">30-Tage-Einarbeitungsplan fertig</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Erste Aufgaben für Woche 1 definiert</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Check-in-Termine im Kalender</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Team über neuen MA informiert</span></div>
</div>
<div class="checklist-col">
<div class="checklist-col-header bk">Buchhaltung / Lohn</div>
<div class="progress-bar-wrap"><div class="progress-bar-fill" id="prog-bk" style="width:0%"></div></div>
<div class="progress-label" id="label-bk">0 / 5 erledigt</div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Mitarbeiter in DATEV angelegt</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">IBAN & Steuerklasse hinterlegt</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">SV-Anmeldung eingereicht</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">Erstes Gehalt berechnet</span></div>
<div class="check-item" onclick="toggle(this)"><div class="check-box"></div><span class="check-label">BG-Anmeldung vollständig</span></div>
</div>
</div>
</div>
</div>
<div class="doc-footer">
Cereda Systems · Onboarding Prozess · Vertraulich nur für internen Gebrauch
</div>
<script>
const colMap = {
0: { id: 'hr', total: 6 },
1: { id: 'it', total: 8 },
2: { id: 'vg', total: 5 },
3: { id: 'bk', total: 5 },
};
function toggle(el) {
el.classList.toggle('checked');
updateProgress();
}
function updateProgress() {
const cols = document.querySelectorAll('.checklist-col');
cols.forEach((col, i) => {
const map = colMap[i];
if (!map) return;
const items = col.querySelectorAll('.check-item');
const checked = col.querySelectorAll('.check-item.checked').length;
const pct = Math.round((checked / map.total) * 100);
const bar = document.getElementById('prog-' + map.id);
const label = document.getElementById('label-' + map.id);
if (bar) bar.style.width = pct + '%';
if (label) label.textContent = `${checked} / ${map.total} erledigt`;
});
}
</script>
</body>
</html>

View File

@@ -1,273 +0,0 @@
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const BASE = 'https://it-nexus.cereda-systems.de';
const USER = 'superadmin';
const PASS = 'Admin123!';
const OUT = path.join(__dirname, 'screenshots');
if (!fs.existsSync(OUT)) fs.mkdirSync(OUT, { recursive: true });
const issues = [];
const checks = [];
function note(section, msg) { issues.push(`[${section}] ${msg}`); console.log(` ⚠️ ${msg}`); }
function ok(section, msg) { checks.push(`[${section}] ✓ ${msg}`); console.log(`${msg}`); }
function info(msg) { console.log(` ${msg}`); }
async function shot(page, name) {
const file = path.join(OUT, `${name}.png`);
await page.screenshot({ path: file, fullPage: true });
console.log(` 📷 ${name}.png gespeichert`);
}
async function waitNet(page) {
await page.waitForLoadState('networkidle').catch(() => {});
await page.waitForTimeout(1500);
}
(async () => {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
// 404-Fehler mit URL aufzeichnen
const notFound404 = [];
const consoleErrors = [];
page.on('response', r => { if (r.status() === 404) notFound404.push(r.url()); });
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
// ── 1. LOGIN ───────────────────────────────────────────────────────────────
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('🔐 LOGIN');
await page.goto(`${BASE}/login`);
await waitNet(page);
await shot(page, '01-login');
const userInput = page.locator('input[type="text"], input[name="username"], input[placeholder*="user" i], input[placeholder*="name" i]').first();
const passInput = page.locator('input[type="password"]').first();
await userInput.fill(USER);
await passInput.fill(PASS);
await shot(page, '02-login-filled');
await passInput.press('Enter');
await waitNet(page);
await shot(page, '03-after-login');
if (page.url().includes('login')) {
note('Login', `Login fehlgeschlagen — URL: ${page.url()}`);
const errText = await page.locator('.error, [class*="error"], [class*="alert"]').first().innerText().catch(() => '');
if (errText) note('Login', `Fehlermeldung: ${errText}`);
await browser.close(); printSummary(); return;
}
ok('Login', `Eingeloggt als ${USER}${page.url()}`);
// ── 2. DASHBOARD ──────────────────────────────────────────────────────────
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📊 DASHBOARD');
await page.goto(`${BASE}/dashboard`);
await waitNet(page);
await shot(page, '04-dashboard');
const bodyText = await page.locator('body').innerText();
if (bodyText.includes('Dashboard') || bodyText.includes('Willkommen')) ok('Dashboard', 'Seite geladen');
else note('Dashboard', 'Dashboard-Inhalt nicht erkennbar');
// ── 3. MONITORING ─────────────────────────────────────────────────────────
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('🖥️ MONITORING');
await page.goto(`${BASE}/monitoring`);
await waitNet(page);
await shot(page, '05-monitoring');
// Geräte
const agentRows = await page.locator('tr[data-id], tbody tr, [class*="agent-row"]').count();
info(`${agentRows} Tabellenzeilen sichtbar`);
// Warte bis Monitoring-Tabelle geladen ist
await page.waitForTimeout(4000);
await shot(page, '05b-monitoring-loaded');
// IT-NB-02 per API prüfen — mit Auth-Token aus localStorage
const apiData = await page.evaluate(async () => {
try {
const token = localStorage.getItem('token') || localStorage.getItem('authToken') ||
Object.keys(localStorage).map(k=>localStorage.getItem(k)).find(v=>v?.startsWith?.('eyJ'));
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const r = await fetch('/api/monitoring', { headers });
if (!r.ok) return { error: r.status };
const j = await r.json();
return j.data?.find(a => a.hostname === 'IT-NB-02') || null;
} catch(e) { return { error: e.message }; }
});
if (apiData && !apiData.error) {
ok('Monitoring', `IT-NB-02 via API gefunden (v${apiData.agent_version})`);
info(`OS: ${apiData.os_name || '?'}`);
info(`BitLocker: ${apiData.bitlocker_status || '?'}`);
info(`Defender: enabled=${apiData.defender_enabled}, age=${apiData.defender_signatures_age}`);
info(`Serial: ${apiData.hardware_serial || '?'}`);
if ((apiData.os_name || '').includes('11')) ok('Monitoring-API', `OS = ${apiData.os_name}`);
else note('Monitoring-API', `OS = ${apiData.os_name || 'null'} — erwartet Windows 11`);
if (apiData.bitlocker_status === 'encrypted') ok('Monitoring-API', 'BitLocker = encrypted ✓');
else note('Monitoring-API', `BitLocker = ${apiData.bitlocker_status || 'null'}`);
if (apiData.defender_enabled) ok('Monitoring-API', `Defender aktiv ✓ (Signaturen ${apiData.defender_signatures_age}d alt)`);
else note('Monitoring-API', 'Defender nicht aktiv oder Daten fehlen');
if (apiData.hardware_serial) ok('Monitoring-API', `Seriennummer = ${apiData.hardware_serial}`);
else note('Monitoring-API', 'Seriennummer fehlt');
} else {
note('Monitoring-API', `Fehler: ${apiData?.error || 'IT-NB-02 nicht gefunden'}`);
}
// Detail-Modal über Lupen-Button öffnen
const itnb02Text = page.locator('text=IT-NB-02').first();
if (await itnb02Text.isVisible().catch(() => false)) {
ok('Monitoring', 'IT-NB-02 in Tabelle sichtbar');
await itnb02Text.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
// Selektoren in Reihenfolge versuchen bis einer funktioniert
let clicked = false;
const selectors = [
'tr:has-text("IT-NB-02") button:first-child',
'tr:has-text("IT-NB-02") button',
'[class*="row"]:has-text("IT-NB-02") button',
'button:near(:text("IT-NB-02"))',
];
for (const sel of selectors) {
try {
const btn = page.locator(sel).first();
if (await btn.isVisible({ timeout: 1000 }).catch(() => false)) {
await btn.click();
clicked = true;
info(`Modal via Selektor geöffnet: ${sel}`);
break;
}
} catch { }
}
if (!clicked) {
// Letzter Fallback: direkt auf IT-NB-02 Text klicken
await itnb02Text.click();
info('Modal via Text-Klick versucht');
}
await page.waitForTimeout(2500);
await shot(page, '06-monitoring-detail-IT-NB-02');
const detail = await page.locator('body').innerText();
// Prüfe ob Modal offen (suche nach Sicherheits-Sektion-Keywords)
const hasModal = detail.includes('Sicherheit') || detail.includes('BitLocker') || detail.includes('Seriennummer');
info(`Modal offen: ${hasModal} | Body-Länge: ${detail.length} Zeichen`);
if (hasModal) {
if (detail.includes('Verschlüsselt')) ok('Monitoring-Modal', 'BitLocker = Verschlüsselt ✓');
else note('Monitoring-Modal', 'BitLocker kein "Verschlüsselt" text');
if (detail.includes('Defender') && detail.includes('Aktiv')) ok('Monitoring-Modal', 'Defender = Aktiv ✓');
else note('Monitoring-Modal', 'Defender Status unklar');
if (detail.includes('5CD44318HR')) ok('Monitoring-Modal', 'Seriennummer 5CD44318HR ✓');
else note('Monitoring-Modal', 'Seriennummer fehlt');
if (detail.includes('Arctic Wolf') || detail.includes('Docker') || detail.includes('IT Nexus Agent'))
ok('Monitoring-Modal', 'Software-Liste befüllt ✓');
else note('Monitoring-Modal', 'Software fehlt');
} else {
note('Monitoring-Modal', 'Detail-Modal hat nicht geöffnet — Sicherheits-Sektion nicht sichtbar');
}
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
} else {
note('Monitoring', 'IT-NB-02 nicht gefunden');
}
// Warnungen prüfen (BitLocker off, Defender inaktiv)
const monBody = await page.locator('body').innerText();
if (monBody.includes('BitLocker aus')) note('Monitoring', 'Andere Geräte: BitLocker nicht aktiv — Warnung sichtbar');
if (monBody.includes('Defender inaktiv')) note('Monitoring', 'Andere Geräte: Defender inaktiv — Warnung sichtbar');
// ── 4. PATCH MANAGEMENT ───────────────────────────────────────────────────
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('🔄 PATCH MANAGEMENT');
await page.goto(`${BASE}/patch-management`);
await waitNet(page);
await shot(page, '07-patch-management');
const patchBody = await page.locator('body').innerText();
// Version
if (patchBody.includes('v2.0.0')) ok('Patch', 'v2.0.0 als aktuelle Version angezeigt ✓');
else note('Patch', 'v2.0.0 nicht sichtbar');
if (patchBody.includes('1/1 aktualisiert')) ok('Patch', 'Test-Gruppe: 1/1 aktualisiert ✓');
else note('Patch', 'Test-Gruppe nicht korrekt aktualisiert');
// IT-NB-02 OS im Patch-Management
const itnb02PatchRow = page.locator('tr').filter({ hasText: 'IT-NB-02' }).first();
if (await itnb02PatchRow.isVisible().catch(() => false)) {
const rowText = await itnb02PatchRow.innerText();
info(`IT-NB-02 Patch-Row: ${rowText.replace(/\s+/g, ' ').substring(0, 120)}`);
if (rowText.includes('Win 11') || rowText.includes('11 Pro')) ok('Patch', 'IT-NB-02 OS = Win 11 ✓');
else if (rowText.includes('Win 10')) note('Patch', 'IT-NB-02 noch Win 10 — Check-in ausstehend');
if (rowText.includes('🪟')) ok('Patch', 'Win11-Badge = 🪟 Win 11 ✓');
else if (rowText.includes('Fähig')) note('Patch', 'Win11-Badge zeigt noch "Fähig" statt "🪟 Win 11"');
}
// Gruppen-Tab
const gruppenTab = page.locator('text=Gruppen').first();
if (await gruppenTab.isVisible()) {
await gruppenTab.click();
await page.waitForTimeout(1000);
await shot(page, '08-patch-gruppen');
}
// ── 5. HELPDESK / TICKETS ─────────────────────────────────────────────────
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('🎫 HELPDESK');
await page.goto(`${BASE}/helpdesk`).catch(() => page.goto(`${BASE}/tickets`));
await waitNet(page);
await shot(page, '09-helpdesk');
const hdBody = await page.locator('body').innerText();
if (hdBody.includes('Ticket') || hdBody.includes('Helpdesk')) ok('Helpdesk', 'Seite geladen ✓');
else note('Helpdesk', 'Inhalt nicht erkennbar');
// ── 6. KI-ASSISTENT ───────────────────────────────────────────────────────
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('🤖 KI-ASSISTENT');
await page.goto(`${BASE}/ai`).catch(() => page.goto(`${BASE}/ai-assistant`)).catch(() => {});
await waitNet(page);
await shot(page, '10-ki');
const kiBody = await page.locator('body').innerText();
if (kiBody.includes('KI') || kiBody.includes('Assistent') || kiBody.includes('Claude')) ok('KI', 'KI-Assistent Seite geladen ✓');
else note('KI', 'KI-Seite nicht gefunden oder leer');
// ── 7. 404 / CONSOLE ERRORS ───────────────────────────────────────────────
if (notFound404.length > 0) {
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('🔴 404-FEHLER:');
[...new Set(notFound404)].forEach(u => note('404', u.replace(BASE, '')));
}
if (consoleErrors.length > 0) {
console.log('\n🔴 KONSOLEN-FEHLER:');
[...new Set(consoleErrors)].slice(0, 5).forEach(e => info(`Console: ${e.substring(0, 100)}`));
}
await browser.close();
printSummary();
})();
function printSummary() {
console.log('\n' + '═'.repeat(60));
console.log(`📊 ZUSAMMENFASSUNG: ${checks.length} OK, ${issues.length} Probleme`);
if (issues.length > 0) {
console.log('\n⚠ ZU FIXEN:');
issues.forEach((i, n) => console.log(` ${n+1}. ${i}`));
} else {
console.log('✅ Alles in Ordnung!');
}
console.log('\n📁 Screenshots: playwright-tests/screenshots/');
console.log('═'.repeat(60) + '\n');
}

View File

@@ -1,60 +0,0 @@
{
"name": "playwright-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "playwright-tests",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"playwright": "^1.59.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

View File

@@ -1,16 +0,0 @@
{
"name": "playwright-tests",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"playwright": "^1.59.1"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 719 KiB

Some files were not shown because too many files have changed in this diff Show More