From 1fca135a834202ede5285a4f4a0f2467300be7e0 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 7 Mar 2026 09:47:25 +0100 Subject: [PATCH] chore: commit uncommitted production changes for v16 migration --- backupapi.md | 564 +++++ docs/tactical-rmm-api.md | 175 ++ msp/doctype/__init__.py | 0 msp/fixtures/print_format.json | 27 + msp/hooks.py | 12 +- msp/label_printing.py | 292 +++ msp/msp/custom/customer.json | 61 - .../doctype/backupreport_instance/__init__.py | 0 .../backupreport_instance.js | 96 + .../backupreport_instance.json | 113 + .../backupreport_instance.py | 306 +++ .../test_backupreport_instance.py | 9 + msp/msp/doctype/backupreport_log/__init__.py | 0 .../backupreport_log/backupreport_log.js | 429 ++++ .../backupreport_log/backupreport_log.json | 190 ++ .../backupreport_log/backupreport_log.py | 9 + .../backupreport_log/test_backupreport_log.py | 9 + .../doctype/backupreport_settings/__init__.py | 0 .../backupreport_settings.js | 8 + .../backupreport_settings.json | 54 + .../backupreport_settings.py | 9 + .../test_backupreport_settings.py | 9 + .../doctype/backupreport_token/__init__.py | 2 + .../backupreport_token/backupreport_token.js | 132 ++ .../backupreport_token.json | 152 ++ .../backupreport_token/backupreport_token.py | 224 ++ .../backupreport_token_host/__init__.py | 2 + .../backupreport_token_host.json | 39 + .../backupreport_token_host.py | 8 + msp/msp/doctype/it_landscape/it_landscape.js | 921 +++++++- .../doctype/it_landscape/it_landscape.json | 40 +- msp/msp/doctype/it_object/it_object.js | 230 +- msp/msp/doctype/it_object/it_object.json | 169 +- .../it_object_hardware_attribute/__init__.py | 2 + .../it_object_hardware_attribute.json | 52 + .../it_object_hardware_attribute.py | 8 + msp/msp/doctype/it_object_patch/__init__.py | 2 + .../it_object_patch/it_object_patch.json | 71 + .../it_object_patch/it_object_patch.py | 8 + .../doctype/it_object_software/__init__.py | 2 + .../it_object_software.json | 56 + .../it_object_software/it_object_software.py | 8 + .../msp_documentation/msp_documentation.js | 2019 +++++++++++++---- .../msp_documentation/msp_documentation.json | 70 +- .../msp_documentation/msp_documentation.py | 102 +- .../doctype/msp_settings/msp_settings.json | 10 +- msp/msp/doctype/rmm_import_agent/__init__.py | 2 + .../rmm_import_agent/rmm_import_agent.json | 162 ++ .../rmm_import_agent/rmm_import_agent.py | 8 + .../doctype/rmm_import_session/__init__.py | 2 + .../rmm_import_session/rmm_import_session.js | 226 ++ .../rmm_import_session.json | 270 +++ .../rmm_import_session/rmm_import_session.py | 54 + .../doctype/rmm_import_settings/__init__.py | 2 + .../rmm_import_settings.js | 104 + .../rmm_import_settings.json | 170 ++ .../rmm_import_settings.py | 91 + msp/msp/doctype/rmm_instance/rmm_instance.js | 86 +- .../doctype/rmm_instance/rmm_instance.json | 32 +- msp/msp/doctype/rmm_instance/rmm_instance.py | 186 +- .../doctype/rmm_status_mapping/__init__.py | 2 + .../rmm_status_mapping.json | 40 + .../rmm_status_mapping/rmm_status_mapping.py | 8 + msp/msp/doctype/rmm_type_mapping/__init__.py | 2 + .../rmm_type_mapping/rmm_type_mapping.json | 40 + .../rmm_type_mapping/rmm_type_mapping.py | 8 + msp/msp/doctype/rmm_update_fields/__init__.py | 2 + .../rmm_update_fields/rmm_update_fields.json | 48 + .../rmm_update_fields/rmm_update_fields.py | 8 + msp/msp/doctype/user_print_setting/README.md | 114 + .../doctype/user_print_setting/__init__.py | 0 .../test_user_print_setting.py | 9 + .../user_print_setting/user_print_setting.js | 8 + .../user_print_setting.json | 80 + .../user_print_setting/user_print_setting.py | 74 + msp/msp/workspace/technik/technik.json | 11 +- msp/patches.txt | 1 + msp/patches/__init__.py | 0 msp/patches/migrate_rmm_settings.py | 50 + msp/public/js/purchase_receipt.js | 296 +++ msp/public/js/quotation.js | 66 + msp/public/js/sales_invoice.js | 237 ++ msp/quotation_tools.py | 48 +- msp/rmm_import.py | 1898 ++++++++++++++++ msp/tactical-rmm.py | 1004 +++++++- scripts/Get-WindowsUpdateDiagnostics-JSON.ps1 | 319 +++ scripts/Get-WindowsUpdateDiagnostics.ps1 | 325 +++ scripts/wu_compare_AVNB01_CWWS03.json | 1150 ++++++++++ todo.md | 53 + 89 files changed, 13362 insertions(+), 635 deletions(-) create mode 100644 backupapi.md create mode 100644 docs/tactical-rmm-api.md create mode 100644 msp/doctype/__init__.py create mode 100644 msp/label_printing.py create mode 100644 msp/msp/doctype/backupreport_instance/__init__.py create mode 100644 msp/msp/doctype/backupreport_instance/backupreport_instance.js create mode 100644 msp/msp/doctype/backupreport_instance/backupreport_instance.json create mode 100644 msp/msp/doctype/backupreport_instance/backupreport_instance.py create mode 100644 msp/msp/doctype/backupreport_instance/test_backupreport_instance.py create mode 100644 msp/msp/doctype/backupreport_log/__init__.py create mode 100644 msp/msp/doctype/backupreport_log/backupreport_log.js create mode 100644 msp/msp/doctype/backupreport_log/backupreport_log.json create mode 100644 msp/msp/doctype/backupreport_log/backupreport_log.py create mode 100644 msp/msp/doctype/backupreport_log/test_backupreport_log.py create mode 100644 msp/msp/doctype/backupreport_settings/__init__.py create mode 100644 msp/msp/doctype/backupreport_settings/backupreport_settings.js create mode 100644 msp/msp/doctype/backupreport_settings/backupreport_settings.json create mode 100644 msp/msp/doctype/backupreport_settings/backupreport_settings.py create mode 100644 msp/msp/doctype/backupreport_settings/test_backupreport_settings.py create mode 100644 msp/msp/doctype/backupreport_token/__init__.py create mode 100644 msp/msp/doctype/backupreport_token/backupreport_token.js create mode 100644 msp/msp/doctype/backupreport_token/backupreport_token.json create mode 100644 msp/msp/doctype/backupreport_token/backupreport_token.py create mode 100644 msp/msp/doctype/backupreport_token_host/__init__.py create mode 100644 msp/msp/doctype/backupreport_token_host/backupreport_token_host.json create mode 100644 msp/msp/doctype/backupreport_token_host/backupreport_token_host.py create mode 100644 msp/msp/doctype/it_object_hardware_attribute/__init__.py create mode 100644 msp/msp/doctype/it_object_hardware_attribute/it_object_hardware_attribute.json create mode 100644 msp/msp/doctype/it_object_hardware_attribute/it_object_hardware_attribute.py create mode 100644 msp/msp/doctype/it_object_patch/__init__.py create mode 100644 msp/msp/doctype/it_object_patch/it_object_patch.json create mode 100644 msp/msp/doctype/it_object_patch/it_object_patch.py create mode 100644 msp/msp/doctype/it_object_software/__init__.py create mode 100644 msp/msp/doctype/it_object_software/it_object_software.json create mode 100644 msp/msp/doctype/it_object_software/it_object_software.py create mode 100644 msp/msp/doctype/rmm_import_agent/__init__.py create mode 100644 msp/msp/doctype/rmm_import_agent/rmm_import_agent.json create mode 100644 msp/msp/doctype/rmm_import_agent/rmm_import_agent.py create mode 100644 msp/msp/doctype/rmm_import_session/__init__.py create mode 100644 msp/msp/doctype/rmm_import_session/rmm_import_session.js create mode 100644 msp/msp/doctype/rmm_import_session/rmm_import_session.json create mode 100644 msp/msp/doctype/rmm_import_session/rmm_import_session.py create mode 100644 msp/msp/doctype/rmm_import_settings/__init__.py create mode 100644 msp/msp/doctype/rmm_import_settings/rmm_import_settings.js create mode 100644 msp/msp/doctype/rmm_import_settings/rmm_import_settings.json create mode 100644 msp/msp/doctype/rmm_import_settings/rmm_import_settings.py create mode 100644 msp/msp/doctype/rmm_status_mapping/__init__.py create mode 100644 msp/msp/doctype/rmm_status_mapping/rmm_status_mapping.json create mode 100644 msp/msp/doctype/rmm_status_mapping/rmm_status_mapping.py create mode 100644 msp/msp/doctype/rmm_type_mapping/__init__.py create mode 100644 msp/msp/doctype/rmm_type_mapping/rmm_type_mapping.json create mode 100644 msp/msp/doctype/rmm_type_mapping/rmm_type_mapping.py create mode 100644 msp/msp/doctype/rmm_update_fields/__init__.py create mode 100644 msp/msp/doctype/rmm_update_fields/rmm_update_fields.json create mode 100644 msp/msp/doctype/rmm_update_fields/rmm_update_fields.py create mode 100644 msp/msp/doctype/user_print_setting/README.md create mode 100644 msp/msp/doctype/user_print_setting/__init__.py create mode 100644 msp/msp/doctype/user_print_setting/test_user_print_setting.py create mode 100644 msp/msp/doctype/user_print_setting/user_print_setting.js create mode 100644 msp/msp/doctype/user_print_setting/user_print_setting.json create mode 100644 msp/msp/doctype/user_print_setting/user_print_setting.py create mode 100644 msp/patches/__init__.py create mode 100644 msp/patches/migrate_rmm_settings.py create mode 100644 msp/public/js/purchase_receipt.js create mode 100644 msp/public/js/quotation.js create mode 100644 msp/public/js/sales_invoice.js create mode 100644 msp/rmm_import.py create mode 100644 scripts/Get-WindowsUpdateDiagnostics-JSON.ps1 create mode 100644 scripts/Get-WindowsUpdateDiagnostics.ps1 create mode 100644 scripts/wu_compare_AVNB01_CWWS03.json create mode 100644 todo.md diff --git a/backupapi.md b/backupapi.md new file mode 100644 index 0000000..c2f8715 --- /dev/null +++ b/backupapi.md @@ -0,0 +1,564 @@ +# Backup API - Admin API Dokumentation + +Dokumentation der Admin-Endpoints für die Entwicklung eines Web-Interfaces. + +## Base URL + +``` +https://backupapi.itsdave.de/api/v1 +``` + +## Authentifizierung + +Alle Admin-Endpoints erfordern den `X-Admin-Key` Header: + +``` +X-Admin-Key: +``` + +**Fehler bei ungültigem Key:** +```json +HTTP 401 +{"detail": "Invalid admin API key"} +``` + +--- + +## Token-Verwaltung + +### Alle Tokens auflisten + +``` +GET /admin/tokens +``` + +**Response:** +```json +{ + "tokens": [ + { + "id": 1, + "name": "prod-servers", + "active": 1, + "created_at": "2026-01-01 10:00:00", + "token_hash_preview": "65248fa2...", + "allowed_hosts": ["web*", "db-primary"] + }, + { + "id": 2, + "name": "monitoring", + "active": 1, + "created_at": "2026-01-02 10:00:00", + "token_hash_preview": "a1b2c3d4...", + "allowed_hosts": [] + } + ] +} +``` + +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `id` | int | Eindeutige Token-ID | +| `name` | string | Token-Name | +| `active` | int | 1 = aktiv, 0 = deaktiviert | +| `created_at` | string | Erstellungszeitpunkt | +| `token_hash_preview` | string | Erste 8 Zeichen des Hashes | +| `allowed_hosts` | array | Host-Patterns (leer = alle Hosts erlaubt) | + +--- + +### Token erstellen + +``` +POST /admin/tokens +Content-Type: multipart/form-data +``` + +| Parameter | Typ | Pflicht | Beschreibung | +|-----------|-----|---------|--------------| +| `name` | string | Ja | Eindeutiger Name (max 100 Zeichen) | + +**Request:** +```bash +curl -X POST "$API/admin/tokens" \ + -H "X-Admin-Key: $ADMIN_KEY" \ + -F "name=webserver-prod" +``` + +**Response (201):** +```json +{ + "status": "success", + "message": "Token created successfully", + "token_id": 5, + "name": "webserver-prod", + "token": "V56pLTCkJTr4weA6Nkmwuw_h4gPO4l6b4hPLvQisxfM", + "warning": "Save this token now! It cannot be retrieved later." +} +``` + +**Wichtig:** Das `token` Feld wird nur einmal zurückgegeben! Es wird als SHA-256 Hash gespeichert und kann nicht wiederhergestellt werden. + +**Fehler (409):** +```json +{"detail": "Token name already exists"} +``` + +--- + +### Token-Details abrufen + +``` +GET /admin/tokens/{token_id} +``` + +**Response:** +```json +{ + "id": 1, + "name": "prod-servers", + "active": 1, + "created_at": "2026-01-01 10:00:00", + "token_hash_preview": "65248fa2...", + "allowed_hosts": ["web*"], + "backup_count": 156 +} +``` + +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `backup_count` | int | Anzahl der Backups mit diesem Token | + +**Fehler (404):** +```json +{"detail": "Token not found"} +``` + +--- + +### Token deaktivieren + +``` +POST /admin/tokens/{token_id}/revoke +``` + +Deaktiviert ein Token. Das Token kann danach nicht mehr zur Authentifizierung verwendet werden. + +**Response:** +```json +{"status": "success", "message": "Token 'prod-servers' revoked"} +``` + +**Fehler (400):** +```json +{"detail": "Token is already revoked"} +``` + +--- + +### Token aktivieren + +``` +POST /admin/tokens/{token_id}/activate +``` + +Reaktiviert ein zuvor deaktiviertes Token. + +**Response:** +```json +{"status": "success", "message": "Token 'prod-servers' activated"} +``` + +**Fehler (400):** +```json +{"detail": "Token is already active"} +``` + +--- + +### Token löschen + +``` +DELETE /admin/tokens/{token_id} +``` + +Löscht ein Token unwiderruflich. Host-Bindings werden automatisch mitgelöscht. + +**Response:** +```json +{"status": "success", "message": "Token 'prod-servers' deleted"} +``` + +--- + +## Host-Binding Verwaltung + +Tokens können auf bestimmte Hosts beschränkt werden. Ein Token ohne Bindings hat Zugriff auf **alle** Hosts. + +### Patterns + +| Pattern | Beschreibung | +|---------|--------------| +| `webserver01` | Exakter Match | +| `web*` | Alle Hosts die mit "web" beginnen | +| `*-prod` | Alle Hosts die mit "-prod" enden | +| `db-?` | Einzelnes Zeichen Wildcard | +| `*` | Alle Hosts (explizit) | + +--- + +### Host-Bindings anzeigen + +``` +GET /admin/tokens/{token_id}/hosts +``` + +**Response:** +```json +{ + "token_id": 1, + "token_name": "prod-servers", + "hosts": [ + {"id": 1, "hostname_pattern": "web*", "created_at": "2026-01-07 20:00:00"}, + {"id": 2, "hostname_pattern": "db-primary", "created_at": "2026-01-07 20:00:00"} + ] +} +``` + +--- + +### Host-Binding hinzufügen + +``` +POST /admin/tokens/{token_id}/hosts +Content-Type: multipart/form-data +``` + +| Parameter | Typ | Pflicht | Beschreibung | +|-----------|-----|---------|--------------| +| `hostname_pattern` | string | Ja | Hostname oder Pattern | + +**Request:** +```bash +curl -X POST "$API/admin/tokens/1/hosts" \ + -H "X-Admin-Key: $ADMIN_KEY" \ + -F "hostname_pattern=web*" +``` + +**Response:** +```json +{"status": "success", "message": "Host pattern 'web*' added to token 'prod-servers'"} +``` + +**Fehler (409):** +```json +{"detail": "Host pattern already exists for this token"} +``` + +--- + +### Host-Binding entfernen + +``` +DELETE /admin/tokens/{token_id}/hosts/{hostname_pattern} +``` + +**Request:** +```bash +curl -X DELETE "$API/admin/tokens/1/hosts/web*" \ + -H "X-Admin-Key: $ADMIN_KEY" +``` + +**Response:** +```json +{"status": "success", "message": "Host pattern 'web*' removed from token 'prod-servers'"} +``` + +--- + +## Backup-Verwaltung + +### Alle Backups auflisten + +``` +GET /admin/backups +``` + +| Parameter | Typ | Default | Beschreibung | +|-----------|-----|---------|--------------| +| `hostname` | string | - | Filter nach Hostname | +| `backup_type` | string | - | Filter nach Typ | +| `token_name` | string | - | Filter nach Token | +| `from_date` | string | - | Von Datum (YYYY-MM-DD) | +| `to_date` | string | - | Bis Datum (YYYY-MM-DD) | +| `limit` | int | 1000 | Max. Ergebnisse (max 10000) | +| `offset` | int | 0 | Offset für Pagination | + +**Response:** +```json +{ + "backups": [ + { + "id": 6, + "hostname": "webserver01", + "backup_type": "full", + "log_type": "json", + "token_name": "prod-servers", + "created_at": "2026-01-07 19:34:45", + "size": 1524 + } + ], + "count": 1, + "total": 156, + "limit": 1000, + "offset": 0 +} +``` + +--- + +### Einzelnes Backup abrufen + +``` +GET /admin/backups/{backup_id} +``` + +**Response:** +```json +{ + "id": 6, + "hostname": "webserver01", + "backup_type": "full", + "log_content": {"status": "success", "files": 1523, "size_mb": 2300}, + "log_type": "json", + "token_name": "prod-servers", + "created_at": "2026-01-07 19:34:45" +} +``` + +Bei `log_type: "json"` wird `log_content` als JSON-Objekt zurückgegeben, sonst als String. + +--- + +### Backup löschen + +``` +DELETE /admin/backups/{backup_id} +``` + +**Response:** +```json +{"status": "success", "message": "Backup 6 deleted"} +``` + +--- + +## Statistiken + +### Statistik-Übersicht + +``` +GET /admin/stats +``` + +| Parameter | Typ | Beschreibung | +|-----------|-----|--------------| +| `from_date` | string | Von Datum (YYYY-MM-DD) | +| `to_date` | string | Bis Datum (YYYY-MM-DD) | + +**Response:** +```json +{ + "total_backups": 156, + "unique_hosts": 12, + "by_type": [ + {"backup_type": "full", "count": 45}, + {"backup_type": "incremental", "count": 89}, + {"backup_type": null, "count": 22} + ], + "by_token": [ + {"token_name": "prod-servers", "count": 120}, + {"token_name": "dev-servers", "count": 36} + ], + "by_day": [ + {"date": "2026-01-07", "count": 24}, + {"date": "2026-01-06", "count": 22} + ] +} +``` + +--- + +## Audit-Logs + +### Audit-Logs abrufen + +``` +GET /admin/audit +``` + +| Parameter | Typ | Default | Beschreibung | +|-----------|-----|---------|--------------| +| `event_type` | string | - | Filter nach Event-Typ | +| `from_date` | string | - | Von Datum (YYYY-MM-DD) | +| `to_date` | string | - | Bis Datum (YYYY-MM-DD) | +| `limit` | int | 100 | Max. Ergebnisse (max 1000) | + +**Event-Typen:** +- `TOKEN_CREATED` - Token erstellt +- `TOKEN_DELETED` - Token gelöscht +- `TOKEN_REVOKED` - Token deaktiviert +- `TOKEN_ACTIVATED` - Token aktiviert +- `TOKEN_HOST_ADDED` - Host-Binding hinzugefügt +- `TOKEN_HOST_REMOVED` - Host-Binding entfernt +- `BACKUP_SUBMITTED` - Backup hochgeladen +- `BACKUP_DELETED` - Backup gelöscht +- `AUTH_FAILED` - Authentifizierung fehlgeschlagen +- `ADMIN_AUTH_SUCCESS` - Admin-Authentifizierung erfolgreich +- `ADMIN_AUTH_FAILED` - Admin-Authentifizierung fehlgeschlagen +- `ACCESS_DENIED` - Zugriff verweigert (Host-Binding) + +**Response:** +```json +{ + "audit_logs": [ + { + "id": 15, + "event_type": "TOKEN_CREATED", + "details": "name=webserver-prod", + "ip_address": "192.168.1.100", + "created_at": "2026-01-07 20:20:38" + } + ] +} +``` + +--- + +## Fehler-Codes + +| Code | Beschreibung | +|------|--------------| +| 400 | Ungültige Anfrage (z.B. Datumsformat, bereits aktiv/inaktiv) | +| 401 | Admin-Key ungültig | +| 404 | Ressource nicht gefunden | +| 409 | Konflikt (z.B. Name existiert bereits) | +| 413 | Datei zu groß | +| 503 | Admin API nicht konfiguriert | + +--- + +## Beispiel: Kompletter Token-Workflow + +```bash +API="https://backupapi.itsdave.de/api/v1" +ADMIN_KEY="your-admin-key" + +# 1. Token erstellen +TOKEN_RESPONSE=$(curl -s -X POST "$API/admin/tokens" \ + -H "X-Admin-Key: $ADMIN_KEY" \ + -F "name=webserver-prod") +echo "$TOKEN_RESPONSE" +TOKEN_ID=$(echo "$TOKEN_RESPONSE" | jq -r '.token_id') + +# 2. Host-Bindings hinzufügen +curl -s -X POST "$API/admin/tokens/$TOKEN_ID/hosts" \ + -H "X-Admin-Key: $ADMIN_KEY" \ + -F "hostname_pattern=web*" + +curl -s -X POST "$API/admin/tokens/$TOKEN_ID/hosts" \ + -H "X-Admin-Key: $ADMIN_KEY" \ + -F "hostname_pattern=nginx-*" + +# 3. Token-Details prüfen +curl -s "$API/admin/tokens/$TOKEN_ID" \ + -H "X-Admin-Key: $ADMIN_KEY" | jq + +# 4. Token deaktivieren (bei Bedarf) +curl -s -X POST "$API/admin/tokens/$TOKEN_ID/revoke" \ + -H "X-Admin-Key: $ADMIN_KEY" + +# 5. Token reaktivieren +curl -s -X POST "$API/admin/tokens/$TOKEN_ID/activate" \ + -H "X-Admin-Key: $ADMIN_KEY" + +# 6. Token löschen +curl -s -X DELETE "$API/admin/tokens/$TOKEN_ID" \ + -H "X-Admin-Key: $ADMIN_KEY" +``` + +--- + +## JavaScript/Fetch Beispiele + +```javascript +const API = 'https://backupapi.itsdave.de/api/v1'; +const ADMIN_KEY = 'your-admin-key'; + +const headers = { + 'X-Admin-Key': ADMIN_KEY +}; + +// Alle Tokens laden +async function getTokens() { + const response = await fetch(`${API}/admin/tokens`, { headers }); + return response.json(); +} + +// Token erstellen +async function createToken(name) { + const formData = new FormData(); + formData.append('name', name); + + const response = await fetch(`${API}/admin/tokens`, { + method: 'POST', + headers, + body: formData + }); + return response.json(); +} + +// Host-Binding hinzufügen +async function addHostBinding(tokenId, pattern) { + const formData = new FormData(); + formData.append('hostname_pattern', pattern); + + const response = await fetch(`${API}/admin/tokens/${tokenId}/hosts`, { + method: 'POST', + headers, + body: formData + }); + return response.json(); +} + +// Token deaktivieren +async function revokeToken(tokenId) { + const response = await fetch(`${API}/admin/tokens/${tokenId}/revoke`, { + method: 'POST', + headers + }); + return response.json(); +} + +// Statistiken laden +async function getStats(fromDate, toDate) { + const params = new URLSearchParams(); + if (fromDate) params.append('from_date', fromDate); + if (toDate) params.append('to_date', toDate); + + const response = await fetch(`${API}/admin/stats?${params}`, { headers }); + return response.json(); +} + +// Backups mit Pagination laden +async function getBackups(page = 0, limit = 50, filters = {}) { + const params = new URLSearchParams({ + limit: limit.toString(), + offset: (page * limit).toString(), + ...filters + }); + + const response = await fetch(`${API}/admin/backups?${params}`, { headers }); + return response.json(); +} +``` diff --git a/docs/tactical-rmm-api.md b/docs/tactical-rmm-api.md new file mode 100644 index 0000000..2f7b3ab --- /dev/null +++ b/docs/tactical-rmm-api.md @@ -0,0 +1,175 @@ +# TacticalRMM API Zugriff + +## RMM Instanz + +**Name:** `RMMINST-Default (Migrated from MSP Settings)` +**API URL:** `https://api.itsdave.de` +**API Key:** `VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5` + +## Credentials aus Frappe abrufen + +```python +import frappe + +rmm = frappe.get_doc("RMM Instance", "RMMINST-Default (Migrated from MSP Settings)") +api_url, headers, verify = rmm.get_api_credentials() +# api_url = "https://api.itsdave.de" +# headers = {"X-API-KEY": "..."} +``` + +## API Endpoints + +### Agents + +```bash +# Alle Agents abrufen +curl -s "https://api.itsdave.de/agents/" \ + -H "X-API-KEY: VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" + +# Agent Details +curl -s "https://api.itsdave.de/agents/{agent_id}/" \ + -H "X-API-KEY: VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" +``` + +### Scripts + +```bash +# Alle Scripts auflisten +curl -s "https://api.itsdave.de/scripts/" \ + -H "X-API-KEY: VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" + +# Script Details +curl -s "https://api.itsdave.de/scripts/{script_id}/" \ + -H "X-API-KEY: VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" +``` + +### Script erstellen + +```bash +# Script Content escapen +SCRIPT_CONTENT=$(cat mein_script.ps1 | python3 -c "import sys, json; print(json.dumps(sys.stdin.read()))") + +# Script hochladen +curl -s -X POST "https://api.itsdave.de/scripts/" \ + -H "X-API-KEY: VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"MSP - Mein Script\", + \"description\": \"Beschreibung des Scripts\", + \"shell\": \"powershell\", + \"category\": \"MSP\", + \"supported_platforms\": [\"windows\"], + \"script_body\": ${SCRIPT_CONTENT}, + \"default_timeout\": 120 + }" +``` + +### Script auf Agent ausführen + +**WICHTIG:** Das Feld `run_on_server` muss explizit auf `false` gesetzt werden! + +```bash +curl -s -X POST "https://api.itsdave.de/agents/{agent_id}/runscript/" \ + -H "X-API-KEY: VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" \ + -H "Content-Type: application/json" \ + -d '{ + "output": "wait", + "emails": [], + "emailMode": "default", + "custom_field": null, + "save_all_output": false, + "script": 154, + "args": [], + "env_vars": [], + "timeout": 120, + "run_as_user": false, + "run_on_server": false + }' +``` + +**Output-Modi:** +- `"wait"` - Wartet auf Ergebnis und gibt es zurück +- `"forget"` - Führt aus ohne auf Ergebnis zu warten +- `"email"` - Sendet Ergebnis per E-Mail + +### Windows Updates + +```bash +# Patches für Agent abrufen +curl -s "https://api.itsdave.de/winupdate/{agent_id}/" \ + -H "X-API-KEY: VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" + +# Update-Scan starten +curl -s -X POST "https://api.itsdave.de/winupdate/{agent_id}/scan/" \ + -H "X-API-KEY: VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" + +# Approved Updates installieren +curl -s -X POST "https://api.itsdave.de/winupdate/{agent_id}/install/" \ + -H "X-API-KEY: VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" +``` + +## Python Beispiel + +```python +import requests +import json + +API_URL = "https://api.itsdave.de" +API_KEY = "VXHWE1ZNNWVYRRU3SYCAJ80CZM9NRFP5" + +headers = {"X-API-KEY": API_KEY, "Content-Type": "application/json"} + +def run_script(agent_id, script_id, timeout=120): + """Führt ein Script auf einem Agent aus und gibt das Ergebnis zurück.""" + payload = { + "output": "wait", + "emails": [], + "emailMode": "default", + "custom_field": None, + "save_all_output": False, + "script": script_id, + "args": [], + "env_vars": [], + "timeout": timeout, + "run_as_user": False, + "run_on_server": False # WICHTIG! + } + + r = requests.post( + f"{API_URL}/agents/{agent_id}/runscript/", + headers=headers, + json=payload, + timeout=timeout + 30 + ) + + if r.status_code == 200: + return json.loads(r.json()) # Doppeltes JSON-Parsing nötig + else: + raise Exception(f"API Error {r.status_code}: {r.text}") + +# Beispiel: Windows Update Diagnostics auf CWWS12 +result = run_script( + agent_id="HTTlEmzqhqGyRNISuTeOMcKWcPEaATdZiSdWcwTR", + script_id=154 +) +print(f"Status: {result['status']}") +print(f"Issues: {result['issues']}") +``` + +## MSP Scripts in TacticalRMM + +| ID | Name | Beschreibung | +|-----|------|--------------| +| 153 | MSP - Windows Update Diagnostics | Text-Ausgabe für manuelle Analyse | +| 154 | MSP - Windows Update Diagnostics JSON | JSON-Ausgabe für programmatische Verarbeitung | + +## Bekannte Probleme + +1. **500 Fehler bei Script-Ausführung:** Prüfen ob `run_on_server: false` gesetzt ist +2. **Leere Antwort:** Agent ist möglicherweise offline +3. **Timeout:** Script-Timeout + 30 Sekunden für Request-Timeout verwenden + +## Swagger UI + +API-Dokumentation verfügbar unter: +`https://api.itsdave.de/api/schema/swagger-ui/` diff --git a/msp/doctype/__init__.py b/msp/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/fixtures/print_format.json b/msp/fixtures/print_format.json index f0ab8cc..50aecc7 100644 --- a/msp/fixtures/print_format.json +++ b/msp/fixtures/print_format.json @@ -25,5 +25,32 @@ "raw_printing": 0, "show_section_headings": 1, "standard": "No" + }, + { + "absolute_value": 0, + "align_labels_right": 0, + "css": ".print-format { page-width: 62mm; page-height: 80mm; margin-top: 3mm; margin-bottom: 3mm; margin-left: 3mm; margin-right: 3mm; } .print-format table, .print-format tr, .print-format td, .print-format div, .print-format p { font-family: Monospace; line-height: 100%; vertical-align: middle; }", + "custom_format": 1, + "default_print_language": null, + "disabled": 0, + "doc_type": "Item", + "docstatus": 0, + "doctype": "Print Format", + "font": "Default", + "format_data": null, + "html": "{% set count_data = frappe.flags.inventory_count_data %}\n

\n\n

\n

{{ doc.name }}

\n

{{ doc.item_name }}

\n{% if count_data %}\n

Gezählt: {{ count_data.counted_qty }}

\n

{{ count_data.counted_at }}

\n{% endif %}", + "line_breaks": 0, + "modified": "2025-01-01 00:00:00.000000", + "module": "MSP", + "name": "Inventory Count Label", + "parent": null, + "parentfield": null, + "parenttype": null, + "print_format_builder": 0, + "print_format_type": "Jinja", + "raw_commands": null, + "raw_printing": 0, + "show_section_headings": 0, + "standard": "No" } ] \ No newline at end of file diff --git a/msp/hooks.py b/msp/hooks.py index 8b9d190..6ffb9d3 100644 --- a/msp/hooks.py +++ b/msp/hooks.py @@ -39,7 +39,12 @@ jinja = { # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} -doctype_js = {"Location" : "public/js/location.js"} +doctype_js = { + "Location": "public/js/location.js", + "Quotation": "public/js/quotation.js", + "Purchase Receipt": "public/js/purchase_receipt.js", + "Sales Invoice": "public/js/sales_invoice.js" +} # Home Pages # ---------- @@ -113,7 +118,10 @@ scheduler_events = { "*/5 * * * *": [ "msp.tools.update_tickets_and_articles" ] - } + }, + "hourly": [ + "msp.msp.doctype.backupreport_instance.backupreport_instance.sync_all_instances" + ] } # scheduler_events = { diff --git a/msp/label_printing.py b/msp/label_printing.py new file mode 100644 index 0000000..991ccf8 --- /dev/null +++ b/msp/label_printing.py @@ -0,0 +1,292 @@ +# Copyright (c) 2024, itsdave GmbH and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ + + +@frappe.whitelist() +def get_available_printers(): + """Get list of all configured Network Printer Settings. + + Returns: + list of dicts with value and label + """ + printers = frappe.get_all( + "Network Printer Settings", + fields=["name", "printer_name"], + order_by="name" + ) + + return [ + {"value": p.name, "label": f"{p.name} ({p.printer_name})"} + for p in printers + ] + + +@frappe.whitelist() +def get_label_print_formats(): + """Get list of Print Formats suitable for labels (Item doctype). + + Returns: + list of dicts with value and label + """ + formats = frappe.get_all( + "Print Format", + filters={"doc_type": "Item", "disabled": 0}, + fields=["name"], + order_by="name" + ) + + return [{"value": f.name, "label": f.name} for f in formats] + + +@frappe.whitelist() +def print_item_labels(item_code, quantity, printer_setting, print_format="label"): + """Print labels for an item in the specified quantity. + + Args: + item_code: The Item code to print labels for + quantity: Number of labels to print + printer_setting: Network Printer Settings name + print_format: Print Format name (default: 'label') + + Returns: + dict with success status and count + """ + from frappe.utils.print_format import print_by_server + + quantity = int(quantity) + if quantity < 1: + frappe.throw(_("Quantity must be at least 1")) + + if quantity > 100: + frappe.throw(_("Maximum 100 labels per print job")) + + # Verify item exists + if not frappe.db.exists("Item", item_code): + frappe.throw(_("Item {0} not found").format(item_code)) + + # Verify printer exists + if not frappe.db.exists("Network Printer Settings", printer_setting): + frappe.throw(_("Printer {0} not found").format(printer_setting)) + + printed = 0 + errors = [] + + for i in range(quantity): + try: + print_by_server( + doctype="Item", + name=item_code, + printer_setting=printer_setting, + print_format=print_format, + no_letterhead=1 + ) + printed += 1 + except Exception as e: + errors.append(str(e)) + frappe.log_error( + title=f"Label Print Failed: {item_code}", + message=f"Copy {i+1}/{quantity}: {str(e)}" + ) + + if errors and printed == 0: + frappe.throw(_("Printing failed: {0}").format(errors[0])) + + return { + "success": True, + "printed": printed, + "total": quantity, + "errors": len(errors) + } + + +@frappe.whitelist() +def print_multiple_item_labels(items, printer_setting, print_format="label"): + """Print labels for multiple items. + + Args: + items: JSON string or list of dicts with item_code and quantity + printer_setting: Network Printer Settings name + print_format: Print Format name + + Returns: + dict with success status and details + """ + import json + + if isinstance(items, str): + items = json.loads(items) + + total_printed = 0 + total_requested = 0 + results = [] + + for item in items: + item_code = item.get("item_code") + quantity = int(item.get("quantity", 0)) + + if quantity < 1: + continue + + total_requested += quantity + + try: + result = print_item_labels( + item_code=item_code, + quantity=quantity, + printer_setting=printer_setting, + print_format=print_format + ) + total_printed += result.get("printed", 0) + results.append({ + "item_code": item_code, + "printed": result.get("printed", 0), + "success": True + }) + except Exception as e: + results.append({ + "item_code": item_code, + "printed": 0, + "success": False, + "error": str(e) + }) + + return { + "success": total_printed > 0, + "total_printed": total_printed, + "total_requested": total_requested, + "results": results + } + + +@frappe.whitelist() +def get_inventory_count_print_formats(): + """Get list of Print Formats for Inventory Count labels. + + Since Inventory Item is a child table, we look for Print Formats + that have 'Inventory Count' in their name or are explicitly marked + for inventory counting. + + Returns: + list of dicts with value and label + """ + formats = frappe.get_all( + "Print Format", + filters=[ + ["disabled", "=", 0], + ["name", "like", "%Inventory Count%"] + ], + fields=["name"], + order_by="name" + ) + + result = [{"value": f.name, "label": f.name} for f in formats] + + # Always include default option + if not any(f["value"] == "Default" for f in result): + result.insert(0, {"value": "Default", "label": "Default (Built-in)"}) + + return result + + +def get_inventory_count_data(): + """Get the current inventory count data stored in frappe.flags. + Called from Print Format Jinja template. + """ + return getattr(frappe.flags, "inventory_count_data", None) + + +@frappe.whitelist() +def print_inventory_count_label(inventory_item_name, quantity, printer_setting, print_format): + """Print count labels for an Inventory Item. + + Uses the standard print_by_server with count data stored in frappe.flags. + + Args: + inventory_item_name: The Inventory Item document name + quantity: Number of labels to print + printer_setting: Network Printer Settings name + print_format: Print Format name for Item doctype + + Returns: + dict with success status and count + """ + from frappe.utils.print_format import print_by_server + + quantity = int(quantity) + if quantity < 1: + frappe.throw(_("Quantity must be at least 1")) + + if quantity > 100: + frappe.throw(_("Maximum 100 labels per print job")) + + # Verify Inventory Item exists and get data + if not frappe.db.exists("Inventory Item", inventory_item_name): + frappe.throw(_("Inventory Item {0} not found").format(inventory_item_name)) + + inv_item = frappe.get_doc("Inventory Item", inventory_item_name) + + # Verify printer exists + if not frappe.db.exists("Network Printer Settings", printer_setting): + frappe.throw(_("Printer {0} not found").format(printer_setting)) + + # Verify item exists + if not frappe.db.exists("Item", inv_item.item_code): + frappe.throw(_("Item {0} not found").format(inv_item.item_code)) + + # Store count data in cache for the Print Format to access + count_data = { + "counted_qty": int(inv_item.counted_qty or 0), + "system_qty": int(inv_item.system_qty or 0), + "difference": int(inv_item.difference or 0), + "warehouse": inv_item.warehouse or "", + "counted_by": inv_item.counted_by or "", + "counted_at": frappe.utils.format_datetime(inv_item.counted_at, "dd.MM.yyyy HH:mm") if inv_item.counted_at else "", + } + # Store in cache with item_code as key (expires in 60 seconds) + cache_key = f"inventory_count_label:{inv_item.item_code}" + frappe.cache().set_value(cache_key, count_data, expires_in_sec=60) + + # Use "Inventory Count Label" print format, fallback to provided format + actual_print_format = print_format + if print_format == "Default" or not frappe.db.exists("Print Format", print_format): + # Check if our custom format exists + if frappe.db.exists("Print Format", "Inventory Count Label"): + actual_print_format = "Inventory Count Label" + else: + frappe.throw(_("Print Format 'Inventory Count Label' not found. Please create it first.")) + + printed = 0 + errors = [] + + for i in range(quantity): + try: + print_by_server( + doctype="Item", + name=inv_item.item_code, + printer_setting=printer_setting, + print_format=actual_print_format, + no_letterhead=1 + ) + printed += 1 + except Exception as e: + errors.append(str(e)) + frappe.log_error( + title=f"Inventory Count Label Print Failed: {inventory_item_name}", + message=f"Copy {i+1}/{quantity}: {str(e)}" + ) + + # Clear cache + frappe.cache().delete_value(cache_key) + + if errors and printed == 0: + frappe.throw(_("Printing failed: {0}").format(errors[0])) + + return { + "success": True, + "printed": printed, + "total": quantity, + "errors": len(errors) + } diff --git a/msp/msp/custom/customer.json b/msp/msp/custom/customer.json index 762afa5..78a804e 100644 --- a/msp/msp/custom/customer.json +++ b/msp/msp/custom/customer.json @@ -1,66 +1,5 @@ { "custom_fields": [ - { - "_assign": null, - "_comments": null, - "_liked_by": null, - "_user_tags": null, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "collapsible_depends_on": null, - "columns": 0, - "creation": "2019-03-12 14:19:15.006481", - "default": null, - "depends_on": null, - "description": null, - "docstatus": 0, - "dt": "Customer", - "fetch_from": null, - "fetch_if_empty": 0, - "fieldname": "send_invoice_by_email", - "fieldtype": "Check", - "hidden": 0, - "hide_border": 0, - "hide_days": 0, - "hide_seconds": 0, - "idx": 47, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_preview": 0, - "in_standard_filter": 0, - "insert_after": "primary_address_and_contact_detail", - "label": "send_invoice_by_email", - "length": 0, - "mandatory_depends_on": null, - "modified": "2019-03-12 14:19:15.006481", - "modified_by": "D.Malinowski@itsdave.de", - "name": "Customer-send_invoice_by_email", - "no_copy": 0, - "non_negative": 0, - "options": null, - "owner": "D.Malinowski@itsdave.de", - "parent": null, - "parentfield": null, - "parenttype": null, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "print_width": null, - "read_only": 0, - "read_only_depends_on": null, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "translatable": 0, - "unique": 0, - "width": null - }, { "_assign": null, "_comments": null, diff --git a/msp/msp/doctype/backupreport_instance/__init__.py b/msp/msp/doctype/backupreport_instance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/msp/doctype/backupreport_instance/backupreport_instance.js b/msp/msp/doctype/backupreport_instance/backupreport_instance.js new file mode 100644 index 0000000..8198dcd --- /dev/null +++ b/msp/msp/doctype/backupreport_instance/backupreport_instance.js @@ -0,0 +1,96 @@ +// Copyright (c) 2026, itsdave GmbH and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Backupreport Instance", { + refresh(frm) { + if (!frm.is_new()) { + // View Logs Button + frm.add_custom_button(__("View Logs"), function() { + frappe.set_route("List", "Backupreport Log", { + backupreport_instance: frm.doc.name + }); + }); + + // View Tokens Button + frm.add_custom_button(__("View Tokens"), function() { + frappe.set_route("List", "Backupreport Token", { + backupreport_instance: frm.doc.name + }); + }); + + // Sync Tokens Button + frm.add_custom_button(__("Sync Tokens"), function() { + frappe.call({ + method: "sync_tokens", + doc: frm.doc, + freeze: true, + freeze_message: __("Synchronisiere Tokens..."), + callback: function(r) { + frm.reload_doc(); + } + }); + }, __("Tokens")); + + // Create Token Button + frm.add_custom_button(__("Create Token"), function() { + frappe.prompt([ + { + label: __("Token Name"), + fieldname: "token_name", + fieldtype: "Data", + reqd: 1 + } + ], function(values) { + frappe.call({ + method: "create_token", + doc: frm.doc, + args: { token_name: values.token_name }, + freeze: true, + freeze_message: __("Erstelle Token..."), + callback: function(r) { + if (r.message && r.message.token_value) { + // Token-Wert in Dialog anzeigen + let d = new frappe.ui.Dialog({ + title: __("Token erstellt"), + fields: [ + { + fieldtype: "HTML", + options: ` +
+ ${r.message.warning} +
+

Token Name: ${r.message.token_name}

+

Token:

+
${r.message.token_value}
+ ` + } + ], + primary_action_label: __("Kopiert & Schließen"), + primary_action: function() { + navigator.clipboard.writeText(r.message.token_value); + frappe.show_alert(__("Token in Zwischenablage kopiert")); + d.hide(); + } + }); + d.show(); + } + frm.reload_doc(); + } + }); + }, __("Neuen Token erstellen"), __("Erstellen")); + }, __("Tokens")); + } + }, + + sync_now(frm) { + frappe.call({ + method: "sync_now", + doc: frm.doc, + freeze: true, + freeze_message: __("Synchronisiere Backups..."), + callback: function(r) { + frm.reload_doc(); + } + }); + } +}); diff --git a/msp/msp/doctype/backupreport_instance/backupreport_instance.json b/msp/msp/doctype/backupreport_instance/backupreport_instance.json new file mode 100644 index 0000000..b30c0df --- /dev/null +++ b/msp/msp/doctype/backupreport_instance/backupreport_instance.json @@ -0,0 +1,113 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:instance_name", + "creation": "2026-01-07 21:43:18.707431", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "instance_name", + "api_url", + "admin_key", + "enabled", + "column_break_sync", + "last_sync", + "last_backup_id", + "section_break_actions", + "sync_now" + ], + "fields": [ + { + "fieldname": "instance_name", + "fieldtype": "Data", + "label": "Instance Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "api_url", + "fieldtype": "Data", + "label": "API URL", + "reqd": 1, + "description": "Base-URL der API (z.B. https://backupapi.itsdave.de/api/v1)" + }, + { + "fieldname": "admin_key", + "fieldtype": "Password", + "label": "Admin Key", + "reqd": 1 + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "column_break_sync", + "fieldtype": "Column Break" + }, + { + "fieldname": "last_sync", + "fieldtype": "Datetime", + "label": "Last Sync", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "last_backup_id", + "fieldtype": "Int", + "label": "Last Backup ID", + "read_only": 1 + }, + { + "fieldname": "section_break_actions", + "fieldtype": "Section Break", + "label": "Actions" + }, + { + "fieldname": "sync_now", + "fieldtype": "Button", + "label": "Sync Now" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-01-07 21:43:27.700833", + "modified_by": "Administrator", + "module": "MSP", + "name": "Backupreport Instance", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "role": "Backup-Log-User" + }, + { + "create": 1, + "delete": 1, + "read": 1, + "role": "Backup-Log-Admin", + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/msp/msp/doctype/backupreport_instance/backupreport_instance.py b/msp/msp/doctype/backupreport_instance/backupreport_instance.py new file mode 100644 index 0000000..5e8bb64 --- /dev/null +++ b/msp/msp/doctype/backupreport_instance/backupreport_instance.py @@ -0,0 +1,306 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +import frappe +import requests +import json +from frappe.model.document import Document +from frappe.utils import now_datetime + + +class BackupreportInstance(Document): + @frappe.whitelist() + def sync_now(self): + """Button-Handler: Synchronisiert Backups von dieser Instanz""" + if not self.enabled: + frappe.throw("Diese Instanz ist deaktiviert") + + new_backups = self.fetch_backups() + self.last_sync = now_datetime() + self.save() + + frappe.msgprint(f"{new_backups} neue Backups synchronisiert") + return new_backups + + def fetch_backups(self): + """Holt neue Backups von der API""" + # Alle Backups abrufen + response = self._api_request("/admin/backups?limit=10000") + if not response: + return 0 + + backups = response.get("backups", []) + new_count = 0 + max_id = self.last_backup_id or 0 + + for backup in backups: + backup_id = backup.get("id") + + # Nur neue Backups verarbeiten + if backup_id <= (self.last_backup_id or 0): + continue + + # Prüfen ob bereits existiert + if frappe.db.exists("Backupreport Log", { + "backupreport_instance": self.name, + "backup_id": backup_id + }): + continue + + # Details abrufen + details = self._api_request(f"/admin/backups/{backup_id}") + if not details: + continue + + # Log erstellen + self._create_log(details) + new_count += 1 + + if backup_id > max_id: + max_id = backup_id + + self.last_backup_id = max_id + return new_count + + def _api_request(self, endpoint, method="GET", data=None): + """HTTP-Request mit Auth-Header""" + url = self.api_url.rstrip("/") + endpoint + headers = {"X-Admin-Key": self.get_password("admin_key")} + + try: + if method == "GET": + response = requests.get(url, headers=headers, timeout=30) + elif method == "POST": + response = requests.post(url, headers=headers, data=data, timeout=30) + else: + raise ValueError(f"Unsupported method: {method}") + + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + frappe.log_error(f"Backup API Error: {str(e)}", "Backupreport Sync") + return None + + @frappe.whitelist() + def sync_tokens(self): + """Synchronisiert alle Tokens von der API""" + if not self.enabled: + frappe.throw("Diese Instanz ist deaktiviert") + + response = self._api_request("/admin/tokens") + if not response: + frappe.throw("Konnte Tokens nicht abrufen") + + tokens = response.get("tokens", []) + synced = 0 + created = 0 + + for token_data in tokens: + token_id = token_data.get("id") + token_name = token_data.get("name") + + # Existiert bereits? + existing = frappe.db.exists("Backupreport Token", { + "backupreport_instance": self.name, + "token_id": token_id + }) + + if existing: + # Update + doc = frappe.get_doc("Backupreport Token", existing) + doc.active = 1 if token_data.get("active") else 0 + doc.token_hash_preview = token_data.get("token_hash_preview", "") + doc.backup_count = token_data.get("backup_count", 0) + + # Host-Bindings aktualisieren + doc.allowed_hosts = [] + for host in token_data.get("allowed_hosts", []): + pattern = host if isinstance(host, str) else host.get("hostname_pattern", str(host)) + doc.append("allowed_hosts", {"hostname_pattern": pattern}) + + doc.save() + synced += 1 + else: + # Neu erstellen + doc = frappe.get_doc({ + "doctype": "Backupreport Token", + "backupreport_instance": self.name, + "token_id": token_id, + "token_name": token_name, + "active": 1 if token_data.get("active") else 0, + "created_at": token_data.get("created_at"), + "token_hash_preview": token_data.get("token_hash_preview", ""), + "backup_count": token_data.get("backup_count", 0) + }) + + # Host-Bindings hinzufügen + for host in token_data.get("allowed_hosts", []): + pattern = host if isinstance(host, str) else host.get("hostname_pattern", str(host)) + doc.append("allowed_hosts", {"hostname_pattern": pattern}) + + doc.insert(ignore_permissions=True) + created += 1 + + frappe.db.commit() + frappe.msgprint(f"Tokens synchronisiert: {created} neu, {synced} aktualisiert") + return {"created": created, "synced": synced} + + @frappe.whitelist() + def create_token(self, token_name): + """Erstellt einen neuen Token über die API""" + if not self.enabled: + frappe.throw("Diese Instanz ist deaktiviert") + + if not token_name: + frappe.throw("Bitte einen Token-Namen eingeben") + + response = self._api_request("/admin/tokens", method="POST", data={"name": token_name}) + if not response: + frappe.throw("Konnte Token nicht erstellen") + + # Token-Wert ist nur jetzt verfügbar! + token_value = response.get("token", "") + token_id = response.get("token_id") + + # Lokalen Eintrag erstellen mit Token-Wert + doc = frappe.get_doc({ + "doctype": "Backupreport Token", + "backupreport_instance": self.name, + "token_id": token_id, + "token_name": token_name, + "active": 1, + "token_value": token_value + }) + doc.insert(ignore_permissions=True) + frappe.db.commit() + + # Token-Wert zurückgeben (wird im Dialog angezeigt) + return { + "token_name": token_name, + "token_value": token_value, + "warning": "Der Token wurde gespeichert. Du kannst ihn jederzeit im Token-Dokument abrufen." + } + + def _create_log(self, backup_data): + """Erstellt einen Backupreport Log Eintrag""" + log_content = backup_data.get("log_content", "") + log_type = backup_data.get("log_type", "") + + # JSON-Felder extrahieren + json_fields = self._parse_json_log(log_content) if log_type == "json" else {} + + # Log-Content als String speichern + if isinstance(log_content, dict): + log_content_str = json.dumps(log_content, indent=2, ensure_ascii=False) + else: + log_content_str = str(log_content) if log_content else "" + + # Backup-Type: Priorität API-Daten, dann aus JSON-Content extrahieren + backup_type = backup_data.get("backup_type") + if not backup_type and isinstance(log_content, dict): + backup_type = log_content.get("backup_type") + + doc = frappe.get_doc({ + "doctype": "Backupreport Log", + "backupreport_instance": self.name, + "backup_id": backup_data.get("id"), + "hostname": backup_data.get("hostname"), + "backup_type": backup_type, + "log_type": log_type, + "token_name": backup_data.get("token_name"), + "backup_date": backup_data.get("created_at"), + "size": backup_data.get("size", 0), + "log_content": log_content_str, + **json_fields + }) + doc.insert(ignore_permissions=True) + return doc + + def _parse_json_log(self, log_content): + """Extrahiert Felder aus JSON-Logs""" + if not isinstance(log_content, dict): + return {} + + # Default-Werte für alle Felder + result = { + "success": 0, + "file_size_mb": 0, + "duration_seconds": 0, + "errors": 0, + "warnings": 0 + } + + backup_type = log_content.get("backup_type", "") + + # rsync-to-usb-v1 Format + if backup_type == "rsync-to-usb-v1": + result["success"] = 1 if log_content.get("status") == "success" else 0 + + backup_info = log_content.get("backup", {}) + if backup_info: + # Duration: try total_duration_seconds first, fallback to duration_seconds + result["duration_seconds"] = ( + backup_info.get("total_duration_seconds") or + backup_info.get("duration_seconds") or 0 + ) + + # Parse file size from rsync_stats if available + rsync_stats = backup_info.get("rsync_stats", "") + if rsync_stats: + import re + # Try "Total transferred:" first, fallback to "Total file size:" + match = re.search(r"Total transferred:\s*([\d,]+)", rsync_stats) + if not match: + match = re.search(r"Total file size:\s*([\d,]+)", rsync_stats) + if match: + bytes_str = match.group(1).replace(",", "") + result["file_size_mb"] = int(bytes_str) / (1024 * 1024) + + # Warnings from array + result["warnings"] = len(log_content.get("warnings", [])) + result["errors"] = 1 if log_content.get("error_message") else 0 + + # snapcontrol-v1 / differential / full Format + else: + backup_info = log_content.get("backup", {}) + if backup_info: + result["success"] = 1 if backup_info.get("success") else 0 + # Bytes zu MB konvertieren (vermeidet Integer-Overflow bei großen Backups) + file_size_bytes = backup_info.get("file_size_bytes", 0) or 0 + result["file_size_mb"] = file_size_bytes / (1024 * 1024) + result["duration_seconds"] = backup_info.get("duration_seconds", 0) or 0 + + # Log-Summary extrahieren + log_summary = log_content.get("log_summary", {}) + if log_summary: + result["errors"] = log_summary.get("errors", 0) or 0 + result["warnings"] = log_summary.get("warnings", 0) or 0 + + return result + + +def sync_all_instances(): + """Scheduled Task: Synchronisiert alle aktiven Backupreport Instances (stündlich)""" + instances = frappe.get_all( + "Backupreport Instance", + filters={"enabled": 1}, + pluck="name" + ) + + total_new = 0 + for instance_name in instances: + try: + doc = frappe.get_doc("Backupreport Instance", instance_name) + new_backups = doc.fetch_backups() + doc.last_sync = now_datetime() + doc.save() + total_new += new_backups + frappe.db.commit() + except Exception as e: + frappe.log_error( + f"Backupreport Sync failed for {instance_name}: {str(e)}", + "Backupreport Scheduled Sync" + ) + + if total_new > 0: + frappe.logger().info(f"Backupreport: {total_new} neue Logs synchronisiert") diff --git a/msp/msp/doctype/backupreport_instance/test_backupreport_instance.py b/msp/msp/doctype/backupreport_instance/test_backupreport_instance.py new file mode 100644 index 0000000..26dc1dd --- /dev/null +++ b/msp/msp/doctype/backupreport_instance/test_backupreport_instance.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, itsdave GmbH and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestBackupreportInstance(FrappeTestCase): + pass diff --git a/msp/msp/doctype/backupreport_log/__init__.py b/msp/msp/doctype/backupreport_log/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/msp/doctype/backupreport_log/backupreport_log.js b/msp/msp/doctype/backupreport_log/backupreport_log.js new file mode 100644 index 0000000..1074b11 --- /dev/null +++ b/msp/msp/doctype/backupreport_log/backupreport_log.js @@ -0,0 +1,429 @@ +// Copyright (c) 2026, itsdave GmbH and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Backupreport Log", { + refresh(frm) { + frm.trigger("render_visualization"); + }, + + render_visualization(frm) { + if (!frm.doc.log_content || !frm.doc.backup_type) { + frm.set_df_property("visualization_html", "options", ""); + return; + } + + let html = ""; + + // Visualisierung basierend auf backup_type + if (frm.doc.backup_type === "snapcontrol-v1" || frm.doc.backup_type === "differential" || frm.doc.backup_type === "full") { + html = frm.events.render_snapcontrol_v1(frm); + } else if (frm.doc.backup_type === "rsync-to-usb-v1") { + html = frm.events.render_rsync_to_usb_v1(frm); + } else if (frm.doc.log_type === "json") { + // Generische JSON-Visualisierung + html = frm.events.render_generic_json(frm); + } else { + // Kein spezielles Rendering + html = `
Keine Visualisierung für Typ "${frm.doc.backup_type}" verfügbar.
`; + } + + frm.set_df_property("visualization_html", "options", html); + }, + + render_snapcontrol_v1(frm) { + let data; + try { + data = JSON.parse(frm.doc.log_content); + } catch (e) { + return `
JSON Parse Error: ${e.message}
`; + } + + const backup = data.backup || {}; + const storage = data.storage || {}; + const target_disk = data.target_disk || {}; + const log_summary = data.log_summary || {}; + const log_entries = data.log_entries || []; + + // Status Badge + const status_color = backup.success ? "green" : "red"; + const status_text = backup.success ? "Erfolgreich" : "Fehlgeschlagen"; + const status_icon = backup.success ? "fa-check-circle" : "fa-times-circle"; + + // Differential Info + let diff_info_html = ""; + if (backup.differential_info) { + const diff = backup.differential_info; + const progress_percent = (diff.current / diff.max) * 100; + diff_info_html = ` +
+ +
+
+ ${diff.current} / ${diff.max} +
+
+ Nächstes Full-Backup in ${diff.next_full_in} Zyklen +
+ `; + } + + // Storage Info + let storage_html = ""; + if (storage.total_bytes > 0) { + const used_percent = ((storage.total_bytes - storage.free_bytes) / storage.total_bytes * 100).toFixed(1); + const free_gb = (storage.free_bytes / 1024 / 1024 / 1024).toFixed(2); + const total_gb = (storage.total_bytes / 1024 / 1024 / 1024).toFixed(2); + storage_html = ` +
+
+
+
Speicher: ${target_disk.disk_name || target_disk.drive_letter || 'N/A'}
+
+
+ ${used_percent}% belegt +
+
+ ${free_gb} GB frei von ${total_gb} GB + ${storage.cycles_count ? `
Zyklen: ${storage.cycles_count} / ${storage.cycles_max}` : ''} +
+
+
+ `; + } + + // Log Entries + let log_entries_html = ""; + if (log_entries.length > 0) { + const entries_list = log_entries.slice(-10).map(entry => { + const level_class = entry.level === "ERROR" ? "danger" : entry.level === "WARNING" ? "warning" : entry.level === "SUCCESS" ? "success" : "secondary"; + return `
+ ${entry.level} + ${entry.timestamp ? entry.timestamp.split('T')[1]?.substring(0,8) : ''} + ${frappe.utils.escape_html(entry.message)} +
`; + }).join(""); + + log_entries_html = ` +
+
+
+
Log Einträge (letzte 10)
+
+ ${entries_list} +
+
+
+
+ `; + } + + return ` + +
+
+ +
+
+
+
+ +
+
${status_text}
+ ${data.computer_name || frm.doc.hostname} +
+
+ + + + + + +
Typ${backup.type || frm.doc.backup_type}
Quelle${backup.source || 'N/A'}
Ziel${backup.target || 'N/A'}
Größe${backup.file_size_human || '0 B'}
Dauer${backup.duration_human || '0 Sekunden'}
+ ${diff_info_html} +
+
+
+ ${storage_html} +
+
+ ${log_entries_html} +
+ ${log_summary.errors > 0 || log_summary.warnings > 0 ? ` +
+
+
+ + ${log_summary.errors > 0 ? `${log_summary.errors} Fehler` : ''} + ${log_summary.errors > 0 && log_summary.warnings > 0 ? ' und ' : ''} + ${log_summary.warnings > 0 ? `${log_summary.warnings} Warnungen` : ''} +
+
+
+ ` : ''} +
+ `; + }, + + render_rsync_to_usb_v1(frm) { + let data; + try { + data = JSON.parse(frm.doc.log_content); + } catch (e) { + return `
JSON Parse Error: ${e.message}
`; + } + + const backup = data.backup || {}; + const disk = data.disk || {}; + const system = data.system || {}; + const warnings = data.warnings || []; + + // Status + const is_success = data.status === "success"; + const status_color = is_success ? "green" : "red"; + const status_text = is_success ? "Erfolgreich" : "Fehlgeschlagen"; + const status_icon = is_success ? "fa-check-circle" : "fa-times-circle"; + + // Parse rsync stats + let rsync_stats = {}; + if (backup.rsync_stats) { + const lines = backup.rsync_stats.split('\n'); + lines.forEach(line => { + const match = line.match(/^- (.+?):\s*(.+)$/); + if (match) { + rsync_stats[match[1].toLowerCase().replace(/\s+/g, '_')] = match[2]; + } + }); + } + + // Parse cleanup stats + let cleanup_stats = {}; + if (backup.cleanup_stats) { + const lines = backup.cleanup_stats.split('\n'); + lines.forEach(line => { + const match = line.match(/^- (.+?):\s*(.+)$/); + if (match) { + cleanup_stats[match[1].toLowerCase().replace(/\s+/g, '_')] = match[2]; + } + }); + } + + // Format bytes to human readable + const formatBytes = (bytes) => { + if (!bytes || isNaN(bytes)) return 'N/A'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let value = parseFloat(bytes.toString().replace(/,/g, '')); + while (value >= 1024 && i < units.length - 1) { + value /= 1024; + i++; + } + return value.toFixed(2) + ' ' + units[i]; + }; + + // Disk rotation card + let disk_html = ""; + if (disk.current_disk || disk.model) { + const rotation_warning = backup.rotation_broken ? + `
+ + ${frappe.utils.escape_html(backup.rotation_message || 'Rotation unterbrochen')} +
` : ''; + + disk_html = ` +
+
+
+
USB-Laufwerk
+ ${rotation_warning} + + + + + + +
Aktuell${disk.current_disk?.name || 'N/A'}
Nächste${disk.next_disk?.name || 'N/A'}
Modell${disk.model || 'N/A'}
Kapazität${disk.capacity || 'N/A'}
Serial${disk.serial || 'N/A'}
+
+
+
+ `; + } + + // Timing card + let timing_html = ` +
+
+
+
Zeitablauf
+ + + + + + + +
Start${backup.start_time || 'N/A'}
Ende${backup.end_time || 'N/A'}
Rsync${backup.duration || 'N/A'}
Monitoring${backup.monitoring_duration || 'N/A'}
Cleanup${backup.cleanup_duration || 'N/A'}
Gesamt${backup.total_duration || 'N/A'}
+
+
+
+ `; + + // Rsync statistics card + let rsync_html = ""; + if (Object.keys(rsync_stats).length > 0) { + rsync_html = ` +
+
+
+
Rsync Statistik
+ + + + + + +
Dateien${rsync_stats.number_of_files || 'N/A'}
Erstellt${rsync_stats.created_files || '0'}
Gelöscht${rsync_stats.deleted_files || '0'}
Übertragen${formatBytes(rsync_stats.total_transferred)}
Geschwindigkeit${rsync_stats.transfer_speed || 'N/A'}
+
+
+
+ `; + } + + // Cleanup statistics card + let cleanup_html = ""; + if (Object.keys(cleanup_stats).length > 0) { + cleanup_html = ` +
+
+
+
Aufräumen
+ + + + + +
Aktiviert${cleanup_stats.cleanup_enabled || 'N/A'}
Dateien gelöscht${cleanup_stats.files_deleted || '0'}
Fehlgeschlagen${cleanup_stats.failed_deletions || '0'}
Platz freigegeben${cleanup_stats.space_freed || 'N/A'}
+
+
+
+ `; + } + + // System info card + let system_html = ""; + if (system.cpu_info || system.memory_total) { + system_html = ` +
+
+
+
System
+ + + + + + +
CPU${system.cpu_info || 'N/A'} (${system.cpu_percent || 'N/A'})
Speicher${system.memory_used || 'N/A'} / ${system.memory_total || 'N/A'} (${system.memory_percent || 'N/A'})
Storage${system.system_storage || 'N/A'}
Quelle${system.source_size || 'N/A'}
Uptime${system.uptime || 'N/A'}
+
+
+
+ `; + } + + // Warnings section + let warnings_html = ""; + if (warnings.length > 0) { + const warning_items = warnings.map(w => + `
${frappe.utils.escape_html(w)}
` + ).join(""); + warnings_html = ` +
+
+ ${warnings.length} Warnung(en) +
${warning_items}
+
+
+ `; + } + + // Error section + let error_html = ""; + if (data.error_message) { + error_html = ` +
+
+ Fehler +
${frappe.utils.escape_html(data.error_message)}
+
+
+ `; + } + + return ` + +
+
+ +
+
+
+
+ +
+
${status_text}
+ ${data.computer_name || frm.doc.hostname} +
+
+ + + + + +
TypRsync to USB
Quelle${backup.source_directory || 'N/A'}
Ziel${backup.destination || 'N/A'}
Zeitstempel${data.timestamp ? data.timestamp.replace('T', ' ').substring(0, 19) : 'N/A'}
+
+
+
+ ${disk_html} +
+
+ ${timing_html} + ${rsync_html} +
+
+ ${cleanup_html} + ${system_html} +
+
+ ${warnings_html} + ${error_html} +
+
+ `; + }, + + render_generic_json(frm) { + let data; + try { + data = JSON.parse(frm.doc.log_content); + } catch (e) { + return `
JSON Parse Error: ${e.message}
`; + } + + return ` +
+ + Backup Type: ${frm.doc.backup_type || 'unbekannt'} - + Keine spezifische Visualisierung vorhanden. +
+ `; + } +}); diff --git a/msp/msp/doctype/backupreport_log/backupreport_log.json b/msp/msp/doctype/backupreport_log/backupreport_log.json new file mode 100644 index 0000000..a7c76a7 --- /dev/null +++ b/msp/msp/doctype/backupreport_log/backupreport_log.json @@ -0,0 +1,190 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "BKLOG-.#####", + "creation": "2026-01-07 21:30:43.716073", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "backupreport_instance", + "backup_id", + "hostname", + "backup_type", + "column_break_info", + "log_type", + "token_name", + "backup_date", + "size", + "section_break_visualization", + "visualization_html", + "section_break_json", + "success", + "file_size_mb", + "duration_seconds", + "column_break_json", + "errors", + "warnings", + "section_break_content", + "log_content" + ], + "fields": [ + { + "fieldname": "backupreport_instance", + "fieldtype": "Link", + "label": "Backupreport Instance", + "options": "Backupreport Instance", + "reqd": 1, + "in_list_view": 1, + "in_standard_filter": 1, + "bold": 1 + }, + { + "fieldname": "backup_id", + "fieldtype": "Int", + "label": "Backup ID", + "reqd": 1 + }, + { + "fieldname": "hostname", + "fieldtype": "Data", + "label": "Hostname", + "in_list_view": 1, + "in_standard_filter": 1 + }, + { + "fieldname": "backup_type", + "fieldtype": "Data", + "label": "Backup Type", + "in_list_view": 1, + "in_standard_filter": 1 + }, + { + "fieldname": "column_break_info", + "fieldtype": "Column Break" + }, + { + "fieldname": "log_type", + "fieldtype": "Data", + "label": "Log Type", + "in_standard_filter": 1 + }, + { + "fieldname": "token_name", + "fieldtype": "Data", + "label": "Token Name", + "in_standard_filter": 1 + }, + { + "fieldname": "backup_date", + "fieldtype": "Datetime", + "label": "Backup Date", + "in_list_view": 1 + }, + { + "fieldname": "size", + "fieldtype": "Float", + "label": "Size (Bytes)" + }, + { + "fieldname": "section_break_visualization", + "fieldtype": "Section Break", + "label": "Visualization" + }, + { + "fieldname": "visualization_html", + "fieldtype": "HTML", + "label": "Visualization" + }, + { + "fieldname": "section_break_json", + "fieldtype": "Section Break", + "label": "JSON Details", + "collapsible": 1, + "depends_on": "eval:doc.log_type=='json'" + }, + { + "default": "0", + "fieldname": "success", + "fieldtype": "Check", + "label": "Success" + }, + { + "fieldname": "file_size_mb", + "fieldtype": "Float", + "label": "File Size (MB)" + }, + { + "fieldname": "duration_seconds", + "fieldtype": "Float", + "label": "Duration (Seconds)" + }, + { + "fieldname": "column_break_json", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "errors", + "fieldtype": "Int", + "label": "Errors" + }, + { + "default": "0", + "fieldname": "warnings", + "fieldtype": "Int", + "label": "Warnings" + }, + { + "fieldname": "section_break_content", + "fieldtype": "Section Break", + "label": "Log Content", + "collapsible": 1 + }, + { + "fieldname": "log_content", + "fieldtype": "Long Text", + "label": "Log Content" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-01-07 21:30:57.360982", + "modified_by": "Administrator", + "module": "MSP", + "name": "Backupreport Log", + "naming_rule": "Expression (old style)", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "export": 1, + "read": 1, + "report": 1, + "role": "Backup-Log-User" + }, + { + "delete": 1, + "export": 1, + "read": 1, + "report": 1, + "role": "Backup-Log-Admin" + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "backup_date", + "sort_order": "DESC", + "states": [] +} diff --git a/msp/msp/doctype/backupreport_log/backupreport_log.py b/msp/msp/doctype/backupreport_log/backupreport_log.py new file mode 100644 index 0000000..79fbda8 --- /dev/null +++ b/msp/msp/doctype/backupreport_log/backupreport_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BackupreportLog(Document): + pass diff --git a/msp/msp/doctype/backupreport_log/test_backupreport_log.py b/msp/msp/doctype/backupreport_log/test_backupreport_log.py new file mode 100644 index 0000000..266ba22 --- /dev/null +++ b/msp/msp/doctype/backupreport_log/test_backupreport_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, itsdave GmbH and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestBackupreportLog(FrappeTestCase): + pass diff --git a/msp/msp/doctype/backupreport_settings/__init__.py b/msp/msp/doctype/backupreport_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/msp/doctype/backupreport_settings/backupreport_settings.js b/msp/msp/doctype/backupreport_settings/backupreport_settings.js new file mode 100644 index 0000000..a8291da --- /dev/null +++ b/msp/msp/doctype/backupreport_settings/backupreport_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, itsdave GmbH and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Backupreport Settings", { +// refresh(frm) { + +// }, +// }); diff --git a/msp/msp/doctype/backupreport_settings/backupreport_settings.json b/msp/msp/doctype/backupreport_settings/backupreport_settings.json new file mode 100644 index 0000000..eb917a7 --- /dev/null +++ b/msp/msp/doctype/backupreport_settings/backupreport_settings.json @@ -0,0 +1,54 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-01-07 21:28:41.105919", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "test_section", + "tester" + ], + "fields": [ + { + "fieldname": "test_section", + "fieldtype": "Section Break", + "label": "test" + }, + { + "fieldname": "tester", + "fieldtype": "Data", + "label": "tester" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-01-07 21:28:57.373669", + "modified_by": "Administrator", + "module": "MSP", + "name": "Backupreport Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "role": "Backup-Log-Admin", + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/msp/msp/doctype/backupreport_settings/backupreport_settings.py b/msp/msp/doctype/backupreport_settings/backupreport_settings.py new file mode 100644 index 0000000..e228939 --- /dev/null +++ b/msp/msp/doctype/backupreport_settings/backupreport_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BackupreportSettings(Document): + pass diff --git a/msp/msp/doctype/backupreport_settings/test_backupreport_settings.py b/msp/msp/doctype/backupreport_settings/test_backupreport_settings.py new file mode 100644 index 0000000..941b421 --- /dev/null +++ b/msp/msp/doctype/backupreport_settings/test_backupreport_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, itsdave GmbH and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestBackupreportSettings(FrappeTestCase): + pass diff --git a/msp/msp/doctype/backupreport_token/__init__.py b/msp/msp/doctype/backupreport_token/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/backupreport_token/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/backupreport_token/backupreport_token.js b/msp/msp/doctype/backupreport_token/backupreport_token.js new file mode 100644 index 0000000..68b6352 --- /dev/null +++ b/msp/msp/doctype/backupreport_token/backupreport_token.js @@ -0,0 +1,132 @@ +// Copyright (c) 2026, itsdave GmbH and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Backupreport Token", { + refresh(frm) { + if (!frm.is_new() && frm.doc.token_id) { + // Sync Button + frm.add_custom_button(__("Sync from API"), function() { + frappe.call({ + method: "sync_from_api", + doc: frm.doc, + freeze: true, + freeze_message: __("Synchronisiere..."), + callback: function(r) { + frm.reload_doc(); + } + }); + }); + + // Activate/Revoke Button + if (frm.doc.active) { + frm.add_custom_button(__("Revoke"), function() { + frappe.confirm( + __("Token wirklich deaktivieren?"), + function() { + frappe.call({ + method: "revoke", + doc: frm.doc, + freeze: true, + callback: function(r) { + frm.reload_doc(); + } + }); + } + ); + }, __("Actions")); + } else { + frm.add_custom_button(__("Activate"), function() { + frappe.call({ + method: "activate", + doc: frm.doc, + freeze: true, + callback: function(r) { + frm.reload_doc(); + } + }); + }, __("Actions")); + } + + // Delete Button + frm.add_custom_button(__("Delete Token"), function() { + frappe.confirm( + __("Token wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden!"), + function() { + frappe.call({ + method: "delete_token", + doc: frm.doc, + freeze: true, + callback: function(r) { + frappe.set_route("List", "Backupreport Token"); + } + }); + } + ); + }, __("Actions")); + + // Show Token Button + frm.add_custom_button(__("Show Token"), function() { + frappe.call({ + method: "get_token_value", + doc: frm.doc, + callback: function(r) { + if (r.message) { + frappe.msgprint({ + title: __("Token Value"), + message: `
${r.message}
`, + indicator: "blue" + }); + } else { + frappe.msgprint(__("Token-Wert nicht verfügbar. Dieser Token wurde extern erstellt.")); + } + } + }); + }, __("Token")); + + // Copy Token Button + frm.add_custom_button(__("Copy Token"), function() { + frappe.call({ + method: "get_token_value", + doc: frm.doc, + callback: function(r) { + if (r.message) { + navigator.clipboard.writeText(r.message).then(function() { + frappe.show_alert({ + message: __("Token in Zwischenablage kopiert"), + indicator: "green" + }); + }); + } else { + frappe.msgprint(__("Token-Wert nicht verfügbar. Dieser Token wurde extern erstellt.")); + } + } + }); + }, __("Token")); + + // Add Host Binding Button - öffnet Dialog + frm.add_custom_button(__("Add Host Binding"), function() { + frappe.prompt([ + { + label: __("Hostname Pattern"), + fieldname: "pattern", + fieldtype: "Data", + reqd: 1, + description: __("z.B. 'web*', 'db-primary', '*-prod'") + } + ], function(values) { + frappe.call({ + method: "add_host_binding", + doc: frm.doc, + args: { pattern: values.pattern }, + freeze: true, + callback: function(r) { + frm.reload_doc(); + } + }); + }, __("Host-Binding hinzufügen"), __("Hinzufügen")); + }, __("Actions")); + } + } +}); + +// Host-Bindings werden über on_update synchronisiert - einfach speichern reicht diff --git a/msp/msp/doctype/backupreport_token/backupreport_token.json b/msp/msp/doctype/backupreport_token/backupreport_token.json new file mode 100644 index 0000000..7be9370 --- /dev/null +++ b/msp/msp/doctype/backupreport_token/backupreport_token.json @@ -0,0 +1,152 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{backupreport_instance}-{token_name}", + "creation": "2026-01-07 22:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "backupreport_instance", + "token_id", + "token_name", + "active", + "column_break_info", + "created_at", + "token_hash_preview", + "backup_count", + "section_break_token", + "token_value", + "token_not_available_html", + "section_break_hosts", + "allowed_hosts", + "section_break_actions" + ], + "fields": [ + { + "fieldname": "backupreport_instance", + "fieldtype": "Link", + "label": "Backupreport Instance", + "options": "Backupreport Instance", + "reqd": 1, + "in_list_view": 1, + "in_standard_filter": 1 + }, + { + "fieldname": "token_id", + "fieldtype": "Int", + "label": "Token ID", + "read_only": 1 + }, + { + "fieldname": "token_name", + "fieldtype": "Data", + "label": "Token Name", + "reqd": 1, + "in_list_view": 1 + }, + { + "default": "1", + "fieldname": "active", + "fieldtype": "Check", + "label": "Active", + "read_only": 1, + "in_list_view": 1 + }, + { + "fieldname": "column_break_info", + "fieldtype": "Column Break" + }, + { + "fieldname": "created_at", + "fieldtype": "Datetime", + "label": "Created At", + "read_only": 1 + }, + { + "fieldname": "token_hash_preview", + "fieldtype": "Data", + "label": "Token Hash Preview", + "read_only": 1 + }, + { + "fieldname": "backup_count", + "fieldtype": "Int", + "label": "Backup Count", + "read_only": 1, + "in_list_view": 1 + }, + { + "fieldname": "section_break_token", + "fieldtype": "Section Break", + "label": "Token" + }, + { + "fieldname": "token_value", + "fieldtype": "Password", + "label": "Token Value", + "description": "Der Token-Wert (nur verfügbar wenn lokal erstellt)" + }, + { + "fieldname": "token_not_available_html", + "fieldtype": "HTML", + "options": "
Token-Wert nicht verfügbar. Dieser Token wurde extern erstellt oder vor der Synchronisierung generiert.
", + "depends_on": "eval:!doc.token_value && doc.token_id" + }, + { + "fieldname": "section_break_hosts", + "fieldtype": "Section Break", + "label": "Allowed Hosts" + }, + { + "fieldname": "allowed_hosts", + "fieldtype": "Table", + "label": "Allowed Hosts", + "options": "Backupreport Token Host" + }, + { + "fieldname": "section_break_actions", + "fieldtype": "Section Break", + "label": "Actions", + "collapsible": 1, + "hidden": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-01-07 22:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "Backupreport Token", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "read": 1, + "role": "Backup-Log-User", + "write": 1 + }, + { + "create": 1, + "delete": 1, + "read": 1, + "role": "Backup-Log-Admin", + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/msp/msp/doctype/backupreport_token/backupreport_token.py b/msp/msp/doctype/backupreport_token/backupreport_token.py new file mode 100644 index 0000000..ce1c8e7 --- /dev/null +++ b/msp/msp/doctype/backupreport_token/backupreport_token.py @@ -0,0 +1,224 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +import frappe +import requests +from frappe.model.document import Document + + +class BackupreportToken(Document): + def before_insert(self): + """Erstellt Token in API wenn manuell angelegt""" + # Nur wenn keine token_id (= manuell erstellt, nicht via Sync) + if not self.token_id: + self._create_in_api() + + def validate(self): + """Validiert und entfernt Duplikate aus Host-Bindings""" + seen = set() + unique_hosts = [] + for host in self.allowed_hosts: + if host.hostname_pattern not in seen: + seen.add(host.hostname_pattern) + unique_hosts.append(host) + self.allowed_hosts = unique_hosts + + def on_update(self): + """Synchronisiert Host-Bindings mit API""" + if self.token_id and not self.flags.ignore_api_sync: + self._sync_host_bindings() + + def on_trash(self): + """Löscht Token in API""" + if self.token_id: + try: + self._api_request(f"/admin/tokens/{self.token_id}", method="DELETE") + except Exception: + pass # Ignorieren wenn API-Löschung fehlschlägt + + def _create_in_api(self): + """Erstellt den Token über die API""" + instance = self.get_instance() + url = instance.api_url.rstrip("/") + "/admin/tokens" + headers = {"X-Admin-Key": instance.get_password("admin_key")} + + try: + response = requests.post(url, headers=headers, data={"name": self.token_name}, timeout=30) + response.raise_for_status() + result = response.json() + + self.token_id = result.get("token_id") + self.token_value = result.get("token", "") + self.active = 1 + + frappe.msgprint( + f"Token in API erstellt. Token-Wert wurde gespeichert.", + indicator="green" + ) + except requests.exceptions.RequestException as e: + frappe.throw(f"Konnte Token nicht in API erstellen: {str(e)}") + + def _sync_host_bindings(self): + """Synchronisiert Host-Bindings zur API""" + # Aktuelle Hosts aus API holen + try: + api_data = self._api_request(f"/admin/tokens/{self.token_id}") + api_hosts = set(api_data.get("allowed_hosts", [])) + except Exception: + return # Bei Fehler nicht synchronisieren + + # Lokale Hosts + local_hosts = set(h.hostname_pattern for h in self.allowed_hosts) + + # Neue Hosts zur API hinzufügen + for host in local_hosts - api_hosts: + try: + self._api_request( + f"/admin/tokens/{self.token_id}/hosts", + method="POST", + data={"hostname_pattern": host} + ) + except Exception: + pass + + # Entfernte Hosts aus API löschen + for host in api_hosts - local_hosts: + try: + self._api_request( + f"/admin/tokens/{self.token_id}/hosts/{host}", + method="DELETE" + ) + except Exception: + pass + + def get_instance(self): + """Holt die zugehörige Backupreport Instance""" + return frappe.get_doc("Backupreport Instance", self.backupreport_instance) + + def _api_request(self, endpoint, method="GET", data=None): + """HTTP-Request an die API""" + instance = self.get_instance() + url = instance.api_url.rstrip("/") + endpoint + headers = {"X-Admin-Key": instance.get_password("admin_key")} + + try: + if method == "GET": + response = requests.get(url, headers=headers, timeout=30) + elif method == "POST": + response = requests.post(url, headers=headers, data=data, timeout=30) + elif method == "DELETE": + response = requests.delete(url, headers=headers, timeout=30) + else: + raise ValueError(f"Unsupported method: {method}") + + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + frappe.log_error(f"Backup API Error: {str(e)}", "Backupreport Token") + frappe.throw(f"API Error: {str(e)}") + + @frappe.whitelist() + def get_token_value(self): + """Gibt den Token-Wert zurück (falls vorhanden)""" + try: + return self.get_password("token_value") + except Exception: + return None + + @frappe.whitelist() + def activate(self): + """Aktiviert den Token über die API""" + if not self.token_id: + frappe.throw("Token hat keine API-ID") + + result = self._api_request(f"/admin/tokens/{self.token_id}/activate", method="POST") + self.active = 1 + self.save() + frappe.msgprint(f"Token '{self.token_name}' aktiviert") + return result + + @frappe.whitelist() + def revoke(self): + """Deaktiviert den Token über die API""" + if not self.token_id: + frappe.throw("Token hat keine API-ID") + + result = self._api_request(f"/admin/tokens/{self.token_id}/revoke", method="POST") + self.active = 0 + self.save() + frappe.msgprint(f"Token '{self.token_name}' deaktiviert") + return result + + @frappe.whitelist() + def delete_token(self): + """Löscht den Token über die API und lokal""" + if not self.token_id: + frappe.throw("Token hat keine API-ID") + + # Erst API, dann lokal + self._api_request(f"/admin/tokens/{self.token_id}", method="DELETE") + token_name = self.token_name + self.delete() + frappe.msgprint(f"Token '{token_name}' gelöscht") + + @frappe.whitelist() + def add_host_binding(self, pattern): + """Fügt ein Host-Binding über die API hinzu""" + if not self.token_id: + frappe.throw("Token hat keine API-ID") + + if not pattern: + frappe.throw("Bitte ein Pattern eingeben") + + result = self._api_request( + f"/admin/tokens/{self.token_id}/hosts", + method="POST", + data={"hostname_pattern": pattern} + ) + + # Lokal hinzufügen + self.append("allowed_hosts", { + "hostname_pattern": pattern + }) + self.save() + frappe.msgprint(f"Host-Binding '{pattern}' hinzugefügt") + return result + + @frappe.whitelist() + def remove_host_binding(self, pattern): + """Entfernt ein Host-Binding über die API""" + if not self.token_id: + frappe.throw("Token hat keine API-ID") + + result = self._api_request( + f"/admin/tokens/{self.token_id}/hosts/{pattern}", + method="DELETE" + ) + + # Lokal entfernen + self.allowed_hosts = [h for h in self.allowed_hosts if h.hostname_pattern != pattern] + self.save() + frappe.msgprint(f"Host-Binding '{pattern}' entfernt") + return result + + @frappe.whitelist() + def sync_from_api(self): + """Synchronisiert Token-Details von der API""" + if not self.token_id: + frappe.throw("Token hat keine API-ID") + + data = self._api_request(f"/admin/tokens/{self.token_id}") + + self.active = 1 if data.get("active") else 0 + self.token_hash_preview = data.get("token_hash_preview", "") + self.backup_count = data.get("backup_count", 0) + + # Host-Bindings synchronisieren + self.allowed_hosts = [] + for host in data.get("allowed_hosts", []): + self.append("allowed_hosts", { + "hostname_pattern": host if isinstance(host, str) else host.get("hostname_pattern", host) + }) + + self.save() + frappe.msgprint("Token synchronisiert") diff --git a/msp/msp/doctype/backupreport_token_host/__init__.py b/msp/msp/doctype/backupreport_token_host/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/backupreport_token_host/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/backupreport_token_host/backupreport_token_host.json b/msp/msp/doctype/backupreport_token_host/backupreport_token_host.json new file mode 100644 index 0000000..b83cb3d --- /dev/null +++ b/msp/msp/doctype/backupreport_token_host/backupreport_token_host.json @@ -0,0 +1,39 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-01-07 22:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "host_id", + "hostname_pattern" + ], + "fields": [ + { + "fieldname": "host_id", + "fieldtype": "Int", + "label": "Host ID", + "read_only": 1 + }, + { + "fieldname": "hostname_pattern", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Hostname Pattern", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-01-07 22:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "Backupreport Token Host", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/msp/msp/doctype/backupreport_token_host/backupreport_token_host.py b/msp/msp/doctype/backupreport_token_host/backupreport_token_host.py new file mode 100644 index 0000000..0136a7c --- /dev/null +++ b/msp/msp/doctype/backupreport_token_host/backupreport_token_host.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class BackupreportTokenHost(Document): + pass diff --git a/msp/msp/doctype/it_landscape/it_landscape.js b/msp/msp/doctype/it_landscape/it_landscape.js index c86a399..6b55d92 100644 --- a/msp/msp/doctype/it_landscape/it_landscape.js +++ b/msp/msp/doctype/it_landscape/it_landscape.js @@ -3,65 +3,910 @@ frappe.ui.form.on('IT Landscape', { refresh: function(frm) { - frm.add_custom_button('Copy SSH Keys', () => frm.trigger('copy_ssh_keys'), 'Actions'); - if (frm.doc.ticket_system_link) { - frm.add_custom_button('Open Ticket System', () => frm.trigger('open_ticket_system'), 'Actions'); - }; - if (frm.doc.monitoring_link) { - frm.add_custom_button('Open Monitoring', () => frm.trigger('open_monitoring'), 'Actions'); - }; - if (frm.doc.rmm_instance) { - frm.add_custom_button('Get Agents From RMM', () => frm.trigger('rmm_get_agents'), 'RMM'); + // Action buttons + frm.add_custom_button('Copy SSH Keys', () => frm.trigger('copy_ssh_keys'), 'Actions'); + if (frm.doc.ticket_system_link) { + frm.add_custom_button('Open Ticket System', () => frm.trigger('open_ticket_system'), 'Actions'); + } + if (frm.doc.monitoring_link) { + frm.add_custom_button('Open Monitoring', () => frm.trigger('open_monitoring'), 'Actions'); + } + + // Workflow buttons for IT Object Import (numbered steps) + if (frm.doc.rmm_instance) { + frm.add_custom_button(__('1. AD-Daten abrufen'), () => frm.trigger('fetch_ad_data'), __('Import Workflow')); + frm.add_custom_button(__('2. RMM-Daten abrufen'), () => frm.trigger('fetch_rmm_data'), __('Import Workflow')); + frm.add_custom_button(__('3. IT Objects erstellen'), () => frm.trigger('start_unified_import'), __('Import Workflow')); + frm.add_custom_button(__('Bestehende synchronisieren'), () => frm.trigger('sync_existing_objects'), __('Import Workflow')); + } + + // Set up field filters for Default-AD fields + frm.trigger('setup_ad_field_filters'); + + // Initialize image gallery + frm.trigger('init_attachment_gallery'); + }, + + // Filter for Default AD Credentials - only show credentials belonging to this landscape + setup_ad_field_filters: function(frm) { + // Filter for default_ad_credentials - show only credentials from this IT Landscape + frm.set_query('default_ad_credentials', function() { + let filters = {}; + // Only show credentials belonging to this IT Landscape + if (frm.doc.name && !frm.is_new()) { + filters['it_landscape'] = frm.doc.name; } - }, + return { filters: filters }; + }); + + // Filter for default_ad_domain_controller - only show IT Objects of this landscape + frm.set_query('default_ad_domain_controller', function() { + let filters = {}; + // Only show objects from this landscape + if (frm.doc.name && !frm.is_new()) { + filters['it_landscape'] = frm.doc.name; + } + return { filters: filters }; + }); + }, + + init_attachment_gallery: function(frm) { + // Inject CSS only once + if (!document.getElementById('it-landscape-gallery-styles')) { + const style = document.createElement('style'); + style.id = 'it-landscape-gallery-styles'; + style.textContent = ` + .attachment-gallery { + margin: 15px 0; + padding: 20px; + background: var(--card-bg); + border-radius: 8px; + border: 1px solid var(--border-color); + } + .attachment-gallery-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + margin-bottom: 15px; + } + .gallery-main-view { + position: relative; + width: 100%; + max-width: 800px; + margin: 0 auto 15px auto; + background: var(--bg-color); + border-radius: 8px; + overflow: hidden; + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; + } + .gallery-main-view .gallery-item { + display: none; + width: 100%; + text-align: center; + } + .gallery-main-view .gallery-item.active { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + .gallery-main-view .gallery-item img { + max-width: 100%; + max-height: 400px; + object-fit: contain; + cursor: zoom-in; + border-radius: 4px; + } + .gallery-main-view .gallery-item.pdf-item { + padding: 40px; + } + .gallery-main-view .pdf-icon { + font-size: 80px; + color: var(--text-muted); + margin-bottom: 15px; + } + .gallery-main-view .pdf-filename { + font-size: 14px; + color: var(--text-color); + word-break: break-word; + max-width: 80%; + } + .gallery-main-view .pdf-open-btn { + margin-top: 15px; + padding: 8px 20px; + background: var(--primary); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + } + .gallery-main-view .pdf-open-btn:hover { + background: var(--primary-dark); + } + .gallery-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 44px; + height: 44px; + background: rgba(0,0,0,0.5); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; + z-index: 10; + } + .gallery-nav-btn:hover { + background: rgba(0,0,0,0.7); + } + .gallery-nav-btn.prev { left: 10px; } + .gallery-nav-btn.next { right: 10px; } + .gallery-nav-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + .gallery-thumbnails { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; + margin-top: 10px; + } + .gallery-thumbnail { + width: 60px; + height: 60px; + border-radius: 6px; + overflow: hidden; + cursor: pointer; + border: 2px solid transparent; + transition: border-color 0.2s, transform 0.2s; + background: var(--bg-color); + display: flex; + align-items: center; + justify-content: center; + } + .gallery-thumbnail:hover { + transform: scale(1.05); + } + .gallery-thumbnail.active { + border-color: var(--primary); + } + .gallery-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; + } + .gallery-thumbnail .thumb-pdf { + font-size: 24px; + color: var(--text-muted); + } + .gallery-counter { + text-align: center; + font-size: 12px; + color: var(--text-muted); + margin-top: 10px; + } + + /* Fullscreen Lightbox */ + .gallery-lightbox { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0,0,0,0.95); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; + overflow: hidden; + } + .gallery-lightbox.active { + opacity: 1; + visibility: visible; + } + .gallery-lightbox-image-container { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + .gallery-lightbox img { + max-width: 95vw; + max-height: 95vh; + object-fit: contain; + transition: transform 0.1s ease-out; + cursor: grab; + user-select: none; + } + .gallery-lightbox img.dragging { + cursor: grabbing; + transition: none; + } + .gallery-lightbox-close { + position: absolute; + top: 20px; + right: 20px; + width: 50px; + height: 50px; + background: rgba(255,255,255,0.1); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 28px; + display: flex; + align-items: center; + justify-content: center; + z-index: 10001; + } + .gallery-lightbox-close:hover { + background: rgba(255,255,255,0.2); + } + .gallery-lightbox-zoom-info { + position: absolute; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0,0,0,0.7); + color: white; + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + opacity: 0; + transition: opacity 0.3s; + pointer-events: none; + } + .gallery-lightbox-zoom-info.visible { + opacity: 1; + } + .gallery-lightbox-hint { + position: absolute; + bottom: 60px; + left: 50%; + transform: translateX(-50%); + color: rgba(255,255,255,0.5); + font-size: 12px; + pointer-events: none; + } + `; + document.head.appendChild(style); + } + + // Remove any existing gallery (prevents duplicates on navigation) + frm.$wrapper.find('.attachment-gallery-container').remove(); + + // Fetch attachments + frappe.db.get_list('File', { + fields: ['name', 'file_name', 'file_url', 'is_private'], + filters: { + 'attached_to_name': frm.docname, + 'attached_to_doctype': 'IT Landscape' + } + }).then(attachments => { + if (!attachments || attachments.length === 0) { + return; + } + + // Separate images and PDFs + const items = attachments.map(att => { + const ext = (att.file_name || '').split('.').pop().toLowerCase(); + const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext); + const isPdf = ext === 'pdf'; + return { + ...att, + isImage, + isPdf, + ext + }; + }); + + // Build gallery HTML + let galleryHtml = ` + + `; + + // Add lightbox to body if not exists + if (!document.getElementById('gallery-lightbox')) { + const lightbox = document.createElement('div'); + lightbox.id = 'gallery-lightbox'; + lightbox.className = 'gallery-lightbox'; + lightbox.innerHTML = ` + + + + + `; + document.body.appendChild(lightbox); + + const img = lightbox.querySelector('img'); + const zoomInfo = lightbox.querySelector('.gallery-lightbox-zoom-info'); + let scale = 1; + let translateX = 0; + let translateY = 0; + let isDragging = false; + let startX, startY, startTranslateX, startTranslateY; + let zoomInfoTimeout; + + function updateTransform() { + img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`; + } + + function showZoomInfo() { + zoomInfo.textContent = `${Math.round(scale * 100)}%`; + zoomInfo.classList.add('visible'); + clearTimeout(zoomInfoTimeout); + zoomInfoTimeout = setTimeout(() => { + zoomInfo.classList.remove('visible'); + }, 1500); + } + + function resetZoom() { + scale = 1; + translateX = 0; + translateY = 0; + updateTransform(); + showZoomInfo(); + } + + // Mouse wheel zoom + lightbox.addEventListener('wheel', (e) => { + if (!lightbox.classList.contains('active')) return; + e.preventDefault(); + + const rect = img.getBoundingClientRect(); + const mouseX = e.clientX - rect.left - rect.width / 2; + const mouseY = e.clientY - rect.top - rect.height / 2; + + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const newScale = Math.min(Math.max(scale * delta, 0.5), 10); + + // Adjust translation to zoom towards mouse position + if (newScale !== scale) { + const scaleRatio = newScale / scale; + translateX = mouseX - (mouseX - translateX) * scaleRatio; + translateY = mouseY - (mouseY - translateY) * scaleRatio; + scale = newScale; + updateTransform(); + showZoomInfo(); + } + }, { passive: false }); + + // Drag to pan + img.addEventListener('mousedown', (e) => { + if (scale <= 1) return; + e.preventDefault(); + isDragging = true; + img.classList.add('dragging'); + startX = e.clientX; + startY = e.clientY; + startTranslateX = translateX; + startTranslateY = translateY; + }); + + document.addEventListener('mousemove', (e) => { + if (!isDragging) return; + translateX = startTranslateX + (e.clientX - startX); + translateY = startTranslateY + (e.clientY - startY); + updateTransform(); + }); + + document.addEventListener('mouseup', () => { + if (isDragging) { + isDragging = false; + img.classList.remove('dragging'); + } + }); + + // Double click to reset + img.addEventListener('dblclick', (e) => { + e.preventDefault(); + resetZoom(); + }); + + // Close lightbox on click (but not on image when zoomed) + lightbox.addEventListener('click', (e) => { + if (e.target.classList.contains('gallery-lightbox-close')) { + lightbox.classList.remove('active'); + resetZoom(); + } else if (e.target === lightbox || e.target.classList.contains('gallery-lightbox-image-container')) { + if (scale <= 1) { + lightbox.classList.remove('active'); + resetZoom(); + } + } + }); + + // Close on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && lightbox.classList.contains('active')) { + lightbox.classList.remove('active'); + resetZoom(); + } + }); + + // Reset zoom when opening new image + lightbox.resetZoom = resetZoom; + } + + // Insert gallery into form dashboard + const $dashboard = frm.$wrapper.find('.form-dashboard.visible-section'); + if ($dashboard.length) { + $dashboard.append(galleryHtml); + } else { + frm.$wrapper.find('.form-layout').prepend(galleryHtml); + } + + // Get gallery container (scoped) + const $gallery = frm.$wrapper.find('.attachment-gallery-container'); + let currentIndex = 0; + + function showItem(index) { + currentIndex = index; + $gallery.find('.gallery-item').removeClass('active'); + $gallery.find('.gallery-item').eq(index).addClass('active'); + $gallery.find('.gallery-thumbnail').removeClass('active'); + $gallery.find('.gallery-thumbnail').eq(index).addClass('active'); + $gallery.find('.gallery-counter .current').text(index + 1); + } + + // Navigation buttons (scoped to this gallery) + $gallery.find('.gallery-nav-btn.next').on('click', function() { + const newIndex = (currentIndex + 1) % items.length; + showItem(newIndex); + }); + + $gallery.find('.gallery-nav-btn.prev').on('click', function() { + const newIndex = (currentIndex - 1 + items.length) % items.length; + showItem(newIndex); + }); + + // Thumbnail clicks + $gallery.find('.gallery-thumbnail').on('click', function() { + const index = parseInt($(this).data('index')); + showItem(index); + }); + + // Image click for lightbox + $gallery.find('.gallery-item img').on('click', function() { + const src = $(this).attr('src'); + const lightbox = document.getElementById('gallery-lightbox'); + lightbox.querySelector('img').src = src; + if (lightbox.resetZoom) lightbox.resetZoom(); + lightbox.classList.add('active'); + }); + + // PDF/File open button + $gallery.find('.pdf-open-btn').on('click', function() { + const url = $(this).data('url'); + window.open(url, '_blank'); + }); + + // Keyboard navigation + $(document).off('keydown.gallery').on('keydown.gallery', function(e) { + if (!$gallery.is(':visible')) return; + if (e.key === 'ArrowRight') { + $gallery.find('.gallery-nav-btn.next').click(); + } else if (e.key === 'ArrowLeft') { + $gallery.find('.gallery-nav-btn.prev').click(); + } + }); + }); + }, + open_ticket_system: function(frm) { window.open(frm.doc.ticket_system_link, '_blank').focus(); }, + open_monitoring: function(frm) { window.open(frm.doc.monitoring_link, '_blank').focus(); }, + rmm_get_agents: function(frm) { frappe.call({ - "method": "msp.tactical-rmm.get_agents", + method: "msp.tactical-rmm.get_agents", args: { - "it_landscape": frm.doc.name, - "rmm_instance": frm.doc.rmm_instance, - "tactical_rmm_tenant_caption": frm.doc.tactical_rmm_tenant_caption + it_landscape: frm.doc.name, + rmm_instance: frm.doc.rmm_instance, + tactical_rmm_tenant_caption: frm.doc.tactical_rmm_tenant_caption }, callback: (response) => { - frappe.msgprint(__(response.message)); - } - - }) + frappe.msgprint(__(response.message)); + } + }); }, - copy_ssh_keys: function(frm) { frappe.call({ - "method": "msp.whitelisted_tools.get_ssh_keys_for_landscape", - args: { - "landscape": frm.doc.name, - }, - callback: (response) => { - if (response.message.startsWith("#")) { - console.log(response.message), - frm.events.CopyToClipboard(response.message), - frappe.msgprint(__('Keys copied to clipboard.')) - } - else { - frappe.msgprint(__(response.message)); - } - - } - - }) + method: "msp.whitelisted_tools.get_ssh_keys_for_landscape", + args: { + landscape: frm.doc.name + }, + callback: (response) => { + if (response.message.startsWith("#")) { + frm.events.CopyToClipboard(response.message); + frappe.msgprint(__('Keys copied to clipboard.')); + } else { + frappe.msgprint(__(response.message)); + } + } + }); }, + CopyToClipboard: function(value) { - var tempInput = document.createElement("textarea"); + if (navigator.clipboard) { + navigator.clipboard.writeText(value); + } else { + const tempInput = document.createElement("textarea"); tempInput.value = value; document.body.appendChild(tempInput); tempInput.select(); document.execCommand("copy"); document.body.removeChild(tempInput); + } + }, + + // Workflow Step 1: Fetch AD Data + fetch_ad_data: function(frm) { + // First, ensure we have an MSP Documentation + frappe.call({ + method: 'msp.rmm_import.get_or_create_msp_documentation', + args: { it_landscape: frm.doc.name }, + callback: function(r) { + if (!r.message) return; + + const msp_doc_info = r.message; + + if (!msp_doc_info.has_ad_config) { + frappe.msgprint({ + title: __('AD-Konfiguration fehlt'), + indicator: 'orange', + message: __('Keine AD-Credentials konfiguriert. Bitte konfigurieren Sie die Default-AD-Credentials in dieser IT Landscape oder in der MSP Documentation "{0}".', [msp_doc_info.name]) + }); + return; + } + + frappe.confirm( + __('AD-Computer-Daten vom Domain Controller abrufen?'), + function() { + frappe.dom.freeze(__('AD-Daten werden abgerufen...')); + frappe.call({ + method: 'msp.tactical-rmm.fetch_and_store_ad_computer_data', + args: { documentation_name: msp_doc_info.name }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.message) { + frappe.show_alert({ + message: __('AD-Daten erfolgreich abgerufen: {0} Computer', [r.message.count || 0]), + indicator: 'green' + }, 5); + } else { + frappe.show_alert({ + message: __('AD-Daten abgerufen'), + indicator: 'green' + }, 5); + } + }, + error: function(r) { + frappe.dom.unfreeze(); + frappe.msgprint(__('Fehler beim Abrufen der AD-Daten')); + } + }); + } + ); + } + }); + }, + + // Workflow Step 2: Fetch RMM Data + fetch_rmm_data: function(frm) { + if (!frm.doc.rmm_instance) { + frappe.msgprint(__('Keine RMM Instance konfiguriert')); + return; + } + if (!frm.doc.tactical_rmm_tenant_caption) { + frappe.msgprint(__('Kein RMM Tenant Caption konfiguriert')); + return; + } + + frappe.confirm( + __('RMM-Agentendaten von Tactical RMM abrufen?'), + function() { + frappe.dom.freeze(__('RMM-Daten werden abgerufen...')); + frappe.call({ + method: 'msp.tactical-rmm.get_agents', + args: { + it_landscape: frm.doc.name, + rmm_instance: frm.doc.rmm_instance, + tactical_rmm_tenant_caption: frm.doc.tactical_rmm_tenant_caption + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.message) { + frappe.show_alert({ + message: r.message, + indicator: 'green' + }, 5); + } + }, + error: function(r) { + frappe.dom.unfreeze(); + frappe.msgprint(__('Fehler beim Abrufen der RMM-Daten')); + } + }); + } + ); + }, + + // Workflow Step 3: Start unified import (create IT Objects) + start_unified_import: function(frm) { + // First, get or create MSP Documentation + frappe.call({ + method: 'msp.rmm_import.get_or_create_msp_documentation', + args: { it_landscape: frm.doc.name }, + callback: function(r) { + if (r.message) { + frm.events.show_import_dialog(frm, r.message); + } + } + }); + }, + + show_import_dialog: function(frm, msp_doc_info) { + const { name: msp_doc_name, created, has_ad_config, has_ad_data } = msp_doc_info; + + // Status-HTML generieren + let status_html = `
+ IT Landscape: ${frm.doc.title || frm.doc.name}
+ RMM Instance: ${frm.doc.rmm_instance || 'Nicht konfiguriert'}
+ RMM Tenant: ${frm.doc.tactical_rmm_tenant_caption || 'Nicht gesetzt'} +
`; + + if (created) { + status_html += `
+ MSP Documentation wurde automatisch erstellt. +
`; + } + + let d = new frappe.ui.Dialog({ + title: __('IT Objects importieren'), + fields: [ + { fieldname: 'source_info', fieldtype: 'HTML', options: status_html }, + + // MSP Documentation Auswahl + { fieldname: 'doc_section', fieldtype: 'Section Break', label: __('Datenquelle') }, + { + fieldname: 'msp_documentation', + fieldtype: 'Link', + label: __('MSP Documentation'), + options: 'MSP Documentation', + default: msp_doc_name, + description: __('Standard-Documentation oder alternative waehlen (z.B. fuer 2. AD)'), + get_query: function() { + return { + filters: { landscape: frm.doc.name } + }; + }, + onchange: function() { + frm.events.update_ad_status(d, d.get_value('msp_documentation')); + } + }, + + // AD-Optionen + { fieldname: 'ad_section', fieldtype: 'Section Break', label: 'Active Directory' }, + { + fieldname: 'include_ad_data', + fieldtype: 'Check', + label: __('AD-Daten einbeziehen'), + default: has_ad_data ? 1 : 0, + description: __('RMM-Agents mit AD-Computerdaten anreichern') + }, + { + fieldname: 'fetch_fresh_ad_data', + fieldtype: 'Check', + label: __('AD-Daten vorher aktualisieren'), + default: 0, + depends_on: 'include_ad_data', + description: __('Aktuelle Daten vom Domain Controller abrufen') + }, + { fieldname: 'ad_status', fieldtype: 'HTML' } + ], + primary_action_label: __('Import starten'), + primary_action: async function(values) { + d.hide(); + + // Optional: AD-Daten vorher aktualisieren + if (values.include_ad_data && values.fetch_fresh_ad_data) { + frappe.dom.freeze(__('AD-Daten werden abgerufen...')); + try { + await frappe.call({ + method: 'msp.tactical-rmm.fetch_and_store_ad_computer_data', + args: { documentation_name: values.msp_documentation } + }); + } catch (e) { + frappe.dom.unfreeze(); + frappe.msgprint(__('Fehler beim Abrufen der AD-Daten: ') + (e.message || e)); + return; + } + frappe.dom.unfreeze(); + } + + // Import-Session erstellen + frappe.dom.freeze(__('Import-Session wird erstellt...')); + frappe.call({ + method: 'msp.rmm_import.create_import_session_from_landscape', + args: { + it_landscape: frm.doc.name, + include_ad_data: values.include_ad_data ? 1 : 0 + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.message) { + frappe.set_route('Form', 'RMM Import Session', r.message); + } + }, + error: function(r) { + frappe.dom.unfreeze(); + frappe.msgprint(__('Fehler beim Erstellen der Import-Session')); + } + }); + } + }); + + // Initial AD-Status anzeigen + frm.events.update_ad_status(d, msp_doc_name); + d.show(); + }, + + update_ad_status: function(dialog, msp_doc_name) { + if (!msp_doc_name) return; + + frappe.call({ + method: 'msp.rmm_import.get_ad_status', + args: { documentation_name: msp_doc_name }, + callback: function(r) { + let html = ''; + if (!r.message) return; + + if (!r.message.has_config) { + html = `
+ Keine AD-Credentials in dieser Documentation. +
`; + dialog.set_value('include_ad_data', 0); + dialog.set_df_property('include_ad_data', 'read_only', 1); + } else if (!r.message.has_data) { + html = `
+ AD-Credentials vorhanden, Daten noch nicht abgerufen. +
Aktivieren Sie "AD-Daten vorher aktualisieren". +
`; + dialog.set_df_property('include_ad_data', 'read_only', 0); + dialog.set_value('fetch_fresh_ad_data', 1); + } else { + html = `
+ AD-Daten vorhanden + (${r.message.computer_count} Computer, Stand: ${r.message.last_update}) +
`; + dialog.set_df_property('include_ad_data', 'read_only', 0); + } + dialog.fields_dict.ad_status.$wrapper.html(html); + } + }); + }, + + sync_existing_objects: function(frm) { + frappe.confirm( + __('Moechten Sie die AD-Daten fuer alle bestehenden IT Objects dieser Landscape synchronisieren?'), + function() { + frappe.dom.freeze(__('AD-Daten werden synchronisiert...')); + frappe.call({ + method: 'msp.rmm_import.sync_ad_data_for_existing_objects', + args: { it_landscape: frm.doc.name }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.message) { + let msg = __('Synchronisation abgeschlossen:') + '
' + + __('Synchronisiert: {0}', [r.message.synced_count]) + '
' + + __('Nicht gefunden: {0}', [r.message.not_found_count]); + if (r.message.errors && r.message.errors.length > 0) { + msg += '

' + __('Fehler:') + '
' + r.message.errors.slice(0, 5).join('
'); + if (r.message.errors.length > 5) { + msg += '
...und ' + (r.message.errors.length - 5) + ' weitere'; + } + } + frappe.msgprint(msg); + } + }, + error: function() { + frappe.dom.unfreeze(); + frappe.msgprint(__('Fehler bei der Synchronisation')); + } + }); + } + ); } -}) +}); diff --git a/msp/msp/doctype/it_landscape/it_landscape.json b/msp/msp/doctype/it_landscape/it_landscape.json index 180470b..7aa78b0 100644 --- a/msp/msp/doctype/it_landscape/it_landscape.json +++ b/msp/msp/doctype/it_landscape/it_landscape.json @@ -22,7 +22,12 @@ "monitoring_link", "ticket_system_link", "tactical_rmm_tenant_caption", - "rmm_instance" + "rmm_instance", + "default_ad_section", + "default_ad_credentials", + "default_ad_domain_controller", + "default_ad_column_break", + "default_ad_use_nat" ], "fields": [ { @@ -119,11 +124,42 @@ "fieldtype": "Link", "label": "RMM Instance", "options": "RMM Instance" + }, + { + "collapsible": 1, + "fieldname": "default_ad_section", + "fieldtype": "Section Break", + "label": "Default Active Directory" + }, + { + "description": "Standard-Credentials fuer LDAP-Verbindungen. Wird bei automatischer MSP-Documentation-Erstellung uebernommen.", + "fieldname": "default_ad_credentials", + "fieldtype": "Link", + "label": "Default AD Credentials", + "options": "IT User Account" + }, + { + "description": "Standard-Domain-Controller fuer LDAP-Verbindungen.", + "fieldname": "default_ad_domain_controller", + "fieldtype": "Link", + "label": "Default Domain Controller", + "options": "IT Object" + }, + { + "fieldname": "default_ad_column_break", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "1:1 NAT-Adresse fuer LDAP-Verbindung verwenden", + "fieldname": "default_ad_use_nat", + "fieldtype": "Check", + "label": "Use 1:1 NAT Address" } ], "image_field": "landscape_image", "links": [], - "modified": "2023-06-08 23:20:35.777651", + "modified": "2026-01-22 10:00:00.000000", "modified_by": "Administrator", "module": "MSP", "name": "IT Landscape", diff --git a/msp/msp/doctype/it_object/it_object.js b/msp/msp/doctype/it_object/it_object.js index e92aa83..4ecfaa6 100644 --- a/msp/msp/doctype/it_object/it_object.js +++ b/msp/msp/doctype/it_object/it_object.js @@ -3,6 +3,8 @@ frappe.ui.form.on('IT Object', { refresh: function (frm) { + // RMM Integration buttons + frm.trigger('setup_rmm_buttons'); const loader = `
@@ -152,6 +154,32 @@ frappe.ui.form.on('IT Object', { if (frm.doc.link) { frm.add_custom_button('Copy Main Admin Account PW', () => frm.trigger('get_pw'), 'Actions'); }; + if (frm.doc.rmm_agent_id && frm.doc.rmm_instance) { + frm.add_custom_button(__('Software Abrufen'), () => frm.trigger('fetch_software'), __('Aktionen')); + }; + }, + fetch_software: function(frm) { + frappe.dom.freeze(__('Rufe Software-Daten ab...')); + frappe.call({ + method: 'msp.rmm_import.fetch_software_for_it_object', + args: { it_object_name: frm.doc.name }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Software-Abruf fehlgeschlagen.') + }); + return; + } + frappe.show_alert({ + message: __('Software-Daten aktualisiert ({0} Einträge)', [r.message.count]), + indicator: 'green' + }); + frm.reload_doc(); + } + }); }, open_admin_interface: function (frm) { window.open(frm.doc.admin_interface_link, '_blank').focus(); @@ -187,5 +215,205 @@ frappe.ui.form.on('IT Object', { document.execCommand("copy"); document.body.removeChild(tempInput); }, - + + setup_rmm_buttons: function(frm) { + if (frm.is_new()) return; + + // Check if already linked to RMM + if (frm.doc.rmm_agent_id && frm.doc.rmm_instance) { + // Show sync button + frm.add_custom_button(__('RMM Daten aktualisieren'), function() { + frappe.dom.freeze(__('Synchronisiere RMM-Daten...')); + frappe.call({ + method: 'msp.rmm_import.sync_matched_object', + args: { it_object_name: frm.doc.name }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('RMM-Sync fehlgeschlagen.') + }); + return; + } + frappe.show_alert({ + message: __('RMM-Daten erfolgreich aktualisiert'), + indicator: 'green' + }); + frm.reload_doc(); + } + }); + }, __('RMM')); + + // Show unlink button + frm.add_custom_button(__('RMM Verknüpfung lösen'), function() { + frappe.confirm( + __('Möchten Sie die Verknüpfung zu RMM Agent "{0}" wirklich aufheben?', [frm.doc.rmm_agent_id]), + function() { + frappe.call({ + method: 'msp.rmm_import.unlink_it_object_from_agent', + args: { it_object_name: frm.doc.name }, + callback: function(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('RMM-Verknüpfung erfolgreich gelöst'), + indicator: 'green' + }); + frm.reload_doc(); + } + } + }); + } + ); + }, __('RMM')); + } else { + // Show link button + frm.add_custom_button(__('Mit RMM Agent verknüpfen'), function() { + frm.trigger('show_rmm_link_dialog'); + }, __('RMM')); + } + }, + + show_rmm_link_dialog: function(frm) { + frappe.dom.freeze(__('Lade verfügbare RMM Agents...')); + + frappe.call({ + method: 'msp.rmm_import.get_available_agents_for_it_object', + args: { it_object_name: frm.doc.name }, + callback: function(r) { + frappe.dom.unfreeze(); + + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Konnte RMM Agents nicht laden.') + }); + return; + } + + const data = r.message; + + if (!data.success) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: data.error || __('Unbekannter Fehler') + }); + return; + } + + if (data.agents.length === 0) { + frappe.msgprint({ + title: __('Keine Agents verfügbar'), + indicator: 'orange', + message: __('Es sind keine unverknüpften RMM Agents verfügbar.') + }); + return; + } + + // Build agent options + let agentOptions = data.agents.map(a => ({ + label: a.label, + value: a.agent_id, + description: `${a.operating_system} | ${a.monitoring_type} | ${a.status}` + })); + + // Show client filter info and best match suggestion + let infoHtml = ''; + + // Show which client/tenant is being filtered + if (data.client_filter) { + infoHtml += ` +
+ Kunde/Tenant: ${data.client_filter} + (${data.agents.length} Agents verfügbar) +
+ `; + } + + // Show best match suggestion + if (data.best_match && data.best_match.agent_id) { + const confidenceClass = data.best_match.confidence >= 90 ? 'green' : + data.best_match.confidence >= 70 ? 'orange' : 'blue'; + infoHtml += ` +
+ Bester Vorschlag: ${data.best_match.hostname}
+ Confidence: ${data.best_match.confidence}% + ${data.best_match.match_reason ? ` - ${data.best_match.match_reason}` : ''} +
+ `; + } + + // Create dialog + let d = new frappe.ui.Dialog({ + title: __('Mit RMM Agent verknüpfen'), + size: 'large', + fields: [ + { + fieldtype: 'HTML', + fieldname: 'info_section', + options: infoHtml + }, + { + fieldtype: 'Autocomplete', + fieldname: 'agent_id', + label: __('RMM Agent auswählen'), + options: agentOptions, + reqd: 1, + default: data.best_match ? data.best_match.agent_id : null + }, + { + fieldtype: 'Check', + fieldname: 'sync_now', + label: __('Daten sofort synchronisieren'), + default: 1 + } + ], + primary_action_label: __('Verknüpfen'), + primary_action: function(values) { + if (!values.agent_id) { + frappe.msgprint(__('Bitte wählen Sie einen Agent aus.')); + return; + } + + frappe.dom.freeze(__('Verknüpfe mit RMM Agent...')); + frappe.call({ + method: 'msp.rmm_import.link_it_object_to_agent', + args: { + it_object_name: frm.doc.name, + agent_id: values.agent_id, + rmm_instance: data.rmm_instance, + sync_now: values.sync_now ? 1 : 0 + }, + callback: function(r) { + frappe.dom.unfreeze(); + d.hide(); + + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Verknüpfung fehlgeschlagen.') + }); + return; + } + + if (r.message && r.message.success) { + frappe.show_alert({ + message: r.message.message, + indicator: r.message.sync_error ? 'orange' : 'green' + }); + frm.reload_doc(); + } + } + }); + } + }); + + d.show(); + } + }); + } }); diff --git a/msp/msp/doctype/it_object/it_object.json b/msp/msp/doctype/it_object/it_object.json index e6a20f1..f4f4d5c 100644 --- a/msp/msp/doctype/it_object/it_object.json +++ b/msp/msp/doctype/it_object/it_object.json @@ -29,16 +29,39 @@ "network_config_section", "ip_adresses", "rmm_data_section", + "rmm_agent_id", + "rmm_instance", + "created_from_rmm", + "last_rmm_sync", + "rmm_column_break", + "rmm_local_ip", + "rmm_public_ip", + "rmm_operating_system", + "rmm_last_seen", + "rmm_last_user", + "rmm_patches_pending", + "rmm_needs_reboot", + "rmm_details_section", "hardware_attributes", + "rmm_software_section", + "installed_software", + "rmm_patches_section", + "installed_patches", + "rmm_raw_data_section", "rmm_specs", "rmm_software", - "created_from_rmm", + "ad_data_section", + "ad_object_guid", + "ad_distinguished_name", + "ad_column_break", + "ad_last_logon", + "ad_account_status", + "ad_operating_system", + "last_ad_sync", "external_links_section", "admin_interface_link", "monitoring_link", "oitc_host_uuid", - "rmm_agent_id", - "rmm_instance", "documentation_section", "visible_in_documentation", "documentation_text" @@ -247,16 +270,154 @@ "fieldtype": "Check", "label": "Created From RMM" }, + { + "fieldname": "last_rmm_sync", + "fieldtype": "Datetime", + "label": "Last RMM Sync", + "read_only": 1 + }, + { + "fieldname": "rmm_column_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "rmm_local_ip", + "fieldtype": "Data", + "label": "RMM Local IP", + "read_only": 1 + }, + { + "fieldname": "rmm_public_ip", + "fieldtype": "Data", + "label": "RMM Public IP", + "read_only": 1 + }, + { + "fieldname": "rmm_operating_system", + "fieldtype": "Data", + "label": "RMM Operating System", + "read_only": 1 + }, + { + "fieldname": "rmm_last_seen", + "fieldtype": "Data", + "label": "RMM Last Seen", + "read_only": 1 + }, + { + "fieldname": "rmm_last_user", + "fieldtype": "Data", + "label": "RMM Last User", + "read_only": 1 + }, + { + "fieldname": "rmm_patches_pending", + "fieldtype": "Int", + "label": "Pending Patches", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "rmm_needs_reboot", + "fieldtype": "Check", + "label": "Needs Reboot (RMM)", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "rmm_details_section", + "fieldtype": "Section Break", + "label": "RMM Details" + }, { "fieldname": "hardware_attributes", "fieldtype": "Table", "label": "Hardware Attributes", "options": "IT Object Hardware Attribute" + }, + { + "collapsible": 1, + "fieldname": "rmm_software_section", + "fieldtype": "Section Break", + "label": "Installed Software" + }, + { + "fieldname": "installed_software", + "fieldtype": "Table", + "label": "Software", + "options": "IT Object Software" + }, + { + "collapsible": 1, + "fieldname": "rmm_patches_section", + "fieldtype": "Section Break", + "label": "Windows Updates" + }, + { + "fieldname": "installed_patches", + "fieldtype": "Table", + "label": "Patches", + "options": "IT Object Patch" + }, + { + "collapsible": 1, + "fieldname": "rmm_raw_data_section", + "fieldtype": "Section Break", + "label": "RMM Raw Data (Legacy)" + }, + { + "collapsible": 1, + "fieldname": "ad_data_section", + "fieldtype": "Section Break", + "label": "Active Directory Data" + }, + { + "description": "Eindeutige AD GUID (fuer Matching)", + "fieldname": "ad_object_guid", + "fieldtype": "Data", + "label": "AD Object GUID", + "read_only": 1 + }, + { + "description": "Vollstaendiger OU-Pfad im AD", + "fieldname": "ad_distinguished_name", + "fieldtype": "Small Text", + "label": "AD Distinguished Name", + "read_only": 1 + }, + { + "fieldname": "ad_column_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "ad_last_logon", + "fieldtype": "Datetime", + "label": "AD Last Logon", + "read_only": 1 + }, + { + "fieldname": "ad_account_status", + "fieldtype": "Select", + "label": "AD Account Status", + "options": "\nEnabled\nDisabled", + "read_only": 1 + }, + { + "fieldname": "ad_operating_system", + "fieldtype": "Data", + "label": "AD Operating System", + "read_only": 1 + }, + { + "fieldname": "last_ad_sync", + "fieldtype": "Datetime", + "label": "Last AD Sync", + "read_only": 1 } ], "image_field": "image", "links": [], - "modified": "2025-07-31 16:07:20.612702", + "modified": "2026-01-22 10:00:00.000000", "modified_by": "Administrator", "module": "MSP", "name": "IT Object", diff --git a/msp/msp/doctype/it_object_hardware_attribute/__init__.py b/msp/msp/doctype/it_object_hardware_attribute/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/it_object_hardware_attribute/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/it_object_hardware_attribute/it_object_hardware_attribute.json b/msp/msp/doctype/it_object_hardware_attribute/it_object_hardware_attribute.json new file mode 100644 index 0000000..6e266c5 --- /dev/null +++ b/msp/msp/doctype/it_object_hardware_attribute/it_object_hardware_attribute.json @@ -0,0 +1,52 @@ +{ + "actions": [], + "creation": "2026-01-07 15:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "attribute_type", + "attribute_value", + "column_break_1", + "attribute_details" + ], + "fields": [ + { + "fieldname": "attribute_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "CPU\nRAM\nDisk\nGPU\nMainboard\nBIOS\nNetwork\nSerial Number\nModel\nManufacturer\nOther", + "reqd": 1 + }, + { + "fieldname": "attribute_value", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Value", + "reqd": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "attribute_details", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Details" + } + ], + "index_web_pages_for_search": 0, + "istable": 1, + "links": [], + "modified": "2026-01-07 15:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "IT Object Hardware Attribute", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/msp/msp/doctype/it_object_hardware_attribute/it_object_hardware_attribute.py b/msp/msp/doctype/it_object_hardware_attribute/it_object_hardware_attribute.py new file mode 100644 index 0000000..5b626e6 --- /dev/null +++ b/msp/msp/doctype/it_object_hardware_attribute/it_object_hardware_attribute.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ITObjectHardwareAttribute(Document): + pass diff --git a/msp/msp/doctype/it_object_patch/__init__.py b/msp/msp/doctype/it_object_patch/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/it_object_patch/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/it_object_patch/it_object_patch.json b/msp/msp/doctype/it_object_patch/it_object_patch.json new file mode 100644 index 0000000..3aa17d2 --- /dev/null +++ b/msp/msp/doctype/it_object_patch/it_object_patch.json @@ -0,0 +1,71 @@ +{ + "actions": [], + "creation": "2026-01-07 15:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "kb_number", + "title", + "severity", + "column_break_1", + "category", + "installed", + "install_date" + ], + "fields": [ + { + "fieldname": "kb_number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "KB Number" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title" + }, + { + "fieldname": "severity", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Severity", + "options": "\nCritical\nImportant\nModerate\nLow\nUnspecified" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "category", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Category" + }, + { + "fieldname": "installed", + "fieldtype": "Check", + "default": "0", + "in_list_view": 1, + "label": "Installed" + }, + { + "fieldname": "install_date", + "fieldtype": "Datetime", + "label": "Install Date" + } + ], + "index_web_pages_for_search": 0, + "istable": 1, + "links": [], + "modified": "2026-01-07 15:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "IT Object Patch", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/msp/msp/doctype/it_object_patch/it_object_patch.py b/msp/msp/doctype/it_object_patch/it_object_patch.py new file mode 100644 index 0000000..0ab3cf2 --- /dev/null +++ b/msp/msp/doctype/it_object_patch/it_object_patch.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ITObjectPatch(Document): + pass diff --git a/msp/msp/doctype/it_object_software/__init__.py b/msp/msp/doctype/it_object_software/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/it_object_software/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/it_object_software/it_object_software.json b/msp/msp/doctype/it_object_software/it_object_software.json new file mode 100644 index 0000000..8ce7fb7 --- /dev/null +++ b/msp/msp/doctype/it_object_software/it_object_software.json @@ -0,0 +1,56 @@ +{ + "actions": [], + "creation": "2026-01-07 15:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "software_name", + "version", + "column_break_1", + "publisher", + "install_date" + ], + "fields": [ + { + "fieldname": "software_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Software Name", + "reqd": 1 + }, + { + "fieldname": "version", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Version" + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "publisher", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Publisher" + }, + { + "fieldname": "install_date", + "fieldtype": "Date", + "label": "Install Date" + } + ], + "index_web_pages_for_search": 0, + "istable": 1, + "links": [], + "modified": "2026-01-07 15:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "IT Object Software", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/msp/msp/doctype/it_object_software/it_object_software.py b/msp/msp/doctype/it_object_software/it_object_software.py new file mode 100644 index 0000000..77d4624 --- /dev/null +++ b/msp/msp/doctype/it_object_software/it_object_software.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ITObjectSoftware(Document): + pass diff --git a/msp/msp/doctype/msp_documentation/msp_documentation.js b/msp/msp/doctype/msp_documentation/msp_documentation.js index c40c50f..74ba34f 100644 --- a/msp/msp/doctype/msp_documentation/msp_documentation.js +++ b/msp/msp/doctype/msp_documentation/msp_documentation.js @@ -4,423 +4,1626 @@ frappe.ui.form.on('MSP Documentation', { refresh(frm) { - frm.add_custom_button('1. Get Tactical Agents', function(){ - frappe.dom.freeze('Fetching Tactical Agents...'); - frappe.call({ - method: 'msp.tactical-rmm.get_agents_pretty', - args: { documentation: frm.doc.name }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Error'), - indicator: 'red', - message: __('Failed to fetch tactical agents. Please try again.') - }); - return; - } - frappe.show_alert({ - message: __('Successfully fetched tactical agents'), - indicator: 'green' - }); - frm.reload_doc(); - } - }); - }, 'Workflow'); + // Check NAT availability on form load + frm.trigger('check_nat_availability'); - frm.add_custom_button('2. Office Search', function(){ - frappe.dom.freeze('Searching for Office installations...'); - frappe.call({ - method: 'msp.tactical-rmm.search_office', - args: { documentation: frm.doc.name }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Error'), - indicator: 'red', - message: __('Failed to complete Office search. Please try again.') - }); - return; - } - frappe.show_alert({ - message: __('Office search completed'), - indicator: 'green' - }); - frm.reload_doc(); - } - }); - }, 'Workflow'); + // Render Windows Update Report from JSON data + frm.trigger('render_windows_update_report'); - // Add new button for IT Objects documentation - frm.add_custom_button('3. Generate IT Objects', function(){ - frappe.dom.freeze('Generating IT Objects documentation...'); - frappe.call({ - method: 'msp.tools.get_documentation_html', - args: { - it_landscape: frm.doc.landscape - }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Error'), - indicator: 'red', - message: __('Failed to generate IT Objects documentation. Please try again.') - }); - return; - } - if (r.message) { - frm.set_value('it_objects', r.message); - frm.save().then(() => { - frappe.show_alert({ - message: __('IT Objects documentation generated successfully'), - indicator: 'green' - }); - }); - } - } - }); - }, 'Workflow'); + // Workflow buttons + frm.add_custom_button('1. Get Tactical Agents', function(){ + frappe.dom.freeze('Fetching Tactical Agents...'); + frappe.call({ + method: 'msp.tactical-rmm.get_agents_pretty', + args: { documentation: frm.doc.name }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Failed to fetch tactical agents. Please try again.') + }); + return; + } + frappe.show_alert({ + message: __('Successfully fetched tactical agents'), + indicator: 'green' + }); + frm.reload_doc(); + } + }); + }, 'Workflow'); - // Add button to fetch and store all RMM agent data as JSON - frm.add_custom_button('4. RMM Daten speichern', function(){ - frappe.dom.freeze('RMM-Daten werden abgerufen und gespeichert...'); - frappe.call({ - method: 'msp.tactical-rmm.fetch_and_store_all_agent_data', - args: { - documentation_name: frm.doc.name - }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Fehler'), - indicator: 'red', - message: __('RMM-Daten konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.') - }); - return; - } - if (r.message && r.message.success) { - frappe.show_alert({ - message: r.message.message || __('RMM-Daten erfolgreich gespeichert'), - indicator: 'green' - }); - frm.reload_doc(); - } - } - }); - }, 'Workflow'); + frm.add_custom_button('2. Office Search', function(){ + frappe.dom.freeze('Searching for Office installations...'); + frappe.call({ + method: 'msp.tactical-rmm.search_office', + args: { documentation_name: frm.doc.name }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Failed to complete Office search. Please try again.') + }); + return; + } + frappe.show_alert({ + message: __('Office search completed'), + indicator: 'green' + }); + frm.reload_doc(); + } + }); + }, 'Workflow'); - // Add button to fetch and store Active Directory computer data as JSON - frm.add_custom_button('5. AD Computer-Daten speichern', function(){ - frappe.dom.freeze('AD-Computer-Daten werden abgerufen und gespeichert...'); - frappe.call({ - method: 'msp.tactical-rmm.fetch_and_store_ad_computer_data', - args: { - documentation_name: frm.doc.name - }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Fehler'), - indicator: 'red', - message: __('AD-Computer-Daten konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.') - }); - return; - } - if (r.message && r.message.success) { - frappe.show_alert({ - message: r.message.message || __('AD-Computer-Daten erfolgreich gespeichert'), - indicator: 'green' - }); - frm.reload_doc(); - } - } - }); - }, 'Workflow'); + // Add new button for IT Objects documentation + frm.add_custom_button('3. Generate IT Objects', function(){ + frappe.dom.freeze('Generating IT Objects documentation...'); + frappe.call({ + method: 'msp.tools.get_documentation_html', + args: { + it_landscape: frm.doc.landscape + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Failed to generate IT Objects documentation. Please try again.') + }); + return; + } + if (r.message) { + frm.set_value('it_objects', r.message); + frm.save().then(() => { + frappe.show_alert({ + message: __('IT Objects documentation generated successfully'), + indicator: 'green' + }); + }); + } + } + }); + }, 'Workflow'); - // Add button to fetch and store Active Directory user data as JSON - frm.add_custom_button('6. AD Benutzer-Daten speichern', function(){ - frappe.dom.freeze('AD-Benutzer-Daten werden abgerufen und gespeichert...'); - frappe.call({ - method: 'msp.tactical-rmm.fetch_and_store_ad_user_data', - args: { - documentation_name: frm.doc.name - }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Fehler'), - indicator: 'red', - message: __('AD-Benutzer-Daten konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.') - }); - return; - } - if (r.message && r.message.success) { - frappe.show_alert({ - message: r.message.message || __('AD-Benutzer-Daten erfolgreich gespeichert'), - indicator: 'green' - }); - frm.reload_doc(); - } - } - }); - }, 'Workflow'); + // Add button to fetch and store all RMM agent data as JSON + frm.add_custom_button('4. RMM Daten speichern', function(){ + frappe.dom.freeze('RMM-Daten werden abgerufen und gespeichert...'); + frappe.call({ + method: 'msp.tactical-rmm.fetch_and_store_all_agent_data', + args: { + documentation_name: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('RMM-Daten konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.') + }); + return; + } + if (r.message && r.message.success) { + frappe.show_alert({ + message: r.message.message || __('RMM-Daten erfolgreich gespeichert'), + indicator: 'green' + }); + frm.reload_doc(); + } + } + }); + }, 'Workflow'); - // Add button to compare RMM and AD data - frm.add_custom_button('7. RMM ↔ AD Abgleich', function(){ - frappe.dom.freeze('RMM- und AD-Daten werden abgeglichen...'); - frappe.call({ - method: 'msp.tactical-rmm.compare_rmm_and_ad_data', - args: { - documentation_name: frm.doc.name - }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Fehler'), - indicator: 'red', - message: __('Datenabgleich konnte nicht durchgeführt werden. Bitte versuchen Sie es erneut.') - }); - return; - } - if (r.message && r.message.success) { - let stats = r.message.stats; - let details = `${stats.total_computers} Computer analysiert: ` + - `${stats.in_both} in beiden Systemen, ` + - `${stats.only_in_rmm} nur RMM, ` + - `${stats.only_in_ad} nur AD`; - - frappe.show_alert({ - message: __('Datenabgleich erfolgreich abgeschlossen. ') + details, - indicator: 'green' - }); - frm.reload_doc(); - } - } - }); - }, 'Workflow'); + // Add button to import IT Objects from RMM + if (frm.doc.tactical_rmm_tenant_caption) { + frm.add_custom_button(__('IT Objects aus RMM importieren'), function(){ + frappe.dom.freeze('Import-Session wird erstellt...'); + frappe.call({ + method: 'msp.rmm_import.create_import_session', + args: { + documentation_name: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Import-Session konnte nicht erstellt werden. Bitte versuchen Sie es erneut.') + }); + return; + } + if (r.message) { + frappe.set_route('Form', 'RMM Import Session', r.message); + } + } + }); + }, __('RMM')); - // Add button for Windows 11 compatibility check - frm.add_custom_button('8. Windows 11 Check', function(){ - frappe.dom.freeze('Windows 11 CPU-Kompatibilität wird geprüft...'); - frappe.call({ - method: 'msp.tactical-rmm.check_windows11_compatibility', - args: { - documentation_name: frm.doc.name - }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Fehler'), - indicator: 'red', - message: __('Windows 11 Kompatibilitätsprüfung konnte nicht durchgeführt werden. Bitte versuchen Sie es erneut.') - }); - return; - } - if (r.message && r.message.success) { - let stats = r.message.stats; - let details = `${stats.total_non_win11} Systeme ohne Windows 11 analysiert: ` + - `${stats.compatible_cpus} kompatible CPUs, ` + - `${stats.incompatible_cpus} inkompatible CPUs, ` + - `${stats.unknown_cpus} unbekannte CPUs`; - - frappe.show_alert({ - message: __('Windows 11 Kompatibilitätsprüfung abgeschlossen. ') + details, - indicator: 'green' - }); - frm.reload_doc(); - } - } - }); - }, 'Workflow'); + // Add button to match existing IT Objects with RMM agents + frm.add_custom_button(__('IT Objects mit RMM verknüpfen'), function(){ + frappe.dom.freeze('Lade Matching-Vorschläge...'); + frappe.call({ + method: 'msp.rmm_import.get_matching_suggestions', + args: { + documentation_name: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Matching-Vorschläge konnten nicht geladen werden.') + }); + return; + } + if (r.message) { + show_rmm_matching_dialog(frm, r.message); + } + } + }); + }, __('RMM')); + } - // Add debug button for CPU compatibility - frm.add_custom_button('🔍 CPU Debug', function(){ - frappe.prompt([ - { - 'fieldname': 'test_cpu', - 'label': 'Test CPU (leer = automatisch)', - 'fieldtype': 'Data', - 'reqd': 0, - 'description': 'Z.B: Intel(R) Core(TM) i3-8100 CPU @ 3.60GHz, 4C/4T' - } - ], function(values) { - frappe.dom.freeze('CPU-Kompatibilität wird debuggt...'); - frappe.call({ - method: 'msp.tactical-rmm.debug_cpu_compatibility', - args: { - documentation_name: frm.doc.name, - test_cpu_string: values.test_cpu || null - }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Fehler'), - indicator: 'red', - message: __('CPU-Debug konnte nicht durchgeführt werden: ') + r.exc - }); - return; - } - if (r.message && r.message.success) { - let debug_info = r.message.debug_info; - let result = r.message.result; - let test_cpu = r.message.test_cpu; - - // Debug-Dialog erstellen - let debug_html = ` -
-

🔍 CPU-Kompatibilitäts Debug

-
Test-CPU: ${test_cpu}
-
System-CPU (uppercase): ${debug_info.system_cpu_upper || 'N/A'}
-
Vendor: ${debug_info.vendor || 'N/A'}
-
Suchset-Größe: ${debug_info.search_set_size || 0}
-
Ergebnis: - ${result.compatible ? '✅ KOMPATIBEL' : '❌ NICHT KOMPATIBEL'} (${result.status}) -
- ${debug_info.match_found ? `
Gefundene CPU: ${debug_info.matching_cpu}
` : ''} - -
📁 Dateipfad-Informationen:
-
-
App-Pfad: ${debug_info.app_path || 'N/A'}
- ${debug_info.files_info ? Object.keys(debug_info.files_info).map(vendor => { - const info = debug_info.files_info[vendor]; - const statusColor = info.exists ? 'green' : 'red'; - const statusIcon = info.exists ? '✅' : '❌'; - return ` -
-
${vendor.toUpperCase()} CPUs: ${statusIcon}
-
- Pfad: ${info.path}
- Existiert: ${info.exists ? 'Ja' : 'Nein'}
- ${info.exists ? `Dateigröße: ${info.size} Bytes` : ''} -
-
- `; - }).join('') : 'Keine Dateipfad-Informationen verfügbar'} - ${debug_info.loaded_counts ? ` -
- Geladene CPUs: - AMD: ${debug_info.loaded_counts.amd}, - Intel: ${debug_info.loaded_counts.intel} -
- ` : ''} - ${debug_info.error ? ` -
- Fehler: ${debug_info.error} -
- ` : ''} -
- -
🔎 Vergleiche (erste ${debug_info.comparisons ? debug_info.comparisons.length : 0}):
-
- `; - - if (debug_info.comparisons) { - debug_info.comparisons.forEach((comp, i) => { - let color = comp.match_result ? 'green' : '#666'; - let icon = comp.match_result ? '✅' : '❌'; - debug_html += ` -
-
${icon} ${comp.match_type}: ${comp.supported_cpu}
- ${comp.extracted_part ? `
Extrahiert: ${comp.extracted_part}
` : ''} -
Details: ${comp.details}
-
- `; - }); - } - - if (debug_info.truncated) { - debug_html += '
... weitere Vergleiche abgeschnitten ...
'; - } - - debug_html += ` -
-
- `; - - frappe.msgprint({ - title: 'CPU-Kompatibilitäts Debug', - message: debug_html, - indicator: result.compatible ? 'green' : 'red' - }); - } - } - }); - }, 'CPU Debug Test', 'Testen'); - }, 'Workflow'); + // Add connectivity test button before AD operations + frm.add_custom_button('🔌 Konnektivitätstest', function(){ + // Create and show dialog immediately with pending status + let dialog = new frappe.ui.Dialog({ + title: 'LDAP-Konnektivitätstest', + size: 'large', + fields: [ + { + fieldtype: 'HTML', + fieldname: 'test_results', + options: ` +
+
+
Ziel-IP:
+
Wird ermittelt...
+
- // Add Excel Export button - frm.add_custom_button('📊 Excel Export', function(){ - frappe.dom.freeze('Excel-Export wird erstellt...'); - frappe.call({ - method: 'msp.tactical-rmm.export_tables_to_excel', - args: { - documentation_name: frm.doc.name - }, - callback: function(r) { - frappe.dom.unfreeze(); - if (r.exc) { - frappe.msgprint({ - title: __('Fehler'), - indicator: 'red', - message: __('Excel-Export konnte nicht erstellt werden: ') + r.exc - }); - return; - } - if (r.message && r.message.success) { - let filename = r.message.filename; - let content = r.message.content; - - // Excel-Datei herunterladen - try { - // Base64 zu Blob konvertieren - const byteCharacters = atob(content); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - }); - - // Download-Link erstellen - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - const timestamp = new Date().toLocaleString('de-DE'); - a.style.display = 'none'; - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - frappe.show_alert({ - message: __('Excel-Export erfolgreich heruntergeladen: ') + filename, - indicator: 'green' - }); - - } catch (download_error) { - console.error('Download-Fehler:', download_error); - frappe.msgprint({ - title: __('Download-Fehler'), - indicator: 'red', - message: __('Die Excel-Datei konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.') - }); - } - - } - } - }); - }, 'Export'); +
+
+
+
+
1. Ping-Test
+
Test wird ausgeführt...
+
+
+
+
+ +
+
+
+
+
2. Port-Test (LDAP 389)
+
Wartet auf Ping...
+
+
+
+
+ +
+
+
+
+
3. LDAP-Authentifizierung
+
Wartet auf Port-Test...
+
+
+
+
+ + +
+ ` + } + ], + primary_action_label: 'Schließen', + primary_action: function() { + dialog.hide(); + } + }); + dialog.show(); + + // Get reference to this dialog's wrapper for scoped selectors + const $wrapper = dialog.$wrapper; + + // Helper function to update test status (scoped to this dialog) + function updateTestStatus(testId, status, message, duration) { + const iconMap = { + 'pending': '⏳', + 'success': '✅', + 'error': '❌', + 'skipped': '⏭️' + }; + const colorMap = { + 'pending': '#dee2e6', + 'success': '#d4edda', + 'error': '#f8d7da', + 'skipped': '#fff3cd' + }; + + $wrapper.find(`.${testId}-icon`).text(iconMap[status] || '❓'); + $wrapper.find(`.${testId}-message`).text(message); + $wrapper.find(`.test-${testId}`).css('background', colorMap[status] || '#f8f9fa'); + + if (duration !== null && duration !== undefined) { + $wrapper.find(`.${testId}-duration`).text(`${duration} ms`); + } + } + + // Run the connectivity test + frappe.call({ + method: 'msp.tactical-rmm.test_ldap_connectivity', + args: { documentation_name: frm.doc.name }, + callback: function(r) { + if (r.exc || !r.message) { + $wrapper.find('.target-ip').text('Fehler'); + updateTestStatus('ping', 'error', 'Test konnte nicht ausgeführt werden', null); + return; + } + + const result = r.message; + + // Update target IP + $wrapper.find('.target-ip').text(result.ip_address || 'Nicht konfiguriert'); + + // Update each test + const tests = result.tests || {}; + + // Ping + if (tests.ping) { + updateTestStatus('ping', tests.ping.status, tests.ping.message, tests.ping.duration_ms); + } + + // Port + if (tests.port) { + updateTestStatus('port', tests.port.status, tests.port.message, tests.port.duration_ms); + } + + // LDAP Bind + if (tests.ldap_bind) { + updateTestStatus('ldap', tests.ldap_bind.status, tests.ldap_bind.message, tests.ldap_bind.duration_ms); + } + + // Overall status + const $statusEl = $wrapper.find('.overall-status'); + $statusEl.show(); + + if (result.overall_status === 'success') { + $statusEl.css('background', '#d4edda').css('color', '#155724'); + $statusEl.text('✅ Alle Tests erfolgreich - LDAP-Verbindung bereit'); + } else if (result.overall_status === 'partial') { + $statusEl.css('background', '#fff3cd').css('color', '#856404'); + $statusEl.text('⚠️ Teilweise erfolgreich - Einige Tests fehlgeschlagen'); + } else { + $statusEl.css('background', '#f8d7da').css('color', '#721c24'); + $statusEl.text('❌ Verbindung fehlgeschlagen'); + } + } + }); + }, 'Diagnose'); + + // Add button to fetch and store Active Directory computer data as JSON + frm.add_custom_button('5. AD Computer-Daten speichern', function(){ + frappe.dom.freeze('AD-Computer-Daten werden abgerufen und gespeichert...'); + frappe.call({ + method: 'msp.tactical-rmm.fetch_and_store_ad_computer_data', + args: { + documentation_name: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('AD-Computer-Daten konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.') + }); + return; + } + if (r.message && r.message.success) { + frappe.show_alert({ + message: r.message.message || __('AD-Computer-Daten erfolgreich gespeichert'), + indicator: 'green' + }); + frm.reload_doc(); + } + } + }); + }, 'Workflow'); + + // Add button to fetch and store Active Directory user data as JSON + frm.add_custom_button('6. AD Benutzer-Daten speichern', function(){ + frappe.dom.freeze('AD-Benutzer-Daten werden abgerufen und gespeichert...'); + frappe.call({ + method: 'msp.tactical-rmm.fetch_and_store_ad_user_data', + args: { + documentation_name: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('AD-Benutzer-Daten konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.') + }); + return; + } + if (r.message && r.message.success) { + frappe.show_alert({ + message: r.message.message || __('AD-Benutzer-Daten erfolgreich gespeichert'), + indicator: 'green' + }); + frm.reload_doc(); + } + } + }); + }, 'Workflow'); + + // Add button to compare RMM and AD data + frm.add_custom_button('7. RMM ↔ AD Abgleich', function(){ + frappe.dom.freeze('RMM- und AD-Daten werden abgeglichen...'); + frappe.call({ + method: 'msp.tactical-rmm.compare_rmm_and_ad_data', + args: { + documentation_name: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Datenabgleich konnte nicht durchgeführt werden. Bitte versuchen Sie es erneut.') + }); + return; + } + if (r.message && r.message.success) { + let stats = r.message.stats; + let details = `${stats.total_computers} Computer analysiert: ` + + `${stats.in_both} in beiden Systemen, ` + + `${stats.only_in_rmm} nur RMM, ` + + `${stats.only_in_ad} nur AD`; + + frappe.show_alert({ + message: __('Datenabgleich erfolgreich abgeschlossen. ') + details, + indicator: 'green' + }); + frm.reload_doc(); + } + } + }); + }, 'Workflow'); + + // Add button for Windows 11 compatibility check + frm.add_custom_button('8. Windows 11 Check', function(){ + frappe.dom.freeze('Windows 11 CPU-Kompatibilität wird geprüft...'); + frappe.call({ + method: 'msp.tactical-rmm.check_windows11_compatibility', + args: { + documentation_name: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Windows 11 Kompatibilitätsprüfung konnte nicht durchgeführt werden. Bitte versuchen Sie es erneut.') + }); + return; + } + if (r.message && r.message.success) { + let stats = r.message.stats; + let details = `${stats.total_non_win11} Systeme ohne Windows 11 analysiert: ` + + `${stats.compatible_cpus} kompatible CPUs, ` + + `${stats.incompatible_cpus} inkompatible CPUs, ` + + `${stats.unknown_cpus} unbekannte CPUs`; + + frappe.show_alert({ + message: __('Windows 11 Kompatibilitätsprüfung abgeschlossen. ') + details, + indicator: 'green' + }); + frm.reload_doc(); + } + } + }); + }, 'Workflow'); + + // Add button for Windows Update status report + frm.add_custom_button('9. Windows Updates', function(){ + frappe.dom.freeze('Windows Update Status wird abgerufen...'); + frappe.call({ + method: 'msp.tactical-rmm.fetch_windows_update_data', + args: { + documentation_name: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Windows Update Status konnte nicht abgerufen werden. Bitte versuchen Sie es erneut.') + }); + return; + } + if (r.message && r.message.success) { + let totals = r.message.totals; + let score = r.message.compliance_score; + let status = r.message.compliance_status; + + let indicator = status === 'green' ? 'green' : status === 'yellow' ? 'orange' : 'red'; + let statusText = status === 'green' ? 'Gut' : status === 'yellow' ? 'Warnung' : 'Kritisch'; + + let details = `${r.message.agent_count} Computer analysiert: ` + + `Compliance ${score}% (${statusText}) - ` + + `${totals.installed} installiert, ` + + `${totals.pending} ausstehend, ` + + `${totals.failed} fehlgeschlagen`; + + frappe.show_alert({ + message: __('Windows Update Status abgerufen. ') + details, + indicator: indicator + }); + frm.reload_doc(); + } + } + }); + }, 'Workflow'); + + // Add debug button for CPU compatibility + frm.add_custom_button('🔍 CPU Debug', function(){ + frappe.prompt([ + { + 'fieldname': 'test_cpu', + 'label': 'Test CPU (leer = automatisch)', + 'fieldtype': 'Data', + 'reqd': 0, + 'description': 'Z.B: Intel(R) Core(TM) i3-8100 CPU @ 3.60GHz, 4C/4T' + } + ], function(values) { + frappe.dom.freeze('CPU-Kompatibilität wird debuggt...'); + frappe.call({ + method: 'msp.tactical-rmm.debug_cpu_compatibility', + args: { + documentation_name: frm.doc.name, + test_cpu_string: values.test_cpu || null + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('CPU-Debug konnte nicht durchgeführt werden: ') + r.exc + }); + return; + } + if (r.message && r.message.success) { + let debug_info = r.message.debug_info; + let result = r.message.result; + let test_cpu = r.message.test_cpu; + + // Debug-Dialog erstellen + let debug_html = ` +
+

🔍 CPU-Kompatibilitäts Debug

+
Test-CPU: ${test_cpu}
+
System-CPU (uppercase): ${debug_info.system_cpu_upper || 'N/A'}
+
Vendor: ${debug_info.vendor || 'N/A'}
+
Suchset-Größe: ${debug_info.search_set_size || 0}
+
Ergebnis: + ${result.compatible ? '✅ KOMPATIBEL' : '❌ NICHT KOMPATIBEL'} (${result.status}) +
+ ${debug_info.match_found ? `
Gefundene CPU: ${debug_info.matching_cpu}
` : ''} + +
📁 Dateipfad-Informationen:
+
+
App-Pfad: ${debug_info.app_path || 'N/A'}
+ ${debug_info.files_info ? Object.keys(debug_info.files_info).map(vendor => { + const info = debug_info.files_info[vendor]; + const statusColor = info.exists ? 'green' : 'red'; + const statusIcon = info.exists ? '✅' : '❌'; + return ` +
+
${vendor.toUpperCase()} CPUs: ${statusIcon}
+
+ Pfad: ${info.path}
+ Existiert: ${info.exists ? 'Ja' : 'Nein'}
+ ${info.exists ? `Dateigröße: ${info.size} Bytes` : ''} +
+
+ `; + }).join('') : 'Keine Dateipfad-Informationen verfügbar'} + ${debug_info.loaded_counts ? ` +
+ Geladene CPUs: + AMD: ${debug_info.loaded_counts.amd}, + Intel: ${debug_info.loaded_counts.intel} +
+ ` : ''} + ${debug_info.error ? ` +
+ Fehler: ${debug_info.error} +
+ ` : ''} +
+ +
🔎 Vergleiche (erste ${debug_info.comparisons ? debug_info.comparisons.length : 0}):
+
+ `; + + if (debug_info.comparisons) { + debug_info.comparisons.forEach((comp, i) => { + let color = comp.match_result ? 'green' : '#666'; + let icon = comp.match_result ? '✅' : '❌'; + debug_html += ` +
+
${icon} ${comp.match_type}: ${comp.supported_cpu}
+ ${comp.extracted_part ? `
Extrahiert: ${comp.extracted_part}
` : ''} +
Details: ${comp.details}
+
+ `; + }); + } + + if (debug_info.truncated) { + debug_html += '
... weitere Vergleiche abgeschnitten ...
'; + } + + debug_html += ` +
+
+ `; + + frappe.msgprint({ + title: 'CPU-Kompatibilitäts Debug', + message: debug_html, + indicator: result.compatible ? 'green' : 'red' + }); + } + } + }); + }, 'CPU Debug Test', 'Testen'); + }, 'Workflow'); + + // Add Excel Export button + frm.add_custom_button('📊 Excel Export', function(){ + frappe.dom.freeze('Excel-Export wird erstellt...'); + frappe.call({ + method: 'msp.tactical-rmm.export_tables_to_excel', + args: { + documentation_name: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Excel-Export konnte nicht erstellt werden: ') + r.exc + }); + return; + } + if (r.message && r.message.success) { + let filename = r.message.filename; + let content = r.message.content; + + // Excel-Datei herunterladen + try { + // Base64 zu Blob konvertieren + const byteCharacters = atob(content); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }); + + // Download-Link erstellen + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + const timestamp = new Date().toLocaleString('de-DE'); + a.style.display = 'none'; + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + frappe.show_alert({ + message: __('Excel-Export erfolgreich heruntergeladen: ') + filename, + indicator: 'green' + }); + + } catch (download_error) { + console.error('Download-Fehler:', download_error); + frappe.msgprint({ + title: __('Download-Fehler'), + indicator: 'red', + message: __('Die Excel-Datei konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.') + }); + } + + } + } + }); + }, 'Export'); + }, + + domain_controller_for_ldap_acquisition(frm) { + frm.trigger('check_nat_availability'); + frm.trigger('update_ip_address'); + }, + + use_nat_address(frm) { + frm.trigger('update_ip_address'); + }, + + check_nat_availability(frm) { + // Hide checkbox if no DC selected + if (!frm.doc.domain_controller_for_ldap_acquisition) { + frm.set_df_property('use_nat_address', 'hidden', 1); + if (frm.doc.use_nat_address) { + frm.set_value('use_nat_address', 0); + } + return; + } + + // Check if NAT network is configured + frappe.call({ + method: 'msp.msp.doctype.msp_documentation.msp_documentation.check_nat_available', + args: { it_object_name: frm.doc.domain_controller_for_ldap_acquisition }, + callback: function(r) { + if (r.message) { + frm.set_df_property('use_nat_address', 'hidden', !r.message.nat_available); + if (!r.message.nat_available && frm.doc.use_nat_address) { + frm.set_value('use_nat_address', 0); + } + } + } + }); + }, + + update_ip_address(frm) { + // Don't auto-update if no DC selected + if (!frm.doc.domain_controller_for_ldap_acquisition) { + return; + } + + // Get effective IP address (original or NAT) + frappe.call({ + method: 'msp.msp.doctype.msp_documentation.msp_documentation.get_effective_ip', + args: { + it_object_name: frm.doc.domain_controller_for_ldap_acquisition, + use_nat: frm.doc.use_nat_address ? 1 : 0 + }, + callback: function(r) { + if (r.message && r.message.ip_address) { + frm.set_value('ip_address', r.message.ip_address); + } + } + }); + }, + + render_windows_update_report(frm) { + // Render Windows Update Report from stored JSON data + if (!frm.doc.windows_update_data_json) { + // No data yet - show placeholder + frm.fields_dict.windows_update_summary_stats.$wrapper.html(` +
+ Keine Windows Update Daten vorhanden. Klicken Sie auf "9. Windows Updates" um Daten abzurufen. +
+ `); + frm.fields_dict.windows_update_report.$wrapper.html(''); + return; + } + + try { + const data = JSON.parse(frm.doc.windows_update_data_json); + const compliance = data.compliance || {}; + const agents = data.agents || []; + const totals = compliance.totals || {installed: 0, pending: 0, failed: 0, total: 0}; + const status = compliance.status || 'green'; + const score = compliance.overall_score || 100; + const by_site = compliance.by_site || {}; + + // Helper: Parse TacticalRMM Datumsformat "MM DD YYYY HH:MM" + function parseRmmDate(dateStr) { + if (!dateStr) return null; + try { + const parts = dateStr.split(' '); + if (parts.length >= 3) { + const month = parseInt(parts[0]) - 1; // JS months are 0-indexed + const day = parseInt(parts[1]); + const year = parseInt(parts[2]); + let hour = 0, minute = 0; + if (parts.length >= 4 && parts[3].includes(':')) { + const timeParts = parts[3].split(':'); + hour = parseInt(timeParts[0]) || 0; + minute = parseInt(timeParts[1]) || 0; + } + return new Date(year, month, day, hour, minute); + } + } catch (e) {} + return null; + } + + // Helper: Parse ISO date (last_seen format) + function parseIsoDate(dateStr) { + if (!dateStr) return null; + try { + return new Date(dateStr); + } catch (e) {} + return null; + } + + // Problem-Erkennung + const now = new Date(); + const DAYS_THRESHOLD = 60; // Warnung wenn Updates älter als 60 Tage + const OFFLINE_THRESHOLD = 7; // Client gilt als "kürzlich online" wenn innerhalb 7 Tagen + const problems = { + serviceNotRunning: [], + outdatedUpdates: [], // Online aber lange keine Updates + offlineOutdated: [], // Offline UND lange keine Updates (erwartbar) + neverUpdated: [] + }; + + for (const agent of agents) { + // WU-Service Status prüfen + // "unknown" ist kein Problem (API liefert diese Info nicht), "error" ist ein Problem + const wuStatus = (agent.wuauserv_status || '').toLowerCase(); + if (wuStatus && wuStatus !== 'running' && wuStatus !== 'run' && wuStatus !== 'unknown') { + problems.serviceNotRunning.push({ + hostname: agent.hostname, + status: agent.wuauserv_status + }); + } + + // Last seen prüfen + const lastSeen = parseIsoDate(agent.last_seen); + const daysOffline = lastSeen ? Math.floor((now - lastSeen) / (1000 * 60 * 60 * 24)) : 9999; + const isRecentlyOnline = daysOffline <= OFFLINE_THRESHOLD; + + // Letztes Update prüfen + if (agent.patches_last_installed) { + const lastUpdate = parseRmmDate(agent.patches_last_installed); + if (lastUpdate) { + const daysSinceUpdate = Math.floor((now - lastUpdate) / (1000 * 60 * 60 * 24)); + if (daysSinceUpdate > DAYS_THRESHOLD) { + if (isRecentlyOnline) { + // PROBLEM: Client ist online aber hat lange keine Updates + problems.outdatedUpdates.push({ + hostname: agent.hostname, + lastUpdate: lastUpdate, + daysSince: daysSinceUpdate, + daysOffline: daysOffline + }); + } else { + // ERWARTET: Client ist offline, daher keine Updates möglich + problems.offlineOutdated.push({ + hostname: agent.hostname, + lastUpdate: lastUpdate, + daysSince: daysSinceUpdate, + daysOffline: daysOffline + }); + } + } + } + } else if (agent.patches && agent.patches.length > 0) { + // Hat Patches aber kein Installationsdatum - möglicherweise nie aktualisiert + const hasInstalledPatches = agent.patches.some(p => p.installed); + if (!hasInstalledPatches && agent.patches.length > 0) { + problems.neverUpdated.push({ + hostname: agent.hostname + }); + } + } + } + + const totalProblems = problems.serviceNotRunning.length + problems.outdatedUpdates.length + problems.neverUpdated.length; + + // Status text and colors + const statusText = {green: 'Alle Systeme aktuell', yellow: 'Updates ausstehend', red: 'Kritische Updates fehlen'}; + const statusBg = {green: '#d4edda', yellow: '#fff3cd', red: '#f8d7da'}; + const statusColor = {green: '#155724', yellow: '#856404', red: '#721c24'}; + + // Render Summary Stats with problems + let summaryHtml = ` +
+ + ${statusText[status]} + + + ${totals.installed} installiert | ${totals.pending} ausstehend | ${totals.failed} fehlgeschlagen + + `; + if (totalProblems > 0) { + summaryHtml += ` + + ⚠️ ${totalProblems} Problem${totalProblems > 1 ? 'e' : ''} erkannt + + `; + } + summaryHtml += `
`; + frm.fields_dict.windows_update_summary_stats.$wrapper.html(summaryHtml); + + // Store problems for use in report + window._wuProblems = problems; + + // Render Full Report + let reportHtml = ` + +
+
+
+
${score}%
+
Compliance Score
+
+
+
+
${totals.installed}
+
Installiert
+
+
+
${totals.pending}
+
Ausstehend
+
+
+
${totals.failed}
+
Fehlgeschlagen
+
+
+
${agents.length}
+
Computer
+
+
+
+ `; + + // Problem-Box wenn echte Probleme erkannt wurden (nur online Clients zählen als Problem) + if (totalProblems > 0) { + reportHtml += ` +
+
+

⚠️ Erkannte Probleme (${totalProblems})

+ `; + + if (problems.serviceNotRunning.length > 0) { + reportHtml += ` +
+ 🔴 Windows Update Service nicht aktiv (${problems.serviceNotRunning.length}): +
+ ${problems.serviceNotRunning.map(p => `${p.hostname} (${p.status})`).join(', ')} +
+
+ `; + } + + if (problems.outdatedUpdates.length > 0) { + reportHtml += ` +
+ 🔴 Online aber keine Updates seit über ${DAYS_THRESHOLD} Tagen (${problems.outdatedUpdates.length}): +
+ ${problems.outdatedUpdates.map(p => `${p.hostname} (${p.daysSince} Tage)`).join(', ')} +
+
+ `; + } + + if (problems.neverUpdated.length > 0) { + reportHtml += ` +
+ 🔴 Keine installierten Updates (${problems.neverUpdated.length}): +
+ ${problems.neverUpdated.map(p => p.hostname).join(', ')} +
+
+ `; + } + + reportHtml += ` +
+
+ `; + } + + // Info-Box für offline Clients mit alten Updates (kein echtes Problem, nur Info) + if (problems.offlineOutdated.length > 0) { + reportHtml += ` +
+
+

ℹ️ Offline-Clients mit veralteten Updates (${problems.offlineOutdated.length})

+

+ Diese Clients sind seit längerem offline und können daher keine Updates erhalten. +

+
+ ${problems.offlineOutdated.map(p => `${p.hostname} (${p.daysOffline} Tage offline, ${p.daysSince} Tage ohne Update)`).join(', ')} +
+
+
+ `; + } + + // Site Overview Table + const siteNames = Object.keys(by_site).sort(); + if (siteNames.length > 0) { + reportHtml += ` +
+

Übersicht nach Standort

+ + + + + + + + + + + + + `; + for (const siteName of siteNames) { + const siteInfo = by_site[siteName]; + reportHtml += ` + + + + + + + + + `; + } + reportHtml += `
StandortComputerComplianceInstalliertAusstehendFehlgeschlagen
${siteName}${siteInfo.agents}${siteInfo.score}%${siteInfo.installed}${siteInfo.pending}${siteInfo.failed}
`; + } + + // Computer Details Table with clickable rows + reportHtml += ` +
+

Details pro Computer (Klicken für Details)

+ + + + + + + + + + + + + + + + `; + + // Store agents data for modal access + window._wuAgentsData = {}; + + const sortedAgents = agents.sort((a, b) => (a.site_name || '').localeCompare(b.site_name || '')); + for (let i = 0; i < sortedAgents.length; i++) { + const agent = sortedAgents[i]; + const agentId = 'agent_' + i; + window._wuAgentsData[agentId] = agent; + + const summary = agent.summary || {}; + const pending = summary.pending || 0; + const installed = summary.installed || 0; + const criticalPending = summary.critical_pending || 0; + + // Status-Berechnung + let statusClass = 'green'; + let statusTextAgent = 'Aktuell'; + if (criticalPending > 0) { + statusClass = 'red'; + statusTextAgent = 'Kritisch'; + } else if (pending > 0) { + statusClass = 'yellow'; + statusTextAgent = 'Ausstehend'; + } + + // WU-Service Status mit Farbcodierung + const wuStatus = agent.wuauserv_status || ''; + const wuStatusLower = wuStatus.toLowerCase(); + let wuServiceHtml = '-'; + if (wuStatus) { + if (wuStatusLower === 'running' || wuStatusLower === 'run') { + wuServiceHtml = `✓ Aktiv`; + } else if (wuStatusLower === 'unknown') { + // API liefert diese Info nicht - neutral anzeigen + wuServiceHtml = ``; + } else if (wuStatusLower === 'error') { + wuServiceHtml = `⚠ Fehler`; + } else { + // Anderer Status (stopped, disabled, etc.) + wuServiceHtml = `⚠ ${wuStatus}`; + } + } + + // Last Seen und Online-Status prüfen + const lastSeen = parseIsoDate(agent.last_seen); + const daysOffline = lastSeen ? Math.floor((now - lastSeen) / (1000 * 60 * 60 * 24)) : 9999; + const isRecentlyOnline = daysOffline <= OFFLINE_THRESHOLD; + + // Letztes Update mit Farbcodierung - Berücksichtigung von Online/Offline + let lastUpdateHtml = '-'; + let daysSinceUpdate = null; + if (agent.patches_last_installed) { + try { + const lastUpdate = parseRmmDate(agent.patches_last_installed); + if (lastUpdate) { + daysSinceUpdate = Math.floor((now - lastUpdate) / (1000 * 60 * 60 * 24)); + const dateStr = lastUpdate.toLocaleDateString('de-DE'); + + if (daysSinceUpdate > DAYS_THRESHOLD) { + if (isRecentlyOnline) { + // PROBLEM: Online aber keine Updates + lastUpdateHtml = `⚠ ${dateStr}`; + } else { + // INFO: Offline, daher erwartbar + lastUpdateHtml = `${dateStr}`; + } + } else if (daysSinceUpdate > 30) { + lastUpdateHtml = `${dateStr}`; + } else { + lastUpdateHtml = `${dateStr}`; + } + } else { + lastUpdateHtml = agent.patches_last_installed; + } + } catch(e) { + lastUpdateHtml = agent.patches_last_installed; + } + } + + // Neustart erforderlich + const needsReboot = agent.needs_reboot ? 'Ja' : 'Nein'; + + // Zeilen-Hintergrund bei Problemen - nur für ONLINE Clients mit alten Updates + let rowStyle = 'cursor: pointer;'; + // "unknown" ist kein Problem, nur "error" oder andere bekannte Probleme + const hasServiceProblem = wuStatus && wuStatusLower !== 'running' && wuStatusLower !== 'run' && wuStatusLower !== 'unknown'; + const hasUpdateProblem = daysSinceUpdate !== null && daysSinceUpdate > DAYS_THRESHOLD && isRecentlyOnline; + const isOfflineOutdated = daysSinceUpdate !== null && daysSinceUpdate > DAYS_THRESHOLD && !isRecentlyOnline; + if (hasServiceProblem) { + rowStyle += ' background: #f8d7da;'; + } else if (hasUpdateProblem) { + rowStyle += ' background: #fff3cd;'; + } else if (isOfflineOutdated) { + rowStyle += ' background: #f8f9fa;'; // Grau für Offline-Clients + } + + reportHtml += ` + + + + + + + + + + + + `; + } + reportHtml += `
ComputerStandortBetriebssystemWU-ServiceLetztes UpdateInstalliertAusstehendNeustartStatus
${agent.hostname || 'Unknown'}${agent.site_name || 'Unknown'}${agent.operating_system || 'Unknown'}${wuServiceHtml}${lastUpdateHtml}${installed}${pending}${needsReboot}${statusTextAgent}
`; + + // Helper functions für Modal (müssen in window scope sein) + window._parseRmmDate = parseRmmDate; + window._parseIsoDate = parseIsoDate; + + // Add click handler function + window._showAgentDetails = function(agentId) { + const agent = window._wuAgentsData[agentId]; + if (!agent) return; + + const installedPatches = agent.installed_patches || agent.patches?.filter(p => p.installed) || []; + const pendingPatches = agent.pending_patches || agent.patches?.filter(p => !p.installed) || []; + + // Parse dates correctly + const lastSeenDate = window._parseIsoDate(agent.last_seen); + const lastUpdateDate = window._parseRmmDate(agent.patches_last_installed); + const lastSeenStr = lastSeenDate ? lastSeenDate.toLocaleString('de-DE') : '-'; + const lastUpdateStr = lastUpdateDate ? lastUpdateDate.toLocaleString('de-DE') : (agent.patches_last_installed || '-'); + + // Calculate days for context + const now = new Date(); + const daysOffline = lastSeenDate ? Math.floor((now - lastSeenDate) / (1000 * 60 * 60 * 24)) : null; + const daysSinceUpdate = lastUpdateDate ? Math.floor((now - lastUpdateDate) / (1000 * 60 * 60 * 24)) : null; + + // Online-Status indicator + let onlineStatus = '-'; + if (daysOffline !== null) { + if (daysOffline === 0) { + onlineStatus = 'Online (heute)'; + } else if (daysOffline <= 7) { + onlineStatus = `Kürzlich online (vor ${daysOffline} Tagen)`; + } else { + onlineStatus = `Offline seit ${daysOffline} Tagen`; + } + } + + let detailHtml = ` +
+
+
+ Betriebssystem: ${agent.operating_system || '-'}
+ Standort: ${agent.site_name || '-'}
+ Online-Status: ${onlineStatus}
+ Letzter Kontakt: ${lastSeenStr}
+ Letztes Update: ${lastUpdateStr}${daysSinceUpdate !== null ? ` (vor ${daysSinceUpdate} Tagen)` : ''} +
+
+ WU-Service: ${agent.wuauserv_status || '-'}
+ Neustart erforderlich: ${agent.needs_reboot ? 'Ja' : 'Nein'}
+ Updates ausstehend: ${agent.has_patches_pending ? 'Ja' : 'Nein'} +
+
+ `; + + // Pending Updates Table + if (pendingPatches.length > 0) { + detailHtml += ` +

⚠️ Ausstehende Updates (${pendingPatches.length})

+
+ + + + + + + + + + + `; + for (const patch of pendingPatches.sort((a,b) => { + const sev = {Critical: 0, Important: 1, Moderate: 2, Low: 3, Unspecified: 4}; + return (sev[a.severity] || 4) - (sev[b.severity] || 4); + })) { + const sevColor = patch.severity === 'Critical' ? '#dc3545' : patch.severity === 'Important' ? '#ffc107' : '#6c757d'; + detailHtml += ` + + + + + + + `; + } + detailHtml += `
KBTitelSchweregradKategorie
${patch.kb || '-'}${(patch.title || '').substring(0, 50)}${(patch.title || '').length > 50 ? '...' : ''}${patch.severity || '-'}${patch.category || '-'}
`; + } else { + detailHtml += `

✅ Keine ausstehenden Updates

`; + } + + // Installed Updates Table + if (installedPatches.length > 0) { + detailHtml += ` +

✅ Installierte Updates (${installedPatches.length})

+
+ + + + + + + + + + + `; + for (const patch of installedPatches) { + let installDate = patch.date_installed || ''; + if (installDate) { + try { installDate = new Date(installDate).toLocaleDateString('de-DE'); } catch(e) {} + } + detailHtml += ` + + + + + + + `; + } + detailHtml += `
KBTitelSchweregradInstalliert am
${patch.kb || '-'}${(patch.title || '').substring(0, 50)}${(patch.title || '').length > 50 ? '...' : ''}${patch.severity || '-'}${installDate || '-'}
`; + } + + detailHtml += `
`; + + frappe.msgprint({ + title: __('Windows Updates: ') + agent.hostname, + message: detailHtml, + wide: true + }); + }; + + // Critical Patches List + const criticalPatches = []; + for (const agent of agents) { + for (const patch of (agent.patches || [])) { + if (!patch.installed && (patch.severity === 'Critical' || patch.severity === 'Important')) { + criticalPatches.push({ + hostname: agent.hostname, + kb: patch.kb || '', + title: patch.title || '', + severity: patch.severity || '', + category: patch.category || '' + }); + } + } + } + + if (criticalPatches.length > 0) { + reportHtml += ` +
+

Ausstehende kritische und wichtige Updates

+ + + + + + + + + + + + `; + + criticalPatches.sort((a, b) => { + if (a.severity === 'Critical' && b.severity !== 'Critical') return -1; + if (a.severity !== 'Critical' && b.severity === 'Critical') return 1; + return a.hostname.localeCompare(b.hostname); + }); + + for (const patch of criticalPatches) { + const severityClass = patch.severity === 'Critical' ? 'red' : 'yellow'; + const titleTruncated = patch.title.length > 60 ? patch.title.substring(0, 60) + '...' : patch.title; + reportHtml += ` + + + + + + + + `; + } + reportHtml += `
ComputerKBTitelSchweregradKategorie
${patch.hostname}${patch.kb}${titleTruncated}${patch.severity}${patch.category}
`; + } + + reportHtml += `
`; + + frm.fields_dict.windows_update_report.$wrapper.html(reportHtml); + + } catch (e) { + console.error('Error rendering Windows Update Report:', e); + frm.fields_dict.windows_update_summary_stats.$wrapper.html(` +
+ Fehler beim Rendern der Windows Update Daten: ${e.message} +
+ `); + } } }); + +// ==================== RMM Matching Dialog ==================== + +function show_rmm_matching_dialog(frm, data) { + const suggestions = data.suggestions || []; + const agents = data.agents || []; + const rmm_instance = data.rmm_instance; + + if (suggestions.length === 0) { + frappe.msgprint({ + title: __('Keine unverknüpften IT Objects'), + message: __('Alle IT Objects dieser Landscape sind bereits mit RMM Agents verknüpft.'), + indicator: 'green' + }); + return; + } + + if (agents.length === 0) { + frappe.msgprint({ + title: __('Keine verfügbaren Agents'), + message: __('Es sind keine unverknüpften RMM Agents verfügbar.'), + indicator: 'orange' + }); + return; + } + + // Build agent options for select + let agentOptionsHtml = ''; + agents.forEach(agent => { + agentOptionsHtml += ``; + }); + + // Build table rows + let tableRows = ''; + suggestions.forEach((s, idx) => { + const confidenceClass = s.confidence >= 90 ? 'success' : s.confidence >= 70 ? 'warning' : s.confidence >= 50 ? 'info' : 'secondary'; + const confidenceText = s.confidence > 0 ? `${s.confidence}%` : '-'; + + let suggestedDisplay = '-'; + if (s.suggested_hostname) { + suggestedDisplay = `${s.suggested_hostname}
${s.suggested_ip || ''}`; + } + + tableRows += ` + + + + + + ${s.it_object_title}
+ ${s.it_object_ip || ''} ${s.it_object_type ? '| ' + s.it_object_type : ''} + + + ${suggestedDisplay} + + + ${confidenceText} + ${s.match_reason ? `
${s.match_reason}` : ''} + + + + + + `; + }); + + const dialogHtml = ` + +
+
+
+
${data.unmatched_count}
+
Unverknüpfte IT Objects
+
+
+
${data.available_agents_count}
+
Verfügbare Agents
+
+
+
${suggestions.filter(s => s.confidence >= 70).length}
+
Gute Vorschläge (≥70%)
+
+
+
+ + + +
+
+ + + + + + + + + + + + ${tableRows} + +
+ + IT ObjectVorgeschlagener AgentConfidenceManuell auswählen
+
+
+ `; + + // Store suggestions for later use + window._rmmMatchingSuggestions = suggestions; + + const dialog = new frappe.ui.Dialog({ + title: __('IT Objects mit RMM Agents verknüpfen'), + size: 'extra-large', + fields: [ + { + fieldtype: 'HTML', + fieldname: 'matching_content', + options: dialogHtml + } + ], + primary_action_label: __('Ausgewählte verknüpfen'), + primary_action: function() { + apply_selected_mappings(dialog, rmm_instance); + } + }); + + dialog.show(); + + // Setup event handlers after dialog is shown + setTimeout(() => { + const $wrapper = dialog.$wrapper; + + // Pre-select suggested agents in dropdowns + suggestions.forEach((s, idx) => { + if (s.suggested_agent_id) { + $wrapper.find(`.rmm-agent-select[data-idx="${idx}"]`).val(s.suggested_agent_id); + } + }); + + // Select all checkbox + $wrapper.find('#select-all-checkbox').on('change', function() { + $wrapper.find('.rmm-match-select').prop('checked', $(this).prop('checked')); + }); + + // Select high confidence + $wrapper.find('#select-high-confidence').on('click', function() { + suggestions.forEach((s, idx) => { + if (s.confidence >= 70) { + $wrapper.find(`.rmm-match-select[data-idx="${idx}"]`).prop('checked', true); + } + }); + }); + + // Select all suggestions + $wrapper.find('#select-all-suggestions').on('click', function() { + suggestions.forEach((s, idx) => { + if (s.suggested_agent_id) { + $wrapper.find(`.rmm-match-select[data-idx="${idx}"]`).prop('checked', true); + } + }); + }); + + // Deselect all + $wrapper.find('#deselect-all').on('click', function() { + $wrapper.find('.rmm-match-select').prop('checked', false); + $wrapper.find('#select-all-checkbox').prop('checked', false); + }); + + // When manual selection changes, update checkbox + $wrapper.find('.rmm-agent-select').on('change', function() { + const idx = $(this).data('idx'); + const hasValue = $(this).val() !== ''; + $wrapper.find(`.rmm-match-select[data-idx="${idx}"]`).prop('checked', hasValue); + }); + + }, 100); +} + +function apply_selected_mappings(dialog, rmm_instance) { + const $wrapper = dialog.$wrapper; + const suggestions = window._rmmMatchingSuggestions || []; + const mappings = []; + + suggestions.forEach((s, idx) => { + const isSelected = $wrapper.find(`.rmm-match-select[data-idx="${idx}"]`).prop('checked'); + if (!isSelected) return; + + const selectedAgentId = $wrapper.find(`.rmm-agent-select[data-idx="${idx}"]`).val(); + if (!selectedAgentId) return; + + mappings.push({ + it_object: s.it_object, + agent_id: selectedAgentId + }); + }); + + if (mappings.length === 0) { + frappe.msgprint(__('Keine Zuordnungen ausgewählt.')); + return; + } + + frappe.dom.freeze(__('Verknüpfe {0} IT Objects...', [mappings.length])); + + frappe.call({ + method: 'msp.rmm_import.apply_rmm_mappings', + args: { + mappings: JSON.stringify(mappings), + rmm_instance: rmm_instance + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Fehler'), + indicator: 'red', + message: __('Verknüpfung fehlgeschlagen.') + }); + return; + } + + if (r.message) { + const result = r.message; + let message = __('Erfolgreich verknüpft: {0} von {1}', [result.success, result.total]); + if (result.errors && result.errors.length > 0) { + message += '

Fehler:
' + result.errors.join('
'); + } + + frappe.msgprint({ + title: __('Verknüpfung abgeschlossen'), + indicator: result.errors.length > 0 ? 'orange' : 'green', + message: message + }); + + dialog.hide(); + } + } + }); +} diff --git a/msp/msp/doctype/msp_documentation/msp_documentation.json b/msp/msp/doctype/msp_documentation/msp_documentation.json index edc1b18..e10f421 100644 --- a/msp/msp/doctype/msp_documentation/msp_documentation.json +++ b/msp/msp/doctype/msp_documentation/msp_documentation.json @@ -12,6 +12,7 @@ "customer_name", "tactical_rmm_tenant_caption", "tactical_rmm_site_name", + "rmm_instance", "generation_date", "introduction_text", "it_objects", @@ -22,6 +23,7 @@ "data_acquisition_section", "credentials_for_ldap_acquisistion", "domain_controller_for_ldap_acquisition", + "use_nat_address", "column_break_ydaj", "upn", "ip_address", @@ -30,7 +32,15 @@ "ad_computer_data_json", "ad_user_data_json", "output", - "windows_11_check_output" + "windows_11_check_output", + "windows_update_section", + "windows_update_compliance_score", + "windows_update_last_sync", + "windows_update_column_break", + "windows_update_summary_stats", + "windows_update_report_section", + "windows_update_report", + "windows_update_data_json" ], "fields": [ { @@ -101,6 +111,13 @@ "fieldtype": "Data", "label": "Tactical RMM Site Name" }, + { + "description": "Overrides the RMM Instance from the linked IT Landscape. Leave empty to inherit.", + "fieldname": "rmm_instance", + "fieldtype": "Link", + "label": "RMM Instance (Override)", + "options": "RMM Instance" + }, { "collapsible": 1, "fieldname": "json_data_section", @@ -139,6 +156,14 @@ "label": "Domain Controller for LDAP Acquisition", "options": "IT Object" }, + { + "default": "0", + "depends_on": "domain_controller_for_ldap_acquisition", + "description": "Use the 1:1 NAT address instead of the direct IP address for LDAP connection", + "fieldname": "use_nat_address", + "fieldtype": "Check", + "label": "Use 1:1 NAT Address" + }, { "fieldname": "column_break_ydaj", "fieldtype": "Column Break" @@ -163,6 +188,49 @@ "fieldname": "windows_11_check_output", "fieldtype": "HTML", "label": "Windows 11 Check Output" + }, + { + "fieldname": "windows_update_section", + "fieldtype": "Section Break", + "label": "Windows Update Status" + }, + { + "fieldname": "windows_update_compliance_score", + "fieldtype": "Percent", + "label": "Compliance Score", + "read_only": 1 + }, + { + "fieldname": "windows_update_last_sync", + "fieldtype": "Datetime", + "label": "Letzter Update-Sync", + "read_only": 1 + }, + { + "fieldname": "windows_update_column_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "windows_update_summary_stats", + "fieldtype": "HTML", + "label": "Update-Zusammenfassung" + }, + { + "collapsible": 1, + "fieldname": "windows_update_report_section", + "fieldtype": "Section Break", + "label": "Windows Update Details" + }, + { + "fieldname": "windows_update_report", + "fieldtype": "HTML", + "label": "Windows Update Report" + }, + { + "fieldname": "windows_update_data_json", + "fieldtype": "Long Text", + "hidden": 1, + "label": "Windows Update Data (JSON)" } ], "index_web_pages_for_search": 1, diff --git a/msp/msp/doctype/msp_documentation/msp_documentation.py b/msp/msp/doctype/msp_documentation/msp_documentation.py index a84c117..a1398f1 100644 --- a/msp/msp/doctype/msp_documentation/msp_documentation.py +++ b/msp/msp/doctype/msp_documentation/msp_documentation.py @@ -1,8 +1,108 @@ # Copyright (c) 2023, itsdave GmbH and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document +from ipaddress import IPv4Address, IPv4Network + class MSPDocumentation(Document): pass + + +@frappe.whitelist() +def check_nat_available(it_object_name): + """ + Prüft ob für das IT Object ein 1:1 NAT-Netzwerk konfiguriert ist. + + Args: + it_object_name: Name des IT Objects (Domain Controller) + + Returns: + dict mit nat_available (bool) und optional nat_network_address + """ + try: + it_object = frappe.get_doc("IT Object", it_object_name) + + if not it_object.main_ip: + return {"nat_available": False} + + ip_address = frappe.get_doc("IP Address", it_object.main_ip) + + if not ip_address.ip_network: + return {"nat_available": False} + + ip_network = frappe.get_doc("IP Network", ip_address.ip_network) + + nat_network_address = ip_network.get("1_to_1_nat_network_address") + nat_available = bool(nat_network_address) + + return { + "nat_available": nat_available, + "nat_network_address": nat_network_address if nat_available else None + } + except Exception as e: + frappe.log_error(f"Fehler bei NAT-Verfügbarkeitsprüfung: {str(e)}", "check_nat_available") + return {"nat_available": False} + + +@frappe.whitelist() +def get_effective_ip(it_object_name, use_nat=False): + """ + Gibt die effektive IP-Adresse zurück (Original oder NAT). + + Bei 1:1 NAT wird der Host-Teil der Original-IP auf das NAT-Netzwerk übertragen. + Beispiel: Original 192.168.1.10 in Netz 192.168.1.0/24, NAT-Netz 10.0.0.0 + -> NAT-IP: 10.0.0.10 + + Args: + it_object_name: Name des IT Objects (Domain Controller) + use_nat: Wenn True, wird die NAT-Adresse berechnet + + Returns: + dict mit ip_address (str) und is_nat (bool) + """ + # use_nat kann als String "0" oder "1" übergeben werden + if isinstance(use_nat, str): + use_nat = use_nat in ("1", "true", "True") + + try: + it_object = frappe.get_doc("IT Object", it_object_name) + + if not it_object.main_ip: + return {"ip_address": None, "is_nat": False} + + ip_address_doc = frappe.get_doc("IP Address", it_object.main_ip) + original_ip = ip_address_doc.ip_address + + if not use_nat: + return {"ip_address": original_ip, "is_nat": False} + + # NAT-IP berechnen + if not ip_address_doc.ip_network: + return {"ip_address": original_ip, "is_nat": False} + + ip_network = frappe.get_doc("IP Network", ip_address_doc.ip_network) + nat_network_address = ip_network.get("1_to_1_nat_network_address") + + if not nat_network_address: + return {"ip_address": original_ip, "is_nat": False} + + # Host-Teil extrahieren und auf NAT-Netz anwenden + original_network = IPv4Network( + f"{ip_network.network_address}/{ip_network.cidr_mask}", + strict=False + ) + original_ip_obj = IPv4Address(original_ip) + + # Host-Teil berechnen (Offset vom Netzwerk-Start) + host_part = int(original_ip_obj) - int(original_network.network_address) + + # NAT-IP berechnen + nat_ip = IPv4Address(int(IPv4Address(nat_network_address)) + host_part) + + return {"ip_address": str(nat_ip), "is_nat": True} + + except Exception as e: + frappe.log_error(f"Fehler bei IP-Berechnung: {str(e)}", "get_effective_ip") + return {"ip_address": None, "is_nat": False} diff --git a/msp/msp/doctype/msp_settings/msp_settings.json b/msp/msp/doctype/msp_settings/msp_settings.json index fb0825e..a65cd41 100644 --- a/msp/msp/doctype/msp_settings/msp_settings.json +++ b/msp/msp/doctype/msp_settings/msp_settings.json @@ -84,17 +84,21 @@ { "fieldname": "tactical_rmm_integration_section", "fieldtype": "Section Break", - "label": "Tactical RMM Integration" + "label": "Tactical RMM Integration (DEPRECATED)" }, { + "description": "DEPRECATED: Use RMM Instance doctype instead. This field will be removed in a future version.", "fieldname": "api_url", "fieldtype": "Data", - "label": "API URL" + "label": "API URL (DEPRECATED)", + "read_only": 1 }, { + "description": "DEPRECATED: Use RMM Instance doctype instead. This field will be removed in a future version.", "fieldname": "api_key", "fieldtype": "Password", - "label": "API Key" + "label": "API Key (DEPRECATED)", + "read_only": 1 } ], "index_web_pages_for_search": 1, diff --git a/msp/msp/doctype/rmm_import_agent/__init__.py b/msp/msp/doctype/rmm_import_agent/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/rmm_import_agent/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/rmm_import_agent/rmm_import_agent.json b/msp/msp/doctype/rmm_import_agent/rmm_import_agent.json new file mode 100644 index 0000000..70aac35 --- /dev/null +++ b/msp/msp/doctype/rmm_import_agent/rmm_import_agent.json @@ -0,0 +1,162 @@ +{ + "actions": [], + "creation": "2026-01-07 10:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "selected", + "hostname", + "monitoring_type", + "site_name", + "operating_system", + "local_ip", + "status", + "action", + "ad_matched", + "section_break_result", + "existing_it_object", + "import_result", + "import_message", + "section_break_hidden", + "agent_id", + "ad_guid", + "ad_account_status", + "ad_last_logon" + ], + "fields": [ + { + "default": "0", + "fieldname": "selected", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Select" + }, + { + "fieldname": "agent_id", + "fieldtype": "Data", + "label": "Agent ID", + "read_only": 1 + }, + { + "fieldname": "hostname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Hostname", + "read_only": 1 + }, + { + "fieldname": "monitoring_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Type", + "read_only": 1 + }, + { + "fieldname": "site_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Site", + "read_only": 1 + }, + { + "fieldname": "operating_system", + "fieldtype": "Data", + "label": "OS", + "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Data", + "label": "RMM Status", + "read_only": 1 + }, + { + "fieldname": "local_ip", + "fieldtype": "Data", + "label": "Local IP", + "read_only": 1 + }, + { + "fieldname": "action", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Action", + "options": "Create\nUpdate\nSkip", + "read_only": 1 + }, + { + "fieldname": "existing_it_object", + "fieldtype": "Link", + "label": "Existing IT Object", + "options": "IT Object", + "read_only": 1 + }, + { + "fieldname": "import_result", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Result", + "options": "\nPending\nSuccess\nFailed", + "read_only": 1 + }, + { + "fieldname": "import_message", + "fieldtype": "Small Text", + "label": "Message", + "read_only": 1 + }, + { + "fieldname": "section_break_result", + "fieldtype": "Section Break", + "label": "Import Result" + }, + { + "fieldname": "section_break_hidden", + "fieldtype": "Section Break", + "hidden": 1 + }, + { + "default": "0", + "description": "AD-Match gefunden", + "fieldname": "ad_matched", + "fieldtype": "Check", + "in_list_view": 1, + "label": "AD Match", + "read_only": 1 + }, + { + "fieldname": "ad_guid", + "fieldtype": "Data", + "hidden": 1, + "label": "AD GUID", + "read_only": 1 + }, + { + "fieldname": "ad_account_status", + "fieldtype": "Data", + "hidden": 1, + "label": "AD Account Status", + "read_only": 1 + }, + { + "fieldname": "ad_last_logon", + "fieldtype": "Data", + "hidden": 1, + "label": "AD Last Logon", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-01-22 10:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "RMM Import Agent", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/msp/msp/doctype/rmm_import_agent/rmm_import_agent.py b/msp/msp/doctype/rmm_import_agent/rmm_import_agent.py new file mode 100644 index 0000000..e11b7f4 --- /dev/null +++ b/msp/msp/doctype/rmm_import_agent/rmm_import_agent.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class RMMImportAgent(Document): + pass diff --git a/msp/msp/doctype/rmm_import_session/__init__.py b/msp/msp/doctype/rmm_import_session/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/rmm_import_session/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/rmm_import_session/rmm_import_session.js b/msp/msp/doctype/rmm_import_session/rmm_import_session.js new file mode 100644 index 0000000..8f1439a --- /dev/null +++ b/msp/msp/doctype/rmm_import_session/rmm_import_session.js @@ -0,0 +1,226 @@ +// Copyright (c) 2026, itsdave GmbH and contributors +// For license information, please see license.txt + +frappe.ui.form.on('RMM Import Session', { + refresh: function(frm) { + // Set indicator color based on status + if (frm.doc.status === 'Completed') { + frm.page.set_indicator(__('Completed'), 'green'); + } else if (frm.doc.status === 'In Progress') { + frm.page.set_indicator(__('In Progress'), 'orange'); + } else if (frm.doc.status === 'Failed') { + frm.page.set_indicator(__('Failed'), 'red'); + } else { + frm.page.set_indicator(__('Draft'), 'blue'); + } + + // Show AD statistics in dashboard if AD data was included + if (frm.doc.include_ad_data) { + frm.dashboard.add_indicator(__('AD Matched: {0}', [frm.doc.ad_matched_count || 0]), 'green'); + frm.dashboard.add_indicator(__('Only RMM: {0}', [frm.doc.rmm_only_count || 0]), 'blue'); + frm.dashboard.add_indicator(__('Only AD: {0}', [frm.doc.ad_only_count || 0]), 'orange'); + } + + // Link back to IT Landscape + if (frm.doc.it_landscape) { + frm.add_custom_button(__('Open IT Landscape'), function() { + frappe.set_route('Form', 'IT Landscape', frm.doc.it_landscape); + }); + } + + // Only show action buttons if status is Draft + if (frm.doc.status === 'Draft') { + // Primary action: Execute Import + frm.add_custom_button(__('Execute Import'), function() { + let selected_count = frm.doc.agent_selection.filter(a => a.selected).length; + if (selected_count === 0) { + frappe.msgprint(__('Please select at least one agent to import.')); + return; + } + + frappe.confirm( + __('Import {0} selected agents?', [selected_count]), + function() { + frm.call({ + method: 'msp.rmm_import.execute_import', + args: { + session_name: frm.doc.name + }, + freeze: true, + freeze_message: __('Importing agents...'), + callback: function(r) { + if (r.message) { + frappe.show_alert({ + message: __('Import completed: {0} created, {1} updated, {2} failed', + [r.message.created, r.message.updated, r.message.failed]), + indicator: r.message.failed > 0 ? 'orange' : 'green' + }); + frm.reload_doc(); + } + } + }); + } + ); + }).addClass('btn-primary'); + + // Selection buttons + frm.add_custom_button(__('Select All New'), function() { + frm.doc.agent_selection.forEach(row => { + if (row.action === 'Create') { + row.selected = 1; + } + }); + frm.refresh_field('agent_selection'); + frm.dirty(); + }, __('Selection')); + + frm.add_custom_button(__('Select All Updates'), function() { + frm.doc.agent_selection.forEach(row => { + if (row.action === 'Update') { + row.selected = 1; + } + }); + frm.refresh_field('agent_selection'); + frm.dirty(); + }, __('Selection')); + + frm.add_custom_button(__('Select All'), function() { + frm.doc.agent_selection.forEach(row => { + if (row.action !== 'Skip') { + row.selected = 1; + } + }); + frm.refresh_field('agent_selection'); + frm.dirty(); + }, __('Selection')); + + frm.add_custom_button(__('Deselect All'), function() { + frm.doc.agent_selection.forEach(row => { + row.selected = 0; + }); + frm.refresh_field('agent_selection'); + frm.dirty(); + }, __('Selection')); + + // AD-based selection buttons (only if AD data is included) + if (frm.doc.include_ad_data) { + frm.add_custom_button(__('Select AD-Matched'), function() { + frm.doc.agent_selection.forEach(row => { + if (row.ad_matched) { + row.selected = 1; + } + }); + frm.refresh_field('agent_selection'); + frm.dirty(); + }, __('Selection')); + + frm.add_custom_button(__('Select Without AD-Match'), function() { + frm.doc.agent_selection.forEach(row => { + if (!row.ad_matched && row.action !== 'Skip') { + row.selected = 1; + } + }); + frm.refresh_field('agent_selection'); + frm.dirty(); + }, __('Selection')); + + frm.add_custom_button(__('Skip Disabled AD'), function() { + let count = 0; + frm.doc.agent_selection.forEach(row => { + if (row.ad_account_status === 'Disabled') { + row.selected = 0; + count++; + } + }); + frm.refresh_field('agent_selection'); + frm.dirty(); + frappe.show_alert({ + message: __('Deselected {0} agents with disabled AD accounts', [count]), + indicator: 'blue' + }); + }, __('Selection')); + } + + // Refresh agents button + frm.add_custom_button(__('Refresh Agents'), function() { + frm.call({ + method: 'msp.rmm_import.refresh_import_session', + args: { + session_name: frm.doc.name + }, + freeze: true, + freeze_message: __('Refreshing agents from RMM...'), + callback: function(r) { + if (r.message) { + frappe.show_alert({ + message: __('Agents refreshed: {0} total', [r.message.agent_count]), + indicator: 'green' + }); + frm.reload_doc(); + } + } + }); + }); + } + + // Style the agent rows based on action and AD match status + frm.fields_dict.agent_selection.$wrapper.find('.grid-row').each(function() { + let row = $(this); + let idx = row.data('idx'); + if (!idx) return; + + let agent_row = frm.doc.agent_selection[idx - 1]; + if (!agent_row) return; + + row.removeClass('indicator-green indicator-blue indicator-gray'); + row.css('background-color', ''); + + // Style based on action + if (agent_row.action === 'Create') { + row.addClass('indicator-green'); + } else if (agent_row.action === 'Update') { + row.addClass('indicator-blue'); + } else if (agent_row.action === 'Skip') { + row.addClass('indicator-gray'); + } + + // Highlight AD-matched rows with a subtle green background + if (frm.doc.include_ad_data && agent_row.ad_matched) { + row.css('background-color', 'rgba(40, 167, 69, 0.1)'); + } + + // Warning style for disabled AD accounts + if (agent_row.ad_account_status === 'Disabled') { + row.css('background-color', 'rgba(255, 193, 7, 0.15)'); + } + }); + }, + + before_save: function(frm) { + // Update statistics before save + let total = frm.doc.agent_selection ? frm.doc.agent_selection.length : 0; + let to_create = 0; + let to_update = 0; + let skipped = 0; + + if (frm.doc.agent_selection) { + frm.doc.agent_selection.forEach(row => { + if (row.action === 'Create') to_create++; + else if (row.action === 'Update') to_update++; + else if (row.action === 'Skip') skipped++; + }); + } + + frm.doc.agents_total = total; + frm.doc.agents_to_create = to_create; + frm.doc.agents_to_update = to_update; + frm.doc.agents_skipped = skipped; + } +}); + +frappe.ui.form.on('RMM Import Agent', { + selected: function(frm, cdt, cdn) { + // Optional: Auto-save when selection changes + // frm.dirty(); + } +}); diff --git a/msp/msp/doctype/rmm_import_session/rmm_import_session.json b/msp/msp/doctype/rmm_import_session/rmm_import_session.json new file mode 100644 index 0000000..20fa841 --- /dev/null +++ b/msp/msp/doctype/rmm_import_session/rmm_import_session.json @@ -0,0 +1,270 @@ +{ + "actions": [], + "autoname": "RMMIMP-.#####", + "creation": "2026-01-07 10:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "source_section", + "documentation", + "rmm_instance", + "it_landscape", + "column_break_source", + "filter_client", + "filter_site", + "status", + "include_ad_data", + "statistics_section", + "agents_total", + "agents_to_create", + "column_break_stats", + "agents_to_update", + "agents_skipped", + "ad_statistics_section", + "ad_matched_count", + "ad_only_count", + "ad_column_break_stats", + "rmm_only_count", + "agents_section", + "agent_selection", + "results_section", + "agents_created", + "agents_updated", + "column_break_results", + "agents_failed", + "import_log" + ], + "fields": [ + { + "fieldname": "source_section", + "fieldtype": "Section Break", + "label": "Import Source" + }, + { + "fieldname": "documentation", + "fieldtype": "Link", + "in_list_view": 1, + "label": "MSP Documentation", + "options": "MSP Documentation", + "read_only": 1 + }, + { + "fieldname": "rmm_instance", + "fieldtype": "Link", + "in_list_view": 1, + "label": "RMM Instance", + "options": "RMM Instance", + "read_only": 1 + }, + { + "fieldname": "it_landscape", + "fieldtype": "Link", + "in_list_view": 1, + "label": "IT Landscape", + "options": "IT Landscape", + "read_only": 1 + }, + { + "fieldname": "column_break_source", + "fieldtype": "Column Break" + }, + { + "fieldname": "filter_client", + "fieldtype": "Data", + "label": "Client Filter", + "read_only": 1 + }, + { + "fieldname": "filter_site", + "fieldtype": "Data", + "label": "Site Filter", + "read_only": 1 + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Draft\nIn Progress\nCompleted\nFailed", + "read_only": 1 + }, + { + "default": "0", + "description": "AD-Daten wurden bei diesem Import einbezogen", + "fieldname": "include_ad_data", + "fieldtype": "Check", + "label": "AD-Daten einbezogen", + "read_only": 1 + }, + { + "fieldname": "statistics_section", + "fieldtype": "Section Break", + "label": "Statistics" + }, + { + "default": "0", + "fieldname": "agents_total", + "fieldtype": "Int", + "label": "Total Agents", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agents_to_create", + "fieldtype": "Int", + "label": "To Create", + "read_only": 1 + }, + { + "fieldname": "column_break_stats", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "agents_to_update", + "fieldtype": "Int", + "label": "To Update", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agents_skipped", + "fieldtype": "Int", + "label": "Skipped", + "read_only": 1 + }, + { + "depends_on": "include_ad_data", + "fieldname": "ad_statistics_section", + "fieldtype": "Section Break", + "label": "AD Statistics" + }, + { + "default": "0", + "description": "Anzahl RMM-Agents mit AD-Match", + "fieldname": "ad_matched_count", + "fieldtype": "Int", + "label": "AD Matched", + "read_only": 1 + }, + { + "default": "0", + "description": "Nur in AD vorhanden (nicht in RMM)", + "fieldname": "ad_only_count", + "fieldtype": "Int", + "label": "Only in AD", + "read_only": 1 + }, + { + "fieldname": "ad_column_break_stats", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Nur in RMM vorhanden (nicht in AD)", + "fieldname": "rmm_only_count", + "fieldtype": "Int", + "label": "Only in RMM", + "read_only": 1 + }, + { + "fieldname": "agents_section", + "fieldtype": "Section Break", + "label": "Agent Selection" + }, + { + "fieldname": "agent_selection", + "fieldtype": "Table", + "label": "Agents", + "options": "RMM Import Agent" + }, + { + "collapsible": 1, + "fieldname": "results_section", + "fieldtype": "Section Break", + "label": "Import Results" + }, + { + "default": "0", + "fieldname": "agents_created", + "fieldtype": "Int", + "label": "Created", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "agents_updated", + "fieldtype": "Int", + "label": "Updated", + "read_only": 1 + }, + { + "fieldname": "column_break_results", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "agents_failed", + "fieldtype": "Int", + "label": "Failed", + "read_only": 1 + }, + { + "fieldname": "import_log", + "fieldtype": "Long Text", + "label": "Import Log", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-01-22 10:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "RMM Import Session", + "naming_rule": "Expression (old style)", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "MSP Admin", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "MSP User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/msp/msp/doctype/rmm_import_session/rmm_import_session.py b/msp/msp/doctype/rmm_import_session/rmm_import_session.py new file mode 100644 index 0000000..4df5851 --- /dev/null +++ b/msp/msp/doctype/rmm_import_session/rmm_import_session.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class RMMImportSession(Document): + def validate(self): + self.update_statistics() + + def update_statistics(self): + """Update statistics based on agent_selection table.""" + if not self.agent_selection: + return + + self.agents_total = len(self.agent_selection) + self.agents_to_create = sum( + 1 for a in self.agent_selection if a.action == "Create" + ) + self.agents_to_update = sum( + 1 for a in self.agent_selection if a.action == "Update" + ) + self.agents_skipped = sum( + 1 for a in self.agent_selection if a.action == "Skip" + ) + + def get_selected_agents(self): + """Get all selected agents.""" + return [a for a in self.agent_selection if a.selected] + + def get_selected_for_create(self): + """Get agents selected for creation.""" + return [ + a for a in self.agent_selection if a.selected and a.action == "Create" + ] + + def get_selected_for_update(self): + """Get agents selected for update.""" + return [ + a for a in self.agent_selection if a.selected and a.action == "Update" + ] + + def add_log(self, message): + """Add a message to the import log.""" + import datetime + + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + log_entry = f"[{timestamp}] {message}\n" + + if self.import_log: + self.import_log += log_entry + else: + self.import_log = log_entry diff --git a/msp/msp/doctype/rmm_import_settings/__init__.py b/msp/msp/doctype/rmm_import_settings/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/rmm_import_settings/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/rmm_import_settings/rmm_import_settings.js b/msp/msp/doctype/rmm_import_settings/rmm_import_settings.js new file mode 100644 index 0000000..d0f5beb --- /dev/null +++ b/msp/msp/doctype/rmm_import_settings/rmm_import_settings.js @@ -0,0 +1,104 @@ +// Copyright (c) 2026, itsdave GmbH and contributors +// For license information, please see license.txt + +frappe.ui.form.on('RMM Import Settings', { + refresh: function(frm) { + // Load available monitoring types and set as options + frm.trigger('load_monitoring_types'); + + // Add button to reset to defaults + frm.add_custom_button(__('Reset to Defaults'), function() { + frappe.confirm( + __('This will reset all mappings to their default values. Continue?'), + function() { + // Clear existing mappings + frm.clear_table('type_mapping'); + frm.clear_table('status_mapping'); + frm.clear_table('fields_to_update_on_sync'); + frm.save().then(() => { + frappe.show_alert({ + message: __('Settings reset to defaults'), + indicator: 'green' + }); + }); + } + ); + }); + + // Add button to refresh monitoring types from all RMM instances + frm.add_custom_button(__('Refresh Types from RMM'), function() { + frappe.dom.freeze(__('Fetching monitoring types from all RMM instances...')); + + // Get all RMM instances and refresh their types + frappe.call({ + method: 'frappe.client.get_list', + args: { + doctype: 'RMM Instance', + fields: ['name'] + }, + callback: function(r) { + if (r.message && r.message.length > 0) { + let promises = r.message.map(inst => { + return frappe.call({ + method: 'msp.msp.doctype.rmm_instance.rmm_instance.refresh_monitoring_types', + args: { rmm_instance: inst.name } + }); + }); + + Promise.all(promises).then(() => { + frappe.dom.unfreeze(); + frappe.show_alert({ + message: __('Monitoring types refreshed from all RMM instances'), + indicator: 'green' + }); + frm.trigger('load_monitoring_types'); + }); + } else { + frappe.dom.unfreeze(); + frappe.msgprint(__('No RMM instances found')); + } + } + }); + }); + }, + + load_monitoring_types: function(frm) { + // Fetch all unique monitoring types from RMM instances + frappe.call({ + method: 'msp.msp.doctype.rmm_instance.rmm_instance.get_all_monitoring_types', + callback: function(r) { + if (r.message && r.message.length > 0) { + // Store for use in child table + frm._available_monitoring_types = r.message; + + // Update the grid to show available types + frm.fields_dict.type_mapping.grid.update_docfield_property( + 'rmm_monitoring_type', + 'options', + r.message.join('\n') + ); + frm.fields_dict.type_mapping.grid.update_docfield_property( + 'rmm_monitoring_type', + 'fieldtype', + 'Select' + ); + frm.refresh_field('type_mapping'); + } + } + }); + } +}); + +// Child table event handlers +frappe.ui.form.on('RMM Type Mapping', { + type_mapping_add: function(frm, cdt, cdn) { + // When a new row is added, ensure select options are available + if (frm._available_monitoring_types) { + frm.fields_dict.type_mapping.grid.update_docfield_property( + 'rmm_monitoring_type', + 'options', + frm._available_monitoring_types.join('\n') + ); + } + } +}); diff --git a/msp/msp/doctype/rmm_import_settings/rmm_import_settings.json b/msp/msp/doctype/rmm_import_settings/rmm_import_settings.json new file mode 100644 index 0000000..a549f77 --- /dev/null +++ b/msp/msp/doctype/rmm_import_settings/rmm_import_settings.json @@ -0,0 +1,170 @@ +{ + "actions": [], + "creation": "2026-01-07 10:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "general_section", + "update_existing", + "auto_create_types", + "column_break_general", + "sync_software", + "sync_patches", + "ad_options_section", + "auto_include_ad_data", + "skip_disabled_ad_accounts", + "ad_column_break", + "sync_ad_fields_on_update", + "type_mapping_section", + "type_mapping", + "status_mapping_section", + "status_mapping", + "update_fields_section", + "update_fields_description", + "fields_to_update_on_sync" + ], + "fields": [ + { + "fieldname": "general_section", + "fieldtype": "Section Break", + "label": "General Settings" + }, + { + "default": "1", + "description": "If checked, existing IT Objects will be updated when importing from RMM", + "fieldname": "update_existing", + "fieldtype": "Check", + "label": "Update Existing Objects" + }, + { + "default": "1", + "description": "Automatically create IT Object Types if they don't exist (Server, Workstation)", + "fieldname": "auto_create_types", + "fieldtype": "Check", + "label": "Auto-Create Missing Types" + }, + { + "fieldname": "column_break_general", + "fieldtype": "Column Break" + }, + { + "default": "1", + "description": "Sync installed software list to rmm_software field", + "fieldname": "sync_software", + "fieldtype": "Check", + "label": "Sync Software" + }, + { + "default": "1", + "description": "Sync Windows Update patch information", + "fieldname": "sync_patches", + "fieldtype": "Check", + "label": "Sync Patches" + }, + { + "fieldname": "ad_options_section", + "fieldtype": "Section Break", + "label": "Active Directory Options" + }, + { + "default": "1", + "description": "AD-Daten automatisch bei Import einbeziehen wenn verfuegbar", + "fieldname": "auto_include_ad_data", + "fieldtype": "Check", + "label": "Auto-Include AD Data" + }, + { + "default": "0", + "description": "Deaktivierte AD-Accounts nicht automatisch zur Auswahl vorschlagen", + "fieldname": "skip_disabled_ad_accounts", + "fieldtype": "Check", + "label": "Skip Disabled AD Accounts" + }, + { + "fieldname": "ad_column_break", + "fieldtype": "Column Break" + }, + { + "default": "1", + "description": "AD-Felder bei Update bestehender IT Objects aktualisieren", + "fieldname": "sync_ad_fields_on_update", + "fieldtype": "Check", + "label": "Sync AD Fields on Update" + }, + { + "fieldname": "type_mapping_section", + "fieldtype": "Section Break", + "label": "Type Mapping" + }, + { + "description": "Map RMM monitoring types (server, workstation) to IT Object Types", + "fieldname": "type_mapping", + "fieldtype": "Table", + "label": "Type Mapping", + "options": "RMM Type Mapping" + }, + { + "fieldname": "status_mapping_section", + "fieldtype": "Section Break", + "label": "Status Mapping" + }, + { + "description": "Map RMM status values (online, offline, overdue) to IT Object Status", + "fieldname": "status_mapping", + "fieldtype": "Table", + "label": "Status Mapping", + "options": "RMM Status Mapping" + }, + { + "fieldname": "update_fields_section", + "fieldtype": "Section Break", + "label": "Update Fields Configuration" + }, + { + "fieldname": "update_fields_description", + "fieldtype": "HTML", + "label": "Description", + "options": "

Configure which fields should be updated when syncing existing IT Objects. By default, RMM-specific fields are updated while user-defined fields (title, type, status) are preserved.

" + }, + { + "description": "Select which fields should be overwritten when updating existing IT Objects", + "fieldname": "fields_to_update_on_sync", + "fieldtype": "Table", + "label": "Fields to Update on Sync", + "options": "RMM Update Fields" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-01-22 10:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "RMM Import Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "MSP Admin", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/msp/msp/doctype/rmm_import_settings/rmm_import_settings.py b/msp/msp/doctype/rmm_import_settings/rmm_import_settings.py new file mode 100644 index 0000000..9a5718f --- /dev/null +++ b/msp/msp/doctype/rmm_import_settings/rmm_import_settings.py @@ -0,0 +1,91 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document + + +class RMMImportSettings(Document): + def validate(self): + self.ensure_default_mappings() + + def ensure_default_mappings(self): + """Ensure default mappings exist if tables are empty.""" + if not self.type_mapping: + self.setup_default_type_mappings() + if not self.status_mapping: + self.setup_default_status_mappings() + if not self.fields_to_update_on_sync: + self.setup_default_update_fields() + + def setup_default_type_mappings(self): + """Setup default type mappings.""" + default_mappings = [ + {"rmm_monitoring_type": "server", "it_object_type": "Server"}, + {"rmm_monitoring_type": "workstation", "it_object_type": "Workstation"}, + ] + for mapping in default_mappings: + # Check if IT Object Type exists + if frappe.db.exists("IT Object Type", mapping["it_object_type"]): + self.append("type_mapping", mapping) + + def setup_default_status_mappings(self): + """Setup default status mappings.""" + default_mappings = [ + {"rmm_status": "online", "it_object_status": "in Production"}, + {"rmm_status": "offline", "it_object_status": "in Maintenance"}, + {"rmm_status": "overdue", "it_object_status": "in Maintenance"}, + ] + for mapping in default_mappings: + self.append("status_mapping", mapping) + + def setup_default_update_fields(self): + """Setup default fields to update on sync.""" + # Fields that should be updated by default (RMM-specific) + update_fields = [ + {"field_name": "rmm_local_ip", "update_on_sync": 1}, + {"field_name": "rmm_public_ip", "update_on_sync": 1}, + {"field_name": "rmm_operating_system", "update_on_sync": 1}, + {"field_name": "rmm_last_seen", "update_on_sync": 1}, + {"field_name": "rmm_last_user", "update_on_sync": 1}, + {"field_name": "rmm_specs", "update_on_sync": 1}, + {"field_name": "rmm_software", "update_on_sync": 1}, + {"field_name": "rmm_patches_pending", "update_on_sync": 1}, + {"field_name": "rmm_needs_reboot", "update_on_sync": 1}, + # Fields that should NOT be updated by default (user-defined) + {"field_name": "title", "update_on_sync": 0}, + {"field_name": "type", "update_on_sync": 0}, + {"field_name": "status", "update_on_sync": 0}, + {"field_name": "serial_number", "update_on_sync": 0}, + {"field_name": "description", "update_on_sync": 0}, + ] + for field in update_fields: + self.append("fields_to_update_on_sync", field) + + def get_type_mapping(self, rmm_monitoring_type): + """Get IT Object Type for a given RMM monitoring type.""" + for mapping in self.type_mapping: + if mapping.rmm_monitoring_type.lower() == rmm_monitoring_type.lower(): + return mapping.it_object_type + return None + + def get_status_mapping(self, rmm_status): + """Get IT Object Status for a given RMM status.""" + for mapping in self.status_mapping: + if mapping.rmm_status.lower() == rmm_status.lower(): + return mapping.it_object_status + return None + + def should_update_field(self, field_name): + """Check if a field should be updated during sync.""" + for field in self.fields_to_update_on_sync: + if field.field_name == field_name: + return bool(field.update_on_sync) + # Default: update RMM fields, don't update others + return field_name.startswith("rmm_") + + +@frappe.whitelist() +def get_settings(): + """Get RMM Import Settings document.""" + return frappe.get_single("RMM Import Settings") diff --git a/msp/msp/doctype/rmm_instance/rmm_instance.js b/msp/msp/doctype/rmm_instance/rmm_instance.js index a2da5c2..f6bf0f4 100644 --- a/msp/msp/doctype/rmm_instance/rmm_instance.js +++ b/msp/msp/doctype/rmm_instance/rmm_instance.js @@ -2,7 +2,89 @@ // For license information, please see license.txt frappe.ui.form.on('RMM Instance', { - // refresh: function(frm) { + refresh: function(frm) { + if (!frm.is_new()) { + frm.add_custom_button(__('Test Connection'), function() { + frappe.dom.freeze(__('Testing connection to Tactical RMM...')); + frappe.call({ + method: 'msp.msp.doctype.rmm_instance.rmm_instance.test_connection', + args: { + rmm_instance: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Connection Failed'), + indicator: 'red', + message: __('Could not connect to Tactical RMM. Please check your credentials.') + }); + return; + } + if (r.message && r.message.success) { + frappe.msgprint({ + title: __('Connection Successful'), + indicator: 'green', + message: `${__('Successfully connected to Tactical RMM!')}

` + + `${__('Agents found')}: ${r.message.agent_count}
` + + `${__('Clients')}: ${r.message.clients.join(', ') || 'None'}` + }); + } else { + let error_msg = r.message.error || __('Unknown error occurred'); + // Check if it's an SSL error and suggest enabling the checkbox + if (error_msg.toLowerCase().includes('ssl') && !frm.doc.ignore_ssl) { + frappe.msgprint({ + title: __('Connection Failed'), + indicator: 'red', + message: `${error_msg}

${__('Tip: Enable "Ignore SSL Certificate" checkbox and try again.')}` + }); + } else { + frappe.msgprint({ + title: __('Connection Failed'), + indicator: 'red', + message: error_msg + }); + } + } + } + }); + }).addClass('btn-primary'); - // } + // Button to refresh monitoring types + frm.add_custom_button(__('Refresh Monitoring Types'), function() { + frappe.dom.freeze(__('Fetching monitoring types from RMM...')); + frappe.call({ + method: 'msp.msp.doctype.rmm_instance.rmm_instance.refresh_monitoring_types', + args: { + rmm_instance: frm.doc.name + }, + callback: function(r) { + frappe.dom.unfreeze(); + if (r.exc) { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: __('Could not fetch monitoring types.') + }); + return; + } + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Found {0} monitoring types: {1}', + [r.message.count, r.message.monitoring_types.join(', ')]), + indicator: 'green' + }); + frm.reload_doc(); + } else { + frappe.msgprint({ + title: __('Error'), + indicator: 'red', + message: r.message.error || __('Unknown error') + }); + } + } + }); + }); + } + } }); diff --git a/msp/msp/doctype/rmm_instance/rmm_instance.json b/msp/msp/doctype/rmm_instance/rmm_instance.json index aed07e1..5d942ff 100644 --- a/msp/msp/doctype/rmm_instance/rmm_instance.json +++ b/msp/msp/doctype/rmm_instance/rmm_instance.json @@ -11,7 +11,11 @@ "type", "api_url", "user", - "key" + "key", + "ignore_ssl", + "monitoring_types_section", + "available_monitoring_types", + "last_types_refresh" ], "fields": [ { @@ -48,6 +52,32 @@ "fieldname": "key", "fieldtype": "Password", "label": "Key" + }, + { + "default": "0", + "description": "Skip SSL certificate verification. Use only for self-signed or expired certificates.", + "fieldname": "ignore_ssl", + "fieldtype": "Check", + "label": "Ignore SSL Certificate" + }, + { + "collapsible": 1, + "fieldname": "monitoring_types_section", + "fieldtype": "Section Break", + "label": "Available Monitoring Types" + }, + { + "description": "Comma-separated list of monitoring types found in this RMM instance (e.g., server,workstation). Click 'Refresh Monitoring Types' to update.", + "fieldname": "available_monitoring_types", + "fieldtype": "Small Text", + "label": "Available Monitoring Types", + "read_only": 1 + }, + { + "fieldname": "last_types_refresh", + "fieldtype": "Datetime", + "label": "Last Types Refresh", + "read_only": 1 } ], "index_web_pages_for_search": 1, diff --git a/msp/msp/doctype/rmm_instance/rmm_instance.py b/msp/msp/doctype/rmm_instance/rmm_instance.py index 52848c3..1f8e620 100644 --- a/msp/msp/doctype/rmm_instance/rmm_instance.py +++ b/msp/msp/doctype/rmm_instance/rmm_instance.py @@ -1,8 +1,190 @@ # Copyright (c) 2023, itsdave GmbH and contributors # For license information, please see license.txt -# import frappe +import frappe +import requests from frappe.model.document import Document +from frappe.utils import now_datetime + class RMMInstance(Document): - pass + def get_monitoring_types_list(self): + """Returns list of available monitoring types.""" + if self.available_monitoring_types: + return [t.strip() for t in self.available_monitoring_types.split(",") if t.strip()] + return [] + + def get_api_credentials(self): + """ + Returns (api_url, headers, verify_ssl) tuple for making API requests to this RMM instance. + + Returns: + tuple: (api_url: str, headers: dict, verify_ssl: bool) + + Raises: + frappe.ValidationError: If api_url or key is missing + """ + if not self.api_url: + frappe.throw(f"API URL is missing for RMM Instance '{self.caption}'") + if not self.key: + frappe.throw(f"API Key is missing for RMM Instance '{self.caption}'") + + headers = { + "Content-Type": "application/json", + "X-API-KEY": self.get_password("key"), + } + + verify_ssl = not self.ignore_ssl + + return self.api_url, headers, verify_ssl + + +@frappe.whitelist() +def test_connection(rmm_instance): + """ + Tests the connection to a Tactical RMM instance. + + Args: + rmm_instance (str): Name of the RMM Instance document + + Returns: + dict: Result with success status, agent count, and client list + """ + try: + doc = frappe.get_doc("RMM Instance", rmm_instance) + api_url, headers, verify_ssl = doc.get_api_credentials() + + # Test connection by fetching agents + response = requests.get( + f"{api_url}/agents/", + headers=headers, + timeout=30, + verify=verify_ssl + ) + + if response.status_code == 200: + agents = response.json() + # Extract unique client names + clients = list(set(agent.get("client_name", "Unknown") for agent in agents)) + clients.sort() + + return { + "success": True, + "agent_count": len(agents), + "clients": clients[:10] # Limit to first 10 clients + } + elif response.status_code == 401: + return { + "success": False, + "error": "Authentication failed. Please check your API key." + } + elif response.status_code == 403: + return { + "success": False, + "error": "Access forbidden. API key may not have sufficient permissions." + } + else: + return { + "success": False, + "error": f"API returned status code {response.status_code}" + } + + except requests.exceptions.SSLError as e: + return { + "success": False, + "error": f"SSL certificate error: {str(e)}. The certificate may be expired or invalid." + } + except requests.exceptions.Timeout: + return { + "success": False, + "error": "Connection timed out. Please check the API URL." + } + except requests.exceptions.ConnectionError: + return { + "success": False, + "error": "Could not connect to server. Please check the API URL." + } + except Exception as e: + return { + "success": False, + "error": str(e) + } + + +@frappe.whitelist() +def refresh_monitoring_types(rmm_instance): + """ + Fetches all unique monitoring types from the RMM instance and stores them. + + Args: + rmm_instance (str): Name of the RMM Instance document + + Returns: + dict: Result with success status and list of monitoring types + """ + try: + doc = frappe.get_doc("RMM Instance", rmm_instance) + api_url, headers, verify_ssl = doc.get_api_credentials() + + # Fetch all agents + response = requests.get( + f"{api_url}/agents/", + headers=headers, + timeout=30, + verify=verify_ssl + ) + + if response.status_code == 200: + agents = response.json() + # Extract unique monitoring types + monitoring_types = set() + for agent in agents: + mt = agent.get("monitoring_type", "") + if mt: + monitoring_types.add(mt.lower()) + + # Sort and store + types_list = sorted(list(monitoring_types)) + doc.available_monitoring_types = ",".join(types_list) + doc.last_types_refresh = now_datetime() + doc.save() + frappe.db.commit() + + return { + "success": True, + "monitoring_types": types_list, + "count": len(types_list) + } + else: + return { + "success": False, + "error": f"API returned status code {response.status_code}" + } + + except Exception as e: + frappe.log_error(f"Error refreshing monitoring types: {str(e)}", "RMM Instance") + return { + "success": False, + "error": str(e) + } + + +@frappe.whitelist() +def get_all_monitoring_types(): + """ + Get all unique monitoring types from all RMM instances. + + Returns: + list: List of unique monitoring types + """ + instances = frappe.get_all("RMM Instance", fields=["available_monitoring_types"]) + all_types = set() + + for inst in instances: + if inst.available_monitoring_types: + for t in inst.available_monitoring_types.split(","): + t = t.strip() + if t: + all_types.add(t) + + return sorted(list(all_types)) diff --git a/msp/msp/doctype/rmm_status_mapping/__init__.py b/msp/msp/doctype/rmm_status_mapping/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/rmm_status_mapping/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/rmm_status_mapping/rmm_status_mapping.json b/msp/msp/doctype/rmm_status_mapping/rmm_status_mapping.json new file mode 100644 index 0000000..b2564f4 --- /dev/null +++ b/msp/msp/doctype/rmm_status_mapping/rmm_status_mapping.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "creation": "2026-01-07 10:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "rmm_status", + "it_object_status" + ], + "fields": [ + { + "fieldname": "rmm_status", + "fieldtype": "Data", + "in_list_view": 1, + "label": "RMM Status", + "reqd": 1 + }, + { + "fieldname": "it_object_status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "IT Object Status", + "options": "in Production\nImplementation\nin Maintenance\nfailed\nDecommissioned", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-01-07 10:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "RMM Status Mapping", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/msp/msp/doctype/rmm_status_mapping/rmm_status_mapping.py b/msp/msp/doctype/rmm_status_mapping/rmm_status_mapping.py new file mode 100644 index 0000000..e012834 --- /dev/null +++ b/msp/msp/doctype/rmm_status_mapping/rmm_status_mapping.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class RMMStatusMapping(Document): + pass diff --git a/msp/msp/doctype/rmm_type_mapping/__init__.py b/msp/msp/doctype/rmm_type_mapping/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/rmm_type_mapping/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/rmm_type_mapping/rmm_type_mapping.json b/msp/msp/doctype/rmm_type_mapping/rmm_type_mapping.json new file mode 100644 index 0000000..d5b5088 --- /dev/null +++ b/msp/msp/doctype/rmm_type_mapping/rmm_type_mapping.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "creation": "2026-01-07 10:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "rmm_monitoring_type", + "it_object_type" + ], + "fields": [ + { + "fieldname": "rmm_monitoring_type", + "fieldtype": "Data", + "in_list_view": 1, + "label": "RMM Monitoring Type", + "reqd": 1 + }, + { + "fieldname": "it_object_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "IT Object Type", + "options": "IT Object Type", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-01-07 10:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "RMM Type Mapping", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/msp/msp/doctype/rmm_type_mapping/rmm_type_mapping.py b/msp/msp/doctype/rmm_type_mapping/rmm_type_mapping.py new file mode 100644 index 0000000..ff0dd59 --- /dev/null +++ b/msp/msp/doctype/rmm_type_mapping/rmm_type_mapping.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class RMMTypeMapping(Document): + pass diff --git a/msp/msp/doctype/rmm_update_fields/__init__.py b/msp/msp/doctype/rmm_update_fields/__init__.py new file mode 100644 index 0000000..fc7534b --- /dev/null +++ b/msp/msp/doctype/rmm_update_fields/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt diff --git a/msp/msp/doctype/rmm_update_fields/rmm_update_fields.json b/msp/msp/doctype/rmm_update_fields/rmm_update_fields.json new file mode 100644 index 0000000..418a089 --- /dev/null +++ b/msp/msp/doctype/rmm_update_fields/rmm_update_fields.json @@ -0,0 +1,48 @@ +{ + "actions": [], + "creation": "2026-01-07 10:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "field_name", + "field_label", + "update_on_sync" + ], + "fields": [ + { + "fieldname": "field_name", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Field Name", + "options": "rmm_local_ip\nrmm_public_ip\nrmm_operating_system\nrmm_last_seen\nrmm_last_user\nrmm_specs\nrmm_software\nrmm_patches_pending\nrmm_needs_reboot\ntitle\ntype\nstatus\nserial_number\ndescription", + "reqd": 1 + }, + { + "fieldname": "field_label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Field Label", + "read_only": 1 + }, + { + "default": "1", + "fieldname": "update_on_sync", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Update on Sync" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-01-07 10:00:00.000000", + "modified_by": "Administrator", + "module": "MSP", + "name": "RMM Update Fields", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/msp/msp/doctype/rmm_update_fields/rmm_update_fields.py b/msp/msp/doctype/rmm_update_fields/rmm_update_fields.py new file mode 100644 index 0000000..8e5f7c9 --- /dev/null +++ b/msp/msp/doctype/rmm_update_fields/rmm_update_fields.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class RMMUpdateFields(Document): + pass diff --git a/msp/msp/doctype/user_print_setting/README.md b/msp/msp/doctype/user_print_setting/README.md new file mode 100644 index 0000000..489aaf3 --- /dev/null +++ b/msp/msp/doctype/user_print_setting/README.md @@ -0,0 +1,114 @@ +# User Print Setting + +## Übersicht + +DocType zur Speicherung von Drucker- und Print-Format-Einstellungen pro Benutzer und DocType. + +## Felder + +| Feld | Typ | Beschreibung | +|------|-----|--------------| +| `user` | Link (User) | Benutzer | +| `reference_doctype` | Link (DocType) | Ziel-DocType (z.B. Purchase Receipt) | +| `printer` | Link (Network Printer Settings) | Standarddrucker | +| `print_format` | Link (Print Format) | Standard-Druckformat | + +## API-Methoden + +### `get_user_print_setting(reference_doctype, user=None)` +Holt die Druckeinstellungen für einen Benutzer und DocType. + +```python +from msp.msp.doctype.user_print_setting.user_print_setting import get_user_print_setting + +settings = get_user_print_setting("Purchase Receipt") +# Returns: {"printer": "Label-Lager", "print_format": "label"} +``` + +### `save_user_print_setting(reference_doctype, printer, print_format, user=None)` +Speichert oder aktualisiert die Druckeinstellungen. + +```python +from msp.msp.doctype.user_print_setting.user_print_setting import save_user_print_setting + +save_user_print_setting("Purchase Receipt", "Label-Lager", "label") +``` + +--- + +# Label Printing API + +## Übersicht + +Backend-API für den Direktdruck von Etiketten auf Netzwerkdrucker via CUPS. + +**Datei:** `apps/msp/msp/label_printing.py` + +## Methoden + +### `get_available_printers()` +Gibt Liste aller konfigurierten Network Printer Settings zurück. + +### `get_label_print_formats()` +Gibt Liste aller Print Formats für den DocType "Item" zurück. + +### `print_item_labels(item_code, quantity, printer_setting, print_format)` +Druckt Labels für einen Artikel. + +```python +from msp.label_printing import print_item_labels + +result = print_item_labels( + item_code="MAPID-123456", + quantity=5, + printer_setting="Label-Lager", + print_format="label" +) +# Returns: {"success": True, "printed": 5, "total": 5, "errors": 0} +``` + +### `print_multiple_item_labels(items, printer_setting, print_format)` +Batch-Druck für mehrere Artikel. + +```python +from msp.label_printing import print_multiple_item_labels + +result = print_multiple_item_labels( + items=[ + {"item_code": "MAPID-123", "quantity": 3}, + {"item_code": "MAPID-456", "quantity": 2} + ], + printer_setting="Label-Lager", + print_format="label" +) +``` + +--- + +# Frontend: Purchase Receipt + +**Datei:** `apps/msp/msp/public/js/purchase_receipt.js` + +## Funktion + +Fügt eine "Aktionen" Button-Group zum Purchase Receipt hinzu mit der Aktion "Print Labels". + +## Dialog + +- Drucker-Auswahl (aus Network Printer Settings) +- Print Format-Auswahl +- Checkbox "Als Standard speichern" +- Tabelle aller Positionen mit editierbaren Mengen +- Druck-Buttons pro Zeile (Menge / Einzeln) +- "Print All" Button + +--- + +# CUPS-Drucker + +Konfigurierte Label-Drucker: + +| Name | IP | Beschreibung | +|------|-----|--------------| +| Label-Lager | 192.168.241.116 | Brother QL-820NWB | +| Label-Vertrieb | 192.168.240.40 | Brother QL-820NWB | diff --git a/msp/msp/doctype/user_print_setting/__init__.py b/msp/msp/doctype/user_print_setting/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/msp/doctype/user_print_setting/test_user_print_setting.py b/msp/msp/doctype/user_print_setting/test_user_print_setting.py new file mode 100644 index 0000000..af311a9 --- /dev/null +++ b/msp/msp/doctype/user_print_setting/test_user_print_setting.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, itsdave GmbH and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestUserPrintSetting(FrappeTestCase): + pass diff --git a/msp/msp/doctype/user_print_setting/user_print_setting.js b/msp/msp/doctype/user_print_setting/user_print_setting.js new file mode 100644 index 0000000..ac1e85b --- /dev/null +++ b/msp/msp/doctype/user_print_setting/user_print_setting.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, itsdave GmbH and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("User Print Setting", { +// refresh(frm) { + +// }, +// }); diff --git a/msp/msp/doctype/user_print_setting/user_print_setting.json b/msp/msp/doctype/user_print_setting/user_print_setting.json new file mode 100644 index 0000000..1a6e407 --- /dev/null +++ b/msp/msp/doctype/user_print_setting/user_print_setting.json @@ -0,0 +1,80 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:{user}-{reference_doctype}", + "creation": "2025-12-30 00:25:23.731438", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "user", + "reference_doctype", + "column_break_1", + "printer", + "print_format" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "User", + "options": "User", + "reqd": 1 + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Reference", + "reqd": 1 + }, + { + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, + { + "fieldname": "printer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Printer", + "options": "Network Printer Settings" + }, + { + "fieldname": "print_format", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Print Format", + "options": "Print Format" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-12-30 00:28:40.061240", + "modified_by": "Administrator", + "module": "MSP", + "name": "User Print Setting", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/msp/msp/doctype/user_print_setting/user_print_setting.py b/msp/msp/doctype/user_print_setting/user_print_setting.py new file mode 100644 index 0000000..bbade11 --- /dev/null +++ b/msp/msp/doctype/user_print_setting/user_print_setting.py @@ -0,0 +1,74 @@ +# Copyright (c) 2025, itsdave GmbH and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class UserPrintSetting(Document): + pass + + +@frappe.whitelist() +def get_user_print_setting(reference_doctype, user=None): + """Get print settings for a specific user and doctype. + + Args: + reference_doctype: The DocType to get settings for + user: Optional user, defaults to current user + + Returns: + dict with printer and print_format or None + """ + if not user: + user = frappe.session.user + + setting = frappe.db.get_value( + "User Print Setting", + {"user": user, "reference_doctype": reference_doctype}, + ["printer", "print_format"], + as_dict=True + ) + + return setting + + +@frappe.whitelist() +def save_user_print_setting(reference_doctype, printer=None, print_format=None, user=None): + """Save or update print settings for a user and doctype. + + Args: + reference_doctype: The DocType to save settings for + printer: Network Printer Settings name + print_format: Print Format name + user: Optional user, defaults to current user + + Returns: + The saved document name + """ + if not user: + user = frappe.session.user + + existing = frappe.db.exists( + "User Print Setting", + {"user": user, "reference_doctype": reference_doctype} + ) + + if existing: + doc = frappe.get_doc("User Print Setting", existing) + doc.printer = printer + doc.print_format = print_format + doc.save(ignore_permissions=True) + else: + doc = frappe.get_doc({ + "doctype": "User Print Setting", + "user": user, + "reference_doctype": reference_doctype, + "printer": printer, + "print_format": print_format + }) + doc.insert(ignore_permissions=True) + + frappe.db.commit() + return doc.name diff --git a/msp/msp/workspace/technik/technik.json b/msp/msp/workspace/technik/technik.json index 10f81bb..8aef2c2 100644 --- a/msp/msp/workspace/technik/technik.json +++ b/msp/msp/workspace/technik/technik.json @@ -5,9 +5,14 @@ "label": "Meine Performance" } ], - "content": "[{\"id\":\"HEmp649Etq\",\"type\":\"header\",\"data\":{\"text\":\"Technik\",\"col\":12}},{\"id\":\"AW8p3HUvHt\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Meine Performance\",\"col\":12}},{\"id\":\"vBNumIecsh\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"zJ7GHiOCcI\",\"type\":\"card\",\"data\":{\"card_name\":\"MSP\",\"col\":5}},{\"id\":\"XNPhytxMcq\",\"type\":\"card\",\"data\":{\"card_name\":\"Service Reports\",\"col\":5}},{\"id\":\"ylnuD9q8fE\",\"type\":\"spacer\",\"data\":{\"col\":12}}]", + "content": "[{\"id\":\"HEmp649Etq\",\"type\":\"header\",\"data\":{\"text\":\"Technik\",\"col\":12}},{\"id\":\"AW8p3HUvHt\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Meine Performance\",\"col\":12}},{\"id\":\"vBNumIecsh\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"zJ7GHiOCcI\",\"type\":\"card\",\"data\":{\"card_name\":\"MSP\",\"col\":5}},{\"id\":\"XNPhytxMcq\",\"type\":\"card\",\"data\":{\"card_name\":\"Service Reports\",\"col\":5}},{\"id\":\"ylnuD9q8fE\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"eEKiBdq-Qj\",\"type\":\"custom_block\",\"data\":{\"custom_block_name\":\"Service Report Work Times Calendar\",\"col\":12}}]", "creation": "2024-06-25 14:11:39.269749", - "custom_blocks": [], + "custom_blocks": [ + { + "custom_block_name": "Service Report Work Times Calendar", + "label": "Service Report Work Times Calendar" + } + ], "docstatus": 0, "doctype": "Workspace", "for_user": "", @@ -77,7 +82,7 @@ "type": "Link" } ], - "modified": "2024-10-11 10:44:54.148354", + "modified": "2026-02-24 17:50:21.661536", "modified_by": "D.Malinowski@itsdave.de", "module": "MSP", "name": "Technik", diff --git a/msp/patches.txt b/msp/patches.txt index e69de29..47f7558 100644 --- a/msp/patches.txt +++ b/msp/patches.txt @@ -0,0 +1 @@ +msp.patches.migrate_rmm_settings diff --git a/msp/patches/__init__.py b/msp/patches/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msp/patches/migrate_rmm_settings.py b/msp/patches/migrate_rmm_settings.py new file mode 100644 index 0000000..ce6b72d --- /dev/null +++ b/msp/patches/migrate_rmm_settings.py @@ -0,0 +1,50 @@ +import frappe + + +def execute(): + """ + Migrates global MSP Settings RMM credentials to a default RMM Instance. + Links all IT Landscapes without an rmm_instance to the new default. + """ + settings = frappe.get_single("MSP Settings") + + # Check if migration is needed + if not settings.api_url: + print("No API URL in MSP Settings. Skipping RMM migration.") + return + + # Check if a migration has already been done (same api_url exists) + existing = frappe.db.exists("RMM Instance", {"api_url": settings.api_url}) + if existing: + print(f"RMM Instance for {settings.api_url} already exists. Skipping migration.") + return + + # Check if api_key exists + api_key = settings.get_password("api_key") if settings.api_key else None + if not api_key: + print("No API Key in MSP Settings. Cannot migrate without credentials.") + return + + # Create default RMM Instance + rmm_instance = frappe.get_doc({ + "doctype": "RMM Instance", + "caption": "Default (Migrated from MSP Settings)", + "type": "Tactical RMM", + "api_url": settings.api_url, + "key": api_key + }) + rmm_instance.insert() + print(f"Created RMM Instance: {rmm_instance.name}") + + # Link to all IT Landscapes without an rmm_instance + landscapes = frappe.get_all( + "IT Landscape", + filters=[["rmm_instance", "is", "not set"]], + pluck="name" + ) + + for landscape_name in landscapes: + frappe.db.set_value("IT Landscape", landscape_name, "rmm_instance", rmm_instance.name) + + frappe.db.commit() + print(f"Linked RMM Instance to {len(landscapes)} IT Landscapes") diff --git a/msp/public/js/purchase_receipt.js b/msp/public/js/purchase_receipt.js new file mode 100644 index 0000000..fcca86f --- /dev/null +++ b/msp/public/js/purchase_receipt.js @@ -0,0 +1,296 @@ +frappe.ui.form.on('Purchase Receipt', { + refresh: function(frm) { + if (!frm.is_new() && frm.doc.docstatus < 2) { + frm.add_custom_button(__('Print Labels'), function() { + show_print_labels_dialog(frm); + }, __("Aktionen")); + + style_actions_button(frm); + } + } +}); + +function style_actions_button(frm) { + setTimeout(() => { + frm.$wrapper + .find('.inner-group-button[data-label="' + __("Aktionen") + '"] > .btn') + .css({ + "background-color": "#e73249", + "border-color": "#e73249", + "color": "#fff", + }); + }, 100); +} + +function show_print_labels_dialog(frm) { + // Load user settings, printers and print formats in parallel + Promise.all([ + frappe.call({ + method: 'msp.msp.doctype.user_print_setting.user_print_setting.get_user_print_setting', + args: { reference_doctype: 'Purchase Receipt' } + }), + frappe.call({ + method: 'msp.label_printing.get_available_printers' + }), + frappe.call({ + method: 'msp.label_printing.get_label_print_formats' + }) + ]).then(([settings_response, printers_response, formats_response]) => { + const user_settings = settings_response.message || {}; + const printers = printers_response.message || []; + const print_formats = formats_response.message || []; + + if (printers.length === 0) { + frappe.msgprint(__('No printers configured. Please add a Network Printer Settings first.')); + return; + } + + // Build items table HTML + let items_html = build_items_table(frm.doc.items); + let total_qty = frm.doc.items.reduce((sum, item) => sum + (item.qty || 0), 0); + + // Create dialog + let d = new frappe.ui.Dialog({ + title: __('Print Labels'), + size: 'large', + fields: [ + { + fieldtype: 'Select', + fieldname: 'printer', + label: __('Printer'), + options: printers.map(p => p.value), + default: user_settings.printer || (printers[0] ? printers[0].value : ''), + reqd: 1 + }, + { + fieldtype: 'Select', + fieldname: 'print_format', + label: __('Print Format'), + options: print_formats.map(f => f.value), + default: user_settings.print_format || 'label', + reqd: 1 + }, + { + fieldtype: 'Check', + fieldname: 'save_as_default', + label: __('Save as default') + }, + { + fieldtype: 'Section Break' + }, + { + fieldtype: 'HTML', + fieldname: 'items_table', + options: items_html + }, + { + fieldtype: 'Section Break' + }, + { + fieldtype: 'HTML', + fieldname: 'total_info', + options: `
${__('Total')}: ${total_qty} ${__('Labels')}
` + } + ], + primary_action_label: __('Print All'), + primary_action: function() { + print_all_labels(d, frm); + } + }); + + // Add event handlers after dialog is shown + d.show(); + setup_dialog_events(d, frm); + }); +} + +function build_items_table(items) { + let rows = items.map((item, idx) => { + return ` + + + ${item.item_code}
+ ${item.item_name || ''} + + + ${item.qty || 0} + + + + + + + + + + `; + }).join(''); + + return ` +
+ + + + + + + + + + + ${rows} + +
${__('Item')}${__('Qty')}${__('Labels')}${__('Action')}
+
+ `; +} + +function setup_dialog_events(d, frm) { + // Update total when quantity changes + d.$wrapper.on('change', '.label-qty-input', function() { + update_total_labels(d); + }); + + // Print single item (quantity from input) + d.$wrapper.on('click', '.print-qty-btn', function() { + let idx = $(this).data('idx'); + let item = frm.doc.items[idx]; + let qty = parseInt(d.$wrapper.find(`.label-qty-input[data-idx="${idx}"]`).val()) || 0; + + if (qty < 1) { + frappe.msgprint(__('Please enter a quantity greater than 0')); + return; + } + + print_single_item_labels(d, item.item_code, qty); + }); + + // Print exactly 1 label + d.$wrapper.on('click', '.print-one-btn', function() { + let idx = $(this).data('idx'); + let item = frm.doc.items[idx]; + print_single_item_labels(d, item.item_code, 1); + }); +} + +function update_total_labels(d) { + let total = 0; + d.$wrapper.find('.label-qty-input').each(function() { + total += parseInt($(this).val()) || 0; + }); + d.$wrapper.find('#total-labels').text(total); +} + +function print_single_item_labels(d, item_code, quantity) { + let printer = d.get_value('printer'); + let print_format = d.get_value('print_format'); + let save_default = d.get_value('save_as_default'); + + if (!printer) { + frappe.msgprint(__('Please select a printer')); + return; + } + + frappe.call({ + method: 'msp.label_printing.print_item_labels', + args: { + item_code: item_code, + quantity: quantity, + printer_setting: printer, + print_format: print_format + }, + freeze: true, + freeze_message: __('Printing {0} labels...', [quantity]), + callback: function(r) { + if (r.message && r.message.success) { + frappe.show_alert({ + message: __('Printed {0} labels for {1}', [r.message.printed, item_code]), + indicator: 'green' + }, 3); + + // Save default settings if checkbox is checked + if (save_default) { + save_user_print_setting(printer, print_format); + } + } + } + }); +} + +function print_all_labels(d, frm) { + let printer = d.get_value('printer'); + let print_format = d.get_value('print_format'); + let save_default = d.get_value('save_as_default'); + + if (!printer) { + frappe.msgprint(__('Please select a printer')); + return; + } + + // Collect items with quantities > 0 + let items = []; + d.$wrapper.find('.label-qty-input').each(function() { + let idx = $(this).data('idx'); + let qty = parseInt($(this).val()) || 0; + if (qty > 0) { + items.push({ + item_code: frm.doc.items[idx].item_code, + quantity: qty + }); + } + }); + + if (items.length === 0) { + frappe.msgprint(__('No items with quantity > 0')); + return; + } + + let total_qty = items.reduce((sum, item) => sum + item.quantity, 0); + + frappe.call({ + method: 'msp.label_printing.print_multiple_item_labels', + args: { + items: items, + printer_setting: printer, + print_format: print_format + }, + freeze: true, + freeze_message: __('Printing {0} labels...', [total_qty]), + callback: function(r) { + if (r.message) { + if (r.message.success) { + frappe.show_alert({ + message: __('Printed {0} of {1} labels', [r.message.total_printed, r.message.total_requested]), + indicator: 'green' + }, 5); + + // Save default settings if checkbox is checked + if (save_default) { + save_user_print_settings(printer, print_format); + } + + d.hide(); + } else { + frappe.msgprint(__('Some labels could not be printed. Check the error log.')); + } + } + } + }); +} + +function save_user_print_setting(printer, print_format) { + frappe.call({ + method: 'msp.msp.doctype.user_print_setting.user_print_setting.save_user_print_setting', + args: { + reference_doctype: 'Purchase Receipt', + printer: printer, + print_format: print_format + } + }); +} diff --git a/msp/public/js/quotation.js b/msp/public/js/quotation.js new file mode 100644 index 0000000..3ba4616 --- /dev/null +++ b/msp/public/js/quotation.js @@ -0,0 +1,66 @@ +frappe.ui.form.on('Quotation', { + refresh: function(frm) { + frm.add_custom_button(__('Anhänge hinzufügen'), function() { + attach_item_attachments_to_quotation(frm); + }, __("Aktionen")); + + frm.add_custom_button(__('Alle Anhänge entfernen'), function() { + remove_all_attachments_from_quotation(frm); + }, __("Aktionen")); + + style_actions_button(frm); + } +}); + +function style_actions_button(frm) { + setTimeout(() => { + frm.$wrapper + .find('.inner-group-button[data-label="' + __("Aktionen") + '"] > .btn') + .css({ + "background-color": "#e73249", + "border-color": "#e73249", + "color": "#fff", + }); + }, 100); +} + +function attach_item_attachments_to_quotation(frm) { + frm.doc.items.forEach(item_row => { + frappe.call({ + method: 'msp.quotation_tools.copy_attachments', + args: { + 'source_doctype': 'Item', + 'source_docname': item_row.item_code, + 'target_doctype': 'Quotation', + 'target_docname': frm.doc.name + }, + callback: function(response) { + if (!response.exc) { + frm.reload_doc(); + } + } + }); + }); + frappe.msgprint(__('Anhänge wurden hinzugefügt.')); +} + +function remove_all_attachments_from_quotation(frm) { + frappe.confirm( + __('Möchten Sie wirklich alle Anhänge aus diesem Angebot entfernen?'), + function() { + frappe.call({ + method: 'msp.quotation_tools.remove_all_attachments', + args: { + 'doctype': 'Quotation', + 'docname': frm.doc.name + }, + callback: function(response) { + if (!response.exc) { + frappe.msgprint(__('Es wurden {0} Anhänge entfernt.', [response.message])); + frm.reload_doc(); + } + } + }); + } + ); +} diff --git a/msp/public/js/sales_invoice.js b/msp/public/js/sales_invoice.js new file mode 100644 index 0000000..3627ac7 --- /dev/null +++ b/msp/public/js/sales_invoice.js @@ -0,0 +1,237 @@ +frappe.ui.form.on('Sales Invoice', { + refresh: function(frm) { + // Buttons nur bei Entwürfen anzeigen + if (frm.doc.docstatus === 0) { + frm.add_custom_button(__('📅 Rechnungsdatum auf heute'), function() { + set_posting_date_to_today(frm); + }, __("Aktionen")); + + frm.add_custom_button(__('🎁 Selektiere Artikel ohne Berechnung'), function() { + show_ohne_berechnung_dialog(frm); + }, __("Aktionen")); + + style_actions_button(frm); + } + } +}); + +function style_actions_button(frm) { + setTimeout(() => { + frm.$wrapper + .find('.inner-group-button[data-label="' + __("Aktionen") + '"] > .btn') + .css({ + "background-color": "#e73249", + "border-color": "#e73249", + "color": "#fff", + }); + }, 100); +} + +function set_posting_date_to_today(frm) { + const today = frappe.datetime.get_today(); + const old_date = frm.doc.posting_date; + + // Aktiviere "Edit Posting Date and Time" + frm.set_value('set_posting_time', 1); + + // Setze Rechnungsdatum auf heute + frm.set_value('posting_date', today); + + // Payment Schedule neu berechnen + if (frm.doc.payment_terms_template) { + // Wenn Payment Terms Template vorhanden, neu berechnen + frappe.call({ + method: "erpnext.controllers.accounts_controller.get_payment_terms", + args: { + terms_template: frm.doc.payment_terms_template, + posting_date: today, + grand_total: frm.doc.rounded_total || frm.doc.grand_total, + base_grand_total: frm.doc.base_rounded_total || frm.doc.base_grand_total, + bill_date: frm.doc.bill_date + }, + callback: function(r) { + if (r.message && !r.exc) { + frm.set_value("payment_schedule", r.message); + + // due_date auf das späteste Datum der Payment Schedule setzen + let max_due_date = today; + r.message.forEach(function(row) { + if (row.due_date && row.due_date > max_due_date) { + max_due_date = row.due_date; + } + }); + frm.set_value('due_date', max_due_date); + + frappe.show_alert({ + message: __('Rechnungsdatum von {0} auf {1} geändert. Zahlungsbedingungen wurden aktualisiert.', + [frappe.datetime.str_to_user(old_date), frappe.datetime.str_to_user(today)]), + indicator: 'green' + }, 5); + } + } + }); + } else { + // Kein Template: due_date direkt auf heute setzen + frm.set_value('due_date', today); + + // Falls payment_schedule Einträge existieren, Datum dort auch aktualisieren + if (frm.doc.payment_schedule && frm.doc.payment_schedule.length > 0) { + frm.doc.payment_schedule.forEach(function(row) { + frappe.model.set_value(row.doctype, row.name, 'due_date', today); + }); + } + + frappe.show_alert({ + message: __('Rechnungsdatum von {0} auf {1} geändert.', + [frappe.datetime.str_to_user(old_date), frappe.datetime.str_to_user(today)]), + indicator: 'green' + }, 5); + } + + frm.refresh_fields(); +} + +function show_ohne_berechnung_dialog(frm) { + // Sammle ausgewählte Artikel + const items_grid_wrapper = frm.fields_dict['items'].grid.wrapper; + const grid_rows = items_grid_wrapper.find('.grid-row'); + let selected_items = []; + + grid_rows.each(function() { + const $row = $(this); + const is_checked = $row.find('.grid-row-check').is(':checked'); + const row_idx = $row.attr('data-idx'); + + // Nur Zeilen mit gültigem data-idx verarbeiten (Header-Zeile überspringen) + if (is_checked && row_idx) { + const row_index = parseInt(row_idx) - 1; + const item = frm.doc.items[row_index]; + + if (item) { + selected_items.push({ + idx: item.idx, + item_code: item.item_code, + item_name: item.item_name, + qty: item.qty, + rate: item.rate, + amount: item.amount, + doctype: item.doctype, + name: item.name, + description: item.description + }); + } + } + }); + + if (selected_items.length === 0) { + frappe.msgprint(__('Bitte wählen Sie mindestens einen Artikel aus.')); + return; + } + + // Summen berechnen + const total_qty = selected_items.reduce((sum, item) => sum + item.qty, 0); + const total_amount = selected_items.reduce((sum, item) => sum + item.amount, 0); + + // Tabelle erstellen + let rows = selected_items.map(item => { + return ` + ${item.idx} + ${item.item_code} + ${item.item_name || ''} + ${item.qty} + ${format_currency(item.rate)} + ${format_currency(item.amount)} + `; + }).join(''); + + const table_html = ` +
+

+ Die folgenden ${selected_items.length} Artikel werden auf 100% Rabatt gesetzt + und mit dem Hinweis "ohne Berechnung" versehen: +

+
+
+ + + + + + + + + + + + + ${rows} + + + + + + + + + +
#ArtikelcodeBezeichnungMengePreisBetrag
Summe${total_qty}${format_currency(total_amount)}
+
+
+ Hinweis: Diese Aktion kann nicht rückgängig gemacht werden. + Der Rabatt muss manuell zurückgesetzt werden. +
+ `; + + const dialog = new frappe.ui.Dialog({ + title: __('Artikel ohne Berechnung'), + size: 'large', + fields: [ + { + fieldtype: 'HTML', + fieldname: 'items_table', + options: table_html + } + ], + primary_action_label: __('Bestätigen'), + primary_action: function() { + apply_ohne_berechnung(frm, selected_items); + dialog.hide(); + }, + secondary_action_label: __('Abbrechen') + }); + + dialog.show(); +} + +function apply_ohne_berechnung(frm, selected_items) { + selected_items.forEach(item => { + // 100% Rabatt setzen + frappe.model.set_value(item.doctype, item.name, 'discount_percentage', 100); + + // Beschreibung bereinigen und "ohne Berechnung" genau einmal anhängen + let desc = item.description || ''; + + // Alle denkbaren Vorkommen entfernen + const patterns = [ + /]*>\s*]*>\s*ohne\s+berechnung\s*<\/strong>\s*<\/p>/gi, + /]*>\s*ohne\s+berechnung\s*<\/p>/gi, + /]*>\s*ohne\s+berechnung\s*<\/strong>/gi, + /\bohne\s+berechnung\b/gi + ]; + patterns.forEach(re => { desc = desc.replace(re, ''); }); + + // Leere Absätze beseitigen + desc = desc.replace(/(]*>\s*<\/p>)+/gi, '').trim(); + + // Standard-Zeile einmal anhängen + const stamp = '

ohne Berechnung

'; + desc = (desc ? desc + stamp : stamp); + + frappe.model.set_value(item.doctype, item.name, 'description', desc); + }); + + frappe.show_alert({ + message: __("{0} Artikel wurden auf 'ohne Berechnung' gesetzt.", [selected_items.length]), + indicator: 'green' + }, 5); +} diff --git a/msp/quotation_tools.py b/msp/quotation_tools.py index 84d2bcb..9d7d739 100644 --- a/msp/quotation_tools.py +++ b/msp/quotation_tools.py @@ -1,6 +1,21 @@ import frappe +@frappe.whitelist() +def remove_all_attachments(doctype, docname): + """Entfernt alle Anhänge von einem Dokument""" + attachments = frappe.get_all("File", filters={ + "attached_to_doctype": doctype, + "attached_to_name": docname + }) + + count = 0 + for attachment in attachments: + frappe.delete_doc("File", attachment.name, ignore_permissions=True) + count += 1 + + return count + @frappe.whitelist() def copy_attachments(source_doctype, source_docname, target_doctype, target_docname): attachments = frappe.get_all("File", filters={"attached_to_doctype": source_doctype, "attached_to_name": source_docname}) @@ -25,35 +40,4 @@ def copy_attachments(source_doctype, source_docname, target_doctype, target_docn "attached_to_doctype": target_doctype, "attached_to_name": target_docname }) - attach.insert(ignore_permissions=True) - -""" Benötigt das folgende Client Script für Quotation Form - -frappe.ui.form.on('Quotation', { - refresh: function(frm) { - frm.add_custom_button(__('Anhänge hinzufügen'), function() { - attach_item_attachments_to_quotation(frm); - }, "Aktionen"); - } -}); - -function attach_item_attachments_to_quotation(frm) { - frm.doc.items.forEach(item_row => { - frappe.call({ - method: 'msp.quotation_tools.copy_attachments', - args: { - 'source_doctype': 'Item', - 'source_docname': item_row.item_code, // Verwenden Sie item_code, um den Anhang vom Artikel zu kopieren - 'target_doctype': 'Quotation', - 'target_docname': frm.doc.name - }, - callback: function(response) { - if (!response.exc) { - frm.reload_doc(); - } - } - }); - }); - frappe.msgprint(__('Anhänge wurden hinzugefügt.')); -} - """ \ No newline at end of file + attach.insert(ignore_permissions=True) \ No newline at end of file diff --git a/msp/rmm_import.py b/msp/rmm_import.py new file mode 100644 index 0000000..a3c29b5 --- /dev/null +++ b/msp/rmm_import.py @@ -0,0 +1,1898 @@ +# Copyright (c) 2026, itsdave GmbH and contributors +# For license information, please see license.txt + +""" +RMM Import Module + +This module provides functionality to import IT Objects from Tactical RMM. +It supports: +- Creating new IT Objects from RMM agents +- Updating existing IT Objects with RMM data +- Configurable field mappings (type, status) +- Selective sync of software and patch data +""" + +import frappe +from frappe import _ +from frappe.utils import now_datetime +import json +import importlib + + +# ==================== Main Whitelisted Functions ==================== + + +@frappe.whitelist() +def create_import_session(documentation_name): + """ + Create a new RMM Import Session and load all agents. + + Args: + documentation_name: Name of the MSP Documentation + + Returns: + Name of the created RMM Import Session + """ + # Load MSP Documentation + doc = frappe.get_doc("MSP Documentation", documentation_name) + + # Get RMM Instance and IT Landscape + rmm_instance = _get_rmm_instance(doc) + it_landscape = doc.landscape + + if not rmm_instance: + frappe.throw(_("No RMM Instance configured for this documentation")) + + if not it_landscape: + frappe.throw(_("No IT Landscape configured for this documentation")) + + # Get client and site filters + client_filter = doc.tactical_rmm_tenant_caption + site_filter = doc.tactical_rmm_site_name + + if not client_filter: + frappe.throw( + _("No Tactical RMM Tenant Caption configured in the documentation") + ) + + # Fetch agents from RMM + agents = _fetch_agents_from_rmm(rmm_instance, client_filter, site_filter) + + # Create Import Session + session = frappe.new_doc("RMM Import Session") + session.documentation = documentation_name + session.rmm_instance = rmm_instance + session.it_landscape = it_landscape + session.filter_client = client_filter + session.filter_site = site_filter or "" + session.status = "Draft" + + # Add agents to selection table + for agent in agents: + existing_it_object = find_existing_it_object(agent["agent_id"], rmm_instance) + + row = session.append("agent_selection", {}) + row.agent_id = agent["agent_id"] + row.hostname = agent["hostname"] + row.monitoring_type = agent.get("monitoring_type", "") + row.site_name = agent.get("site_name", "") + row.operating_system = agent.get("operating_system", "") + row.status = agent.get("status", "") + row.local_ip = _get_first_local_ip(agent) + + if existing_it_object: + row.action = "Update" + row.existing_it_object = existing_it_object + else: + row.action = "Create" + + row.selected = 0 # Not selected by default + row.import_result = "Pending" + + session.insert() + frappe.db.commit() + + return session.name + + +@frappe.whitelist() +def refresh_import_session(session_name): + """ + Refresh the agent list in an existing Import Session. + + Args: + session_name: Name of the RMM Import Session + + Returns: + dict with agent_count + """ + session = frappe.get_doc("RMM Import Session", session_name) + + if session.status != "Draft": + frappe.throw(_("Can only refresh sessions in Draft status")) + + # Fetch agents from RMM + agents = _fetch_agents_from_rmm( + session.rmm_instance, session.filter_client, session.filter_site + ) + + # Clear existing selection + session.agent_selection = [] + + # Add agents to selection table + for agent in agents: + existing_it_object = find_existing_it_object( + agent["agent_id"], session.rmm_instance + ) + + row = session.append("agent_selection", {}) + row.agent_id = agent["agent_id"] + row.hostname = agent["hostname"] + row.monitoring_type = agent.get("monitoring_type", "") + row.site_name = agent.get("site_name", "") + row.operating_system = agent.get("operating_system", "") + row.status = agent.get("status", "") + row.local_ip = _get_first_local_ip(agent) + + if existing_it_object: + row.action = "Update" + row.existing_it_object = existing_it_object + else: + row.action = "Create" + + row.selected = 0 + row.import_result = "Pending" + + session.save() + frappe.db.commit() + + return {"agent_count": len(session.agent_selection)} + + +@frappe.whitelist() +def execute_import(session_name): + """ + Execute the import for all selected agents. + + Args: + session_name: Name of the RMM Import Session + + Returns: + dict with created, updated, failed counts + """ + session = frappe.get_doc("RMM Import Session", session_name) + + if session.status != "Draft": + frappe.throw(_("Can only execute imports for sessions in Draft status")) + + # Get settings + settings = frappe.get_single("RMM Import Settings") + + # Get all agents from RMM for full data + all_agents = _fetch_agents_from_rmm( + session.rmm_instance, session.filter_client, session.filter_site + ) + + # Create a lookup dict by agent_id + agent_data_lookup = {a["agent_id"]: a for a in all_agents} + + # Update status + session.status = "In Progress" + session.import_log = "" + session.add_log(f"Starting import for session {session_name}") + session.save() + frappe.db.commit() + + created = 0 + updated = 0 + failed = 0 + + try: + for row in session.agent_selection: + if not row.selected: + continue + + agent_data = agent_data_lookup.get(row.agent_id) + if not agent_data: + row.import_result = "Failed" + row.import_message = "Agent not found in RMM" + failed += 1 + session.add_log(f"FAILED: {row.hostname} - Agent not found in RMM") + continue + + try: + if row.action == "Create": + it_object = create_it_object_from_agent( + agent_data, + session.it_landscape, + session.rmm_instance, + settings, + ) + row.import_result = "Success" + row.import_message = f"Created {it_object}" + row.existing_it_object = it_object + created += 1 + session.add_log(f"CREATED: {row.hostname} -> {it_object}") + + elif row.action == "Update": + if not settings.update_existing: + row.import_result = "Failed" + row.import_message = "Update disabled in settings" + failed += 1 + continue + + update_it_object_from_agent( + row.existing_it_object, agent_data, settings + ) + row.import_result = "Success" + row.import_message = f"Updated {row.existing_it_object}" + updated += 1 + session.add_log(f"UPDATED: {row.hostname} -> {row.existing_it_object}") + + except Exception as e: + row.import_result = "Failed" + row.import_message = str(e)[:200] + failed += 1 + session.add_log(f"FAILED: {row.hostname} - {str(e)}") + frappe.log_error( + f"RMM Import Error for {row.hostname}: {str(e)}", "RMM Import" + ) + + # Update session results + session.status = "Completed" if failed == 0 else "Completed" + session.agents_created = created + session.agents_updated = updated + session.agents_failed = failed + session.add_log( + f"Import completed: {created} created, {updated} updated, {failed} failed" + ) + session.save() + frappe.db.commit() + + except Exception as e: + session.status = "Failed" + session.add_log(f"Import failed with error: {str(e)}") + session.save() + frappe.db.commit() + frappe.throw(str(e)) + + return {"created": created, "updated": updated, "failed": failed} + + +@frappe.whitelist() +def select_all_agents(session_name, action_filter=None): + """ + Select all agents in a session, optionally filtered by action. + + Args: + session_name: Name of the RMM Import Session + action_filter: Optional filter for action (Create, Update) + """ + session = frappe.get_doc("RMM Import Session", session_name) + + for row in session.agent_selection: + if action_filter: + if row.action == action_filter: + row.selected = 1 + else: + if row.action != "Skip": + row.selected = 1 + + session.save() + return {"selected": sum(1 for r in session.agent_selection if r.selected)} + + +@frappe.whitelist() +def deselect_all_agents(session_name): + """Deselect all agents in a session.""" + session = frappe.get_doc("RMM Import Session", session_name) + + for row in session.agent_selection: + row.selected = 0 + + session.save() + return {"selected": 0} + + +# ==================== IT Object Matching Functions ==================== + + +@frappe.whitelist() +def get_matching_suggestions(documentation_name): + """ + Get suggestions for matching existing IT Objects with RMM Agents. + + Matching is based on: + - Hostname similarity + - IP address match + - Serial number match + + Args: + documentation_name: Name of the MSP Documentation + + Returns: + dict with unmatched_objects, agents, and suggestions + """ + doc = frappe.get_doc("MSP Documentation", documentation_name) + + rmm_instance = _get_rmm_instance(doc) + it_landscape = doc.landscape + + if not rmm_instance or not it_landscape: + frappe.throw(_("RMM Instance and IT Landscape are required")) + + # Get IT Objects without RMM assignment + unmatched_objects = frappe.get_all( + "IT Object", + filters={ + "it_landscape": it_landscape, + "rmm_agent_id": ["in", ["", None]] + }, + fields=["name", "title", "main_ip", "serial_number", "type", "status", "rmm_local_ip"] + ) + + # Get main IP addresses for objects + for obj in unmatched_objects: + if obj.main_ip: + ip_doc = frappe.db.get_value("IP Address", obj.main_ip, "ip_address") + obj["ip_address"] = ip_doc or "" + else: + obj["ip_address"] = obj.get("rmm_local_ip", "") + + # Get RMM Agents + client_filter = doc.tactical_rmm_tenant_caption + site_filter = doc.tactical_rmm_site_name + + if not client_filter: + frappe.throw(_("Tactical RMM Tenant Caption is required")) + + agents = _fetch_agents_from_rmm(rmm_instance, client_filter, site_filter) + + # Find already matched agent IDs + matched_agent_ids = set( + frappe.get_all( + "IT Object", + filters={"rmm_instance": rmm_instance, "rmm_agent_id": ["is", "set"]}, + pluck="rmm_agent_id" + ) + ) + + # Filter out already matched agents + available_agents = [a for a in agents if a["agent_id"] not in matched_agent_ids] + + # Generate matching suggestions + suggestions = [] + for obj in unmatched_objects: + best_match = _find_best_match(obj, available_agents) + suggestions.append({ + "it_object": obj["name"], + "it_object_title": obj["title"], + "it_object_ip": obj.get("ip_address", ""), + "it_object_serial": obj.get("serial_number", ""), + "it_object_type": obj.get("type", ""), + "suggested_agent_id": best_match["agent_id"] if best_match else None, + "suggested_hostname": best_match["hostname"] if best_match else None, + "suggested_ip": _get_first_local_ip(best_match) if best_match else None, + "confidence": best_match["confidence"] if best_match else 0, + "match_reason": best_match["match_reason"] if best_match else "", + }) + + # Sort by confidence (highest first) + suggestions.sort(key=lambda x: x["confidence"], reverse=True) + + # Format agents for dropdown selection + agent_options = [ + { + "agent_id": a["agent_id"], + "hostname": a["hostname"], + "local_ip": _get_first_local_ip(a), + "site_name": a.get("site_name", ""), + "monitoring_type": a.get("monitoring_type", ""), + "label": f"{a['hostname']} ({_get_first_local_ip(a)}) - {a.get('site_name', '')}" + } + for a in available_agents + ] + + return { + "unmatched_count": len(unmatched_objects), + "available_agents_count": len(available_agents), + "suggestions": suggestions, + "agents": agent_options, + "rmm_instance": rmm_instance + } + + +def _find_best_match(it_object, agents): + """ + Find the best matching RMM agent for an IT Object. + + Scoring system (additive, capped at 100 for display): + - AD GUID exact match: 150 points (definitive, highest priority) + - IP address exact match: 90 points (strong indicator, but NAT can cause duplicates) + - Serial number exact match: 85 points + - Hostname exact match: 100 points (definitive) + - Hostname similar (contains): 15 points (adds to IP match for 100%+) + - Partial hostname match: up to 10 points + + Examples: + - AD GUID match = 100% (displayed, actually 150 internal) + - IP match alone = 90% + - IP match + hostname similar = 100%+ (displayed as 100%) + - Hostname exact = 100% + - Serial + hostname similar = 100% + + Note: Client/Tenant filtering is already applied before this function, + so all agents are from the same customer. + + Returns agent dict with added 'confidence' and 'match_reason' fields. + """ + if not agents: + return None + + obj_title = (it_object.get("title") or "").lower().strip() + obj_ip = (it_object.get("ip_address") or "").strip() + obj_serial = (it_object.get("serial_number") or "").lower().strip() + obj_ad_guid = (it_object.get("ad_object_guid") or "").strip() + + best_match = None + best_score = 0 + + for agent in agents: + agent_hostname = (agent.get("hostname") or "").lower().strip() + # Check both local_ip (from processed data) and local_ips (from raw data) + agent_ip = (agent.get("local_ip") or _get_first_local_ip(agent) or "").strip() + agent_serial = (agent.get("serial_number") or "").lower().strip() + agent_ad_guid = (agent.get("ad_guid") or "").strip() + + score = 0 + reasons = [] + + # AD GUID exact match - definitive, highest priority + if obj_ad_guid and agent_ad_guid and obj_ad_guid == agent_ad_guid: + score += 150 + reasons.append("AD GUID") + + # IP address exact match - strong but not definitive (NAT scenarios) + if obj_ip and agent_ip and obj_ip == agent_ip: + score += 90 + reasons.append(f"IP-Adresse ({obj_ip})") + + # Serial number match (strong indicator) + if obj_serial and agent_serial and obj_serial == agent_serial: + # Ignore generic serials + if obj_serial not in ["to be filled by o.e.m.", "default string", ""]: + score += 85 + reasons.append("Seriennummer") + + # Exact hostname match - definitive + if obj_title and agent_hostname and obj_title == agent_hostname: + score += 100 + reasons.append("Hostname exakt") + + # Hostname contains or is contained (case-insensitive) + # This adds to IP match to reach 100%+ + elif obj_title and agent_hostname: + if obj_title in agent_hostname or agent_hostname in obj_title: + score += 15 + reasons.append("Hostname ähnlich") + else: + # Partial hostname match (word-based) + obj_words = set(obj_title.replace("-", " ").replace("_", " ").split()) + agent_words = set(agent_hostname.replace("-", " ").replace("_", " ").split()) + common_words = obj_words & agent_words + if len(common_words) >= 1 and len(common_words) >= len(obj_words) * 0.5: + word_score = min(10, len(common_words) * 5) + score += word_score + reasons.append(f"Hostname teilweise ({', '.join(common_words)})") + + if score > best_score: + best_score = score + best_match = agent.copy() + # Cap confidence at 100 for display purposes + best_match["confidence"] = min(100, score) + best_match["match_reason"] = ", ".join(reasons) + + # Only return matches with reasonable confidence + if best_score >= 50: + return best_match + + return None + + +@frappe.whitelist() +def apply_rmm_mappings(mappings, rmm_instance): + """ + Apply RMM agent mappings to IT Objects. + + Args: + mappings: JSON string of list with {it_object, agent_id} dicts + rmm_instance: Name of the RMM Instance + + Returns: + dict with success count and errors + """ + if isinstance(mappings, str): + mappings = json.loads(mappings) + + success_count = 0 + errors = [] + + for mapping in mappings: + it_object_name = mapping.get("it_object") + agent_id = mapping.get("agent_id") + + if not it_object_name or not agent_id: + continue + + try: + it_object = frappe.get_doc("IT Object", it_object_name) + it_object.rmm_agent_id = agent_id + it_object.rmm_instance = rmm_instance + it_object.last_rmm_sync = now_datetime() + it_object.save() + success_count += 1 + except Exception as e: + errors.append(f"{it_object_name}: {str(e)}") + frappe.log_error(f"Error mapping {it_object_name}: {str(e)}", "RMM Mapping") + + frappe.db.commit() + + return { + "success": success_count, + "errors": errors, + "total": len(mappings) + } + + +@frappe.whitelist() +def fetch_software_for_it_object(it_object_name): + """ + Fetch and update installed software for a single IT Object from RMM. + + Args: + it_object_name: Name of the IT Object + + Returns: + dict with success status and software count + """ + it_object = frappe.get_doc("IT Object", it_object_name) + + if not it_object.rmm_agent_id or not it_object.rmm_instance: + frappe.throw(_("IT Object hat keine RMM-Verknüpfung")) + + software_list = _get_software_list(it_object.rmm_agent_id, it_object.rmm_instance) + _populate_software_table(it_object, software_list) + it_object.save() + + return {"success": True, "count": len(software_list)} + + +@frappe.whitelist() +def sync_matched_object(it_object_name): + """ + Sync a single IT Object that has an RMM mapping with current RMM data. + + Args: + it_object_name: Name of the IT Object + + Returns: + dict with success status + """ + it_object = frappe.get_doc("IT Object", it_object_name) + + if not it_object.rmm_agent_id or not it_object.rmm_instance: + frappe.throw(_("IT Object has no RMM mapping")) + + # Get settings + settings = frappe.get_single("RMM Import Settings") + + # Fetch agent data + rmm_doc = frappe.get_doc("RMM Instance", it_object.rmm_instance) + api_url, headers, verify_ssl = rmm_doc.get_api_credentials() + + import requests + response = requests.get( + f"{api_url}/agents/{it_object.rmm_agent_id}/", + headers=headers, + timeout=30, + verify=verify_ssl + ) + + if response.status_code != 200: + frappe.throw(_(f"Could not fetch agent data: HTTP {response.status_code}")) + + agent_data = response.json() + + # Update the IT Object + update_it_object_from_agent(it_object_name, agent_data, settings) + + return {"success": True, "message": _("IT Object updated from RMM")} + + +@frappe.whitelist() +def get_available_agents_for_it_object(it_object_name): + """ + Get available RMM agents for linking to an IT Object. + + Finds the RMM instance via the IT Object's landscape and returns + all unlinked agents with matching suggestions. + + IMPORTANT: Filters agents by the tactical_rmm_tenant_caption from IT Landscape + to only show agents belonging to the same customer/client. + + Args: + it_object_name: Name of the IT Object + + Returns: + dict with agents list, current_mapping info, and best_match suggestion + """ + it_object = frappe.get_doc("IT Object", it_object_name) + + # Check if already mapped + current_mapping = None + if it_object.rmm_agent_id and it_object.rmm_instance: + current_mapping = { + "agent_id": it_object.rmm_agent_id, + "rmm_instance": it_object.rmm_instance + } + + # Get RMM instance and client filter from landscape + rmm_instance = None + client_filter = None + if it_object.it_landscape: + landscape = frappe.get_doc("IT Landscape", it_object.it_landscape) + rmm_instance = landscape.rmm_instance + client_filter = landscape.tactical_rmm_tenant_caption + + if not rmm_instance: + return { + "success": False, + "error": _("No RMM Instance configured for this IT Landscape"), + "agents": [], + "current_mapping": current_mapping + } + + # Get all agents from RMM + tactical_rmm = importlib.import_module("msp.tactical-rmm") + all_agents = tactical_rmm.get_all_agents(rmm_instance_name=rmm_instance) + + # Filter by client/tenant if configured in IT Landscape + if client_filter: + all_agents = [ + a for a in all_agents + if a.get("client_name", "").lower() == client_filter.lower() + ] + + # Find already mapped agent IDs + matched_agent_ids = set( + frappe.get_all( + "IT Object", + filters={ + "rmm_instance": rmm_instance, + "rmm_agent_id": ["is", "set"], + "name": ["!=", it_object_name] # Exclude current object + }, + pluck="rmm_agent_id" + ) + ) + + # Filter out already matched agents (except current mapping) + available_agents = [] + for agent in all_agents: + agent_id = agent.get("agent_id") + if agent_id not in matched_agent_ids: + available_agents.append({ + "agent_id": agent_id, + "hostname": agent.get("hostname", ""), + "client_name": agent.get("client_name", ""), + "site_name": agent.get("site_name", ""), + "local_ip": _get_first_local_ip(agent), + "public_ip": agent.get("public_ip", ""), + "operating_system": agent.get("operating_system", ""), + "monitoring_type": agent.get("monitoring_type", ""), + "status": agent.get("status", ""), + "label": f"{agent.get('hostname', '')} ({_get_first_local_ip(agent)}) - {agent.get('site_name', '')}" + }) + + # Find best match for current IT Object + it_object_data = { + "title": it_object.title, + "ip_address": "", + "serial_number": it_object.serial_number + } + + # Get IP address - check main_ip link first + if it_object.main_ip: + ip_doc = frappe.db.get_value("IP Address", it_object.main_ip, "ip_address") + it_object_data["ip_address"] = ip_doc or "" + elif it_object.rmm_local_ip: + it_object_data["ip_address"] = it_object.rmm_local_ip + + # Find best match + best_match = _find_best_match(it_object_data, available_agents) + + return { + "success": True, + "agents": available_agents, + "rmm_instance": rmm_instance, + "client_filter": client_filter, + "current_mapping": current_mapping, + "best_match": { + "agent_id": best_match["agent_id"] if best_match else None, + "hostname": best_match["hostname"] if best_match else None, + "confidence": best_match["confidence"] if best_match else 0, + "match_reason": best_match["match_reason"] if best_match else "" + } if best_match else None + } + + +@frappe.whitelist() +def link_it_object_to_agent(it_object_name, agent_id, rmm_instance, sync_now=False): + """ + Link a single IT Object to an RMM agent. + + Args: + it_object_name: Name of the IT Object + agent_id: RMM Agent ID + rmm_instance: RMM Instance name + sync_now: If True, immediately sync data from RMM + + Returns: + dict with success status + """ + it_object = frappe.get_doc("IT Object", it_object_name) + + it_object.rmm_agent_id = agent_id + it_object.rmm_instance = rmm_instance + it_object.last_rmm_sync = now_datetime() + it_object.save() + frappe.db.commit() + + result = { + "success": True, + "message": _("IT Object linked to RMM agent") + } + + # Optionally sync data + if sync_now: + try: + sync_result = sync_matched_object(it_object_name) + result["sync_result"] = sync_result + result["message"] = _("IT Object linked and synced from RMM") + except Exception as e: + result["sync_error"] = str(e) + result["message"] = _("IT Object linked, but sync failed: {0}").format(str(e)) + + return result + + +@frappe.whitelist() +def unlink_it_object_from_agent(it_object_name): + """ + Remove RMM agent link from an IT Object. + + Args: + it_object_name: Name of the IT Object + + Returns: + dict with success status + """ + it_object = frappe.get_doc("IT Object", it_object_name) + + it_object.rmm_agent_id = None + it_object.rmm_instance = None + it_object.save() + frappe.db.commit() + + return { + "success": True, + "message": _("RMM link removed from IT Object") + } + + +# ==================== Core Import Functions ==================== + + +def find_existing_it_object(agent_id, rmm_instance): + """ + Find an existing IT Object by RMM agent ID and instance. + + Args: + agent_id: RMM Agent ID + rmm_instance: RMM Instance name + + Returns: + IT Object name or None + """ + return frappe.db.get_value( + "IT Object", {"rmm_agent_id": agent_id, "rmm_instance": rmm_instance}, "name" + ) + + +def create_it_object_from_agent(agent, it_landscape, rmm_instance, settings): + """ + Create a new IT Object from RMM agent data. + + Args: + agent: Agent data dict from RMM + it_landscape: IT Landscape name + rmm_instance: RMM Instance name + settings: RMM Import Settings document + + Returns: + Name of the created IT Object + """ + it_object = frappe.new_doc("IT Object") + + # Basic fields + it_object.title = agent["hostname"] + it_object.it_landscape = it_landscape + it_object.rmm_agent_id = agent["agent_id"] + it_object.rmm_instance = rmm_instance + it_object.created_from_rmm = 1 + it_object.last_rmm_sync = now_datetime() + + # Type mapping + monitoring_type = agent.get("monitoring_type", "") + it_object_type = _apply_type_mapping(monitoring_type, settings) + if it_object_type: + it_object.type = it_object_type + + # Status mapping + rmm_status = agent.get("status", "") + it_object_status = _apply_status_mapping(rmm_status, settings) + if it_object_status: + it_object.status = it_object_status + + # RMM-specific fields + it_object.rmm_local_ip = _get_first_local_ip(agent) + it_object.rmm_public_ip = agent.get("public_ip", "") + it_object.rmm_operating_system = agent.get("operating_system", "") + it_object.rmm_last_seen = agent.get("last_seen", "") + it_object.rmm_last_user = agent.get("logged_username", "") + it_object.rmm_needs_reboot = 1 if agent.get("needs_reboot") else 0 + + # AD-specific fields (if merged data available) + if agent.get("ad_matched"): + it_object.ad_object_guid = agent.get("ad_guid", "") + it_object.ad_distinguished_name = agent.get("ad_distinguished_name", "") + it_object.ad_account_status = agent.get("ad_account_status", "") + it_object.ad_operating_system = agent.get("ad_operating_system", "") + + # Parse AD last logon if available + ad_last_logon = agent.get("ad_last_logon", "") + if ad_last_logon: + try: + from frappe.utils import get_datetime + it_object.ad_last_logon = get_datetime(ad_last_logon) + except Exception: + pass + + it_object.last_ad_sync = now_datetime() + + # Serial number + serial = agent.get("serial_number", "") + if serial and serial not in ["To Be Filled By O.E.M.", "Default string"]: + it_object.serial_number = serial + + # Format specs (legacy markdown) + it_object.rmm_specs = format_rmm_specs(agent) + + # Populate hardware attributes child table + _populate_hardware_attributes(it_object, agent) + + # Sync software if enabled + if settings.sync_software: + try: + software_list = _get_software_list(agent["agent_id"], rmm_instance) + _populate_software_table(it_object, software_list) + # Also keep legacy markdown + it_object.rmm_software = _format_software_markdown(software_list) + except Exception as e: + frappe.log_error(f"Error fetching software: {e}", "RMM Import") + + # Sync patches if enabled + if settings.sync_patches: + try: + patches_list = _get_patches_list(agent["agent_id"], rmm_instance) + _populate_patches_table(it_object, patches_list) + # Update pending count + it_object.rmm_patches_pending = sum(1 for p in patches_list if not p.get("installed")) + except Exception as e: + frappe.log_error(f"Error fetching patches: {e}", "RMM Import") + + it_object.insert() + return it_object.name + + +def update_it_object_from_agent(it_object_name, agent, settings): + """ + Update an existing IT Object from RMM agent data. + + Args: + it_object_name: Name of the IT Object to update + agent: Agent data dict from RMM + settings: RMM Import Settings document + """ + it_object = frappe.get_doc("IT Object", it_object_name) + + # Always update sync timestamp + it_object.last_rmm_sync = now_datetime() + + # Update fields based on settings + if settings.should_update_field("rmm_local_ip"): + it_object.rmm_local_ip = _get_first_local_ip(agent) + + if settings.should_update_field("rmm_public_ip"): + it_object.rmm_public_ip = agent.get("public_ip", "") + + if settings.should_update_field("rmm_operating_system"): + it_object.rmm_operating_system = agent.get("operating_system", "") + + if settings.should_update_field("rmm_last_seen"): + it_object.rmm_last_seen = agent.get("last_seen", "") + + if settings.should_update_field("rmm_last_user"): + it_object.rmm_last_user = agent.get("logged_username", "") + + if settings.should_update_field("rmm_needs_reboot"): + it_object.rmm_needs_reboot = 1 if agent.get("needs_reboot") else 0 + + if settings.should_update_field("rmm_specs"): + it_object.rmm_specs = format_rmm_specs(agent) + # Also update hardware attributes table + _populate_hardware_attributes(it_object, agent) + + if settings.should_update_field("title"): + it_object.title = agent["hostname"] + + if settings.should_update_field("type"): + monitoring_type = agent.get("monitoring_type", "") + it_object_type = _apply_type_mapping(monitoring_type, settings) + if it_object_type: + it_object.type = it_object_type + + if settings.should_update_field("status"): + rmm_status = agent.get("status", "") + it_object_status = _apply_status_mapping(rmm_status, settings) + if it_object_status: + it_object.status = it_object_status + + if settings.should_update_field("serial_number"): + serial = agent.get("serial_number", "") + if serial and serial not in ["To Be Filled By O.E.M.", "Default string"]: + it_object.serial_number = serial + + # Sync software if enabled + if settings.sync_software and settings.should_update_field("rmm_software"): + try: + software_list = _get_software_list(agent["agent_id"], it_object.rmm_instance) + _populate_software_table(it_object, software_list) + # Also keep legacy markdown + it_object.rmm_software = _format_software_markdown(software_list) + except Exception as e: + frappe.log_error(f"Error fetching software: {e}", "RMM Import") + + # Sync patches if enabled + if settings.sync_patches and settings.should_update_field("rmm_patches_pending"): + try: + patches_list = _get_patches_list(agent["agent_id"], it_object.rmm_instance) + _populate_patches_table(it_object, patches_list) + # Update pending count + it_object.rmm_patches_pending = sum(1 for p in patches_list if not p.get("installed")) + except Exception as e: + frappe.log_error(f"Error fetching patches: {e}", "RMM Import") + + # Sync AD fields if enabled and AD data available + sync_ad = getattr(settings, 'sync_ad_fields_on_update', True) + if sync_ad and agent.get("ad_matched"): + it_object.ad_object_guid = agent.get("ad_guid", "") + it_object.ad_distinguished_name = agent.get("ad_distinguished_name", "") + it_object.ad_account_status = agent.get("ad_account_status", "") + it_object.ad_operating_system = agent.get("ad_operating_system", "") + + # Parse AD last logon if available + ad_last_logon = agent.get("ad_last_logon", "") + if ad_last_logon: + try: + from frappe.utils import get_datetime + it_object.ad_last_logon = get_datetime(ad_last_logon) + except Exception: + pass + + it_object.last_ad_sync = now_datetime() + + it_object.save() + + +# ==================== Helper Functions ==================== + + +def _get_rmm_instance(doc): + """Get RMM Instance from MSP Documentation (with cascade).""" + # Priority: doc.rmm_instance > doc.landscape.rmm_instance + if doc.rmm_instance: + return doc.rmm_instance + + if doc.landscape: + landscape = frappe.get_doc("IT Landscape", doc.landscape) + return landscape.rmm_instance + + return None + + +def _fetch_agents_from_rmm(rmm_instance, client_filter, site_filter=None): + """ + Fetch agents from RMM API. + + Uses the existing tactical-rmm.py functions. + """ + # Import the existing tactical-rmm module (has hyphen in filename) + tactical_rmm = importlib.import_module("msp.tactical-rmm") + + # Get all agents + all_agents = tactical_rmm.get_all_agents(rmm_instance_name=rmm_instance) + + # Filter by client + filtered = [ + a for a in all_agents if a.get("client_name", "").lower() == client_filter.lower() + ] + + # Filter by site if specified + if site_filter: + filtered = [ + a for a in filtered if a.get("site_name", "").lower() == site_filter.lower() + ] + + return filtered + + +def _get_first_local_ip(agent): + """Get the first local IP from agent data.""" + local_ips = agent.get("local_ips", []) + if isinstance(local_ips, list) and local_ips: + return local_ips[0] + elif isinstance(local_ips, str): + return local_ips + return "" + + +def _apply_type_mapping(monitoring_type, settings): + """Apply type mapping from settings.""" + if not monitoring_type: + return None + + # Try to get from settings mapping + mapped_type = settings.get_type_mapping(monitoring_type) + if mapped_type: + return mapped_type + + # Auto-create if enabled + if settings.auto_create_types: + type_name = monitoring_type.capitalize() + if not frappe.db.exists("IT Object Type", type_name): + it_type = frappe.new_doc("IT Object Type") + it_type.title = type_name + it_type.insert() + + # Add to mapping for future use + settings.append( + "type_mapping", + {"rmm_monitoring_type": monitoring_type, "it_object_type": type_name}, + ) + settings.save() + return type_name + + return None + + +def _apply_status_mapping(rmm_status, settings): + """Apply status mapping from settings.""" + if not rmm_status: + return None + + return settings.get_status_mapping(rmm_status) + + +def format_rmm_specs(agent): + """ + Format agent hardware specs as Markdown. + + Args: + agent: Agent data dict from RMM + + Returns: + Markdown formatted string + """ + lines = ["## Hardware"] + + # Make/Model + make_model = agent.get("make_model", "") + if make_model and make_model != "System manufacturer System Product Name": + lines.append(f"- **Model:** {make_model}") + elif make_model == "System manufacturer System Product Name": + lines.append("- **Model:** Not specified") + + # Serial + serial = agent.get("serial_number", "") + if serial and serial not in ["To Be Filled By O.E.M.", "Default string"]: + lines.append(f"- **Serial:** {serial}") + + # CPU + cpu = agent.get("cpu_model", []) + if isinstance(cpu, list): + cpu = ", ".join(cpu) + if cpu: + lines.append(f"- **CPU:** {cpu}") + + # GPU + gpu = agent.get("graphics", "") + if gpu: + lines.append(f"- **GPU:** {gpu}") + + # Disks + disks = agent.get("physical_disks", []) + if isinstance(disks, list): + disks = ", ".join(disks) + if disks: + lines.append(f"- **Disks:** {disks}") + + lines.append("") + lines.append("## System") + + # OS + os = agent.get("operating_system", "") + if os: + lines.append(f"- **OS:** {os}") + + # Status + status = agent.get("status", "") + if status: + lines.append(f"- **Status:** {status}") + + # Last Seen + last_seen = agent.get("last_seen", "") + if last_seen: + lines.append(f"- **Last Seen:** {last_seen}") + + # Last User + last_user = agent.get("logged_username", "") + if last_user: + lines.append(f"- **Last User:** {last_user}") + + # Needs Reboot + needs_reboot = agent.get("needs_reboot", False) + lines.append(f"- **Needs Reboot:** {'Yes' if needs_reboot else 'No'}") + + return "\n".join(lines) + + +def _get_software_markdown(agent_id, rmm_instance): + """ + Get installed software as Markdown. + + Uses existing tactical-rmm.py function. + """ + tactical_rmm = importlib.import_module("msp.tactical-rmm") + + software_list = tactical_rmm.get_software_for_agent( + agent_id, rmm_instance_name=rmm_instance + ) + + if not software_list: + return "No software data available." + + lines = ["## Installed Software", ""] + + # Sort by name + software_list = sorted(software_list, key=lambda x: x.get("name", "").lower()) + + for sw in software_list[:100]: # Limit to 100 entries + name = sw.get("name", "Unknown") + version = sw.get("version", "") + if version: + lines.append(f"- {name} ({version})") + else: + lines.append(f"- {name}") + + if len(software_list) > 100: + lines.append(f"\n*...and {len(software_list) - 100} more*") + + return "\n".join(lines) + + +def _get_patch_summary(agent_id, rmm_instance): + """ + Get Windows patch summary for an agent. + + Uses existing tactical-rmm.py function. + """ + tactical_rmm = importlib.import_module("msp.tactical-rmm") + + patches = tactical_rmm.get_patches_for_agent( + agent_id, rmm_instance_name=rmm_instance + ) + + if not patches: + return {"pending": 0, "installed": 0} + + pending = sum(1 for p in patches if p.get("installed") == False) + installed = sum(1 for p in patches if p.get("installed") == True) + + return {"pending": pending, "installed": installed} + + +# ==================== Child Table Population Functions ==================== + + +def _populate_hardware_attributes(it_object, agent): + """ + Populate the hardware_attributes child table from agent data. + + Clears existing entries and adds new ones from the agent. + """ + # Clear existing entries + it_object.hardware_attributes = [] + + # Model/Manufacturer + make_model = agent.get("make_model", "") + if make_model and make_model not in ["System manufacturer System Product Name", ""]: + # Try to split into manufacturer and model + parts = make_model.split(" ", 1) + if len(parts) == 2: + it_object.append("hardware_attributes", { + "attribute_type": "Manufacturer", + "attribute_value": parts[0], + "attribute_details": "" + }) + it_object.append("hardware_attributes", { + "attribute_type": "Model", + "attribute_value": parts[1], + "attribute_details": "" + }) + else: + it_object.append("hardware_attributes", { + "attribute_type": "Model", + "attribute_value": make_model, + "attribute_details": "" + }) + + # Serial Number + serial = agent.get("serial_number", "") + if serial and serial not in ["To Be Filled By O.E.M.", "Default string", ""]: + it_object.append("hardware_attributes", { + "attribute_type": "Serial Number", + "attribute_value": serial, + "attribute_details": "" + }) + + # CPU - deduplicate entries (multi-core CPUs list each core) + cpu = agent.get("cpu_model", []) + if isinstance(cpu, list) and cpu: + # Deduplicate and count + cpu_counts = {} + for c in cpu: + # Clean up the CPU name (remove core/thread info if present) + c_clean = c.strip() + cpu_counts[c_clean] = cpu_counts.get(c_clean, 0) + 1 + + # Format as "CPU Name (xN)" if multiple + cpu_parts = [] + for cpu_name, count in cpu_counts.items(): + if count > 1: + cpu_parts.append(f"{cpu_name} (x{count})") + else: + cpu_parts.append(cpu_name) + cpu = ", ".join(cpu_parts) + elif isinstance(cpu, str): + cpu = cpu.strip() + + if cpu: + it_object.append("hardware_attributes", { + "attribute_type": "CPU", + "attribute_value": cpu, + "attribute_details": "" + }) + + # RAM + total_ram = agent.get("total_ram", "") + if total_ram: + it_object.append("hardware_attributes", { + "attribute_type": "RAM", + "attribute_value": f"{total_ram} GB" if isinstance(total_ram, (int, float)) else str(total_ram), + "attribute_details": "" + }) + + # GPU + gpu = agent.get("graphics", "") + if gpu: + it_object.append("hardware_attributes", { + "attribute_type": "GPU", + "attribute_value": gpu, + "attribute_details": "" + }) + + # Disks + disks = agent.get("physical_disks", []) + if disks: + if isinstance(disks, list): + for i, disk in enumerate(disks): + it_object.append("hardware_attributes", { + "attribute_type": "Disk", + "attribute_value": disk if isinstance(disk, str) else str(disk), + "attribute_details": f"Disk {i+1}" + }) + else: + it_object.append("hardware_attributes", { + "attribute_type": "Disk", + "attribute_value": str(disks), + "attribute_details": "" + }) + + # BIOS Version + bios_ver = agent.get("bios_ver", "") + if bios_ver: + it_object.append("hardware_attributes", { + "attribute_type": "BIOS", + "attribute_value": bios_ver, + "attribute_details": "" + }) + + +def _get_software_list(agent_id, rmm_instance): + """ + Get installed software as list. + + Uses existing tactical-rmm.py function. + """ + tactical_rmm = importlib.import_module("msp.tactical-rmm") + + result = tactical_rmm.get_software_for_agent( + agent_id, rmm_instance_name=rmm_instance + ) + + # API returns {"id": .., "software": [...], "agent": ..} — extract the list + if isinstance(result, dict): + return result.get("software", []) + + return result or [] + + +def _populate_software_table(it_object, software_list): + """ + Populate the installed_software child table. + + Clears existing entries and adds new ones. + """ + # Clear existing entries + it_object.installed_software = [] + + # Sort by name + software_list = sorted(software_list, key=lambda x: x.get("name", "").lower()) + + for sw in software_list: + name = sw.get("name", "") + if not name: + continue + + # Parse install date if available + install_date = None + install_date_str = sw.get("install_date", "") + if install_date_str: + try: + from frappe.utils import getdate + install_date = getdate(install_date_str) + except Exception: + pass + + it_object.append("installed_software", { + "software_name": name[:140], # Limit length + "version": (sw.get("version", "") or "")[:140], + "publisher": (sw.get("publisher", "") or "")[:140], + "install_date": install_date + }) + + +def _format_software_markdown(software_list): + """ + Format software list as Markdown (legacy format). + """ + if not software_list: + return "No software data available." + + lines = ["## Installed Software", ""] + + # Sort by name + software_list = sorted(software_list, key=lambda x: x.get("name", "").lower()) + + for sw in software_list[:100]: # Limit to 100 entries + name = sw.get("name", "Unknown") + version = sw.get("version", "") + if version: + lines.append(f"- {name} ({version})") + else: + lines.append(f"- {name}") + + if len(software_list) > 100: + lines.append(f"\n*...and {len(software_list) - 100} more*") + + return "\n".join(lines) + + +def _get_patches_list(agent_id, rmm_instance): + """ + Get Windows patches as list. + + Uses existing tactical-rmm.py function. + """ + tactical_rmm = importlib.import_module("msp.tactical-rmm") + + patches = tactical_rmm.get_patches_for_agent( + agent_id, rmm_instance_name=rmm_instance + ) + + return patches or [] + + +def _populate_patches_table(it_object, patches_list): + """ + Populate the installed_patches child table. + + Clears existing entries and adds new ones. + """ + # Clear existing entries + it_object.installed_patches = [] + + for patch in patches_list: + kb = patch.get("kb", "") + title = patch.get("title", "") + + if not kb and not title: + continue + + # Parse install date if available + install_date = None + install_date_str = patch.get("date_installed", "") + if install_date_str: + try: + from frappe.utils import get_datetime + install_date = get_datetime(install_date_str) + except Exception: + pass + + it_object.append("installed_patches", { + "kb_number": kb or "", + "title": (title or "")[:140], # Limit length + "severity": patch.get("severity", "") or "", + "category": (patch.get("category", "") or "")[:140], + "installed": 1 if patch.get("installed") else 0, + "install_date": install_date + }) + + +# ==================== AD Integration Functions ==================== + + +@frappe.whitelist() +def get_or_create_msp_documentation(it_landscape): + """ + Findet oder erstellt MSP Documentation fuer eine IT Landscape. + Bei Neuerstellung werden Default-AD-Credentials von IT Landscape uebernommen. + + Args: + it_landscape: Name der IT Landscape + + Returns: + dict: {name: str, created: bool, has_ad_config: bool, has_ad_data: bool} + """ + # Suche bestehende MSP Documentation + existing = frappe.db.get_value( + "MSP Documentation", + {"landscape": it_landscape}, + "name" + ) + + if existing: + doc = frappe.get_doc("MSP Documentation", existing) + has_ad_data = bool(doc.ad_computer_data_json and len(doc.ad_computer_data_json) > 10) + return { + "name": existing, + "created": False, + "has_ad_config": bool(doc.credentials_for_ldap_acquisistion), + "has_ad_data": has_ad_data + } + + # Erstelle neue MSP Documentation mit Default-AD aus IT Landscape + landscape = frappe.get_doc("IT Landscape", it_landscape) + + new_doc = frappe.new_doc("MSP Documentation") + new_doc.landscape = it_landscape + new_doc.customer = landscape.customer + new_doc.tactical_rmm_tenant_caption = landscape.tactical_rmm_tenant_caption + + # Default-AD-Konfiguration von IT Landscape uebernehmen + if hasattr(landscape, 'default_ad_credentials') and landscape.default_ad_credentials: + new_doc.credentials_for_ldap_acquisistion = landscape.default_ad_credentials + if hasattr(landscape, 'default_ad_domain_controller') and landscape.default_ad_domain_controller: + new_doc.domain_controller_for_ldap_acquisition = landscape.default_ad_domain_controller + if hasattr(landscape, 'default_ad_use_nat') and landscape.default_ad_use_nat: + new_doc.use_nat_address = landscape.default_ad_use_nat + + new_doc.insert() + frappe.db.commit() + + has_ad_config = bool( + hasattr(landscape, 'default_ad_credentials') and landscape.default_ad_credentials + ) + + return { + "name": new_doc.name, + "created": True, + "has_ad_config": has_ad_config, + "has_ad_data": False # Neu erstellt, noch keine Daten + } + + +@frappe.whitelist() +def get_ad_status(documentation_name): + """ + Gibt AD-Status einer MSP Documentation zurueck. + + Args: + documentation_name: Name der MSP Documentation + + Returns: + dict: {has_config, has_data, computer_count, last_update} + """ + doc = frappe.get_doc("MSP Documentation", documentation_name) + + has_config = bool(doc.credentials_for_ldap_acquisistion) + has_data = bool(doc.ad_computer_data_json and len(doc.ad_computer_data_json) > 10) + + computer_count = 0 + last_update = None + if has_data: + try: + data = json.loads(doc.ad_computer_data_json) + computer_count = len(data) if isinstance(data, list) else 0 + except Exception: + pass + last_update = frappe.utils.format_datetime(doc.modified, "dd.MM.yyyy HH:mm") + + return { + "has_config": has_config, + "has_data": has_data, + "computer_count": computer_count, + "last_update": last_update + } + + +@frappe.whitelist() +def create_import_session_from_landscape(it_landscape, include_ad_data=False): + """ + Create a new RMM Import Session from an IT Landscape. + Optionally includes AD data for enrichment. + + Args: + it_landscape: Name of the IT Landscape + include_ad_data: Whether to include AD data + + Returns: + Name of the created RMM Import Session + """ + include_ad_data = frappe.parse_json(include_ad_data) if isinstance(include_ad_data, str) else include_ad_data + + # Load IT Landscape + landscape = frappe.get_doc("IT Landscape", it_landscape) + + rmm_instance = landscape.rmm_instance + if not rmm_instance: + frappe.throw(_("No RMM Instance configured for this IT Landscape")) + + client_filter = landscape.tactical_rmm_tenant_caption + if not client_filter: + frappe.throw(_("No Tactical RMM Tenant Caption configured in the IT Landscape")) + + # Find or create MSP Documentation + msp_doc_info = get_or_create_msp_documentation(it_landscape) + documentation_name = msp_doc_info["name"] + + # Fetch agents from RMM + agents = _fetch_agents_from_rmm(rmm_instance, client_filter, None) + + # Merge with AD data if requested + ad_data = [] + ad_stats = {"matched": 0, "rmm_only": 0, "ad_only": 0} + + if include_ad_data and msp_doc_info["has_ad_data"]: + merged_agents, ad_stats = merge_rmm_and_ad_data(documentation_name, agents) + agents = merged_agents + + # Load AD data for reference + msp_doc = frappe.get_doc("MSP Documentation", documentation_name) + try: + ad_data = json.loads(msp_doc.ad_computer_data_json or "[]") + except Exception: + ad_data = [] + + # Create Import Session + session = frappe.new_doc("RMM Import Session") + session.documentation = documentation_name + session.rmm_instance = rmm_instance + session.it_landscape = it_landscape + session.filter_client = client_filter + session.filter_site = "" + session.status = "Draft" + session.include_ad_data = 1 if include_ad_data else 0 + + # AD statistics + if include_ad_data: + session.ad_matched_count = ad_stats.get("matched", 0) + session.ad_only_count = ad_stats.get("ad_only", 0) + session.rmm_only_count = ad_stats.get("rmm_only", 0) + + # Get settings + settings = frappe.get_single("RMM Import Settings") + + # Add agents to selection table + for agent in agents: + existing_it_object = find_existing_it_object(agent["agent_id"], rmm_instance) + + # Also check by AD GUID if available + if not existing_it_object and agent.get("ad_guid"): + existing_it_object = frappe.db.get_value( + "IT Object", + {"ad_object_guid": agent["ad_guid"], "it_landscape": it_landscape}, + "name" + ) + + row = session.append("agent_selection", {}) + row.agent_id = agent["agent_id"] + row.hostname = agent["hostname"] + row.monitoring_type = agent.get("monitoring_type", "") + row.site_name = agent.get("site_name", "") + row.operating_system = agent.get("operating_system", "") + row.status = agent.get("status", "") + row.local_ip = _get_first_local_ip(agent) + + # AD fields + row.ad_matched = 1 if agent.get("ad_matched") else 0 + row.ad_guid = agent.get("ad_guid", "") + row.ad_account_status = agent.get("ad_account_status", "") + row.ad_last_logon = agent.get("ad_last_logon", "") + + if existing_it_object: + row.action = "Update" + row.existing_it_object = existing_it_object + else: + row.action = "Create" + + # Auto-select based on settings + if settings.skip_disabled_ad_accounts and agent.get("ad_account_status") == "Disabled": + row.selected = 0 + else: + row.selected = 0 # Not selected by default + + row.import_result = "Pending" + + # Update statistics + session.agents_total = len(session.agent_selection) + session.agents_to_create = sum(1 for r in session.agent_selection if r.action == "Create") + session.agents_to_update = sum(1 for r in session.agent_selection if r.action == "Update") + session.agents_skipped = sum(1 for r in session.agent_selection if r.action == "Skip") + + session.insert() + frappe.db.commit() + + return session.name + + +def merge_rmm_and_ad_data(documentation_name, rmm_agents=None): + """ + Kombiniert RMM-Agents mit AD-Computerdaten. + + Args: + documentation_name: Name der MSP Documentation + rmm_agents: Optional list of RMM agents (fetched if not provided) + + Returns: + tuple: (merged_agents, stats) + - merged_agents: Liste mit angereicherten Agent-Dicts + - stats: {total_rmm, total_ad, matched, rmm_only, ad_only} + """ + msp_doc = frappe.get_doc("MSP Documentation", documentation_name) + + # Load AD data + ad_data = [] + if msp_doc.ad_computer_data_json: + try: + ad_data = json.loads(msp_doc.ad_computer_data_json) + except Exception: + ad_data = [] + + if not ad_data: + # No AD data available, return agents unchanged + stats = { + "total_rmm": len(rmm_agents) if rmm_agents else 0, + "total_ad": 0, + "matched": 0, + "rmm_only": len(rmm_agents) if rmm_agents else 0, + "ad_only": 0 + } + return rmm_agents or [], stats + + # Fetch RMM agents if not provided + if rmm_agents is None: + rmm_instance = _get_rmm_instance(msp_doc) + client_filter = msp_doc.tactical_rmm_tenant_caption + rmm_agents = _fetch_agents_from_rmm(rmm_instance, client_filter, msp_doc.tactical_rmm_site_name) + + # Import the matching function from tactical-rmm + tactical_rmm = importlib.import_module("msp.tactical-rmm") + _find_best_ad_match = tactical_rmm._find_best_ad_match + + # Track matched AD items + matched_ad_guids = set() + merged_agents = [] + matched_count = 0 + + for agent in rmm_agents: + agent_copy = agent.copy() + hostname = agent.get("hostname", "") + + # Find AD match + ad_match = _find_best_ad_match(hostname, ad_data) + + if ad_match: + matched_count += 1 + agent_copy["ad_matched"] = True + agent_copy["ad_guid"] = ad_match.get("objectGUID", "") or ad_match.get("Object GUID", "") + agent_copy["ad_distinguished_name"] = ad_match.get("distinguishedName", "") or ad_match.get("Distinguished Name", "") + + # Account status + enabled = ad_match.get("Enabled", ad_match.get("enabled", "")) + if enabled == True or enabled == "TRUE" or enabled == "True": + agent_copy["ad_account_status"] = "Enabled" + elif enabled == False or enabled == "FALSE" or enabled == "False": + agent_copy["ad_account_status"] = "Disabled" + else: + agent_copy["ad_account_status"] = str(enabled) if enabled else "" + + # Last logon + last_logon = ad_match.get("lastLogon", "") or ad_match.get("Last Logon Date", "") + agent_copy["ad_last_logon"] = str(last_logon) if last_logon else "" + + # OS from AD + agent_copy["ad_operating_system"] = ad_match.get("operatingSystem", "") or ad_match.get("Operating System", "") + + # Track matched GUID + if agent_copy["ad_guid"]: + matched_ad_guids.add(agent_copy["ad_guid"]) + else: + agent_copy["ad_matched"] = False + + merged_agents.append(agent_copy) + + # Count AD-only entries (not matched to any RMM agent) + ad_only_count = 0 + for ad_item in ad_data: + ad_guid = ad_item.get("objectGUID", "") or ad_item.get("Object GUID", "") + if ad_guid and ad_guid not in matched_ad_guids: + ad_only_count += 1 + + stats = { + "total_rmm": len(rmm_agents), + "total_ad": len(ad_data), + "matched": matched_count, + "rmm_only": len(rmm_agents) - matched_count, + "ad_only": ad_only_count + } + + return merged_agents, stats + + +@frappe.whitelist() +def sync_ad_data_for_existing_objects(it_landscape): + """ + Synchronisiert AD-Daten zu bestehenden IT Objects basierend auf + AD GUID oder Hostname-Match. + + Args: + it_landscape: Name der IT Landscape + + Returns: + dict: {synced_count, not_found_count, errors} + """ + # Get MSP Documentation + msp_doc_name = frappe.db.get_value( + "MSP Documentation", + {"landscape": it_landscape}, + "name" + ) + + if not msp_doc_name: + return {"synced_count": 0, "not_found_count": 0, "errors": ["No MSP Documentation found"]} + + msp_doc = frappe.get_doc("MSP Documentation", msp_doc_name) + + # Load AD data + if not msp_doc.ad_computer_data_json: + return {"synced_count": 0, "not_found_count": 0, "errors": ["No AD data available"]} + + try: + ad_data = json.loads(msp_doc.ad_computer_data_json) + except Exception as e: + return {"synced_count": 0, "not_found_count": 0, "errors": [f"Invalid AD data: {str(e)}"]} + + # Import matching function + tactical_rmm = importlib.import_module("msp.tactical-rmm") + _find_best_ad_match = tactical_rmm._find_best_ad_match + + # Get existing IT Objects for this landscape + it_objects = frappe.get_all( + "IT Object", + filters={"it_landscape": it_landscape}, + fields=["name", "title", "ad_object_guid"] + ) + + synced_count = 0 + not_found_count = 0 + errors = [] + + for obj in it_objects: + try: + it_object = frappe.get_doc("IT Object", obj["name"]) + + # First try matching by existing AD GUID + ad_match = None + if it_object.ad_object_guid: + for ad_item in ad_data: + ad_guid = ad_item.get("objectGUID", "") or ad_item.get("Object GUID", "") + if ad_guid == it_object.ad_object_guid: + ad_match = ad_item + break + + # If no match by GUID, try hostname matching + if not ad_match: + ad_match = _find_best_ad_match(it_object.title, ad_data) + + if ad_match: + # Update AD fields + it_object.ad_object_guid = ad_match.get("objectGUID", "") or ad_match.get("Object GUID", "") + it_object.ad_distinguished_name = ad_match.get("distinguishedName", "") or ad_match.get("Distinguished Name", "") + + # Account status + enabled = ad_match.get("Enabled", ad_match.get("enabled", "")) + if enabled == True or enabled == "TRUE" or enabled == "True": + it_object.ad_account_status = "Enabled" + elif enabled == False or enabled == "FALSE" or enabled == "False": + it_object.ad_account_status = "Disabled" + + # Last logon + last_logon = ad_match.get("lastLogon", "") or ad_match.get("Last Logon Date", "") + if last_logon: + try: + from frappe.utils import get_datetime + it_object.ad_last_logon = get_datetime(last_logon) + except Exception: + pass + + # OS from AD + it_object.ad_operating_system = ad_match.get("operatingSystem", "") or ad_match.get("Operating System", "") + it_object.last_ad_sync = now_datetime() + + it_object.save() + synced_count += 1 + else: + not_found_count += 1 + + except Exception as e: + errors.append(f"{obj['name']}: {str(e)}") + + frappe.db.commit() + + return { + "synced_count": synced_count, + "not_found_count": not_found_count, + "errors": errors + } diff --git a/msp/tactical-rmm.py b/msp/tactical-rmm.py index 540abc9..489da4a 100644 --- a/msp/tactical-rmm.py +++ b/msp/tactical-rmm.py @@ -1,5 +1,4 @@ import frappe -from frappe.utils.password import get_decrypted_password import requests import json @@ -10,15 +9,78 @@ import os from ldap3 import Server, Connection, ALL, NTLM, SUBTREE from .tools import render_card_html, render_single_card + +def _get_rmm_credentials(rmm_instance_name=None, documentation_name=None): + """ + Resolves RMM Instance credentials with priority: + 1. Explicit rmm_instance_name + 2. MSP Documentation.rmm_instance (override) + 3. MSP Documentation.landscape.rmm_instance (inherited) + + Args: + rmm_instance_name (str, optional): Explicit RMM Instance name + documentation_name (str, optional): MSP Documentation to resolve from + + Returns: + tuple: (api_url: str, headers: dict, verify_ssl: bool) + + Raises: + frappe.ValidationError: If no RMM Instance can be resolved + """ + resolved_instance = None + + if rmm_instance_name: + resolved_instance = rmm_instance_name + elif documentation_name: + doc = frappe.get_doc("MSP Documentation", documentation_name) + if doc.rmm_instance: + resolved_instance = doc.rmm_instance + elif doc.landscape: + landscape = frappe.get_doc("IT Landscape", doc.landscape) + if landscape.rmm_instance: + resolved_instance = landscape.rmm_instance + else: + frappe.throw(f"No RMM Instance set on IT Landscape '{doc.landscape}'") + else: + frappe.throw("No landscape set and no RMM Instance override on MSP Documentation") + else: + frappe.throw("Either rmm_instance or documentation_name must be provided") + + rmm_doc = frappe.get_doc("RMM Instance", resolved_instance) + return rmm_doc.get_api_credentials() + + @frappe.whitelist() -def get_agents(it_landscape, rmm_instance = None, tactical_rmm_tenant_caption = None): - pass +def get_agents(it_landscape, rmm_instance=None, tactical_rmm_tenant_caption=None): + """ + Fetches agents from RMM for an IT Landscape. + + Args: + it_landscape (str): IT Landscape document name + rmm_instance (str, optional): RMM Instance name (falls back to landscape's rmm_instance) + tactical_rmm_tenant_caption (str, optional): Filter by client name in RMM + + Returns: + str: Success message with agent count + """ + if not rmm_instance: + landscape_doc = frappe.get_doc("IT Landscape", it_landscape) + rmm_instance = landscape_doc.rmm_instance + if not rmm_instance: + frappe.throw(f"No RMM Instance set on IT Landscape '{it_landscape}'") + + agents = get_all_agents(rmm_instance_name=rmm_instance) + + if tactical_rmm_tenant_caption: + agents = [a for a in agents if a.get("client_name") == tactical_rmm_tenant_caption] + + return f"Found {len(agents)} agents" @frappe.whitelist() -def get_relevant_software_for_agent(agent_id): +def get_relevant_software_for_agent(agent_id, rmm_instance_name=None, documentation_name=None): found_software = [] - software_for_agent = get_software_for_agent(agent_id) + software_for_agent = get_software_for_agent(agent_id, rmm_instance_name=rmm_instance_name, documentation_name=documentation_name) if "software" in software_for_agent: for s in software_for_agent["software"]: result = _match_software(s) @@ -36,25 +98,34 @@ def _match_software(rmm_software_elememt): return None @frappe.whitelist() -def search_office(agents = None): +def search_office(agents=None, documentation_name=None): + """ + Searches for Office installations across agents. + + Args: + agents (list, optional): Pre-fetched list of agents + documentation_name (str, optional): MSP Documentation to resolve RMM Instance from + """ points_for_found_strings = {} agents_with_office = [] if not agents: - agents = get_all_agents() + if not documentation_name: + frappe.throw("documentation_name is required when agents list is not provided") + agents = get_all_agents(documentation_name=documentation_name) todo = len(agents) count = 0 for agent in agents: found_office = False count = count + 1 print("verarbeite agent " + str(count) + " von " + str(todo)) - software_for_agent = get_software_for_agent(agent["agent_id"]) + software_for_agent = get_software_for_agent(agent["agent_id"], documentation_name=documentation_name) if "software" in software_for_agent: for s in software_for_agent["software"]: if (s["name"].lower().startswith("Microsoft Office".lower())): found_string = s["name"] found_office = True - + if found_string in points_for_found_strings.keys(): points_for_found_strings[found_string] = points_for_found_strings[found_string] + 1 else: @@ -63,19 +134,20 @@ def search_office(agents = None): agents_with_office.append(agent) else: pass - + print(points_for_found_strings) #print(len(agents_with_office)) - search_for_office_patches(agents_with_office) + search_for_office_patches(agents_with_office, documentation_name=documentation_name) print(points_for_found_strings) -def search_for_office_patches(agents_with_office): + +def search_for_office_patches(agents_with_office, documentation_name=None): count = 0 for agent in agents_with_office: found_patches_for_office = False - patches = get_patches_for_agent(agent["agent_id"]) + patches = get_patches_for_agent(agent["agent_id"], documentation_name=documentation_name) for patch in patches: if "office".lower() in patch["title"].lower(): found_patches_for_office = True @@ -96,7 +168,7 @@ def get_agents_pretty(documentation): client_name = documentation_doc.tactical_rmm_tenant_caption site_name = documentation_doc.tactical_rmm_site_name - agents = get_all_agents() + agents = get_all_agents(documentation_name=documentation) # Filter and organize agents agent_list = [] @@ -159,58 +231,71 @@ def get_agents_pretty(documentation): return agent_list -def get_all_agents(): - settings = frappe.get_single("MSP Settings") - if not settings.api_key: - frappe.throw("API Key is missing") - if not settings.api_url: - frappe.throw("API URL is missing") - - API = settings.api_url - HEADERS = { - "Content-Type": "application/json", - "X-API-KEY": get_decrypted_password("MSP Settings", "MSP Settings", "api_key", raise_exception=True), - } +def get_all_agents(rmm_instance_name=None, documentation_name=None): + """ + Fetches all agents from a Tactical RMM instance. - agents = requests.get(f"{API}/agents/?detail=true", headers=HEADERS) + Args: + rmm_instance_name (str, optional): Explicit RMM Instance name + documentation_name (str, optional): MSP Documentation to resolve RMM Instance from + + Returns: + list: List of agent dictionaries from RMM API + """ + api_url, headers, verify_ssl = _get_rmm_credentials( + rmm_instance_name=rmm_instance_name, + documentation_name=documentation_name + ) + + agents = requests.get(f"{api_url}/agents/?detail=true", headers=headers, verify=verify_ssl) return agents.json() -def get_software_for_agent(agent_id=None): - settings = frappe.get_single("MSP Settings") - if not settings.api_key: - frappe.throw("API Key is missing") - if not settings.api_url: - frappe.throw("API URL is missing") - - API = settings.api_url - HEADERS = { - "Content-Type": "application/json", - "X-API-KEY": get_decrypted_password("MSP Settings", "MSP Settings", "api_key", raise_exception=True), - } +def get_software_for_agent(agent_id, rmm_instance_name=None, documentation_name=None): + """ + Fetches installed software for a specific agent. + + Args: + agent_id (str): The Tactical RMM agent ID + rmm_instance_name (str, optional): Explicit RMM Instance name + documentation_name (str, optional): MSP Documentation to resolve RMM Instance from + + Returns: + dict: Software information from RMM API + """ if not agent_id: frappe.throw("Agent ID fehlt") - software_for_agent = requests.get(f"{API}/software/{agent_id}/", headers=HEADERS) + api_url, headers, verify_ssl = _get_rmm_credentials( + rmm_instance_name=rmm_instance_name, + documentation_name=documentation_name + ) + + software_for_agent = requests.get(f"{api_url}/software/{agent_id}/", headers=headers, verify=verify_ssl) return software_for_agent.json() -def get_patches_for_agent(agent_id=None): - settings = frappe.get_single("MSP Settings") - if not settings.api_key: - frappe.throw("API Key is missing") - if not settings.api_url: - frappe.throw("API URL is missing") - - API = settings.api_url - HEADERS = { - "Content-Type": "application/json", - "X-API-KEY": get_decrypted_password("MSP Settings", "MSP Settings", "api_key", raise_exception=True), - } +def get_patches_for_agent(agent_id, rmm_instance_name=None, documentation_name=None): + """ + Fetches Windows patches for a specific agent. + + Args: + agent_id (str): The Tactical RMM agent ID + rmm_instance_name (str, optional): Explicit RMM Instance name + documentation_name (str, optional): MSP Documentation to resolve RMM Instance from + + Returns: + list: Patch information from RMM API + """ if not agent_id: frappe.throw("Agent ID fehlt") - patches_for_agent = requests.get(f"{API}/winupdate/{agent_id}/", headers=HEADERS) + api_url, headers, verify_ssl = _get_rmm_credentials( + rmm_instance_name=rmm_instance_name, + documentation_name=documentation_name + ) + + patches_for_agent = requests.get(f"{api_url}/winupdate/{agent_id}/", headers=headers, verify=verify_ssl) return patches_for_agent.json() @@ -275,9 +360,9 @@ def fetch_and_store_all_agent_data(documentation_name): client_name = documentation_doc.tactical_rmm_tenant_caption site_name = documentation_doc.tactical_rmm_site_name - + # Alle Agent-Daten vom RMM holen - all_agents = get_all_agents() + all_agents = get_all_agents(documentation_name=documentation_name) # Nach Mandant und optional nach Site filtern filtered_agents = [] @@ -314,6 +399,574 @@ def fetch_and_store_all_agent_data(documentation_name): frappe.throw(f"Fehler beim Speichern der RMM-Daten: {str(e)}") +# ==================== Windows Update Reporting ==================== + +def _calculate_compliance_score(agents_with_patches): + """ + Berechnet den Compliance-Score basierend auf installierten vs. ausstehenden Updates. + + Gewichtung: + - Critical: 4.0 + - Important: 3.0 + - Moderate: 2.0 + - Low/Unspecified: 1.0 + + Args: + agents_with_patches (list): Liste von Agents mit ihren Patch-Daten + + Returns: + dict: { + "overall_score": float, + "status": "green"|"yellow"|"red", + "by_site": {site_name: {"score": float, "status": str}}, + "totals": {"installed": int, "pending": int, "failed": int, "total": int} + } + """ + weights = { + "Critical": 4.0, + "Important": 3.0, + "Moderate": 2.0, + "Low": 1.0, + "Unspecified": 1.0 + } + + total_weight = 0 + installed_weight = 0 + totals = {"installed": 0, "pending": 0, "failed": 0, "total": 0} + + # Aggregation nach Site + site_data = {} + + for agent in agents_with_patches: + site_name = agent.get("site_name", "Unknown") + if site_name not in site_data: + site_data[site_name] = { + "total_weight": 0, + "installed_weight": 0, + "installed": 0, + "pending": 0, + "failed": 0, + "total": 0, + "agents": 0 + } + site_data[site_name]["agents"] += 1 + + for patch in agent.get("patches", []): + severity = patch.get("severity", "Unspecified") + weight = weights.get(severity, 1.0) + is_installed = patch.get("installed", False) + result = patch.get("result", "pending") + + total_weight += weight + site_data[site_name]["total_weight"] += weight + totals["total"] += 1 + site_data[site_name]["total"] += 1 + + if is_installed: + installed_weight += weight + site_data[site_name]["installed_weight"] += weight + totals["installed"] += 1 + site_data[site_name]["installed"] += 1 + elif result == "failed": + totals["failed"] += 1 + site_data[site_name]["failed"] += 1 + else: + totals["pending"] += 1 + site_data[site_name]["pending"] += 1 + + # Gesamt-Score berechnen + overall_score = (installed_weight / total_weight * 100) if total_weight > 0 else 100.0 + + def get_status(score): + if score >= 90: + return "green" + elif score >= 70: + return "yellow" + else: + return "red" + + # Site-Scores berechnen + by_site = {} + for site_name, data in site_data.items(): + site_score = (data["installed_weight"] / data["total_weight"] * 100) if data["total_weight"] > 0 else 100.0 + by_site[site_name] = { + "score": round(site_score, 1), + "status": get_status(site_score), + "installed": data["installed"], + "pending": data["pending"], + "failed": data["failed"], + "total": data["total"], + "agents": data["agents"] + } + + return { + "overall_score": round(overall_score, 1), + "status": get_status(overall_score), + "by_site": by_site, + "totals": totals + } + + +def _generate_windows_update_html(compliance_data, agents_with_patches): + """ + Generiert HTML-Report für Windows Update Status. + + Args: + compliance_data (dict): Ergebnis von _calculate_compliance_score + agents_with_patches (list): Liste von Agents mit Patch-Daten + + Returns: + str: HTML-String mit dem Report + """ + status_colors = { + "green": "#28a745", + "yellow": "#ffc107", + "red": "#dc3545" + } + + status_labels = { + "green": "Gut", + "yellow": "Warnung", + "red": "Kritisch" + } + + totals = compliance_data["totals"] + overall_status = compliance_data["status"] + overall_score = compliance_data["overall_score"] + + # CSS Styles + html = """ + + """ + + # Header mit Score und Zusammenfassung + html += f""" +
+
+
+
{overall_score}%
+
Compliance Score
+
+ {status_labels[overall_status]} +
+
+
+
+
{totals['installed']}
+
Installiert
+
+
+
{totals['pending']}
+
Ausstehend
+
+
+
{totals['failed']}
+
Fehlgeschlagen
+
+
+
{len(agents_with_patches)}
+
Computer
+
+
+
+ """ + + # Site-Übersicht + html += """ +
+

Übersicht nach Standort

+ + + + + + + + + + + + + """ + + for site_name, site_info in sorted(compliance_data["by_site"].items()): + html += f""" + + + + + + + + + """ + + html += """ + +
StandortComputerComplianceInstalliertAusstehendFehlgeschlagen
{site_name}{site_info['agents']}{site_info['score']}%{site_info['installed']}{site_info['pending']}{site_info['failed']}
+
+ """ + + # Computer-Details + html += """ +
+

Details pro Computer

+ + + + + + + + + + + + + """ + + for agent in sorted(agents_with_patches, key=lambda x: x.get("site_name", "")): + patches = agent.get("patches", []) + pending = len([p for p in patches if not p.get("installed", False) and p.get("result") != "failed"]) + critical_pending = len([p for p in patches if not p.get("installed", False) and p.get("severity") == "Critical"]) + + if critical_pending > 0: + status_class = "red" + status_text = "Kritisch" + elif pending > 0: + status_class = "yellow" + status_text = "Ausstehend" + else: + status_class = "green" + status_text = "Aktuell" + + html += f""" + + + + + + + + + """ + + html += """ + +
ComputerStandortBetriebssystemAusstehendKritischStatus
{agent.get('hostname', 'Unknown')}{agent.get('site_name', 'Unknown')}{agent.get('operating_system', 'Unknown')}{pending}{critical_pending}{status_text}
+
+ """ + + # Kritische ausstehende Updates + critical_patches = [] + for agent in agents_with_patches: + for patch in agent.get("patches", []): + if not patch.get("installed", False) and patch.get("severity") in ["Critical", "Important"]: + critical_patches.append({ + "hostname": agent.get("hostname"), + "kb": patch.get("kb", ""), + "title": patch.get("title", ""), + "severity": patch.get("severity", ""), + "category": patch.get("category", "") + }) + + if critical_patches: + html += """ +
+

Ausstehende kritische und wichtige Updates

+
+ + + + + + + + + + + + """ + + for patch in sorted(critical_patches, key=lambda x: (0 if x["severity"] == "Critical" else 1, x["hostname"])): + severity_class = "red" if patch["severity"] == "Critical" else "yellow" + html += f""" + + + + + + + + """ + + html += """ + +
ComputerKBTitelSchweregradKategorie
{patch['hostname']}{patch['kb']}{patch['title'][:80]}{'...' if len(patch['title']) > 80 else ''}{patch['severity']}{patch['category']}
+
+
+ """ + + html += "
" + + return html + + +def _generate_summary_stats_html(compliance_data): + """ + Generiert kompakte Zusammenfassungs-HTML für das Summary-Feld. + """ + totals = compliance_data["totals"] + status = compliance_data["status"] + + status_text = { + "green": "Alle Systeme aktuell", + "yellow": "Updates ausstehend", + "red": "Kritische Updates fehlen" + } + + return f""" +
+ + {status_text[status]} + + + {totals['installed']} installiert | {totals['pending']} ausstehend | {totals['failed']} fehlgeschlagen + +
+ """ + + +@frappe.whitelist() +def fetch_windows_update_data(documentation_name): + """ + Holt Windows Update Daten für alle Agents eines Mandanten/Site und generiert den Report. + + Args: + documentation_name (str): Name der MSP Documentation + + Returns: + dict: Erfolgsmeldung mit Compliance-Score und Anzahl der Agents + """ + try: + documentation_doc = frappe.get_doc("MSP Documentation", documentation_name) + + if not documentation_doc.tactical_rmm_tenant_caption: + frappe.throw("Tenant Caption fehlt in der MSP Documentation") + + client_name = documentation_doc.tactical_rmm_tenant_caption + site_name = documentation_doc.tactical_rmm_site_name + + # Alle Agents vom RMM holen + all_agents = get_all_agents(documentation_name=documentation_name) + + # Nach Mandant und optional nach Site filtern + filtered_agents = [ + agent for agent in all_agents + if agent["client_name"] == client_name and (not site_name or agent["site_name"] == site_name) + ] + + if not filtered_agents: + frappe.throw(f"Keine Agents gefunden für Mandant '{client_name}'" + + (f" und Site '{site_name}'" if site_name else "")) + + # Patch-Daten für jeden Agent abrufen + agents_with_patches = [] + for agent in filtered_agents: + try: + patches = get_patches_for_agent( + agent["agent_id"], + documentation_name=documentation_name + ) + patch_list = patches if isinstance(patches, list) else [] + + # Patches in installiert und ausstehend aufteilen + installed_patches = [p for p in patch_list if p.get("installed", False)] + pending_patches = [p for p in patch_list if not p.get("installed", False)] + + # Berechne patches_last_installed aus den Patch-Daten + # Finde das neueste date_installed aus allen installierten Patches + patches_last_installed = "" + installed_dates = [] + for p in installed_patches: + date_inst = p.get("date_installed") + if date_inst: + installed_dates.append(date_inst) + + if installed_dates: + # Sortiere nach Datum (neuestes zuerst) und nimm das erste + # TacticalRMM Format: "MM DD YYYY HH:MM" (z.B. "01 07 2026 09:21") + def parse_rmm_date(date_str): + """Parse TacticalRMM date format to comparable tuple""" + try: + parts = date_str.split() + if len(parts) >= 3: + month = int(parts[0]) + day = int(parts[1]) + year = int(parts[2]) + hour = 0 + minute = 0 + if len(parts) >= 4 and ":" in parts[3]: + time_parts = parts[3].split(":") + hour = int(time_parts[0]) + minute = int(time_parts[1]) if len(time_parts) > 1 else 0 + return (year, month, day, hour, minute) + except (ValueError, IndexError): + pass + return (0, 0, 0, 0, 0) # Fallback für ungültige Daten + + try: + # Sortiere nach geparstem Datum (neuestes zuerst) + sorted_dates = sorted(installed_dates, key=parse_rmm_date, reverse=True) + patches_last_installed = sorted_dates[0] + except Exception: + patches_last_installed = installed_dates[0] if installed_dates else "" + + # wuauserv_status: Versuche aus Agent-Daten, ansonsten als "unknown" + # Die TacticalRMM API liefert wuauserv_status nur bei bestimmten Agent-Details + wuauserv_status = agent.get("wuauserv_status", "") + if not wuauserv_status: + # Fallback: Wenn pending patches existieren und kürzlich installiert wurde, + # nehmen wir an, dass der Dienst läuft + wuauserv_status = "unknown" + + agents_with_patches.append({ + "agent_id": agent["agent_id"], + "hostname": agent["hostname"], + "site_name": agent["site_name"], + "operating_system": agent.get("operating_system", ""), + "last_seen": agent.get("last_seen", ""), + "status": agent.get("status", ""), + # Update-spezifische Felder - berechnet aus Patch-Daten + "patches_last_installed": patches_last_installed, + "wuauserv_status": wuauserv_status, + "needs_reboot": agent.get("needs_reboot", False), + "has_patches_pending": len(pending_patches) > 0, + # Alle Patches + "patches": patch_list, + # Aufgeteilte Listen für einfachere Anzeige + "installed_patches": installed_patches, + "pending_patches": pending_patches, + # Zusammenfassung + "summary": { + "total": len(patch_list), + "installed": len(installed_patches), + "pending": len(pending_patches), + "critical_pending": len([p for p in pending_patches if p.get("severity") == "Critical"]), + "important_pending": len([p for p in pending_patches if p.get("severity") == "Important"]) + } + }) + except Exception as e: + # Bei Fehlern für einzelne Agents weitermachen + frappe.log_error( + f"Fehler beim Abrufen der Patches für Agent {agent['hostname']}: {str(e)}", + "fetch_windows_update_data" + ) + agents_with_patches.append({ + "agent_id": agent["agent_id"], + "hostname": agent["hostname"], + "site_name": agent["site_name"], + "operating_system": agent.get("operating_system", ""), + "last_seen": agent.get("last_seen", ""), + "status": agent.get("status", ""), + "patches_last_installed": "", # Kann nicht ermittelt werden + "wuauserv_status": "error", # Fehler beim Abrufen + "needs_reboot": agent.get("needs_reboot", False), + "has_patches_pending": False, + "patches": [], + "installed_patches": [], + "pending_patches": [], + "summary": {"total": 0, "installed": 0, "pending": 0, "critical_pending": 0, "important_pending": 0}, + "error": str(e) + }) + + # Compliance-Score berechnen + compliance_data = _calculate_compliance_score(agents_with_patches) + + # HTML-Report generieren + report_html = _generate_windows_update_html(compliance_data, agents_with_patches) + summary_html = _generate_summary_stats_html(compliance_data) + + # Daten speichern + documentation_doc.windows_update_data_json = json.dumps({ + "agents": agents_with_patches, + "compliance": compliance_data, + "last_sync": datetime.datetime.now().isoformat() + }, indent=2, default=str) + documentation_doc.windows_update_last_sync = datetime.datetime.now() + documentation_doc.windows_update_compliance_score = compliance_data["overall_score"] + documentation_doc.windows_update_report = report_html + documentation_doc.windows_update_summary_stats = summary_html + documentation_doc.save() + + return { + "success": True, + "message": f"Windows Update Daten erfolgreich abgerufen. {len(agents_with_patches)} Computer analysiert.", + "agent_count": len(agents_with_patches), + "compliance_score": compliance_data["overall_score"], + "compliance_status": compliance_data["status"], + "totals": compliance_data["totals"] + } + + except Exception as e: + frappe.log_error(f"Fehler beim Abrufen der Windows Update Daten: {str(e)}", "fetch_windows_update_data") + frappe.throw(f"Fehler beim Abrufen der Windows Update Daten: {str(e)}") + + +# ==================== Ende Windows Update Reporting ==================== + + @frappe.whitelist() def fetch_and_store_ad_computer_data(documentation_name): """ @@ -350,20 +1003,24 @@ def fetch_and_store_ad_computer_data(documentation_name): username = f"{domain}\\{ldap_credentials.username}" password = ldap_credentials.get_password() - # Server-Name ermitteln - entweder aus Domain Controller oder Domain + # Server-Name ermitteln - Priorität: 1) ip_address Feld, 2) Domain Controller, 3) Domain server_name = domain # Standard-Fallback - - # Prüfen ob Domain Controller für LDAP-Acquisition angegeben ist - if documentation_doc.domain_controller_for_ldap_acquisition: + + # Prüfen ob IP-Adresse im Documentation-Dokument eingetragen ist (kann manuell oder automatisch gesetzt sein) + if documentation_doc.ip_address: + server_name = documentation_doc.ip_address + print(f"INFO: IP-Adresse aus MSP Documentation verwendet: {server_name}") + # Fallback: Prüfen ob Domain Controller für LDAP-Acquisition angegeben ist + elif documentation_doc.domain_controller_for_ldap_acquisition: try: # IT Object (Domain Controller) laden domain_controller = frappe.get_doc("IT Object", documentation_doc.domain_controller_for_ldap_acquisition) - + # Prüfen ob eine Haupt-IP-Adresse angegeben ist if domain_controller.main_ip: # IP Address Dokument laden ip_address_doc = frappe.get_doc("IP Address", domain_controller.main_ip) - + # IP-Adresse als Server-Name verwenden if ip_address_doc.ip_address: server_name = ip_address_doc.ip_address @@ -376,7 +1033,7 @@ def fetch_and_store_ad_computer_data(documentation_name): frappe.log_error(f"Fehler beim Laden des Domain Controllers: {str(dc_error)} - verwende Domain als Fallback", "fetch_and_store_ad_computer_data") else: print("INFO: Kein Domain Controller angegeben - verwende Domain als Server-Name") - + # Search Base aus Domain konstruieren domain_parts = domain.split('.') search_base = ','.join([f'DC={part}' for part in domain_parts]) @@ -534,21 +1191,25 @@ def fetch_and_store_ad_user_data(documentation_name): domain = ldap_credentials.domain username = f"{domain}\\{ldap_credentials.username}" password = ldap_credentials.get_password() - - # Server-Name ermitteln - entweder aus Domain Controller oder Domain + + # Server-Name ermitteln - Priorität: 1) ip_address Feld, 2) Domain Controller, 3) Domain server_name = domain # Standard-Fallback - - # Prüfen ob Domain Controller für LDAP-Acquisition angegeben ist - if documentation_doc.domain_controller_for_ldap_acquisition: + + # Prüfen ob IP-Adresse im Documentation-Dokument eingetragen ist (kann manuell oder automatisch gesetzt sein) + if documentation_doc.ip_address: + server_name = documentation_doc.ip_address + print(f"INFO: IP-Adresse aus MSP Documentation verwendet: {server_name}") + # Fallback: Prüfen ob Domain Controller für LDAP-Acquisition angegeben ist + elif documentation_doc.domain_controller_for_ldap_acquisition: try: # IT Object (Domain Controller) laden domain_controller = frappe.get_doc("IT Object", documentation_doc.domain_controller_for_ldap_acquisition) - + # Prüfen ob eine Haupt-IP-Adresse angegeben ist if domain_controller.main_ip: # IP Address Dokument laden ip_address_doc = frappe.get_doc("IP Address", domain_controller.main_ip) - + # IP-Adresse als Server-Name verwenden if ip_address_doc.ip_address: server_name = ip_address_doc.ip_address @@ -561,22 +1222,22 @@ def fetch_and_store_ad_user_data(documentation_name): frappe.log_error(f"Fehler beim Laden des Domain Controllers: {str(dc_error)} - verwende Domain als Fallback", "fetch_and_store_ad_user_data") else: print("INFO: Kein Domain Controller angegeben - verwende Domain als Server-Name") - + # Search Base aus Domain konstruieren domain_parts = domain.split('.') search_base = ','.join([f'DC={part}' for part in domain_parts]) - + # LDAP-Server verbinden server = Server(server_name, get_info=ALL) conn = Connection(server, user=username, password=password, authentication=NTLM) - + # Verbindung herstellen if not conn.bind(): frappe.throw(f"LDAP-Verbindung fehlgeschlagen. Prüfen Sie Server, Benutzername und Passwort.") - + # Server-Info für automatische Domain-Erkennung server_info = server.info - + # Search Base validieren und ggf. aus Server-Info extrahieren if hasattr(server_info, 'naming_contexts') and server_info.naming_contexts: for nc in server_info.naming_contexts: @@ -681,6 +1342,201 @@ def fetch_and_store_ad_user_data(documentation_name): frappe.throw(f"Fehler beim Speichern der AD-Benutzer-Daten: {str(e)}") +@frappe.whitelist() +def test_ldap_connectivity(documentation_name): + """ + Testet die LDAP-Konnektivität in mehreren Schritten: + 1. Ping-Test (ICMP-Erreichbarkeit) + 2. Port-Test (LDAP-Port 389 erreichbar) + 3. LDAP-Bind-Test (Authentifizierung) + + Args: + documentation_name (str): Name der MSP Documentation + + Returns: + dict: Testergebnisse mit Status für jeden Schritt + """ + import subprocess + import socket + + results = { + "success": True, + "tests": { + "ping": {"status": "pending", "message": "", "duration_ms": None}, + "port": {"status": "pending", "message": "", "duration_ms": None}, + "ldap_bind": {"status": "pending", "message": "", "duration_ms": None} + }, + "ip_address": None, + "overall_status": "pending" + } + + try: + # MSP Documentation laden + documentation_doc = frappe.get_doc("MSP Documentation", documentation_name) + + # IP-Adresse ermitteln + ip_address = None + if documentation_doc.ip_address: + ip_address = documentation_doc.ip_address + elif documentation_doc.domain_controller_for_ldap_acquisition: + try: + dc = frappe.get_doc("IT Object", documentation_doc.domain_controller_for_ldap_acquisition) + if dc.main_ip: + ip_doc = frappe.get_doc("IP Address", dc.main_ip) + ip_address = ip_doc.ip_address + except Exception: + pass + + if not ip_address: + results["success"] = False + results["overall_status"] = "error" + results["tests"]["ping"]["status"] = "error" + results["tests"]["ping"]["message"] = "Keine IP-Adresse konfiguriert. Bitte Domain Controller auswählen." + return results + + results["ip_address"] = ip_address + + # ============ TEST 1: PING ============ + import time + ping_start = time.time() + try: + # Ping mit 5 Sekunden Timeout, 1 Paket + ping_result = subprocess.run( + ["ping", "-c", "1", "-W", "5", ip_address], + capture_output=True, + text=True, + timeout=6 + ) + ping_duration = int((time.time() - ping_start) * 1000) + results["tests"]["ping"]["duration_ms"] = ping_duration + + if ping_result.returncode == 0: + # Extrahiere RTT aus Ping-Output + output = ping_result.stdout + rtt_match = re.search(r'time[=<](\d+\.?\d*)\s*ms', output) + rtt = rtt_match.group(1) if rtt_match else "?" + results["tests"]["ping"]["status"] = "success" + results["tests"]["ping"]["message"] = f"Host erreichbar (RTT: {rtt} ms)" + else: + results["tests"]["ping"]["status"] = "error" + results["tests"]["ping"]["message"] = "Host nicht erreichbar (Timeout oder keine Antwort)" + results["overall_status"] = "error" + return results + + except subprocess.TimeoutExpired: + results["tests"]["ping"]["status"] = "error" + results["tests"]["ping"]["message"] = "Ping-Timeout (> 5 Sekunden)" + results["tests"]["ping"]["duration_ms"] = 5000 + results["overall_status"] = "error" + return results + except Exception as e: + results["tests"]["ping"]["status"] = "error" + results["tests"]["ping"]["message"] = f"Ping-Fehler: {str(e)}" + results["overall_status"] = "error" + return results + + # ============ TEST 2: PORT 389 (LDAP) ============ + port_start = time.time() + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + result = sock.connect_ex((ip_address, 389)) + port_duration = int((time.time() - port_start) * 1000) + results["tests"]["port"]["duration_ms"] = port_duration + sock.close() + + if result == 0: + results["tests"]["port"]["status"] = "success" + results["tests"]["port"]["message"] = "LDAP-Port 389 erreichbar" + else: + results["tests"]["port"]["status"] = "error" + results["tests"]["port"]["message"] = f"LDAP-Port 389 nicht erreichbar (Error: {result})" + results["overall_status"] = "partial" + # Port nicht erreichbar, aber Ping OK - weiter zum nächsten Test wäre sinnlos + return results + + except socket.timeout: + results["tests"]["port"]["status"] = "error" + results["tests"]["port"]["message"] = "Port-Test Timeout (> 5 Sekunden)" + results["tests"]["port"]["duration_ms"] = 5000 + results["overall_status"] = "partial" + return results + except Exception as e: + results["tests"]["port"]["status"] = "error" + results["tests"]["port"]["message"] = f"Port-Test Fehler: {str(e)}" + results["overall_status"] = "partial" + return results + + # ============ TEST 3: LDAP BIND (Authentifizierung) ============ + if not documentation_doc.credentials_for_ldap_acquisistion: + results["tests"]["ldap_bind"]["status"] = "skipped" + results["tests"]["ldap_bind"]["message"] = "Keine LDAP-Credentials konfiguriert" + results["overall_status"] = "partial" + return results + + ldap_start = time.time() + try: + ldap_credentials = frappe.get_doc("IT User Account", documentation_doc.credentials_for_ldap_acquisistion) + + if not ldap_credentials.username or not ldap_credentials.domain: + results["tests"]["ldap_bind"]["status"] = "error" + results["tests"]["ldap_bind"]["message"] = "Unvollständige LDAP-Credentials (Benutzername oder Domain fehlt)" + results["overall_status"] = "partial" + return results + + domain = ldap_credentials.domain + username = f"{domain}\\{ldap_credentials.username}" + password = ldap_credentials.get_password() + + # LDAP-Server mit Timeout verbinden + server = Server(ip_address, get_info=ALL, connect_timeout=5) + conn = Connection( + server, + user=username, + password=password, + authentication=NTLM, + receive_timeout=5 + ) + + # Bind versuchen + bind_success = conn.bind() + ldap_duration = int((time.time() - ldap_start) * 1000) + results["tests"]["ldap_bind"]["duration_ms"] = ldap_duration + + if bind_success: + results["tests"]["ldap_bind"]["status"] = "success" + results["tests"]["ldap_bind"]["message"] = f"LDAP-Authentifizierung erfolgreich (User: {ldap_credentials.username})" + results["overall_status"] = "success" + conn.unbind() + else: + results["tests"]["ldap_bind"]["status"] = "error" + error_msg = conn.result.get('description', 'Unbekannter Fehler') if conn.result else 'Bind fehlgeschlagen' + results["tests"]["ldap_bind"]["message"] = f"LDAP-Authentifizierung fehlgeschlagen: {error_msg}" + results["overall_status"] = "partial" + + except Exception as e: + ldap_duration = int((time.time() - ldap_start) * 1000) + results["tests"]["ldap_bind"]["duration_ms"] = ldap_duration + results["tests"]["ldap_bind"]["status"] = "error" + error_str = str(e) + if "timeout" in error_str.lower(): + results["tests"]["ldap_bind"]["message"] = "LDAP-Bind Timeout (> 5 Sekunden)" + else: + results["tests"]["ldap_bind"]["message"] = f"LDAP-Bind Fehler: {error_str[:100]}" + results["overall_status"] = "partial" + + return results + + except Exception as e: + frappe.log_error(f"Fehler beim LDAP-Konnektivitätstest: {str(e)}", "test_ldap_connectivity") + return { + "success": False, + "overall_status": "error", + "error": str(e), + "tests": results.get("tests", {}) + } + + @frappe.whitelist() def compare_rmm_and_ad_data(documentation_name): """ diff --git a/scripts/Get-WindowsUpdateDiagnostics-JSON.ps1 b/scripts/Get-WindowsUpdateDiagnostics-JSON.ps1 new file mode 100644 index 0000000..b873317 --- /dev/null +++ b/scripts/Get-WindowsUpdateDiagnostics-JSON.ps1 @@ -0,0 +1,319 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Windows Update Diagnostics Script - JSON Output +.DESCRIPTION + Sammelt Windows Update Diagnoseinformationen und gibt sie als JSON aus. + Berücksichtigt TacticalRMM-verwaltete Systeme (AUOptions=1 ist dort normal). +.NOTES + Version: 2.2 + Datum: 2026-01-07 + Output: JSON + Changelog: Fixed connectivity check - use download.windowsupdate.com instead of SOAP endpoints +#> + +$ErrorActionPreference = "SilentlyContinue" + +# Detect TacticalRMM Agent +$tacticalInstalled = $false +$tacticalService = Get-Service -Name "tacticalrmm" -ErrorAction SilentlyContinue +if ($tacticalService) { + $tacticalInstalled = $true +} + +# Result object +$result = @{ + hostname = $env:COMPUTERNAME + timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss") + managed_by = @{ + tactical_rmm = $tacticalInstalled + tactical_service_status = if ($tacticalService) { $tacticalService.Status.ToString() } else { $null } + } + os = @{ + caption = "" + build = "" + version = "" + } + services = @() + configuration = @{ + wsus_server = $null + au_options = $null + au_options_text = "" + use_wsus = $false + policies = @{} + } + updates = @{ + history_count = 0 + last_success = $null + days_since_last_success = $null + pending_count = 0 + pending_critical = 0 + pending_important = 0 + pending_driver = 0 + pending_list = @() + recent_history = @() + } + storage = @{ + software_distribution_mb = 0 + download_folder_mb = 0 + datastore_mb = 0 + } + errors = @() + connectivity = @() + issues = @() + status = "OK" +} + +# OS Info +try { + $osInfo = Get-CimInstance Win32_OperatingSystem + $result.os.caption = $osInfo.Caption + $result.os.version = $osInfo.Version + $ntVersion = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue + $result.os.build = "$([System.Environment]::OSVersion.Version.Build).$($ntVersion.UBR)" +} catch {} + +# Services +# Note: BITS and other services start on-demand, so we check StartType not current Status +$serviceList = @( + @{Name="wuauserv"; DisplayName="Windows Update"; Critical=$true; MustRun=$false}, + @{Name="bits"; DisplayName="BITS"; Critical=$false; MustRun=$false}, # Starts on demand + @{Name="cryptsvc"; DisplayName="CryptSvc"; Critical=$false; MustRun=$false}, + @{Name="msiserver"; DisplayName="MSIServer"; Critical=$false; MustRun=$false}, + @{Name="TrustedInstaller"; DisplayName="TrustedInstaller"; Critical=$false; MustRun=$false} +) + +foreach ($svc in $serviceList) { + $service = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + $startType = (Get-CimInstance Win32_Service -Filter "Name='$($svc.Name)'" -ErrorAction SilentlyContinue).StartMode + + # Service is OK if: exists AND (running OR start type is Manual/Auto, not Disabled) + $isDisabled = ($startType -eq "Disabled") + $isOk = $service -and (-not $isDisabled) + + $svcResult = @{ + name = $svc.Name + display_name = $svc.DisplayName + status = if ($service) { $service.Status.ToString() } else { "NotFound" } + start_type = $startType + critical = $svc.Critical + disabled = $isDisabled + ok = $isOk + } + $result.services += $svcResult + + # Only flag as issue if service is disabled or not found + if ($svc.Critical -and (-not $service -or $isDisabled)) { + $result.issues += "Service '$($svc.DisplayName)' ist deaktiviert oder nicht vorhanden" + } +} + +# Configuration +$wuPolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" +if (Test-Path $wuPolicyPath) { + $wuPolicy = Get-ItemProperty -Path $wuPolicyPath -ErrorAction SilentlyContinue + $result.configuration.wsus_server = $wuPolicy.WUServer + $result.configuration.use_wsus = ($wuPolicy.UseWUServer -eq 1) + + $auPath = "$wuPolicyPath\AU" + if (Test-Path $auPath) { + $auPolicy = Get-ItemProperty -Path $auPath -ErrorAction SilentlyContinue + $result.configuration.au_options = $auPolicy.AUOptions + $result.configuration.policies = @{ + no_auto_update = $auPolicy.NoAutoUpdate + au_options = $auPolicy.AUOptions + scheduled_install_day = $auPolicy.ScheduledInstallDay + scheduled_install_time = $auPolicy.ScheduledInstallTime + } + + # Translate AUOptions + $auText = switch ($auPolicy.AUOptions) { + 1 { "Deaktiviert (RMM-verwaltet)" } + 2 { "Benachrichtigen vor Download" } + 3 { "Automatisch downloaden, benachrichtigen vor Installation" } + 4 { "Automatisch downloaden und installieren" } + 5 { "Lokaler Admin kann Einstellung wählen" } + default { "Nicht konfiguriert" } + } + $result.configuration.au_options_text = $auText + + # AUOptions=1 is EXPECTED when TacticalRMM is installed (TRMM manages updates) + # Only flag as issue if AUOptions=1 AND no RMM is installed + if ($auPolicy.AUOptions -eq 1 -and -not $tacticalInstalled) { + $result.issues += "Windows Update ist per Policy deaktiviert (AUOptions=1) ohne RMM-Verwaltung" + } + } +} + +# Update History and Pending +try { + $Session = New-Object -ComObject Microsoft.Update.Session + $Searcher = $Session.CreateUpdateSearcher() + $result.updates.history_count = $Searcher.GetTotalHistoryCount() + + # Recent history + if ($result.updates.history_count -gt 0) { + $History = $Searcher.QueryHistory(0, [Math]::Min(10, $result.updates.history_count)) + foreach ($Update in $History) { + $resultCode = switch ($Update.ResultCode) { + 0 { "NotStarted" } + 1 { "InProgress" } + 2 { "Succeeded" } + 3 { "SucceededWithErrors" } + 4 { "Failed" } + 5 { "Aborted" } + default { "Unknown" } + } + + $result.updates.recent_history += @{ + date = $Update.Date.ToString("yyyy-MM-ddTHH:mm:ss") + title = $Update.Title + result = $resultCode + succeeded = ($Update.ResultCode -eq 2) + } + } + + # Find last success + $allHistory = $Searcher.QueryHistory(0, $result.updates.history_count) + foreach ($Update in $allHistory) { + if ($Update.ResultCode -eq 2) { + $result.updates.last_success = $Update.Date.ToString("yyyy-MM-ddTHH:mm:ss") + $result.updates.days_since_last_success = [math]::Round(((Get-Date) - $Update.Date).TotalDays) + break + } + } + } + + if (-not $result.updates.last_success) { + $result.issues += "Kein erfolgreiches Update in der Historie gefunden" + } elseif ($result.updates.days_since_last_success -gt 60) { + $result.issues += "Letztes erfolgreiches Update vor $($result.updates.days_since_last_success) Tagen" + } + + # Pending updates + $SearchResult = $Searcher.Search("IsInstalled=0") + $result.updates.pending_count = $SearchResult.Updates.Count + + foreach ($Update in $SearchResult.Updates) { + $severity = if ($Update.MsrcSeverity) { $Update.MsrcSeverity } else { "Unspecified" } + $isDriver = $Update.Categories | Where-Object { $_.Name -like "*Driver*" } + + $pendingUpdate = @{ + title = $Update.Title + kb = ($Update.KBArticleIDs -join ",") + severity = $severity + size_mb = [math]::Round($Update.MaxDownloadSize / 1MB, 1) + downloaded = $Update.IsDownloaded + is_driver = ($null -ne $isDriver) + categories = @($Update.Categories | ForEach-Object { $_.Name }) + } + $result.updates.pending_list += $pendingUpdate + + if ($severity -eq "Critical") { $result.updates.pending_critical++ } + elseif ($severity -eq "Important") { $result.updates.pending_important++ } + if ($isDriver) { $result.updates.pending_driver++ } + } + + if ($result.updates.pending_critical -gt 0) { + $result.issues += "$($result.updates.pending_critical) kritische Updates ausstehend" + } +} catch { + $result.errors += "Update-Abfrage fehlgeschlagen: $_" +} + +# Storage +$sdPath = "$env:SystemRoot\SoftwareDistribution" +if (Test-Path $sdPath) { + $sdSize = (Get-ChildItem -Path $sdPath -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum + $result.storage.software_distribution_mb = [math]::Round($sdSize / 1MB, 2) + + $dlPath = "$sdPath\Download" + if (Test-Path $dlPath) { + $dlSize = (Get-ChildItem -Path $dlPath -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum + $result.storage.download_folder_mb = [math]::Round($dlSize / 1MB, 2) + } + + $dbPath = "$sdPath\DataStore\DataStore.edb" + if (Test-Path $dbPath) { + $result.storage.datastore_mb = [math]::Round((Get-Item $dbPath).Length / 1MB, 2) + if ($result.storage.datastore_mb -gt 500) { + $result.issues += "DataStore.edb sehr groß ($($result.storage.datastore_mb) MB)" + } + } +} + +# Event Log Errors +try { + $StartDate = (Get-Date).AddDays(-7) + $WUErrors = Get-WinEvent -FilterHashtable @{ + LogName = 'System' + ProviderName = 'Microsoft-Windows-WindowsUpdateClient' + Level = 2,3 + StartTime = $StartDate + } -MaxEvents 5 -ErrorAction SilentlyContinue + + foreach ($Event in $WUErrors) { + $result.errors += @{ + date = $Event.TimeCreated.ToString("yyyy-MM-ddTHH:mm:ss") + level = if ($Event.Level -eq 2) { "Error" } else { "Warning" } + id = $Event.Id + message = ($Event.Message -split "`n")[0] + } + } +} catch {} + +# Connectivity +# Note: update.microsoft.com and windowsupdate.microsoft.com are SOAP/WCF services +# They don't respond to HTTP GET requests - use download URLs instead +$testUrls = @( + @{url="https://www.microsoft.com"; name="Microsoft"; critical=$false}, + @{url="http://download.windowsupdate.com"; name="Windows Update Download"; critical=$true}, + @{url="https://www.catalog.update.microsoft.com"; name="Windows Update Catalog"; critical=$false}, + @{url="https://download.microsoft.com"; name="Microsoft Download"; critical=$false} +) + +foreach ($test in $testUrls) { + try { + $response = Invoke-WebRequest -Uri $test.url -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop + $result.connectivity += @{ + name = $test.name + url = $test.url + status = $response.StatusCode + ok = $true + } + } catch { + $result.connectivity += @{ + name = $test.name + url = $test.url + status = 0 + error = $_.Exception.Message + ok = $false + } + if ($test.critical) { + $result.issues += "Keine Verbindung zu $($test.name)" + } + } +} + +# Final status determination +# Critical issues: connectivity problems, critical pending updates, disabled services +# Warning issues: old updates, many pending updates +if ($result.issues.Count -gt 0) { + $hasCritical = $result.issues | Where-Object { + $_ -match "kritisch|keine Verbindung|deaktiviert oder nicht vorhanden|ohne RMM" + } + $result.status = if ($hasCritical) { "CRITICAL" } else { "WARNING" } +} else { + $result.status = "OK" +} + +# Add summary +$result.summary = @{ + total_issues = $result.issues.Count + is_rmm_managed = $tacticalInstalled + needs_attention = ($result.status -ne "OK") + connectivity_ok = ($result.connectivity | Where-Object { -not $_.ok }).Count -eq 0 +} + +# Output as JSON +$result | ConvertTo-Json -Depth 5 -Compress diff --git a/scripts/Get-WindowsUpdateDiagnostics.ps1 b/scripts/Get-WindowsUpdateDiagnostics.ps1 new file mode 100644 index 0000000..a81a6f0 --- /dev/null +++ b/scripts/Get-WindowsUpdateDiagnostics.ps1 @@ -0,0 +1,325 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Windows Update Diagnostics Script for TacticalRMM +.DESCRIPTION + Sammelt umfassende Diagnoseinformationen zum Windows Update Status. + Ausgabe erfolgt als strukturierter Text fuer TacticalRMM. +.NOTES + Version: 1.0 + Datum: 2026-01-07 + Fuer: MSP Documentation / TacticalRMM +#> + +[CmdletBinding()] +param() + +$ErrorActionPreference = "SilentlyContinue" + +function Write-Section { + param([string]$Title) + Write-Output "" + Write-Output "=" * 60 + Write-Output " $Title" + Write-Output "=" * 60 +} + +function Write-SubSection { + param([string]$Title) + Write-Output "" + Write-Output "--- $Title ---" +} + +# Header +Write-Output "WINDOWS UPDATE DIAGNOSTICS" +Write-Output "Hostname: $env:COMPUTERNAME" +Write-Output "Datum: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" +Write-Output "OS: $((Get-CimInstance Win32_OperatingSystem).Caption)" +Write-Output "Build: $([System.Environment]::OSVersion.Version.Build).$((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion').UBR)" + +# ============================================================ +# 1. WINDOWS UPDATE DIENSTE +# ============================================================ +Write-Section "1. WINDOWS UPDATE DIENSTE" + +$services = @( + @{Name="wuauserv"; DisplayName="Windows Update"}, + @{Name="bits"; DisplayName="Background Intelligent Transfer"}, + @{Name="cryptsvc"; DisplayName="Cryptographic Services"}, + @{Name="msiserver"; DisplayName="Windows Installer"}, + @{Name="TrustedInstaller"; DisplayName="Windows Modules Installer"} +) + +foreach ($svc in $services) { + $service = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if ($service) { + $startType = (Get-CimInstance Win32_Service -Filter "Name='$($svc.Name)'").StartMode + $status = if ($service.Status -eq "Running") { "[OK]" } else { "[!!]" } + Write-Output "$status $($svc.DisplayName) ($($svc.Name)): $($service.Status) / StartType: $startType" + } else { + Write-Output "[??] $($svc.DisplayName) ($($svc.Name)): Nicht gefunden" + } +} + +# ============================================================ +# 2. WSUS / WINDOWS UPDATE KONFIGURATION +# ============================================================ +Write-Section "2. WINDOWS UPDATE KONFIGURATION" + +Write-SubSection "Registry: WindowsUpdate Policy" +$wuPolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate" +if (Test-Path $wuPolicyPath) { + $wuPolicy = Get-ItemProperty -Path $wuPolicyPath -ErrorAction SilentlyContinue + Write-Output "WUServer: $($wuPolicy.WUServer)" + Write-Output "WUStatusServer: $($wuPolicy.WUStatusServer)" + Write-Output "UseWUServer: $($wuPolicy.UseWUServer)" + Write-Output "DoNotConnectToWindowsUpdateInternetLocations: $($wuPolicy.DoNotConnectToWindowsUpdateInternetLocations)" + + $auPath = "$wuPolicyPath\AU" + if (Test-Path $auPath) { + $auPolicy = Get-ItemProperty -Path $auPath -ErrorAction SilentlyContinue + Write-Output "" + Write-Output "AU Policy:" + Write-Output " NoAutoUpdate: $($auPolicy.NoAutoUpdate)" + Write-Output " AUOptions: $($auPolicy.AUOptions)" + Write-Output " ScheduledInstallDay: $($auPolicy.ScheduledInstallDay)" + Write-Output " ScheduledInstallTime: $($auPolicy.ScheduledInstallTime)" + Write-Output " UseWUServer: $($auPolicy.UseWUServer)" + } +} else { + Write-Output "Keine WSUS/GPO Konfiguration gefunden (Standard Windows Update)" +} + +Write-SubSection "Registry: Windows Update Settings" +$wuSettingsPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" +if (Test-Path $wuSettingsPath) { + $wuSettings = Get-ItemProperty -Path $wuSettingsPath -ErrorAction SilentlyContinue + Write-Output "AccountDomainSid: $($wuSettings.AccountDomainSid)" + Write-Output "SusClientId: $($wuSettings.SusClientId)" +} + +# ============================================================ +# 3. WINDOWS UPDATE HISTORIE +# ============================================================ +Write-Section "3. WINDOWS UPDATE HISTORIE (letzte 20)" + +try { + $Session = New-Object -ComObject Microsoft.Update.Session + $Searcher = $Session.CreateUpdateSearcher() + $HistoryCount = $Searcher.GetTotalHistoryCount() + + Write-Output "Gesamte Historie-Eintraege: $HistoryCount" + Write-Output "" + + if ($HistoryCount -gt 0) { + $History = $Searcher.QueryHistory(0, [Math]::Min(20, $HistoryCount)) + + foreach ($Update in $History) { + $ResultCode = switch ($Update.ResultCode) { + 0 { "NotStarted" } + 1 { "InProgress" } + 2 { "Succeeded" } + 3 { "SucceededWithErrors" } + 4 { "Failed" } + 5 { "Aborted" } + default { "Unknown" } + } + + $Status = if ($Update.ResultCode -eq 2) { "[OK]" } else { "[!!]" } + $Date = $Update.Date.ToString("yyyy-MM-dd HH:mm") + $Title = $Update.Title + if ($Title.Length -gt 60) { $Title = $Title.Substring(0, 57) + "..." } + + Write-Output "$Status [$Date] $ResultCode" + Write-Output " $Title" + } + } +} catch { + Write-Output "Fehler beim Abrufen der Update-Historie: $_" +} + +# ============================================================ +# 4. AUSSTEHENDE UPDATES +# ============================================================ +Write-Section "4. AUSSTEHENDE UPDATES" + +try { + $Searcher = $Session.CreateUpdateSearcher() + $SearchResult = $Searcher.Search("IsInstalled=0") + + Write-Output "Ausstehende Updates: $($SearchResult.Updates.Count)" + Write-Output "" + + if ($SearchResult.Updates.Count -gt 0) { + foreach ($Update in $SearchResult.Updates) { + $Severity = if ($Update.MsrcSeverity) { $Update.MsrcSeverity } else { "Unspecified" } + $Size = [math]::Round($Update.MaxDownloadSize / 1MB, 1) + + Write-Output "[$Severity] $($Update.Title)" + Write-Output " KB: $($Update.KBArticleIDs -join ', ') | Groesse: ${Size}MB | Downloaded: $($Update.IsDownloaded)" + } + } else { + Write-Output "Keine ausstehenden Updates gefunden." + } +} catch { + Write-Output "Fehler beim Suchen nach Updates: $_" +} + +# ============================================================ +# 5. LETZTE ERFOLGREICHE INSTALLATION +# ============================================================ +Write-Section "5. LETZTE ERFOLGREICHE UPDATE-INSTALLATION" + +try { + $LastSuccess = $null + $History = $Searcher.QueryHistory(0, $HistoryCount) + + foreach ($Update in $History) { + if ($Update.ResultCode -eq 2) { + $LastSuccess = $Update + break + } + } + + if ($LastSuccess) { + $DaysAgo = [math]::Round((Get-Date) - $LastSuccess.Date).TotalDays + Write-Output "Datum: $($LastSuccess.Date.ToString('yyyy-MM-dd HH:mm:ss'))" + Write-Output "Tage her: $DaysAgo" + Write-Output "Update: $($LastSuccess.Title)" + + if ($DaysAgo -gt 60) { + Write-Output "" + Write-Output "[WARNUNG] Letztes erfolgreiches Update ist mehr als 60 Tage her!" + } + } else { + Write-Output "Kein erfolgreiches Update in der Historie gefunden!" + } +} catch { + Write-Output "Fehler: $_" +} + +# ============================================================ +# 6. SOFTWAREDISTRIBUTION ORDNER +# ============================================================ +Write-Section "6. SOFTWAREDISTRIBUTION STATUS" + +$sdPath = "$env:SystemRoot\SoftwareDistribution" +if (Test-Path $sdPath) { + $sdSize = (Get-ChildItem -Path $sdPath -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum + $sdSizeMB = [math]::Round($sdSize / 1MB, 2) + + Write-Output "Pfad: $sdPath" + Write-Output "Groesse: ${sdSizeMB} MB" + + $downloadPath = "$sdPath\Download" + if (Test-Path $downloadPath) { + $dlSize = (Get-ChildItem -Path $downloadPath -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum + $dlSizeMB = [math]::Round($dlSize / 1MB, 2) + Write-Output "Download-Ordner: ${dlSizeMB} MB" + } + + $dataStorePath = "$sdPath\DataStore\DataStore.edb" + if (Test-Path $dataStorePath) { + $dbSize = [math]::Round((Get-Item $dataStorePath).Length / 1MB, 2) + Write-Output "DataStore.edb: ${dbSize} MB" + + if ($dbSize -gt 500) { + Write-Output "[WARNUNG] DataStore.edb ist sehr gross - Reset empfohlen" + } + } +} else { + Write-Output "[FEHLER] SoftwareDistribution Ordner nicht gefunden!" +} + +# ============================================================ +# 7. WINDOWS UPDATE FEHLER (Event Log) +# ============================================================ +Write-Section "7. WINDOWS UPDATE FEHLER (letzte 7 Tage)" + +try { + $StartDate = (Get-Date).AddDays(-7) + $WUErrors = Get-WinEvent -FilterHashtable @{ + LogName = 'System' + ProviderName = 'Microsoft-Windows-WindowsUpdateClient' + Level = 2,3 # Error, Warning + StartTime = $StartDate + } -MaxEvents 10 -ErrorAction SilentlyContinue + + if ($WUErrors) { + Write-Output "Gefundene Fehler/Warnungen: $($WUErrors.Count)" + Write-Output "" + + foreach ($Event in $WUErrors) { + $Level = if ($Event.Level -eq 2) { "[ERROR]" } else { "[WARN]" } + Write-Output "$Level [$($Event.TimeCreated.ToString('yyyy-MM-dd HH:mm'))] ID:$($Event.Id)" + Write-Output " $($Event.Message.Split("`n")[0])" + } + } else { + Write-Output "Keine Windows Update Fehler in den letzten 7 Tagen." + } +} catch { + Write-Output "Event Log konnte nicht abgefragt werden: $_" +} + +# ============================================================ +# 8. NETZWERK-KONNEKTIVITAET +# ============================================================ +Write-Section "8. WINDOWS UPDATE KONNEKTIVITAET" + +$testUrls = @( + "https://www.microsoft.com", + "https://update.microsoft.com", + "https://windowsupdate.microsoft.com" +) + +foreach ($url in $testUrls) { + try { + $response = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop + Write-Output "[OK] $url (Status: $($response.StatusCode))" + } catch { + Write-Output "[!!] $url (Fehler: $($_.Exception.Message))" + } +} + +# ============================================================ +# 9. ZUSAMMENFASSUNG +# ============================================================ +Write-Section "9. ZUSAMMENFASSUNG" + +$issues = @() + +# Check services +$wuService = Get-Service -Name wuauserv -ErrorAction SilentlyContinue +if ($wuService.Status -ne "Running") { + $issues += "Windows Update Dienst laeuft nicht" +} + +# Check last update age +if ($LastSuccess -and $DaysAgo -gt 60) { + $issues += "Letztes Update vor mehr als $DaysAgo Tagen" +} + +# Check WSUS +if ((Test-Path $wuPolicyPath) -and $wuPolicy.UseWUServer -eq 1) { + $issues += "WSUS konfiguriert: $($wuPolicy.WUServer)" +} + +# Check pending updates +if ($SearchResult -and $SearchResult.Updates.Count -gt 5) { + $issues += "$($SearchResult.Updates.Count) ausstehende Updates" +} + +if ($issues.Count -eq 0) { + Write-Output "Status: OK - Keine offensichtlichen Probleme gefunden" +} else { + Write-Output "Status: PROBLEME ERKANNT" + Write-Output "" + foreach ($issue in $issues) { + Write-Output " [!] $issue" + } +} + +Write-Output "" +Write-Output "=" * 60 +Write-Output " DIAGNOSTICS ENDE" +Write-Output "=" * 60 diff --git a/scripts/wu_compare_AVNB01_CWWS03.json b/scripts/wu_compare_AVNB01_CWWS03.json new file mode 100644 index 0000000..8ea5874 --- /dev/null +++ b/scripts/wu_compare_AVNB01_CWWS03.json @@ -0,0 +1,1150 @@ +{ + "CWWS03": { + "os": { + "caption": "Microsoft Windows 10 Pro", + "build": "19045.6456", + "version": "10.0.19045" + }, + "summary": { + "connectivity_ok": false, + "needs_attention": true, + "is_rmm_managed": true, + "total_issues": 2 + }, + "services": [ + { + "start_type": "Manual", + "name": "wuauserv", + "critical": true, + "ok": true, + "disabled": false, + "status": "Stopped", + "display_name": "Windows Update" + }, + { + "start_type": "Manual", + "name": "bits", + "critical": false, + "ok": true, + "disabled": false, + "status": "Stopped", + "display_name": "BITS" + }, + { + "start_type": "Auto", + "name": "cryptsvc", + "critical": false, + "ok": true, + "disabled": false, + "status": "Running", + "display_name": "CryptSvc" + }, + { + "start_type": "Manual", + "name": "msiserver", + "critical": false, + "ok": true, + "disabled": false, + "status": "Stopped", + "display_name": "MSIServer" + }, + { + "start_type": "Manual", + "name": "TrustedInstaller", + "critical": false, + "ok": true, + "disabled": false, + "status": "Stopped", + "display_name": "TrustedInstaller" + } + ], + "updates": { + "pending_important": 0, + "pending_driver": 13, + "days_since_last_success": 28, + "last_success": "2025-12-10T10:08:44", + "recent_history": [ + { + "date": "2025-12-10T10:08:44", + "title": "Windows-Tool zum Entfernen bsartiger Software x64 - v5.138 (KB890830)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-11-18T10:05:32", + "title": "2025-11 Sicherheitsupdate fr Windows 10 Version 22H2 fr x64-basierte Systeme (KB5072653)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-11-12T10:08:45", + "title": "Windows-Tool zum Entfernen bsartiger Software x64 - v5.137 (KB890830)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-11-07T07:45:21", + "title": "9WZDNCRD29V9-MICROSOFT.MICROSOFTOFFICEHUB", + "result": "Failed", + "succeeded": false + }, + { + "date": "2025-10-15T09:26:51", + "title": "2025-10 Kumulatives Update fr .NET Framework 3.5, 4.8 und 4.8.1 fr Windows 10 Version 22H2 fr x64 ((KB5066747))", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-10-15T09:25:29", + "title": "Windows-Tool zum Entfernen bsartiger Software x64 - v5.136 (KB890830)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-10-15T09:22:41", + "title": "2025-10 Kumulatives Update fr Windows 10 Version 22H2 fr x64-basierte Systeme (KB5066791)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-09-26T09:26:43", + "title": "Windows-Tool zum Entfernen bsartiger Software x64 - v5.135 (KB890830)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-09-26T09:23:46", + "title": "2025-09 Kumulatives Update fr Windows 10 Version 22H2 fr x64-basierte Systeme (KB5065429)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-09-26T09:06:03", + "title": "2025-09 Kumulatives Update fr .NET Framework 3.5, 4.8 und 4.8.1 fr Windows 10 Version 22H2 fr x64 ((KB5065957))", + "result": "Succeeded", + "succeeded": true + } + ], + "pending_critical": 0, + "pending_list": [ + { + "size_mb": 0.1, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Fujitsu Technology Solutions - LCD - FUJITSU B22W-6 LED proGREEN", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 6.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "FUJITSU - Firmware - 1.33.0.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.5, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - SoftwareComponent - 1.46.2024.221", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.4, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek - Net - 10.72.524.2024", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 11.9, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek Semiconductor Corp. - MEDIA - 6.0.9738.1", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek Semiconductor Corp. - Extension - 6.0.9738.1", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - System - 2512.7.3.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.8, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - SoftwareComponent - 2512.7.1.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 49.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - SoftwareComponent - 2514.7.7.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 17.5, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - SoftwareComponent - 1.76.95.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 5.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "SAMSUNG Electronics Co., Ltd. - USB - 2.21.0.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 5.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "SAMSUNG Electronics Co., Ltd. - Modem - 2.21.0.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Driver Update (2540.8.7.0)", + "categories": [ + "Drivers" + ] + } + ], + "pending_count": 13, + "history_count": 294 + }, + "issues": [ + "Keine Verbindung zu Windows Update", + "Keine Verbindung zu Windows Update Alt" + ], + "connectivity": [ + { + "ok": true, + "status": 200, + "url": "https://www.microsoft.com", + "name": "Microsoft" + }, + { + "ok": false, + "status": 0, + "url": "https://update.microsoft.com", + "name": "Windows Update", + "error": "Der Remoteserver hat einen Fehler zurckgegeben: (403) Unzulssig." + }, + { + "ok": false, + "status": 0, + "url": "https://windowsupdate.microsoft.com", + "name": "Windows Update Alt", + "error": "Der Remoteserver hat einen Fehler zurckgegeben: (403) Unzulssig." + } + ], + "hostname": "CWWS03", + "errors": [ + { + "date": "2026-01-05T10:01:51", + "message": "Installationsfehler: Die Installation des folgenden Updates ist mit Fehler 0x80073D02 fehlgeschlagen: 9WZDNCRFJBMP-MICROSOFT.WINDOWSSTORE", + "level": "Error", + "id": 20 + } + ], + "storage": { + "software_distribution_mb": 49.89, + "datastore_mb": 40.25, + "download_folder_mb": 0 + }, + "timestamp": "2026-01-07T12:15:56", + "status": "CRITICAL", + "managed_by": { + "tactical_service_status": "Running", + "tactical_rmm": true + }, + "configuration": { + "policies": { + "au_options": 1, + "scheduled_install_time": null, + "no_auto_update": null, + "scheduled_install_day": null + }, + "use_wsus": false, + "wsus_server": null, + "au_options": 1, + "au_options_text": "Deaktiviert (RMM-verwaltet)" + } + }, + "AVNB01": { + "os": { + "caption": "Microsoft Windows 11 Pro", + "build": "22631.3880", + "version": "10.0.22631" + }, + "summary": { + "connectivity_ok": false, + "needs_attention": true, + "is_rmm_managed": true, + "total_issues": 2 + }, + "services": [ + { + "start_type": "Manual", + "name": "wuauserv", + "critical": true, + "ok": true, + "disabled": false, + "status": "Stopped", + "display_name": "Windows Update" + }, + { + "start_type": "Auto", + "name": "bits", + "critical": false, + "ok": true, + "disabled": false, + "status": "Running", + "display_name": "BITS" + }, + { + "start_type": "Auto", + "name": "cryptsvc", + "critical": false, + "ok": true, + "disabled": false, + "status": "Running", + "display_name": "CryptSvc" + }, + { + "start_type": "Manual", + "name": "msiserver", + "critical": false, + "ok": true, + "disabled": false, + "status": "Stopped", + "display_name": "MSIServer" + }, + { + "start_type": "Manual", + "name": "TrustedInstaller", + "critical": false, + "ok": true, + "disabled": false, + "status": "Stopped", + "display_name": "TrustedInstaller" + } + ], + "updates": { + "pending_important": 0, + "pending_driver": 51, + "days_since_last_success": 0, + "last_success": "2026-01-07T05:20:39", + "recent_history": [ + { + "date": "2026-01-07T05:20:39", + "title": "Security Intelligence-Update fr Microsoft Defender Antivirus - KB2267602 (Version 1.443.538.0) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2026-01-05T16:29:34", + "title": "Security Intelligence-Update fr Microsoft Defender Antivirus - KB2267602 (Version 1.443.512.0) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2026-01-05T06:50:05", + "title": "Security Intelligence-Update fr Microsoft Defender Antivirus - KB2267602 (Version 1.443.506.0) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-12-18T21:59:01", + "title": "Security Intelligence-Update fr Microsoft Defender Antivirus - KB2267602 (Version 1.443.201.0) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-12-18T21:48:49", + "title": "Update fr Microsoft Defender Antivirus Antischadsoftwareplattform - KB4052623 (Version 4.18.25110.6) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-12-17T06:48:57", + "title": "Security Intelligence-Update fr Microsoft Defender Antivirus - KB2267602 (Version 1.443.170.0) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-12-15T04:47:28", + "title": "Security Intelligence-Update fr Microsoft Defender Antivirus - KB2267602 (Version 1.443.124.0) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-12-14T04:47:29", + "title": "Security Intelligence-Update fr Microsoft Defender Antivirus - KB2267602 (Version 1.443.102.0) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-12-13T04:57:46", + "title": "Security Intelligence-Update fr Microsoft Defender Antivirus - KB2267602 (Version 1.443.78.0) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + }, + { + "date": "2025-12-11T17:43:49", + "title": "Security Intelligence-Update fr Microsoft Defender Antivirus - KB2267602 (Version 1.443.43.0) - Aktueller Kanal (Allgemein)", + "result": "Succeeded", + "succeeded": true + } + ], + "pending_critical": 0, + "pending_list": [ + { + "size_mb": 0, + "downloaded": null, + "kb": "", + "is_driver": false, + "severity": "Unspecified", + "title": null, + "categories": [ + null + ] + }, + { + "size_mb": 0, + "downloaded": null, + "kb": "", + "is_driver": false, + "severity": "Unspecified", + "title": null, + "categories": [ + null + ] + }, + { + "size_mb": 0, + "downloaded": null, + "kb": "", + "is_driver": false, + "severity": "Unspecified", + "title": null, + "categories": [ + null + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Fujitsu Technology Solutions - Monitor - 8/24/2017 12:00:00 AM - 1.0.0.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 17.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - SoftwareComponent - 1.72.189.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - Extension - 9.0.11701.44281", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.5, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - SoftwareComponent - 1.46.2024.221", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Cirrus Logic, Inc. - MEDIA - 21.51.46.157", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 10.7, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Sonitude, Inc. - AudioProcessingObject - 2.0.11.61", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 1.4, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Synaptics Incorporated - Biometric - 6.0.130.1110", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 1.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Sonitude, Inc. - Extension - 17.20.39.4170", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Cirrus Logic, Inc. - Extension - 1.35.5.195", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 101.7, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel(R) Corporation - System - 10.29.0.11633", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - Ports - 2441.7.0.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - Extension - 2441.7.0.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - Extension - 255.91.103.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 126.6, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - SoftwareComponent - 255.91.103.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 12.8, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek - SoftwareComponent - 11.0.6000.50", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - System - 2512.7.3.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.8, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - SoftwareComponent - 2512.7.1.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 49.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - SoftwareComponent - 2514.7.7.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 18.5, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - net - 23.140.0.3", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 5.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Corporation - Bluetooth - 23.140.0.5", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - Extension - 23.140.0.3", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 1030.9, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "NVIDIA - Display - 32.0.15.7344", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 21.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek - AudioProcessingObject - 13.2697.1817.552", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 10.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek - SoftwareComponent - 1.0.858.7", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 10.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek - SoftwareComponent - 12.0.6000.364", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.1, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek Semiconductor Corp. - Extension - 6.0.9844.1", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.1, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek Semiconductor Corp. - Extension - 6.0.9844.1", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 12.3, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek Semiconductor Corp. - MEDIA - 6.0.9844.1", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Corporation - Extension - 40.25.725.165", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 6.4, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Corporation - SoftwareComponent - 40.25.725.165", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.4, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel - Net - 2.1.5.6", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 320.6, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek Semiconductor Corp. - Extension - 7.0.7.117", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 1.8, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek - Extension - 9.21.3690.692", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 1.8, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek - Extension - 9.21.3690.692", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 320.6, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek Semiconductor Corp. - Extension - 7.0.7.117", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "HP Development Company, L.P. - Keyboard - 11.1.9.1", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 2.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "HP Development Company, L.P. - SoftwareComponent - 8.10.29.1", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "HP Development Company, L.P. - Extension - 8.10.29.1", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 136.3, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "HP Inc. - SoftwareComponent - 1.83.4311.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 14.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "HP Inc. - SoftwareComponent - 4.2.2494.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.4, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Realtek - Net - 1153.19.602.2025", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 53.8, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "HP Inc. - SoftwareComponent - 4.8.125.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "HP Inc. - Extension - 4.8.125.0", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 3.7, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "HP Inc. - SoftwareDevice - 1.1.8.64", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 17.3, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "HP Driver Update (1.10.0.0)", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.2, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Driver Update (2540.8.7.0)", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 689.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Corporation Driver Update (32.0.101.7077)", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Corporation Driver Update (32.0.101.7077)", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0, + "downloaded": null, + "kb": "", + "is_driver": false, + "severity": "Unspecified", + "title": null, + "categories": [ + null + ] + }, + { + "size_mb": 0.1, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Driver Update (3.1.0.4599)", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.1, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Driver Update (3.1.0.4599)", + "categories": [ + "Drivers" + ] + }, + { + "size_mb": 0.0, + "downloaded": false, + "kb": "", + "is_driver": true, + "severity": "Unspecified", + "title": "Intel Driver Update (3.1.0.4599)", + "categories": [ + "Drivers" + ] + } + ], + "pending_count": 55, + "history_count": 243 + }, + "issues": [ + "Keine Verbindung zu Windows Update", + "Keine Verbindung zu Windows Update Alt" + ], + "connectivity": [ + { + "ok": true, + "status": 200, + "url": "https://www.microsoft.com", + "name": "Microsoft" + }, + { + "ok": false, + "status": 0, + "url": "https://update.microsoft.com", + "name": "Windows Update", + "error": "Der Remoteserver hat einen Fehler zurckgegeben: (403) Unzulssig." + }, + { + "ok": false, + "status": 0, + "url": "https://windowsupdate.microsoft.com", + "name": "Windows Update Alt", + "error": "Der Remoteserver hat einen Fehler zurckgegeben: (403) Unzulssig." + } + ], + "hostname": "AVNB01", + "errors": [ + { + "date": "2026-01-06T06:43:39", + "message": "Installationsfehler: Die Installation des folgenden Updates ist mit Fehler 0x80073D02 fehlgeschlagen: 9N9PHDT62W94-AD2F1837.myHP", + "level": "Error", + "id": 20 + }, + { + "date": "2026-01-05T09:37:59", + "message": "Installationsfehler: Die Installation des folgenden Updates ist mit Fehler 0x80073D02 fehlgeschlagen: 9NMPJ99VJBWV-Microsoft.YourPhone", + "level": "Error", + "id": 20 + }, + { + "date": "2026-01-05T09:35:45", + "message": "Installationsfehler: Die Installation des folgenden Updates ist mit Fehler 0x80073D02 fehlgeschlagen: 9NTXGKQ8P7N0-MicrosoftWindows.CrossDevice", + "level": "Error", + "id": 20 + }, + { + "date": "2026-01-05T09:35:01", + "message": "Installationsfehler: Die Installation des folgenden Updates ist mit Fehler 0x80073D02 fehlgeschlagen: 9NTXGKQ8P7N0-MicrosoftWindows.CrossDevice", + "level": "Error", + "id": 20 + } + ], + "storage": { + "software_distribution_mb": 46.96, + "datastore_mb": 36.5, + "download_folder_mb": 0.51 + }, + "timestamp": "2026-01-07T12:15:55", + "status": "CRITICAL", + "managed_by": { + "tactical_service_status": "Running", + "tactical_rmm": true + }, + "configuration": { + "policies": { + "au_options": 1, + "scheduled_install_time": null, + "no_auto_update": null, + "scheduled_install_day": null + }, + "use_wsus": false, + "wsus_server": null, + "au_options": 1, + "au_options_text": "Deaktiviert (RMM-verwaltet)" + } + } +} \ No newline at end of file diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..516fce7 --- /dev/null +++ b/todo.md @@ -0,0 +1,53 @@ +# MSP App - TODO + +## Windows Update Reporting + +### Scan-Button für einzelne Agents +- [ ] Button in Computer-Tabelle oder Detail-Modal hinzufügen +- [ ] API-Aufruf: `POST /winupdate/{agent_id}/scan/` - Scan anstoßen +- [ ] API-Aufruf: `POST /winupdate/{agent_id}/install/` - Approved Updates installieren +- [ ] Feedback nach Auslösung anzeigen (asynchrone Operation) +- [ ] Optional: Bulk-Scan für alle Agents eines Mandanten + +### Windows Update Diagnostics Scripts (TacticalRMM) +- [x] **ID 153**: MSP - Windows Update Diagnostics (Text-Ausgabe) +- [x] **ID 154**: MSP - Windows Update Diagnostics JSON (strukturierte Ausgabe) + +JSON-Skript liefert: +- `hostname`, `timestamp`, `os` (caption, build, version) +- `services` - Status aller relevanten Dienste (wuauserv, bits, cryptsvc, etc.) +- `configuration` - WSUS-Einstellungen, AU-Optionen +- `updates` - Historie, letzte erfolgreiche Installation, ausstehende Updates +- `storage` - SoftwareDistribution Ordnergröße +- `errors` - Fehler aus Event Log (letzte 7 Tage) +- `connectivity` - Erreichbarkeit der Update-Server +- `issues` - Array mit erkannten Problemen +- `status` - OK / WARNING / CRITICAL + +### Erkenntnisse aus CWWS12-Analyse (07.01.2026) +- `patches_last_installed` im Agent-Endpoint zeigt Scan-Zeitpunkt, nicht Install-Zeitpunkt +- Echtes Installationsdatum kommt aus `date_installed` Feld der einzelnen Patches +- OS Build-Nummer kann als Indikator für Update-Status verwendet werden +- TacticalRMM API bietet kein Ereignis-Protokoll für Windows Update Scans + +### TacticalRMM Windows Update Verhalten +- **TRMM setzt AUOptions=1** bei Agent-Installation → Updates werden von TRMM verwaltet +- AUOptions=1 ist daher **kein Problem** bei TRMM-verwalteten Systemen +- BITS-Dienst startet on-demand → "Stopped" ist normal +- TRMM prüft Patch-Policy alle 8 Stunden +- "Other" Kategorie = reguläre monatliche Updates (Microsoft-Benennung) +- Wenn Agent offline bei Patch-Zeitpunkt → kein "install when online" + +### Echte Probleme erkennen +| Befund | Bei TRMM-System | Ohne TRMM | +|--------|-----------------|-----------| +| AUOptions=1 | ✅ Normal | ❌ Problem | +| BITS Stopped | ✅ Normal | ✅ Normal (on-demand) | +| 403 von update.microsoft.com | ❌ Problem | ❌ Problem | +| Keine erfolgreichen Updates | ⚠️ Prüfen | ⚠️ Prüfen | +| Kritische Updates pending | ❌ Problem | ❌ Problem | + +### Nächste Schritte +- [ ] JSON-Output des Diagnostics-Skripts in ERPNext verarbeiten +- [ ] Automatisches Ausführen des Skripts bei Problemerkennung +- [ ] Ergebnisse im Detail-Modal der MSP Documentation anzeigen