WAU Compliance: agent collector, backend endpoints, frontend badge + panel
- Agent: WAU registry config check (include/exclude URLs), pending updates count - Backend: /wau/compliance per customer, /wau/compliance/summary all customers - Frontend: compliance badge in customer list, compliance panel in WAU tab - config.js: no longer gitignored (secrets-free, reads from env/window.__RMM_CONFIG__) - vite.config.js: reads backend URL from .env instead of hardcoded value - .env.example added for clean setup
This commit is contained in:
parent
bf51ed8569
commit
54ddc0b902
4
.gitignore
vendored
4
.gitignore
vendored
@ -34,8 +34,8 @@ updater/rmm-updater
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# config.js hat echte IPs und API-Keys — nie einchecken
|
||||
frontend/src/config.js
|
||||
# .env hat echte IPs und API-Keys — nie einchecken
|
||||
frontend/.env
|
||||
!frontend/src/config.example.js
|
||||
|
||||
# ========================
|
||||
|
||||
@ -31,6 +31,7 @@ type WindowsData struct {
|
||||
Disks []DiskInfo `json:"disks"`
|
||||
Services []SvcInfo `json:"services,omitempty"`
|
||||
InstalledSoftware []SoftwareInfo `json:"installed_software,omitempty"`
|
||||
WAU *WAUInfo `json:"wau,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
}
|
||||
@ -123,6 +124,10 @@ func Collect() (*SystemData, error) {
|
||||
// Installierte Software (Registry)
|
||||
wd.InstalledSoftware = getInstalledSoftware()
|
||||
|
||||
// WAU-Status (Installation + Konfiguration + Pending Updates)
|
||||
wauInfo := getWAUInfo(wd.InstalledSoftware)
|
||||
wd.WAU = &wauInfo
|
||||
|
||||
return &SystemData{Windows: wd}, nil
|
||||
}
|
||||
|
||||
|
||||
100
agent-windows/collector/wau.go
Normal file
100
agent-windows/collector/wau.go
Normal file
@ -0,0 +1,100 @@
|
||||
//go:build windows
|
||||
|
||||
package collector
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// WAUInfo enthält den Status von Winget-AutoUpdate auf diesem Gerät
|
||||
type WAUInfo struct {
|
||||
Installed bool `json:"installed"`
|
||||
Version string `json:"version,omitempty"`
|
||||
IncludeURL string `json:"include_url,omitempty"`
|
||||
ExcludeURL string `json:"exclude_url,omitempty"`
|
||||
PendingCount int `json:"pending_updates"`
|
||||
}
|
||||
|
||||
// getWAUInfo liest WAU-Installations- und Konfigurationsstatus
|
||||
func getWAUInfo(software []SoftwareInfo) WAUInfo {
|
||||
info := WAUInfo{}
|
||||
|
||||
// WAU in installierter Software suchen
|
||||
for _, s := range software {
|
||||
lower := strings.ToLower(s.Name)
|
||||
if strings.Contains(lower, "winget-autoupdate") || lower == "wau" {
|
||||
info.Installed = true
|
||||
info.Version = s.Version
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// WAU-Konfiguration aus Registry lesen
|
||||
// WAU speichert seine Einstellungen unter HKLM\SOFTWARE\Winget-AutoUpdate
|
||||
regPaths := []string{
|
||||
`SOFTWARE\Winget-AutoUpdate`,
|
||||
`SOFTWARE\Policies\Microsoft\Windows\Winget-AutoUpdate`,
|
||||
}
|
||||
for _, path := range regPaths {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if url, _, err := k.GetStringValue("WAU_IncludeListURL"); err == nil && url != "" {
|
||||
info.IncludeURL = url
|
||||
}
|
||||
if url, _, err := k.GetStringValue("WAU_ExcludeListURL"); err == nil && url != "" {
|
||||
info.ExcludeURL = url
|
||||
}
|
||||
k.Close()
|
||||
if info.IncludeURL != "" || info.ExcludeURL != "" {
|
||||
info.Installed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pending updates via winget upgrade --list zählen
|
||||
wp := findWingetExe()
|
||||
if wp != "" {
|
||||
out, err := runPS(`& "` + wp + `" upgrade --list --accept-source-agreements 2>&1`)
|
||||
if err == nil {
|
||||
info.PendingCount = countWingetUpgradeLines(out)
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// findWingetExe sucht winget.exe im System
|
||||
func findWingetExe() string {
|
||||
if path, err := exec.LookPath("winget"); err == nil {
|
||||
return path
|
||||
}
|
||||
script := `Get-ChildItem "C:\Program Files\WindowsApps" -Filter winget.exe -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName`
|
||||
out, err := runPS(script)
|
||||
if err == nil && strings.TrimSpace(out) != "" {
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// countWingetUpgradeLines zählt Pakete mit Updates aus "winget upgrade --list" Output
|
||||
func countWingetUpgradeLines(output string) int {
|
||||
lines := strings.Split(output, "\n")
|
||||
count := 0
|
||||
pastSeparator := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.Contains(trimmed, "---") {
|
||||
pastSeparator = true
|
||||
continue
|
||||
}
|
||||
if pastSeparator && trimmed != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@ -27,7 +27,7 @@ import (
|
||||
"github.com/cynfo/rmm-agent-windows/ws"
|
||||
)
|
||||
|
||||
var Version = "1.1.0"
|
||||
var Version = "1.2.0"
|
||||
|
||||
const (
|
||||
ServiceName = "RMMAgent"
|
||||
|
||||
@ -10,10 +10,14 @@ import (
|
||||
// setupWAURoutes registriert alle WAU-Endpunkte
|
||||
func (h *Handler) setupWAURoutes(mux *http.ServeMux) {
|
||||
// Authentifizierte Endpunkte (API-Key oder JWT)
|
||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/rules", h.listWAURules)
|
||||
mux.HandleFunc("POST /api/v1/customers/{id}/wau/rules", h.addWAURule)
|
||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/rules", h.listWAURules)
|
||||
mux.HandleFunc("POST /api/v1/customers/{id}/wau/rules", h.addWAURule)
|
||||
mux.HandleFunc("DELETE /api/v1/customers/{id}/wau/rules/{rid}", h.deleteWAURule)
|
||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/inventory", h.getWAUInventory)
|
||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/inventory", h.getWAUInventory)
|
||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/compliance", h.getWAUCompliance)
|
||||
|
||||
// Compliance-Summary für alle Kunden
|
||||
mux.HandleFunc("GET /api/v1/wau/compliance/summary", h.getWAUComplianceSummary)
|
||||
|
||||
// Plaintext-Endpunkte für WAU (kein Auth nötig — enthält nur App-IDs)
|
||||
mux.HandleFunc("GET /api/v1/customers/{id}/wau/included", h.wauIncludedList)
|
||||
@ -149,3 +153,51 @@ func (h *Handler) serveWAUList(w http.ResponseWriter, r *http.Request, ruleType
|
||||
w.Write([]byte("\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// wauExpectedURLs leitet die erwarteten WAU-URLs aus dem Request-Host ab
|
||||
func wauExpectedURLs(r *http.Request, customerID int) (includeURL, excludeURL string) {
|
||||
scheme := "https"
|
||||
if r.TLS == nil && (r.Header.Get("X-Forwarded-Proto") == "" || r.Header.Get("X-Forwarded-Proto") == "http") {
|
||||
scheme = "http"
|
||||
}
|
||||
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
|
||||
scheme = fwd
|
||||
}
|
||||
host := r.Host
|
||||
base := scheme + "://" + host
|
||||
includeURL = base + "/api/v1/customers/" + strconv.Itoa(customerID) + "/wau/included"
|
||||
excludeURL = base + "/api/v1/customers/" + strconv.Itoa(customerID) + "/wau/excluded"
|
||||
return
|
||||
}
|
||||
|
||||
// GET /api/v1/customers/{id}/wau/compliance
|
||||
func (h *Handler) getWAUCompliance(w http.ResponseWriter, r *http.Request) {
|
||||
customerID, err := strconv.Atoi(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Kunden-ID")
|
||||
return
|
||||
}
|
||||
includeURL, excludeURL := wauExpectedURLs(r, customerID)
|
||||
agents, err := h.db.GetWAUCompliance(customerID, includeURL, excludeURL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Compliance-Daten")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"agents": agents,
|
||||
"include_url": includeURL,
|
||||
"exclude_url": excludeURL,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/wau/compliance/summary — Zusammenfassung für alle Kunden
|
||||
func (h *Handler) getWAUComplianceSummary(w http.ResponseWriter, r *http.Request) {
|
||||
// Ohne customer_id können wir keine URLs ableiten — wir geben Summary ohne URL-Check zurück
|
||||
// oder nutzen customer_id=0 als Wildcard (kein URL-Check)
|
||||
summaries, err := h.db.GetAllCustomersWAUSummary("", "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Compliance-Daten")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package db
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/cynfo/rmm-backend/models"
|
||||
)
|
||||
@ -133,3 +134,217 @@ func (d *Database) GetWAUSoftwareInventory(customerID int) ([]models.WAUSoftware
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// GetWAUCompliance berechnet den Compliance-Status aller Windows-Agents eines Kunden
|
||||
// Prüft: WAU installiert, URLs korrekt, alle allow-Pakete installiert
|
||||
func (d *Database) GetWAUCompliance(customerID int, expectedIncludeURL, expectedExcludeURL string) ([]models.WAUAgentCompliance, error) {
|
||||
// Alle Windows-Agents des Kunden mit ihren system_data holen
|
||||
rows, err := d.db.Query(`
|
||||
SELECT a.id, a.hostname, sd.data_json
|
||||
FROM agents a
|
||||
JOIN system_data sd ON sd.agent_id = a.id
|
||||
WHERE a.customer_id = $1
|
||||
AND sd.data_json ? 'windows'
|
||||
ORDER BY lower(a.hostname)
|
||||
`, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Allow-Pakete (= Required) für diesen Kunden laden
|
||||
allowRules, err := d.GetWAUList(customerID, "allow")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowRulesFull, err := d.ListWAURules(customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Map winget_id → display_name
|
||||
displayNames := make(map[string]string)
|
||||
for _, r := range allowRulesFull {
|
||||
if r.RuleType == "allow" {
|
||||
displayNames[r.WingetID] = r.DisplayName
|
||||
}
|
||||
}
|
||||
|
||||
var result []models.WAUAgentCompliance
|
||||
|
||||
for rows.Next() {
|
||||
var agentID, hostname string
|
||||
var dataJSON []byte
|
||||
if err := rows.Scan(&agentID, &hostname, &dataJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// JSON parsen (nur was wir brauchen)
|
||||
var data struct {
|
||||
Windows struct {
|
||||
InstalledSoftware []struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
} `json:"installed_software"`
|
||||
WAU *struct {
|
||||
Installed bool `json:"installed"`
|
||||
IncludeURL string `json:"include_url"`
|
||||
ExcludeURL string `json:"exclude_url"`
|
||||
PendingCount int `json:"pending_updates"`
|
||||
} `json:"wau"`
|
||||
} `json:"windows"`
|
||||
}
|
||||
if err := json.Unmarshal(dataJSON, &data); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
ac := models.WAUAgentCompliance{
|
||||
AgentID: agentID,
|
||||
AgentName: hostname,
|
||||
}
|
||||
|
||||
// WAU-Status
|
||||
if data.Windows.WAU != nil {
|
||||
wau := data.Windows.WAU
|
||||
ac.WAUInstalled = wau.Installed
|
||||
ac.IncludeURL = wau.IncludeURL
|
||||
ac.ExcludeURL = wau.ExcludeURL
|
||||
ac.PendingCount = wau.PendingCount
|
||||
|
||||
// URL-Check: beide URLs müssen gesetzt sein und passen
|
||||
includeOK := expectedIncludeURL == "" || wau.IncludeURL == expectedIncludeURL
|
||||
excludeOK := expectedExcludeURL == "" || wau.ExcludeURL == expectedExcludeURL
|
||||
ac.WAUConfigOK = wau.Installed && includeOK && excludeOK
|
||||
}
|
||||
|
||||
// Installierte Software als lowercase-Set für schnelles Lookup
|
||||
installedSet := make(map[string]bool)
|
||||
for _, s := range data.Windows.InstalledSoftware {
|
||||
installedSet[strings.ToLower(s.Name)] = true
|
||||
}
|
||||
|
||||
// Pro allow-Paket prüfen ob installiert
|
||||
for _, wingetID := range allowRules {
|
||||
displayName := displayNames[wingetID]
|
||||
if displayName == "" {
|
||||
displayName = wingetID
|
||||
}
|
||||
pkg := models.WAUPackageCompliance{
|
||||
WingetID: wingetID,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
// Fuzzy-Match: display_name (lowercase) muss in installierter Software enthalten sein
|
||||
lowerDisplay := strings.ToLower(displayName)
|
||||
for installedName := range installedSet {
|
||||
if strings.Contains(installedName, lowerDisplay) ||
|
||||
strings.Contains(lowerDisplay, installedName) {
|
||||
pkg.Installed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Winget-ID-Teile als Fallback (z.B. "Mozilla.Firefox" → "firefox")
|
||||
if !pkg.Installed {
|
||||
parts := strings.Split(strings.ToLower(wingetID), ".")
|
||||
if len(parts) > 1 {
|
||||
appName := parts[len(parts)-1]
|
||||
for installedName := range installedSet {
|
||||
if strings.Contains(installedName, appName) {
|
||||
pkg.Installed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ac.Packages = append(ac.Packages, pkg)
|
||||
}
|
||||
|
||||
// Gesamt-Compliance: WAU installiert + korrekt + alle Pakete da + keine Updates ausstehend
|
||||
allInstalled := true
|
||||
for _, p := range ac.Packages {
|
||||
if !p.Installed {
|
||||
allInstalled = false
|
||||
break
|
||||
}
|
||||
}
|
||||
ac.Compliant = ac.WAUInstalled && ac.WAUConfigOK && allInstalled && ac.PendingCount == 0
|
||||
|
||||
result = append(result, ac)
|
||||
}
|
||||
if result == nil {
|
||||
result = []models.WAUAgentCompliance{}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetWAUComplianceSummary gibt eine kompakte Zusammenfassung für die Kundenliste zurück
|
||||
func (d *Database) GetWAUComplianceSummary(customerID int, expectedIncludeURL, expectedExcludeURL string) (*models.WAUComplianceSummary, error) {
|
||||
agents, err := d.GetWAUCompliance(customerID, expectedIncludeURL, expectedExcludeURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary := &models.WAUComplianceSummary{
|
||||
CustomerID: customerID,
|
||||
TotalAgents: len(agents),
|
||||
}
|
||||
|
||||
for _, a := range agents {
|
||||
if a.Compliant {
|
||||
summary.CompliantAgents++
|
||||
}
|
||||
if !a.WAUInstalled {
|
||||
summary.WAUMissing++
|
||||
} else if !a.WAUConfigOK {
|
||||
summary.WAUMisconfigured++
|
||||
}
|
||||
if a.PendingCount > 0 {
|
||||
summary.UpdatesPending++
|
||||
}
|
||||
for _, p := range a.Packages {
|
||||
if !p.Installed {
|
||||
summary.PackagesMissing++
|
||||
break // pro Agent nur einmal zählen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status ableiten
|
||||
if summary.WAUMissing > 0 || summary.PackagesMissing > 0 {
|
||||
summary.Status = "critical"
|
||||
} else if summary.WAUMisconfigured > 0 || summary.UpdatesPending > 0 {
|
||||
summary.Status = "warning"
|
||||
} else if summary.TotalAgents == 0 {
|
||||
summary.Status = "unknown"
|
||||
} else {
|
||||
summary.Status = "ok"
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// GetAllCustomersWAUSummary gibt Compliance-Summary für alle Kunden zurück
|
||||
func (d *Database) GetAllCustomersWAUSummary(includeURL, excludeURL string) ([]models.WAUComplianceSummary, error) {
|
||||
rows, err := d.db.Query(`SELECT id FROM customers ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var summaries []models.WAUComplianceSummary
|
||||
for rows.Next() {
|
||||
var id int
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
s, err := d.GetWAUComplianceSummary(id, includeURL, excludeURL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if s.TotalAgents > 0 {
|
||||
summaries = append(summaries, *s)
|
||||
}
|
||||
}
|
||||
if summaries == nil {
|
||||
summaries = []models.WAUComplianceSummary{}
|
||||
}
|
||||
return summaries, rows.Err()
|
||||
}
|
||||
|
||||
@ -17,3 +17,36 @@ type WAUSoftwareEntry struct {
|
||||
DeviceCount int `json:"device_count"`
|
||||
Versions []string `json:"versions"`
|
||||
}
|
||||
|
||||
// WAUPackageCompliance beschreibt den Compliance-Status eines einzelnen Pakets auf einem Gerät
|
||||
type WAUPackageCompliance struct {
|
||||
WingetID string `json:"winget_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Installed bool `json:"installed"`
|
||||
HasUpdates bool `json:"has_updates"` // true wenn PendingCount > 0 und dieses Paket betroffen
|
||||
}
|
||||
|
||||
// WAUAgentCompliance beschreibt den vollständigen WAU-Compliance-Status eines Agents
|
||||
type WAUAgentCompliance struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
AgentName string `json:"agent_name"`
|
||||
WAUInstalled bool `json:"wau_installed"`
|
||||
WAUConfigOK bool `json:"wau_config_ok"` // Include/Exclude-URLs korrekt gesetzt
|
||||
IncludeURL string `json:"include_url"` // tatsächlich konfigurierte URL
|
||||
ExcludeURL string `json:"exclude_url"` // tatsächlich konfigurierte URL
|
||||
PendingCount int `json:"pending_updates"`
|
||||
Packages []WAUPackageCompliance `json:"packages"`
|
||||
Compliant bool `json:"compliant"` // true wenn alles OK
|
||||
}
|
||||
|
||||
// WAUComplianceSummary fasst den Compliance-Status eines Kunden zusammen (für die Kundenliste)
|
||||
type WAUComplianceSummary struct {
|
||||
CustomerID int `json:"customer_id"`
|
||||
TotalAgents int `json:"total_agents"`
|
||||
CompliantAgents int `json:"compliant_agents"`
|
||||
WAUMissing int `json:"wau_missing"` // Agents ohne WAU
|
||||
WAUMisconfigured int `json:"wau_misconfigured"` // WAU installiert, aber falsche URLs
|
||||
PackagesMissing int `json:"packages_missing"` // Agents mit fehlenden Pflichtpaketen
|
||||
UpdatesPending int `json:"updates_pending"` // Agents mit ausstehenden Updates
|
||||
Status string `json:"status"` // "ok" | "warning" | "critical"
|
||||
}
|
||||
|
||||
6
frontend/.env.example
Normal file
6
frontend/.env.example
Normal file
@ -0,0 +1,6 @@
|
||||
# RMM Frontend Konfiguration
|
||||
# Datei kopieren: cp .env.example .env (wird nicht eingecheckt)
|
||||
|
||||
VITE_BACKEND_HOST=your-backend.example.com
|
||||
VITE_BACKEND_PORT=8443
|
||||
VITE_API_KEY=your-api-key-here
|
||||
@ -275,6 +275,14 @@ class ApiClient {
|
||||
return this.get(`/api/v1/customers/${customerId}/wau/inventory`)
|
||||
}
|
||||
|
||||
getWAUCompliance(customerId) {
|
||||
return this.get(`/api/v1/customers/${customerId}/wau/compliance`)
|
||||
}
|
||||
|
||||
getWAUComplianceSummary() {
|
||||
return this.get('/api/v1/wau/compliance/summary')
|
||||
}
|
||||
|
||||
createAPIKey(name, permissions = 'read', customerId = null) {
|
||||
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
|
||||
}
|
||||
|
||||
8
frontend/src/config.js
Normal file
8
frontend/src/config.js
Normal file
@ -0,0 +1,8 @@
|
||||
// Frontend-Konfiguration
|
||||
// Priorität: 1. window.__RMM_CONFIG__ (Docker Runtime), 2. Vite ENV (lokaler Build)
|
||||
const rt = window.__RMM_CONFIG__ || {}
|
||||
|
||||
export const BACKEND_HOST = rt.backendHost || import.meta.env.VITE_BACKEND_HOST || 'localhost'
|
||||
export const BACKEND_PORT = rt.backendPort || import.meta.env.VITE_BACKEND_PORT || '8443'
|
||||
export const BACKEND_URL = `https://${BACKEND_HOST}:${BACKEND_PORT}`
|
||||
export const API_KEY = rt.apiKey || import.meta.env.VITE_API_KEY || ''
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle, ShieldCheck, ShieldX, Package, Search, RefreshCw } from 'lucide-react'
|
||||
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle, ShieldCheck, ShieldX, ShieldAlert, Package, Search, RefreshCw, CircleCheck, CircleX, CircleAlert, Info } from 'lucide-react'
|
||||
|
||||
const PERM_LABELS = {
|
||||
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50' },
|
||||
@ -10,6 +10,45 @@ const PERM_LABELS = {
|
||||
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50' },
|
||||
}
|
||||
|
||||
// ── WAU Compliance Badge ──────────────────────────────────────────────────────
|
||||
function WAUComplianceBadge({ summary }) {
|
||||
if (!summary) return null
|
||||
if (summary.total_agents === 0) return null
|
||||
|
||||
const { status, compliant_agents, total_agents, wau_missing, wau_misconfigured, packages_missing, updates_pending } = summary
|
||||
|
||||
const configs = {
|
||||
ok: { icon: CircleCheck, cls: 'text-green-400', bg: 'bg-green-900/20 border-green-700/40', label: `WAU OK (${compliant_agents}/${total_agents})` },
|
||||
warning: { icon: CircleAlert, cls: 'text-yellow-400', bg: 'bg-yellow-900/20 border-yellow-700/40', label: buildWarningLabel({ wau_misconfigured, updates_pending, compliant_agents, total_agents }) },
|
||||
critical: { icon: CircleX, cls: 'text-red-400', bg: 'bg-red-900/20 border-red-700/40', label: buildCriticalLabel({ wau_missing, packages_missing, compliant_agents, total_agents }) },
|
||||
unknown: { icon: Info, cls: 'text-gray-500', bg: 'bg-gray-800/40 border-gray-700/40', label: 'Keine Windows-Agents' },
|
||||
}
|
||||
|
||||
const cfg = configs[status] || configs.unknown
|
||||
const Icon = cfg.icon
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[10px] font-medium ${cfg.cls} ${cfg.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{cfg.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function buildWarningLabel({ wau_misconfigured, updates_pending, compliant_agents, total_agents }) {
|
||||
const parts = []
|
||||
if (wau_misconfigured > 0) parts.push(`${wau_misconfigured} fehlkonfiguriert`)
|
||||
if (updates_pending > 0) parts.push(`${updates_pending} Updates`)
|
||||
return parts.length ? parts.join(', ') : `${compliant_agents}/${total_agents} OK`
|
||||
}
|
||||
|
||||
function buildCriticalLabel({ wau_missing, packages_missing, compliant_agents, total_agents }) {
|
||||
const parts = []
|
||||
if (wau_missing > 0) parts.push(`${wau_missing} ohne WAU`)
|
||||
if (packages_missing > 0) parts.push(`${packages_missing} Pakete fehlen`)
|
||||
return parts.length ? parts.join(', ') : `${compliant_agents}/${total_agents} OK`
|
||||
}
|
||||
|
||||
export default function Customers() {
|
||||
const [customers, setCustomers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@ -17,12 +56,21 @@ export default function Customers() {
|
||||
const [editId, setEditId] = useState(null)
|
||||
const [form, setForm] = useState({ number: '', name: '' })
|
||||
const [expandedId, setExpandedId] = useState(null)
|
||||
const [complianceSummaries, setComplianceSummaries] = useState({}) // customerID → summary
|
||||
|
||||
const reload = () => {
|
||||
api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { reload() }, [])
|
||||
const loadCompliance = () => {
|
||||
api.getWAUComplianceSummary().then(data => {
|
||||
const map = {}
|
||||
;(data || []).forEach(s => { map[s.customer_id] = s })
|
||||
setComplianceSummaries(map)
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => { reload(); loadCompliance() }, [])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!form.number || !form.name) return
|
||||
@ -68,6 +116,7 @@ export default function Customers() {
|
||||
<th className="px-4 py-2 font-medium w-6"></th>
|
||||
<th className="px-4 py-2 font-medium">Nummer</th>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium hidden lg:table-cell">WAU</th>
|
||||
<th className="px-4 py-2 font-medium hidden md:table-cell">Erstellt</th>
|
||||
<th className="px-4 py-2 font-medium w-24"></th>
|
||||
</tr>
|
||||
@ -156,6 +205,9 @@ export default function Customers() {
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-orange-400 font-mono">{c.number}</td>
|
||||
<td className="px-4 py-2.5 text-white">{c.name}</td>
|
||||
<td className="px-4 py-2.5 hidden lg:table-cell">
|
||||
<WAUComplianceBadge summary={complianceSummaries[c.id]} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">
|
||||
{new Date(c.created_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
@ -179,7 +231,7 @@ export default function Customers() {
|
||||
)}
|
||||
{expandedId === c.id && (
|
||||
<tr key={`expand-${c.id}`}>
|
||||
<td colSpan={5} className="px-0 py-0">
|
||||
<td colSpan={6} className="px-0 py-0">
|
||||
<CustomerDetailPanel customerId={c.id} customerNumber={c.number} />
|
||||
</td>
|
||||
</tr>
|
||||
@ -339,6 +391,165 @@ function APIKeysTab({ customerId }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── WAU Compliance Panel ──────────────────────────────────────────────────────
|
||||
function WAUCompliancePanel({ compliance, loading, onRefresh }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (loading) return (
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3 text-xs text-gray-500 flex items-center gap-2">
|
||||
<RefreshCw className="w-3 h-3 animate-spin" /> Compliance wird geprueft...
|
||||
</div>
|
||||
)
|
||||
|
||||
const agents = compliance?.agents || []
|
||||
if (agents.length === 0) return null
|
||||
|
||||
const compliantCount = agents.filter(a => a.compliant).length
|
||||
const allOk = compliantCount === agents.length
|
||||
|
||||
const overallStatus = agents.some(a => !a.wau_installed || a.packages?.some(p => !p.installed))
|
||||
? 'critical'
|
||||
: agents.some(a => !a.wau_config_ok || a.pending_updates > 0)
|
||||
? 'warning'
|
||||
: 'ok'
|
||||
|
||||
const statusColors = {
|
||||
ok: 'border-green-700/40 bg-green-900/10',
|
||||
warning: 'border-yellow-700/40 bg-yellow-900/10',
|
||||
critical: 'border-red-700/40 bg-red-900/10',
|
||||
}
|
||||
const headerColors = {
|
||||
ok: 'text-green-400',
|
||||
warning: 'text-yellow-400',
|
||||
critical: 'text-red-400',
|
||||
}
|
||||
const StatusIcon = overallStatus === 'ok' ? CircleCheck : overallStatus === 'warning' ? CircleAlert : CircleX
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-3 ${statusColors[overallStatus]}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className={`w-4 h-4 ${headerColors[overallStatus]}`} />
|
||||
<span className={`text-xs font-medium ${headerColors[overallStatus]}`}>
|
||||
WAU Compliance: {compliantCount}/{agents.length} Agents konform
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onRefresh} className="text-gray-600 hover:text-gray-400 transition-colors">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 flex items-center gap-1"
|
||||
>
|
||||
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{agents.map(agent => (
|
||||
<AgentComplianceRow key={agent.agent_id} agent={agent} expectedIncludeUrl={compliance?.include_url} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentComplianceRow({ agent, expectedIncludeUrl }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const missingPkgs = (agent.packages || []).filter(p => !p.installed)
|
||||
const hasIssues = !agent.wau_installed || !agent.wau_config_ok || missingPkgs.length > 0 || agent.pending_updates > 0
|
||||
|
||||
const rowColor = agent.compliant ? 'text-green-400' : hasIssues ? 'text-red-400' : 'text-yellow-400'
|
||||
const RowIcon = agent.compliant ? CircleCheck : CircleX
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900/60 rounded border border-gray-700/50">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-800/40 transition-colors"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<RowIcon className={`w-3.5 h-3.5 flex-shrink-0 ${rowColor}`} />
|
||||
<span className="text-xs text-white font-mono flex-1">{agent.agent_name}</span>
|
||||
<div className="flex gap-1 flex-wrap justify-end">
|
||||
{!agent.wau_installed && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">WAU fehlt</span>
|
||||
)}
|
||||
{agent.wau_installed && !agent.wau_config_ok && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 border border-yellow-700/40 text-yellow-400">Falsche URLs</span>
|
||||
)}
|
||||
{missingPkgs.length > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">{missingPkgs.length} Paket(e) fehlen</span>
|
||||
)}
|
||||
{agent.pending_updates > 0 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 border border-yellow-700/40 text-yellow-400">{agent.pending_updates} Updates</span>
|
||||
)}
|
||||
{agent.compliant && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-900/40 border border-green-700/40 text-green-400">OK</span>
|
||||
)}
|
||||
</div>
|
||||
{open ? <ChevronDown className="w-3 h-3 text-gray-600 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 text-gray-600 flex-shrink-0" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-4 pb-3 pt-1 space-y-2 border-t border-gray-700/40">
|
||||
{/* WAU Status */}
|
||||
<div className="text-[10px] text-gray-400 space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{agent.wau_installed
|
||||
? <CircleCheck className="w-3 h-3 text-green-400" />
|
||||
: <CircleX className="w-3 h-3 text-red-400" />
|
||||
}
|
||||
<span>WAU {agent.wau_installed ? `installiert` : 'nicht installiert'}</span>
|
||||
</div>
|
||||
{agent.wau_installed && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{agent.wau_config_ok
|
||||
? <CircleCheck className="w-3 h-3 text-green-400" />
|
||||
: <CircleX className="w-3 h-3 text-red-400" />
|
||||
}
|
||||
<span>
|
||||
Konfiguration {agent.wau_config_ok ? 'korrekt' : 'falsch'}
|
||||
{!agent.wau_config_ok && agent.include_url && (
|
||||
<span className="text-gray-600 ml-1">({agent.include_url || 'keine URL'})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{agent.pending_updates > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CircleAlert className="w-3 h-3 text-yellow-400" />
|
||||
<span>{agent.pending_updates} ausstehende Updates</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paket-Status */}
|
||||
{(agent.packages || []).length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-[10px] text-gray-600 font-medium uppercase tracking-wide mb-1">Pflichtpakete</div>
|
||||
{(agent.packages || []).map(pkg => (
|
||||
<div key={pkg.winget_id} className="flex items-center gap-1.5 text-[10px]">
|
||||
{pkg.installed
|
||||
? <CircleCheck className="w-3 h-3 text-green-400 flex-shrink-0" />
|
||||
: <CircleX className="w-3 h-3 text-red-400 flex-shrink-0" />
|
||||
}
|
||||
<span className={pkg.installed ? 'text-gray-400' : 'text-red-300'}>{pkg.display_name}</span>
|
||||
<span className="text-gray-700 font-mono">{pkg.winget_id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── WAU-Tab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// Heuristik: Anzeigename → winget-ID-Vorschlag
|
||||
@ -364,6 +575,10 @@ function WauTab({ customerId, customerNumber }) {
|
||||
// Manuelles Formular (Fallback)
|
||||
const [showManual, setShowManual] = useState(false)
|
||||
const [manualForm, setManualForm] = useState({ wingetId: '', displayName: '', ruleType: 'allow' })
|
||||
// Compliance
|
||||
const [compliance, setCompliance] = useState(null)
|
||||
const [loadingComp, setLoadingComp] = useState(false)
|
||||
const [showCompliance, setShowCompliance] = useState(false)
|
||||
|
||||
const reload = () => api.getWAURules(customerId).then(setRules)
|
||||
|
||||
@ -372,9 +587,15 @@ function WauTab({ customerId, customerNumber }) {
|
||||
api.getWAUInventory(customerId).then(setInventory).finally(() => setLoadingInv(false))
|
||||
}
|
||||
|
||||
const loadCompliance = () => {
|
||||
setLoadingComp(true)
|
||||
api.getWAUCompliance(customerId).then(setCompliance).finally(() => setLoadingComp(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
reload()
|
||||
loadInventory()
|
||||
loadCompliance()
|
||||
api.getSettings().then(s => {
|
||||
const url = s?.data?.backend_url_external || s?.backend_url_external
|
||||
if (url) setExternalUrl(url.replace(/\/$/, ''))
|
||||
@ -441,6 +662,9 @@ function WauTab({ customerId, customerNumber }) {
|
||||
return (
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
|
||||
{/* WAU Compliance Status */}
|
||||
<WAUCompliancePanel compliance={compliance} loading={loadingComp} onRefresh={loadCompliance} />
|
||||
|
||||
{/* Installationsbefehl */}
|
||||
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'https://your-backend:8443', // TODO: set your backend URL
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const backendHost = env.VITE_BACKEND_HOST || 'localhost'
|
||||
const backendPort = env.VITE_BACKEND_PORT || '8443'
|
||||
const backendUrl = `https://${backendHost}:${backendPort}`
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user