chore: commit uncommitted production changes for v16 migration

This commit is contained in:
Dave
2026-03-07 09:47:25 +01:00
parent 208212a883
commit 1fca135a83
89 changed files with 13362 additions and 635 deletions
+564
View File
@@ -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: <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();
}
```
+175
View File
@@ -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/`
View File
+27
View File
@@ -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<p style=\"font-size:100%;\" class=\"text-center\">\n<img id=\"qr\" width=\"66%\" src=\"https://api.qrserver.com/v1/create-qr-code/?size=300x300&data={{ doc.name }}&format=svg&color=000000\">\n</p>\n<p style=\"font-size:150%;\" class=\"text-center\">{{ doc.name }}</p>\n<p style=\"font-size:100%;\" class=\"text-center\">{{ doc.item_name }}</p>\n{% if count_data %}\n<p style=\"font-size:200%;\" class=\"text-center\"><b>Gezählt: {{ count_data.counted_qty }}</b></p>\n<p style=\"font-size:80%;\" class=\"text-center\">{{ count_data.counted_at }}</p>\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"
}
]
+10 -2
View File
@@ -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 = {
+292
View File
@@ -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)
}
-61
View File
@@ -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,
@@ -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: `
<div class="alert alert-warning">
<strong>${r.message.warning}</strong>
</div>
<p><strong>Token Name:</strong> ${r.message.token_name}</p>
<p><strong>Token:</strong></p>
<pre style="user-select: all; background: #f5f5f5; padding: 10px; border-radius: 4px;">${r.message.token_value}</pre>
`
}
],
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();
}
});
}
});
@@ -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": []
}
@@ -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")
@@ -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
@@ -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 = `<div class="text-muted">Keine Visualisierung für Typ "${frm.doc.backup_type}" verfügbar.</div>`;
}
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 `<div class="alert alert-danger">JSON Parse Error: ${e.message}</div>`;
}
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 = `
<div class="mt-3">
<label class="text-muted">Differential Zyklus</label>
<div class="progress" style="height: 20px;">
<div class="progress-bar bg-info" style="width: ${progress_percent}%">
${diff.current} / ${diff.max}
</div>
</div>
<small class="text-muted">Nächstes Full-Backup in ${diff.next_full_in} Zyklen</small>
</div>
`;
}
// 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 = `
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title"><i class="fa fa-hdd-o"></i> Speicher: ${target_disk.disk_name || target_disk.drive_letter || 'N/A'}</h6>
<div class="progress mb-2" style="height: 20px;">
<div class="progress-bar ${used_percent > 90 ? 'bg-danger' : used_percent > 70 ? 'bg-warning' : 'bg-success'}"
style="width: ${used_percent}%">
${used_percent}% belegt
</div>
</div>
<small class="text-muted">${free_gb} GB frei von ${total_gb} GB</small>
${storage.cycles_count ? `<br><small class="text-muted">Zyklen: ${storage.cycles_count} / ${storage.cycles_max}</small>` : ''}
</div>
</div>
</div>
`;
}
// 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 `<div class="d-flex align-items-start mb-1">
<span class="badge badge-${level_class} mr-2" style="min-width: 60px;">${entry.level}</span>
<small class="text-muted mr-2">${entry.timestamp ? entry.timestamp.split('T')[1]?.substring(0,8) : ''}</small>
<span style="word-break: break-word;">${frappe.utils.escape_html(entry.message)}</span>
</div>`;
}).join("");
log_entries_html = `
<div class="col-12 mt-3">
<div class="card">
<div class="card-body">
<h6 class="card-title"><i class="fa fa-list"></i> Log Einträge (letzte 10)</h6>
<div style="max-height: 200px; overflow-y: auto; font-size: 12px;">
${entries_list}
</div>
</div>
</div>
</div>
`;
}
return `
<style>
.backup-viz .card { border: 1px solid var(--border-color); }
.backup-viz .card-body { padding: 12px; }
.backup-viz .card-title { margin-bottom: 10px; font-weight: 600; }
.backup-viz .badge { font-size: 11px; }
</style>
<div class="backup-viz">
<div class="row">
<!-- Status Card -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<i class="fa ${status_icon} fa-2x text-${status_color} mr-3"></i>
<div>
<h5 class="mb-0 text-${status_color}">${status_text}</h5>
<small class="text-muted">${data.computer_name || frm.doc.hostname}</small>
</div>
</div>
<table class="table table-sm table-borderless mb-0">
<tr><td class="text-muted" style="width:40%">Typ</td><td><strong>${backup.type || frm.doc.backup_type}</strong></td></tr>
<tr><td class="text-muted">Quelle</td><td>${backup.source || 'N/A'}</td></tr>
<tr><td class="text-muted">Ziel</td><td>${backup.target || 'N/A'}</td></tr>
<tr><td class="text-muted">Größe</td><td>${backup.file_size_human || '0 B'}</td></tr>
<tr><td class="text-muted">Dauer</td><td>${backup.duration_human || '0 Sekunden'}</td></tr>
</table>
${diff_info_html}
</div>
</div>
</div>
${storage_html}
</div>
<div class="row">
${log_entries_html}
</div>
${log_summary.errors > 0 || log_summary.warnings > 0 ? `
<div class="row mt-3">
<div class="col-12">
<div class="alert ${log_summary.errors > 0 ? 'alert-danger' : 'alert-warning'} mb-0">
<i class="fa fa-exclamation-triangle mr-2"></i>
${log_summary.errors > 0 ? `<strong>${log_summary.errors} Fehler</strong>` : ''}
${log_summary.errors > 0 && log_summary.warnings > 0 ? ' und ' : ''}
${log_summary.warnings > 0 ? `<strong>${log_summary.warnings} Warnungen</strong>` : ''}
</div>
</div>
</div>
` : ''}
</div>
`;
},
render_rsync_to_usb_v1(frm) {
let data;
try {
data = JSON.parse(frm.doc.log_content);
} catch (e) {
return `<div class="alert alert-danger">JSON Parse Error: ${e.message}</div>`;
}
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 ?
`<div class="alert alert-warning mb-2 p-2" style="font-size: 11px;">
<i class="fa fa-exclamation-triangle mr-1"></i>
${frappe.utils.escape_html(backup.rotation_message || 'Rotation unterbrochen')}
</div>` : '';
disk_html = `
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title"><i class="fa fa-usb"></i> USB-Laufwerk</h6>
${rotation_warning}
<table class="table table-sm table-borderless mb-0" style="font-size: 12px;">
<tr><td class="text-muted" style="width:40%">Aktuell</td><td><strong>${disk.current_disk?.name || 'N/A'}</strong></td></tr>
<tr><td class="text-muted">Nächste</td><td>${disk.next_disk?.name || 'N/A'}</td></tr>
<tr><td class="text-muted">Modell</td><td>${disk.model || 'N/A'}</td></tr>
<tr><td class="text-muted">Kapazität</td><td>${disk.capacity || 'N/A'}</td></tr>
<tr><td class="text-muted">Serial</td><td><code style="font-size: 10px;">${disk.serial || 'N/A'}</code></td></tr>
</table>
</div>
</div>
</div>
`;
}
// Timing card
let timing_html = `
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title"><i class="fa fa-clock-o"></i> Zeitablauf</h6>
<table class="table table-sm table-borderless mb-0" style="font-size: 12px;">
<tr><td class="text-muted" style="width:40%">Start</td><td>${backup.start_time || 'N/A'}</td></tr>
<tr><td class="text-muted">Ende</td><td>${backup.end_time || 'N/A'}</td></tr>
<tr><td class="text-muted">Rsync</td><td><strong>${backup.duration || 'N/A'}</strong></td></tr>
<tr><td class="text-muted">Monitoring</td><td>${backup.monitoring_duration || 'N/A'}</td></tr>
<tr><td class="text-muted">Cleanup</td><td>${backup.cleanup_duration || 'N/A'}</td></tr>
<tr><td class="text-muted">Gesamt</td><td><strong>${backup.total_duration || 'N/A'}</strong></td></tr>
</table>
</div>
</div>
</div>
`;
// Rsync statistics card
let rsync_html = "";
if (Object.keys(rsync_stats).length > 0) {
rsync_html = `
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title"><i class="fa fa-exchange"></i> Rsync Statistik</h6>
<table class="table table-sm table-borderless mb-0" style="font-size: 12px;">
<tr><td class="text-muted" style="width:50%">Dateien</td><td>${rsync_stats.number_of_files || 'N/A'}</td></tr>
<tr><td class="text-muted">Erstellt</td><td>${rsync_stats.created_files || '0'}</td></tr>
<tr><td class="text-muted">Gelöscht</td><td>${rsync_stats.deleted_files || '0'}</td></tr>
<tr><td class="text-muted">Übertragen</td><td>${formatBytes(rsync_stats.total_transferred)}</td></tr>
<tr><td class="text-muted">Geschwindigkeit</td><td>${rsync_stats.transfer_speed || 'N/A'}</td></tr>
</table>
</div>
</div>
</div>
`;
}
// Cleanup statistics card
let cleanup_html = "";
if (Object.keys(cleanup_stats).length > 0) {
cleanup_html = `
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title"><i class="fa fa-trash"></i> Aufräumen</h6>
<table class="table table-sm table-borderless mb-0" style="font-size: 12px;">
<tr><td class="text-muted" style="width:50%">Aktiviert</td><td>${cleanup_stats.cleanup_enabled || 'N/A'}</td></tr>
<tr><td class="text-muted">Dateien gelöscht</td><td>${cleanup_stats.files_deleted || '0'}</td></tr>
<tr><td class="text-muted">Fehlgeschlagen</td><td>${cleanup_stats.failed_deletions || '0'}</td></tr>
<tr><td class="text-muted">Platz freigegeben</td><td><strong>${cleanup_stats.space_freed || 'N/A'}</strong></td></tr>
</table>
</div>
</div>
</div>
`;
}
// System info card
let system_html = "";
if (system.cpu_info || system.memory_total) {
system_html = `
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body">
<h6 class="card-title"><i class="fa fa-server"></i> System</h6>
<table class="table table-sm table-borderless mb-0" style="font-size: 12px;">
<tr><td class="text-muted" style="width:40%">CPU</td><td>${system.cpu_info || 'N/A'} (${system.cpu_percent || 'N/A'})</td></tr>
<tr><td class="text-muted">Speicher</td><td>${system.memory_used || 'N/A'} / ${system.memory_total || 'N/A'} (${system.memory_percent || 'N/A'})</td></tr>
<tr><td class="text-muted">Storage</td><td>${system.system_storage || 'N/A'}</td></tr>
<tr><td class="text-muted">Quelle</td><td>${system.source_size || 'N/A'}</td></tr>
<tr><td class="text-muted">Uptime</td><td>${system.uptime || 'N/A'}</td></tr>
</table>
</div>
</div>
</div>
`;
}
// Warnings section
let warnings_html = "";
if (warnings.length > 0) {
const warning_items = warnings.map(w =>
`<div class="mb-1"><i class="fa fa-exclamation-triangle text-warning mr-1"></i> ${frappe.utils.escape_html(w)}</div>`
).join("");
warnings_html = `
<div class="col-12 mb-3">
<div class="alert alert-warning mb-0" style="font-size: 12px;">
<strong><i class="fa fa-exclamation-triangle mr-1"></i> ${warnings.length} Warnung(en)</strong>
<div class="mt-2">${warning_items}</div>
</div>
</div>
`;
}
// Error section
let error_html = "";
if (data.error_message) {
error_html = `
<div class="col-12 mb-3">
<div class="alert alert-danger mb-0" style="font-size: 12px;">
<strong><i class="fa fa-times-circle mr-1"></i> Fehler</strong>
<div class="mt-2">${frappe.utils.escape_html(data.error_message)}</div>
</div>
</div>
`;
}
return `
<style>
.backup-viz .card { border: 1px solid var(--border-color); }
.backup-viz .card-body { padding: 12px; }
.backup-viz .card-title { margin-bottom: 10px; font-weight: 600; }
</style>
<div class="backup-viz">
<div class="row">
<!-- Status Card -->
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<i class="fa ${status_icon} fa-2x text-${status_color} mr-3"></i>
<div>
<h5 class="mb-0 text-${status_color}">${status_text}</h5>
<small class="text-muted">${data.computer_name || frm.doc.hostname}</small>
</div>
</div>
<table class="table table-sm table-borderless mb-0" style="font-size: 12px;">
<tr><td class="text-muted" style="width:40%">Typ</td><td><strong>Rsync to USB</strong></td></tr>
<tr><td class="text-muted">Quelle</td><td>${backup.source_directory || 'N/A'}</td></tr>
<tr><td class="text-muted">Ziel</td><td>${backup.destination || 'N/A'}</td></tr>
<tr><td class="text-muted">Zeitstempel</td><td>${data.timestamp ? data.timestamp.replace('T', ' ').substring(0, 19) : 'N/A'}</td></tr>
</table>
</div>
</div>
</div>
${disk_html}
</div>
<div class="row">
${timing_html}
${rsync_html}
</div>
<div class="row">
${cleanup_html}
${system_html}
</div>
<div class="row">
${warnings_html}
${error_html}
</div>
</div>
`;
},
render_generic_json(frm) {
let data;
try {
data = JSON.parse(frm.doc.log_content);
} catch (e) {
return `<div class="alert alert-danger">JSON Parse Error: ${e.message}</div>`;
}
return `
<div class="alert alert-info">
<i class="fa fa-info-circle mr-2"></i>
Backup Type: <strong>${frm.doc.backup_type || 'unbekannt'}</strong> -
Keine spezifische Visualisierung vorhanden.
</div>
`;
}
});
@@ -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": []
}
@@ -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
@@ -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
@@ -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) {
// },
// });
@@ -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": []
}
@@ -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
@@ -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
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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: `<pre style="user-select: all; background: #f5f5f5; padding: 10px; border-radius: 4px;">${r.message}</pre>`,
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
@@ -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": "<div class=\"alert alert-warning\">Token-Wert nicht verfügbar. Dieser Token wurde extern erstellt oder vor der Synchronisierung generiert.</div>",
"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": []
}
@@ -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")
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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": []
}
@@ -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
+883 -38
View File
@@ -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 = `
<div class="attachment-gallery-container">
<div class="attachment-gallery">
<div class="attachment-gallery-title">Attachments (${items.length})</div>
<div class="gallery-main-view">
<button class="gallery-nav-btn prev" ${items.length <= 1 ? 'style="display:none"' : ''}></button>
<button class="gallery-nav-btn next" ${items.length <= 1 ? 'style="display:none"' : ''}></button>
`;
// Main view items
items.forEach((item, index) => {
if (item.isImage) {
galleryHtml += `
<div class="gallery-item ${index === 0 ? 'active' : ''}" data-index="${index}" data-url="${item.file_url}">
<img src="${item.file_url}" alt="${item.file_name}" title="Click to view fullscreen">
</div>
`;
} else if (item.isPdf) {
galleryHtml += `
<div class="gallery-item pdf-item ${index === 0 ? 'active' : ''}" data-index="${index}" data-url="${item.file_url}">
<div class="pdf-icon">📄</div>
<div class="pdf-filename">${item.file_name}</div>
<button class="pdf-open-btn" data-url="${item.file_url}">PDF öffnen</button>
</div>
`;
} else {
galleryHtml += `
<div class="gallery-item pdf-item ${index === 0 ? 'active' : ''}" data-index="${index}" data-url="${item.file_url}">
<div class="pdf-icon">📎</div>
<div class="pdf-filename">${item.file_name}</div>
<button class="pdf-open-btn" data-url="${item.file_url}">Datei öffnen</button>
</div>
`;
}
});
galleryHtml += `
</div>
<div class="gallery-counter"><span class="current">1</span> / ${items.length}</div>
<div class="gallery-thumbnails">
`;
// Thumbnails
items.forEach((item, index) => {
if (item.isImage) {
galleryHtml += `
<div class="gallery-thumbnail ${index === 0 ? 'active' : ''}" data-index="${index}">
<img src="${item.file_url}" alt="${item.file_name}">
</div>
`;
} else if (item.isPdf) {
galleryHtml += `
<div class="gallery-thumbnail ${index === 0 ? 'active' : ''}" data-index="${index}">
<span class="thumb-pdf">📄</span>
</div>
`;
} else {
galleryHtml += `
<div class="gallery-thumbnail ${index === 0 ? 'active' : ''}" data-index="${index}">
<span class="thumb-pdf">📎</span>
</div>
`;
}
});
galleryHtml += `
</div>
</div>
</div>
`;
// 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 = `
<button class="gallery-lightbox-close">×</button>
<div class="gallery-lightbox-image-container">
<img src="" alt="Fullscreen view">
</div>
<div class="gallery-lightbox-zoom-info">100%</div>
<div class="gallery-lightbox-hint">Mausrad zum Zoomen · Ziehen zum Verschieben · Doppelklick zum Zurücksetzen</div>
`;
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 = `<div class="alert alert-info">
<b>IT Landscape:</b> ${frm.doc.title || frm.doc.name}<br>
<b>RMM Instance:</b> ${frm.doc.rmm_instance || '<span class="text-danger">Nicht konfiguriert</span>'}<br>
<b>RMM Tenant:</b> ${frm.doc.tactical_rmm_tenant_caption || '<span class="text-warning">Nicht gesetzt</span>'}
</div>`;
if (created) {
status_html += `<div class="alert alert-success">
<i class="fa fa-check"></i> MSP Documentation wurde automatisch erstellt.
</div>`;
}
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 = `<div class="alert alert-secondary mt-2">
<i class="fa fa-info-circle"></i> Keine AD-Credentials in dieser Documentation.
</div>`;
dialog.set_value('include_ad_data', 0);
dialog.set_df_property('include_ad_data', 'read_only', 1);
} else if (!r.message.has_data) {
html = `<div class="alert alert-warning mt-2">
<i class="fa fa-clock-o"></i> AD-Credentials vorhanden, Daten noch nicht abgerufen.
<br><small>Aktivieren Sie "AD-Daten vorher aktualisieren".</small>
</div>`;
dialog.set_df_property('include_ad_data', 'read_only', 0);
dialog.set_value('fetch_fresh_ad_data', 1);
} else {
html = `<div class="alert alert-success mt-2">
<i class="fa fa-check"></i> AD-Daten vorhanden
(${r.message.computer_count} Computer, Stand: ${r.message.last_update})
</div>`;
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:') + '<br>' +
__('Synchronisiert: {0}', [r.message.synced_count]) + '<br>' +
__('Nicht gefunden: {0}', [r.message.not_found_count]);
if (r.message.errors && r.message.errors.length > 0) {
msg += '<br><br>' + __('Fehler:') + '<br>' + r.message.errors.slice(0, 5).join('<br>');
if (r.message.errors.length > 5) {
msg += '<br>...und ' + (r.message.errors.length - 5) + ' weitere';
}
}
frappe.msgprint(msg);
}
},
error: function() {
frappe.dom.unfreeze();
frappe.msgprint(__('Fehler bei der Synchronisation'));
}
});
}
);
}
})
});
+38 -2
View File
@@ -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",
+229 -1
View File
@@ -3,6 +3,8 @@
frappe.ui.form.on('IT Object', {
refresh: function (frm) {
// RMM Integration buttons
frm.trigger('setup_rmm_buttons');
const loader = `
<div class="line-wobble"></div>
@@ -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 += `
<div style="margin-bottom: 10px; padding: 8px 12px; background: #e3f2fd; border-radius: 4px; font-size: 0.9em;">
<strong>Kunde/Tenant:</strong> ${data.client_filter}
<span style="color: #666;"> (${data.agents.length} Agents verfügbar)</span>
</div>
`;
}
// 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 += `
<div style="margin-bottom: 15px; padding: 12px; background: #e8f5e9; border-radius: 6px; border-left: 4px solid #4caf50;">
<strong>Bester Vorschlag:</strong> ${data.best_match.hostname}<br>
<span style="color: ${confidenceClass};">Confidence: ${data.best_match.confidence}%</span>
${data.best_match.match_reason ? ` - ${data.best_match.match_reason}` : ''}
</div>
`;
}
// 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();
}
});
}
});
+165 -4
View File
@@ -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",
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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
}
@@ -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
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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
}
@@ -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
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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
}
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -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}
@@ -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,
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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": []
}
@@ -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
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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();
}
});
@@ -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
}
@@ -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
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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')
);
}
}
});
@@ -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": "<p class=\"text-muted\">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.</p>"
},
{
"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": []
}
@@ -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")
+84 -2
View File
@@ -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!')}<br><br>` +
`<b>${__('Agents found')}:</b> ${r.message.agent_count}<br>` +
`<b>${__('Clients')}:</b> ${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}<br><br>${__('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')
});
}
}
});
});
}
}
});
+31 -1
View File
@@ -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,
+184 -2
View File
@@ -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))
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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": []
}
@@ -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
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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": []
}
@@ -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
@@ -0,0 +1,2 @@
# Copyright (c) 2026, itsdave GmbH and contributors
# For license information, please see license.txt
@@ -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": []
}
@@ -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
@@ -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 |
@@ -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
@@ -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) {
// },
// });
@@ -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": []
}
@@ -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
+8 -3
View File
@@ -5,9 +5,14 @@
"label": "Meine Performance"
}
],
"content": "[{\"id\":\"HEmp649Etq\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\">Technik</span>\",\"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\":\"<span class=\\\"h4\\\">Technik</span>\",\"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",
+1
View File
@@ -0,0 +1 @@
msp.patches.migrate_rmm_settings
View File
+50
View File
@@ -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")
+296
View File
@@ -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: `<div class="text-muted">${__('Total')}: <span id="total-labels">${total_qty}</span> ${__('Labels')}</div>`
}
],
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 `
<tr data-idx="${idx}" data-item-code="${item.item_code}">
<td style="vertical-align: middle;">
<strong>${item.item_code}</strong><br>
<small class="text-muted">${item.item_name || ''}</small>
</td>
<td style="vertical-align: middle; text-align: center;">
${item.qty || 0}
</td>
<td style="vertical-align: middle; width: 80px;">
<input type="number" class="form-control label-qty-input"
data-idx="${idx}" value="${item.qty || 0}" min="0" max="100"
style="width: 70px; text-align: center;">
</td>
<td style="vertical-align: middle; text-align: center;">
<button class="btn btn-xs btn-primary print-qty-btn" data-idx="${idx}" title="${__('Print quantity')}">
<i class="fa fa-print"></i>
</button>
<button class="btn btn-xs btn-default print-one-btn" data-idx="${idx}" title="${__('Print 1')}">
1
</button>
</td>
</tr>
`;
}).join('');
return `
<div class="table-responsive">
<table class="table table-bordered table-hover" id="label-items-table">
<thead>
<tr>
<th>${__('Item')}</th>
<th style="text-align: center; width: 80px;">${__('Qty')}</th>
<th style="text-align: center; width: 100px;">${__('Labels')}</th>
<th style="text-align: center; width: 100px;">${__('Action')}</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
</div>
`;
}
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
}
});
}
+66
View File
@@ -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();
}
}
});
}
);
}
+237
View File
@@ -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 `<tr>
<td style="padding: 8px;">${item.idx}</td>
<td style="padding: 8px;">${item.item_code}</td>
<td style="padding: 8px;">${item.item_name || ''}</td>
<td style="padding: 8px; text-align: right;">${item.qty}</td>
<td style="padding: 8px; text-align: right;">${format_currency(item.rate)}</td>
<td style="padding: 8px; text-align: right;">${format_currency(item.amount)}</td>
</tr>`;
}).join('');
const table_html = `
<div style="margin-bottom: 15px;">
<p style="font-size: 14px; margin-bottom: 10px;">
Die folgenden <strong>${selected_items.length} Artikel</strong> werden auf <strong>100% Rabatt</strong> gesetzt
und mit dem Hinweis <strong>"ohne Berechnung"</strong> versehen:
</p>
</div>
<div style="max-height: 400px; overflow-y: auto;">
<table class="table table-bordered" style="margin-bottom: 0;">
<thead style="background-color: var(--bg-light-gray);">
<tr>
<th style="padding: 8px; width: 40px;">#</th>
<th style="padding: 8px;">Artikelcode</th>
<th style="padding: 8px;">Bezeichnung</th>
<th style="padding: 8px; text-align: right; width: 80px;">Menge</th>
<th style="padding: 8px; text-align: right; width: 100px;">Preis</th>
<th style="padding: 8px; text-align: right; width: 120px;">Betrag</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
<tfoot style="background-color: var(--bg-light-gray); font-weight: bold;">
<tr>
<td colspan="3" style="padding: 8px;">Summe</td>
<td style="padding: 8px; text-align: right;">${total_qty}</td>
<td style="padding: 8px;"></td>
<td style="padding: 8px; text-align: right;">${format_currency(total_amount)}</td>
</tr>
</tfoot>
</table>
</div>
<div style="margin-top: 15px; padding: 10px; background-color: var(--alert-bg-warning); border-radius: 4px;">
<strong>Hinweis:</strong> Diese Aktion kann nicht rückgängig gemacht werden.
Der Rabatt muss manuell zurückgesetzt werden.
</div>
`;
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 = [
/<p[^>]*>\s*<strong[^>]*>\s*ohne\s+berechnung\s*<\/strong>\s*<\/p>/gi,
/<p[^>]*>\s*ohne\s+berechnung\s*<\/p>/gi,
/<strong[^>]*>\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(/(<p[^>]*>\s*<\/p>)+/gi, '').trim();
// Standard-Zeile einmal anhängen
const stamp = '<p><strong>ohne Berechnung</strong></p>';
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);
}
+16 -32
View File
@@ -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.'));
}
"""
attach.insert(ignore_permissions=True)
+1898
View File
File diff suppressed because it is too large Load Diff
+930 -74
View File
File diff suppressed because it is too large Load Diff
@@ -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
+325
View File
@@ -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
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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