mirror of
https://github.com/itsdave-de/msp.git
synced 2026-08-13 23:20:05 -03:00
chore: commit uncommitted production changes for v16 migration
This commit is contained in:
+564
@@ -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();
|
||||
}
|
||||
```
|
||||
@@ -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/`
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -3,65 +3,910 @@
|
||||
|
||||
frappe.ui.form.on('IT Landscape', {
|
||||
refresh: function(frm) {
|
||||
// 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');
|
||||
};
|
||||
if (frm.doc.rmm_instance) {
|
||||
frm.add_custom_button('Get Agents From RMM', () => frm.trigger('rmm_get_agents'), 'RMM');
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
copy_ssh_keys: function(frm) {
|
||||
frappe.call({
|
||||
"method": "msp.whitelisted_tools.get_ssh_keys_for_landscape",
|
||||
method: "msp.whitelisted_tools.get_ssh_keys_for_landscape",
|
||||
args: {
|
||||
"landscape": frm.doc.name,
|
||||
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 {
|
||||
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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -188,4 +216,204 @@ frappe.ui.form.on('IT Object', {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
@@ -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')
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
msp.patches.migrate_rmm_settings
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+15
-31
@@ -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})
|
||||
@@ -26,34 +41,3 @@ def copy_attachments(source_doctype, source_docname, target_doctype, target_docn
|
||||
"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.'));
|
||||
}
|
||||
"""
|
||||
+1898
File diff suppressed because it is too large
Load Diff
+912
-56
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
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user