feat: WAU-Integration + Windows Software-Inventar (v1.1.0)
Windows Agent (v1.1.0):
- Software-Inventar aus Windows-Registry (64-bit + 32-bit)
Sammelt Name, Version, Hersteller, InstallDate aller installierten Apps
Filtert SystemComponent-Einträge heraus, sortiert alphabetisch
Funktioniert unter SYSTEM-Account (kein winget nötig)
- Version auf 1.1.0 gehoben
Backend:
- customer_wau_rules Tabelle (Migration automatisch)
- WAU-Regeln pro Kunde (allow/block, winget-ID + Anzeigename)
- GET/POST/DELETE /api/v1/customers/{id}/wau/rules
- GET /api/v1/customers/{id}/wau/inventory (SQL-Aggregation aus Agenten-Daten)
- GET /api/v1/customers/{id}/wau/included (Plaintext für WAU)
- GET /api/v1/customers/{id}/wau/excluded (Plaintext für WAU)
Frontend:
- Kunden-Seite: Tab-Navigation (Agents | API-Keys | WAU)
- WAU-Tab pro Kunde: Allowlist + Blocklist mit Inline-Assign aus Inventar
Allow/Block-Buttons direkt in Inventar-Zeile, winget-ID vorgeschlagen
Installationsbefehl mit externer Backend-URL aus Systemeinstellungen
- Windows-Panel Software-Tab: Installiert-Liste aus Registry-Daten
- Windows-Panel WAU-Tab: Status (installiert/Task/letzter Lauf)
WAU installieren via MSI-Download + msiexec (synchron)
Jetzt ausführen, Log anzeigen
Gewünschten Zustand herstellen (Allowlist auf Client installieren)
This commit is contained in:
parent
7a5f4c0564
commit
bf51ed8569
@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -21,16 +22,17 @@ type SystemData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WindowsData struct {
|
type WindowsData struct {
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
OSVersion string `json:"os_version"`
|
OSVersion string `json:"os_version"`
|
||||||
OSBuild string `json:"os_build"`
|
OSBuild string `json:"os_build"`
|
||||||
UptimeSecs int64 `json:"uptime_seconds"`
|
UptimeSecs int64 `json:"uptime_seconds"`
|
||||||
CPU CPUInfo `json:"cpu"`
|
CPU CPUInfo `json:"cpu"`
|
||||||
Memory MemoryInfo `json:"memory"`
|
Memory MemoryInfo `json:"memory"`
|
||||||
Disks []DiskInfo `json:"disks"`
|
Disks []DiskInfo `json:"disks"`
|
||||||
Services []SvcInfo `json:"services,omitempty"`
|
Services []SvcInfo `json:"services,omitempty"`
|
||||||
Domain string `json:"domain,omitempty"`
|
InstalledSoftware []SoftwareInfo `json:"installed_software,omitempty"`
|
||||||
LastUpdate time.Time `json:"last_update"`
|
Domain string `json:"domain,omitempty"`
|
||||||
|
LastUpdate time.Time `json:"last_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CPUInfo struct {
|
type CPUInfo struct {
|
||||||
@ -62,6 +64,13 @@ type SvcInfo struct {
|
|||||||
StartType string `json:"start_type"`
|
StartType string `json:"start_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SoftwareInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Publisher string `json:"publisher,omitempty"`
|
||||||
|
InstallDate string `json:"install_date,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// MEMORYSTATUSEX fuer GlobalMemoryStatusEx
|
// MEMORYSTATUSEX fuer GlobalMemoryStatusEx
|
||||||
type memoryStatusEx struct {
|
type memoryStatusEx struct {
|
||||||
dwLength uint32
|
dwLength uint32
|
||||||
@ -111,6 +120,9 @@ func Collect() (*SystemData, error) {
|
|||||||
// Domain
|
// Domain
|
||||||
wd.Domain = getDomain()
|
wd.Domain = getDomain()
|
||||||
|
|
||||||
|
// Installierte Software (Registry)
|
||||||
|
wd.InstalledSoftware = getInstalledSoftware()
|
||||||
|
|
||||||
return &SystemData{Windows: wd}, nil
|
return &SystemData{Windows: wd}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,3 +371,73 @@ func min(a, b int) int {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getInstalledSoftware liest installierte Software aus der Windows-Registry.
|
||||||
|
// Funktioniert auch unter SYSTEM-Account (kein winget noetig).
|
||||||
|
func getInstalledSoftware() []SoftwareInfo {
|
||||||
|
var result []SoftwareInfo
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
regPaths := []struct {
|
||||||
|
root registry.Key
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`},
|
||||||
|
{registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rp := range regPaths {
|
||||||
|
k, err := registry.OpenKey(rp.root, rp.path, registry.READ|registry.ENUMERATE_SUB_KEYS)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subkeys, err := k.ReadSubKeyNames(-1)
|
||||||
|
k.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subkey := range subkeys {
|
||||||
|
sk, err := registry.OpenKey(rp.root, rp.path+`\`+subkey, registry.READ)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _, _ := sk.GetStringValue("DisplayName")
|
||||||
|
sk.Close()
|
||||||
|
|
||||||
|
if name == "" || seen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
|
||||||
|
// Nochmal öffnen für weitere Felder (getrennt um Close sauber zu halten)
|
||||||
|
sk2, err := registry.OpenKey(rp.root, rp.path+`\`+subkey, registry.READ)
|
||||||
|
if err != nil {
|
||||||
|
result = append(result, SoftwareInfo{Name: name})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
version, _, _ := sk2.GetStringValue("DisplayVersion")
|
||||||
|
publisher, _, _ := sk2.GetStringValue("Publisher")
|
||||||
|
installDate, _, _ := sk2.GetStringValue("InstallDate")
|
||||||
|
|
||||||
|
// SystemComponent überspringen (Windows-interne Komponenten)
|
||||||
|
sysComp, _, _ := sk2.GetIntegerValue("SystemComponent")
|
||||||
|
sk2.Close()
|
||||||
|
if sysComp == 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, SoftwareInfo{
|
||||||
|
Name: name,
|
||||||
|
Version: version,
|
||||||
|
Publisher: publisher,
|
||||||
|
InstallDate: installDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import (
|
|||||||
"github.com/cynfo/rmm-agent-windows/ws"
|
"github.com/cynfo/rmm-agent-windows/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "1.0.0"
|
var Version = "1.1.0"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ServiceName = "RMMAgent"
|
ServiceName = "RMMAgent"
|
||||||
|
|||||||
@ -61,6 +61,9 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
|||||||
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
|
mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey)
|
||||||
mux.HandleFunc("GET /api/v1/customers/{id}/apikeys", h.listCustomerAPIKeys)
|
mux.HandleFunc("GET /api/v1/customers/{id}/apikeys", h.listCustomerAPIKeys)
|
||||||
|
|
||||||
|
// WAU-Regeln
|
||||||
|
h.setupWAURoutes(mux)
|
||||||
|
|
||||||
// System Settings
|
// System Settings
|
||||||
mux.HandleFunc("GET /api/v1/settings", h.getSettings)
|
mux.HandleFunc("GET /api/v1/settings", h.getSettings)
|
||||||
mux.HandleFunc("PUT /api/v1/settings", h.updateSettings)
|
mux.HandleFunc("PUT /api/v1/settings", h.updateSettings)
|
||||||
|
|||||||
151
backend/api/wau.go
Normal file
151
backend/api/wau.go
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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("DELETE /api/v1/customers/{id}/wau/rules/{rid}", h.deleteWAURule)
|
||||||
|
mux.HandleFunc("GET /api/v1/customers/{id}/wau/inventory", h.getWAUInventory)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
mux.HandleFunc("GET /api/v1/customers/{id}/wau/excluded", h.wauExcludedList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/customers/{id}/wau/rules
|
||||||
|
func (h *Handler) listWAURules(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
|
||||||
|
}
|
||||||
|
rules, err := h.db.ListWAURules(customerID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Fehler beim Laden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/customers/{id}/wau/rules
|
||||||
|
func (h *Handler) addWAURule(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
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
WingetID string `json:"winget_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
RuleType string `json:"rule_type"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.WingetID = strings.TrimSpace(req.WingetID)
|
||||||
|
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
||||||
|
|
||||||
|
if req.WingetID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "winget_id erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.RuleType != "allow" && req.RuleType != "block" {
|
||||||
|
writeError(w, http.StatusBadRequest, "rule_type muss 'allow' oder 'block' sein")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DisplayName == "" {
|
||||||
|
req.DisplayName = req.WingetID
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, err := h.db.AddWAURule(customerID, req.WingetID, req.DisplayName, req.RuleType)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Fehler beim Speichern")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "wau.rule.add", "customer", strconv.Itoa(customerID), "",
|
||||||
|
req.RuleType+": "+req.WingetID)
|
||||||
|
writeJSON(w, http.StatusCreated, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/customers/{id}/wau/rules/{rid}
|
||||||
|
func (h *Handler) deleteWAURule(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
|
||||||
|
}
|
||||||
|
ruleID, err := strconv.Atoi(r.PathValue("rid"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültige Regel-ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := h.db.DeleteWAURule(customerID, ruleID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "Regel nicht gefunden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.auditLog(r, "wau.rule.delete", "customer", strconv.Itoa(customerID), "", strconv.Itoa(ruleID))
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"message": "Regel gelöscht"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/customers/{id}/wau/inventory
|
||||||
|
func (h *Handler) getWAUInventory(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
|
||||||
|
}
|
||||||
|
entries, err := h.db.GetWAUSoftwareInventory(customerID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Fehler beim Laden des Inventars")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/customers/{id}/wau/included → Plaintext für WAU
|
||||||
|
func (h *Handler) wauIncludedList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.serveWAUList(w, r, "allow")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/customers/{id}/wau/excluded → Plaintext für WAU
|
||||||
|
func (h *Handler) wauExcludedList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.serveWAUList(w, r, "block")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serveWAUList(w http.ResponseWriter, r *http.Request, ruleType string) {
|
||||||
|
customerID, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Ungültige Kunden-ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids, err := h.db.GetWAUList(customerID, ruleType)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(strings.Join(ids, "\r\n")))
|
||||||
|
if len(ids) > 0 {
|
||||||
|
w.Write([]byte("\r\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -280,6 +280,18 @@ func (d *Database) migrate() error {
|
|||||||
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS script_timeout INT DEFAULT 300`)
|
d.db.Exec(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS script_timeout INT DEFAULT 300`)
|
||||||
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`)
|
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_active_next_run ON tasks(active, next_run) WHERE active = TRUE`)
|
||||||
|
|
||||||
|
// WAU-Regeln pro Kunde
|
||||||
|
d.db.Exec(`CREATE TABLE IF NOT EXISTS customer_wau_rules (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
winget_id TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
rule_type TEXT NOT NULL CHECK (rule_type IN ('allow', 'block')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(customer_id, winget_id)
|
||||||
|
)`)
|
||||||
|
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_wau_rules_customer ON customer_wau_rules(customer_id, rule_type)`)
|
||||||
|
|
||||||
// Retention Policy (90 Tage)
|
// Retention Policy (90 Tage)
|
||||||
d.setupRetention()
|
d.setupRetention()
|
||||||
|
|
||||||
|
|||||||
135
backend/db/wau.go
Normal file
135
backend/db/wau.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/cynfo/rmm-backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListWAURules gibt alle WAU-Regeln eines Kunden zurück
|
||||||
|
func (d *Database) ListWAURules(customerID int) ([]models.WAURule, error) {
|
||||||
|
rows, err := d.db.Query(`
|
||||||
|
SELECT id, customer_id, winget_id, display_name, rule_type, created_at
|
||||||
|
FROM customer_wau_rules
|
||||||
|
WHERE customer_id = $1
|
||||||
|
ORDER BY rule_type, lower(display_name)
|
||||||
|
`, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var rules []models.WAURule
|
||||||
|
for rows.Next() {
|
||||||
|
var r models.WAURule
|
||||||
|
if err := rows.Scan(&r.ID, &r.CustomerID, &r.WingetID, &r.DisplayName, &r.RuleType, &r.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules = append(rules, r)
|
||||||
|
}
|
||||||
|
if rules == nil {
|
||||||
|
rules = []models.WAURule{}
|
||||||
|
}
|
||||||
|
return rules, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddWAURule fügt eine neue Regel hinzu (upsert auf winget_id)
|
||||||
|
func (d *Database) AddWAURule(customerID int, wingetID, displayName, ruleType string) (*models.WAURule, error) {
|
||||||
|
var r models.WAURule
|
||||||
|
err := d.db.QueryRow(`
|
||||||
|
INSERT INTO customer_wau_rules (customer_id, winget_id, display_name, rule_type)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (customer_id, winget_id) DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
rule_type = EXCLUDED.rule_type
|
||||||
|
RETURNING id, customer_id, winget_id, display_name, rule_type, created_at
|
||||||
|
`, customerID, wingetID, displayName, ruleType).Scan(
|
||||||
|
&r.ID, &r.CustomerID, &r.WingetID, &r.DisplayName, &r.RuleType, &r.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWAURule löscht eine Regel (per ID, gehört zu customerID)
|
||||||
|
func (d *Database) DeleteWAURule(customerID, ruleID int) (bool, error) {
|
||||||
|
res, err := d.db.Exec(`
|
||||||
|
DELETE FROM customer_wau_rules WHERE id = $1 AND customer_id = $2
|
||||||
|
`, ruleID, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWAUList gibt alle winget-IDs eines bestimmten Typs zurück (für den Plaintext-Endpoint)
|
||||||
|
func (d *Database) GetWAUList(customerID int, ruleType string) ([]string, error) {
|
||||||
|
rows, err := d.db.Query(`
|
||||||
|
SELECT winget_id FROM customer_wau_rules
|
||||||
|
WHERE customer_id = $1 AND rule_type = $2
|
||||||
|
ORDER BY lower(winget_id)
|
||||||
|
`, customerID, ruleType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var ids []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
return ids, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWAUSoftwareInventory aggregiert installierte Software aller Windows-Agents eines Kunden
|
||||||
|
func (d *Database) GetWAUSoftwareInventory(customerID int) ([]models.WAUSoftwareEntry, error) {
|
||||||
|
rows, err := d.db.Query(`
|
||||||
|
SELECT
|
||||||
|
app->>'name' AS name,
|
||||||
|
COALESCE(app->>'publisher', '') AS publisher,
|
||||||
|
COUNT(DISTINCT sd.agent_id) AS device_count,
|
||||||
|
jsonb_agg(DISTINCT app->>'version') FILTER (WHERE (app->>'version') != '') AS versions
|
||||||
|
FROM agents a
|
||||||
|
JOIN system_data sd ON sd.agent_id = a.id
|
||||||
|
CROSS JOIN jsonb_array_elements(sd.data_json->'windows'->'installed_software') AS app
|
||||||
|
WHERE a.customer_id = $1
|
||||||
|
AND sd.data_json ? 'windows'
|
||||||
|
AND jsonb_typeof(sd.data_json->'windows'->'installed_software') = 'array'
|
||||||
|
AND (app->>'name') IS NOT NULL
|
||||||
|
AND (app->>'name') != ''
|
||||||
|
GROUP BY app->>'name', app->>'publisher'
|
||||||
|
ORDER BY lower(app->>'name')
|
||||||
|
`, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []models.WAUSoftwareEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e models.WAUSoftwareEntry
|
||||||
|
var versionsJSON sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&e.Name, &e.Publisher, &e.DeviceCount, &versionsJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if versionsJSON.Valid && versionsJSON.String != "" && versionsJSON.String != "null" {
|
||||||
|
json.Unmarshal([]byte(versionsJSON.String), &e.Versions)
|
||||||
|
}
|
||||||
|
if e.Versions == nil {
|
||||||
|
e.Versions = []string{}
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
if entries == nil {
|
||||||
|
entries = []models.WAUSoftwareEntry{}
|
||||||
|
}
|
||||||
|
return entries, rows.Err()
|
||||||
|
}
|
||||||
19
backend/models/wau.go
Normal file
19
backend/models/wau.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type WAURule struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
CustomerID int `json:"customer_id"`
|
||||||
|
WingetID string `json:"winget_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
RuleType string `json:"rule_type"` // "allow" | "block"
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WAUSoftwareEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Publisher string `json:"publisher"`
|
||||||
|
DeviceCount int `json:"device_count"`
|
||||||
|
Versions []string `json:"versions"`
|
||||||
|
}
|
||||||
@ -254,6 +254,27 @@ class ApiClient {
|
|||||||
return this.get(`/api/v1/customers/${customerId}/apikeys`)
|
return this.get(`/api/v1/customers/${customerId}/apikeys`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WAU
|
||||||
|
getWAURules(customerId) {
|
||||||
|
return this.get(`/api/v1/customers/${customerId}/wau/rules`)
|
||||||
|
}
|
||||||
|
|
||||||
|
addWAURule(customerId, wingetId, displayName, ruleType) {
|
||||||
|
return this.post(`/api/v1/customers/${customerId}/wau/rules`, {
|
||||||
|
winget_id: wingetId,
|
||||||
|
display_name: displayName,
|
||||||
|
rule_type: ruleType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteWAURule(customerId, ruleId) {
|
||||||
|
return this.del(`/api/v1/customers/${customerId}/wau/rules/${ruleId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
getWAUInventory(customerId) {
|
||||||
|
return this.get(`/api/v1/customers/${customerId}/wau/inventory`)
|
||||||
|
}
|
||||||
|
|
||||||
createAPIKey(name, permissions = 'read', customerId = null) {
|
createAPIKey(name, permissions = 'read', customerId = null) {
|
||||||
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
|
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import ScriptModal from './ScriptModal'
|
|||||||
import {
|
import {
|
||||||
X, Cpu, MemoryStick, HardDrive, Monitor, Server, Settings,
|
X, Cpu, MemoryStick, HardDrive, Monitor, Server, Settings,
|
||||||
Download, Trash2, RefreshCw, Package, Search, Play,
|
Download, Trash2, RefreshCw, Package, Search, Play,
|
||||||
ShieldCheck, Bot, Cable, CalendarClock,
|
ShieldCheck, Bot, Cable, CalendarClock, Zap, CheckCircle2, AlertCircle, Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// ── Navigation ────────────────────────────────────────────────────────────────
|
// ── Navigation ────────────────────────────────────────────────────────────────
|
||||||
@ -23,6 +23,7 @@ const subTabs = {
|
|||||||
{ id: 'installed', label: 'Installiert', icon: Package },
|
{ id: 'installed', label: 'Installiert', icon: Package },
|
||||||
{ id: 'winget', label: 'winget Updates', icon: RefreshCw },
|
{ id: 'winget', label: 'winget Updates', icon: RefreshCw },
|
||||||
{ id: 'wupdates', label: 'Windows Updates', icon: ShieldCheck },
|
{ id: 'wupdates', label: 'Windows Updates', icon: ShieldCheck },
|
||||||
|
{ id: 'wau', label: 'WAU', icon: Zap },
|
||||||
],
|
],
|
||||||
system: [
|
system: [
|
||||||
{ id: 'services', label: 'Dienste', icon: Settings },
|
{ id: 'services', label: 'Dienste', icon: Settings },
|
||||||
@ -136,9 +137,10 @@ export default function WindowsPanel({ agentId, customers, onClose, onReload })
|
|||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (mainTab === 'uebersicht') return <OverviewTab win={win} agent={agent} status={status} />
|
if (mainTab === 'uebersicht') return <OverviewTab win={win} agent={agent} status={status} />
|
||||||
const t = activeSubTab
|
const t = activeSubTab
|
||||||
if (t === 'installed') return <WingetListTab agentId={agentId} />
|
if (t === 'installed') return <InstalledSoftwareTab win={win} />
|
||||||
if (t === 'winget') return <WingetUpgradeTab agentId={agentId} />
|
if (t === 'winget') return <WingetUpgradeTab agentId={agentId} />
|
||||||
if (t === 'wupdates') return <WindowsUpdatesTab agentId={agentId} />
|
if (t === 'wupdates') return <WindowsUpdatesTab agentId={agentId} />
|
||||||
|
if (t === 'wau') return <WAUTab agentId={agentId} customerId={agent.customer_id} />
|
||||||
if (t === 'services') return <ServicesTab win={win} />
|
if (t === 'services') return <ServicesTab win={win} />
|
||||||
if (t === 'tunnel') return <TunnelTab agentId={agentId} agent={agent} />
|
if (t === 'tunnel') return <TunnelTab agentId={agentId} agent={agent} />
|
||||||
if (t === 'tasks') return <TasksTab agentId={agentId} agentName={agent.name} />
|
if (t === 'tasks') return <TasksTab agentId={agentId} agentName={agent.name} />
|
||||||
@ -309,56 +311,74 @@ function OverviewTab({ win, agent, status }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Software / winget ─────────────────────────────────────────────────────────
|
// ── Software / installiert ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function WingetListTab({ agentId }) {
|
function InstalledSoftwareTab({ win }) {
|
||||||
const [output, setOutput] = useState(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const software = win?.installed_software || []
|
||||||
|
|
||||||
const load = async () => {
|
const filtered = search
|
||||||
setLoading(true)
|
? software.filter(s =>
|
||||||
try {
|
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
const res = await api.execCommand(agentId, 'winget list --accept-source-agreements', 30)
|
(s.publisher || '').toLowerCase().includes(search.toLowerCase())
|
||||||
setOutput(res?.output || res?.data?.output || '')
|
)
|
||||||
} catch (e) {
|
: software
|
||||||
setOutput(`Fehler: ${e.message}`)
|
|
||||||
} finally {
|
if (software.length === 0) {
|
||||||
setLoading(false)
|
return (
|
||||||
}
|
<div className="text-gray-500 text-sm">
|
||||||
|
Keine Software-Daten verfügbar — Agent läuft möglicherweise noch auf einer alten Version.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [agentId])
|
|
||||||
|
|
||||||
const lines = output ? output.split('\n').filter(l => l.trim() && !l.startsWith('-')) : []
|
|
||||||
const filtered = search ? lines.filter(l => l.toLowerCase().includes(search.toLowerCase())) : lines
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2.5 top-2 w-3.5 h-3.5 text-gray-500" />
|
<Search className="absolute left-2.5 top-2 w-3.5 h-3.5 text-gray-500" />
|
||||||
<input value={search} onChange={e => setSearch(e.target.value)}
|
<input value={search} onChange={e => setSearch(e.target.value)}
|
||||||
placeholder="Software suchen..."
|
placeholder="Name oder Hersteller suchen..."
|
||||||
className="w-full pl-8 pr-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500" />
|
className="w-full pl-8 pr-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<button onClick={load} disabled={loading}
|
<span className="text-xs text-gray-500 shrink-0">{filtered.length} / {software.length}</span>
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors">
|
</div>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
Aktualisieren
|
<div className="bg-gray-950 rounded border border-gray-800 overflow-hidden">
|
||||||
</button>
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-left text-gray-500">
|
||||||
|
<th className="px-3 py-2 font-medium">Name</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Version</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Hersteller</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Installiert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-900">
|
||||||
|
{filtered.map((s, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-900/60">
|
||||||
|
<td className="px-3 py-1.5 text-white">{s.name}</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-400 font-mono">{s.version || '—'}</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-500">{s.publisher || '—'}</td>
|
||||||
|
<td className="px-3 py-1.5 text-gray-600">{formatInstallDate(s.install_date)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-gray-500">Keine Treffer</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
|
||||||
<div className="text-gray-500 text-sm">Lade Liste...</div>
|
|
||||||
) : (
|
|
||||||
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-[500px] overflow-y-auto">
|
|
||||||
{filtered.join('\n') || 'Keine Eintraege'}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatInstallDate(d) {
|
||||||
|
if (!d || d.length < 8) return '—'
|
||||||
|
// Format: YYYYMMDD
|
||||||
|
return `${d.slice(6, 8)}.${d.slice(4, 6)}.${d.slice(0, 4)}`
|
||||||
|
}
|
||||||
|
|
||||||
function WingetUpgradeTab({ agentId }) {
|
function WingetUpgradeTab({ agentId }) {
|
||||||
const [output, setOutput] = useState(null)
|
const [output, setOutput] = useState(null)
|
||||||
const [running, setRunning] = useState(false)
|
const [running, setRunning] = useState(false)
|
||||||
@ -580,6 +600,283 @@ function AgentInfoTab({ agent, status }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WAU-Tab ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function WAUTab({ agentId, customerId }) {
|
||||||
|
const [status, setStatus] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [running, setRunning] = useState(false)
|
||||||
|
const [log, setLog] = useState(null)
|
||||||
|
const [installing, setInstalling] = useState(false)
|
||||||
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
|
const [allowRules, setAllowRules] = useState([])
|
||||||
|
const [enforcing, setEnforcing] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getSettings().then(s => {
|
||||||
|
const url = s?.data?.backend_url_external || s?.backend_url_external
|
||||||
|
if (url) setExternalUrl(url.replace(/\/$/, ''))
|
||||||
|
})
|
||||||
|
if (customerId) {
|
||||||
|
api.getWAURules(customerId).then(rules => {
|
||||||
|
setAllowRules((rules || []).filter(r => r.rule_type === 'allow'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [customerId])
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// Task prüfen (einfachste Methode, kein komplexes Escaping)
|
||||||
|
const taskRes = await api.execCommand(agentId,
|
||||||
|
"if (Get-ScheduledTask -TaskPath '\\WAU\\' -TaskName 'Winget-AutoUpdate' -ErrorAction SilentlyContinue) { 'TASK_FOUND' } else { 'TASK_NOT_FOUND' }",
|
||||||
|
15)
|
||||||
|
const taskOut = (taskRes?.output || taskRes?.data?.output || '').trim()
|
||||||
|
const taskFound = taskOut.includes('TASK_FOUND')
|
||||||
|
|
||||||
|
if (!taskFound) {
|
||||||
|
setStatus({ Installed: false, Version: '', TaskExists: false, TaskState: '', LastRun: '' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version + letzter Lauf
|
||||||
|
const infoRes = await api.execCommand(agentId,
|
||||||
|
"$t = Get-ScheduledTask -TaskPath '\\WAU\\' -TaskName 'Winget-AutoUpdate' -EA SilentlyContinue; $i = $t | Get-ScheduledTaskInfo; $v = (Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -EA SilentlyContinue | Where-Object { $_.DisplayName -like '*Winget-AutoUpdate*' } | Select-Object -First 1).DisplayVersion; $lr = if ($i.LastRunTime.Year -gt 2000) { $i.LastRunTime.ToString('yyyy-MM-dd HH:mm') } else { '-' }; Write-Output \"$v|$($t.State)|$lr\"",
|
||||||
|
15)
|
||||||
|
const infoOut = (infoRes?.output || infoRes?.data?.output || '').trim()
|
||||||
|
const [version, taskState, lastRun] = infoOut.split('|')
|
||||||
|
|
||||||
|
setStatus({ Installed: true, Version: version || '', TaskExists: true, TaskState: taskState || '', LastRun: lastRun || '' })
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(null)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installWAU = async () => {
|
||||||
|
if (!externalUrl || !customerId) return
|
||||||
|
setInstalling(true)
|
||||||
|
setLog('WAU wird heruntergeladen und installiert — bitte warten (1-3 Minuten)...')
|
||||||
|
const listPath = `${externalUrl}/api/v1/customers/${customerId}/wau/included`
|
||||||
|
const blockPath = `${externalUrl}/api/v1/customers/${customerId}/wau/excluded`
|
||||||
|
// MSI direkt herunterladen + synchron mit msiexec installieren
|
||||||
|
const cmd = `$msi="$env:TEMP\\WAU.msi"; Invoke-WebRequest 'https://github.com/Romanitho/Winget-AutoUpdate/releases/latest/download/WAU.msi' -OutFile $msi -UseBasicParsing; $args=@('/i',$msi,'/qn','NOTIFICATIONLEVEL=2','RUNONSTARTUP=1','UPDATESINTERVAL=Daily','LISTPATH=${listPath}','BLOCKEDAPPSLISTPATH=${blockPath}'); $p=Start-Process msiexec -ArgumentList $args -Wait -PassThru; if(Get-ScheduledTask 'Winget-AutoUpdate' -EA SilentlyContinue){"WAU erfolgreich installiert (ExitCode: $($p.ExitCode))"}else{"Installation fehlgeschlagen (ExitCode: $($p.ExitCode))"}`
|
||||||
|
try {
|
||||||
|
const res = await api.execCommand(agentId, cmd, 300)
|
||||||
|
setLog(res?.output || res?.data?.output || '(keine Ausgabe)')
|
||||||
|
setTimeout(load, 3000)
|
||||||
|
} catch (e) {
|
||||||
|
setLog(`Fehler: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
setInstalling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runNow = async () => {
|
||||||
|
setRunning(true)
|
||||||
|
setLog(null)
|
||||||
|
try {
|
||||||
|
const res = await api.execCommand(agentId,
|
||||||
|
"Start-ScheduledTask -TaskPath '\\WAU\\' -TaskName 'Winget-AutoUpdate'; Start-Sleep 2; Write-Output 'WAU gestartet'",
|
||||||
|
15)
|
||||||
|
setLog(res?.output || res?.data?.output || 'Gestartet')
|
||||||
|
setTimeout(load, 5000) // Status nach 5s neu laden
|
||||||
|
} catch (e) {
|
||||||
|
setLog(`Fehler: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
setRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enforceAllowlist = async () => {
|
||||||
|
if (!allowRules.length) return
|
||||||
|
setEnforcing(true)
|
||||||
|
setLog(`Installiere ${allowRules.length} Pakete aus der Allowlist...\n`)
|
||||||
|
// PowerShell-Loop: jedes Paket einzeln installieren, Ergebnis ausgeben
|
||||||
|
const ids = allowRules.map(r => r.winget_id).join("','")
|
||||||
|
const cmd = `$pkgs=@('${ids}'); foreach($p in $pkgs){ Write-Output ">>> $p"; winget install --id $p --silent --accept-package-agreements --accept-source-agreements 2>&1 | Select-Object -Last 3 | Out-String; Write-Output "" }; Write-Output "=== Fertig ==="`
|
||||||
|
try {
|
||||||
|
const res = await api.execCommand(agentId, cmd, 600)
|
||||||
|
setLog(res?.output || res?.data?.output || '(keine Ausgabe)')
|
||||||
|
} catch (e) {
|
||||||
|
setLog(`Fehler: ${e.message}`)
|
||||||
|
} finally {
|
||||||
|
setEnforcing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLog = async () => {
|
||||||
|
const res = await api.execCommand(agentId,
|
||||||
|
"Get-Content 'C:\\ProgramData\\Winget-AutoUpdate\\Logs\\WAU-updates.log' -Tail 30 -ErrorAction SilentlyContinue",
|
||||||
|
15)
|
||||||
|
setLog(res?.output || res?.data?.output || 'Kein Log gefunden')
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [agentId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
|
||||||
|
{/* Status-Card */}
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-yellow-400" />
|
||||||
|
<span className="text-sm font-medium text-white">Winget-AutoUpdate</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={load} disabled={loading} className="text-gray-600 hover:text-gray-400">
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-500 text-sm">Prüfe Status...</div>
|
||||||
|
) : status === null ? (
|
||||||
|
<div className="text-red-400 text-sm">Status konnte nicht abgerufen werden</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Installiert / nicht installiert */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{status.Installed ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-gray-600 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-white">
|
||||||
|
{status.Installed ? 'WAU installiert' : 'WAU nicht installiert'}
|
||||||
|
</div>
|
||||||
|
{status.Version && (
|
||||||
|
<div className="text-xs text-gray-500">Version {status.Version}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task-Status + letzter Lauf */}
|
||||||
|
{status.Installed && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||||
|
<div className="bg-gray-900/60 rounded p-2">
|
||||||
|
<div className="text-[10px] text-gray-500 mb-1">Geplante Aufgabe</div>
|
||||||
|
<div className={`text-xs font-medium ${
|
||||||
|
status.TaskExists ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{status.TaskExists ? status.TaskState || 'Vorhanden' : 'Fehlt'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900/60 rounded p-2">
|
||||||
|
<div className="text-[10px] text-gray-500 mb-1">Letzter Lauf</div>
|
||||||
|
<div className="text-xs text-gray-300 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3 text-gray-600" />
|
||||||
|
{status.LastRun || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Aktionen */}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
{status.Installed ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={runNow}
|
||||||
|
disabled={running}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-600 hover:bg-yellow-500 disabled:bg-gray-700 rounded text-xs text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Zap className={`w-3.5 h-3.5 ${running ? 'animate-pulse' : ''}`} />
|
||||||
|
{running ? 'Läuft...' : 'Jetzt ausführen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={loadLog}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
Log anzeigen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{externalUrl && customerId ? (
|
||||||
|
<button
|
||||||
|
onClick={installWAU}
|
||||||
|
disabled={installing}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-600 hover:bg-yellow-500 disabled:bg-gray-700 rounded text-xs text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Download className={`w-3.5 h-3.5 ${installing ? 'animate-bounce' : ''}`} />
|
||||||
|
{installing ? 'Installiere...' : 'WAU jetzt installieren'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{!externalUrl
|
||||||
|
? 'Externe Backend-URL nicht konfiguriert (Einstellungen)'
|
||||||
|
: 'Kein Kunde zugeordnet — Agent zuerst einem Kunden zuweisen'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kunden-Listen Hinweis */}
|
||||||
|
{status.Installed && customerId && (
|
||||||
|
<div className="text-[10px] text-gray-600 border-t border-gray-700/50 pt-2 mt-1">
|
||||||
|
Allow/Blocklisten werden verwaltet unter: Kunden → Kunde auswählen → WAU-Tab
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Allowlist Desired State */}
|
||||||
|
{customerId && (
|
||||||
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-4 h-4 text-green-400" />
|
||||||
|
<span className="text-sm font-medium text-white">Gewünschten Zustand herstellen</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-600">{allowRules.length} Pakete in Allowlist</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allowRules.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-600">Keine Allowlist konfiguriert — in der Kundenverwaltung unter WAU hinterlegen.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1.5 max-h-28 overflow-y-auto">
|
||||||
|
{allowRules.map(r => (
|
||||||
|
<span key={r.id} className="text-[10px] font-mono px-1.5 py-0.5 bg-gray-900 border border-gray-700 rounded text-gray-400">
|
||||||
|
{r.winget_id}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={enforceAllowlist}
|
||||||
|
disabled={enforcing}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-700 hover:bg-green-600 disabled:bg-gray-700 rounded text-xs text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Download className={`w-3.5 h-3.5 ${enforcing ? 'animate-bounce' : ''}`} />
|
||||||
|
{enforcing ? 'Installiere...' : 'Fehlende Software installieren'}
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] text-gray-600">Bereits installierte Pakete werden übersprungen</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log-Ausgabe */}
|
||||||
|
{log && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Log-Ausgabe</div>
|
||||||
|
<pre className="bg-gray-950 rounded p-3 text-xs text-gray-300 font-mono overflow-x-auto whitespace-pre max-h-64 overflow-y-auto">
|
||||||
|
{log}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hilfskomponenten ──────────────────────────────────────────────────────────
|
// ── Hilfskomponenten ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StatCard({ icon: Icon, label, value, sub, color, pct }) {
|
function StatCard({ icon: Icon, label, value, sub, color, pct }) {
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { copyToClipboard } from '../utils/clipboard'
|
import { copyToClipboard } from '../utils/clipboard'
|
||||||
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle } from 'lucide-react'
|
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle, ShieldCheck, ShieldX, Package, Search, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
const PERM_LABELS = {
|
const PERM_LABELS = {
|
||||||
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50' },
|
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50' },
|
||||||
@ -180,7 +180,7 @@ export default function Customers() {
|
|||||||
{expandedId === c.id && (
|
{expandedId === c.id && (
|
||||||
<tr key={`expand-${c.id}`}>
|
<tr key={`expand-${c.id}`}>
|
||||||
<td colSpan={5} className="px-0 py-0">
|
<td colSpan={5} className="px-0 py-0">
|
||||||
<CustomerKeyPanel customerId={c.id} />
|
<CustomerDetailPanel customerId={c.id} customerNumber={c.number} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@ -196,106 +196,497 @@ export default function Customers() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomerKeyPanel({ customerId }) {
|
// ── CustomerDetailPanel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CustomerDetailPanel({ customerId, customerNumber }) {
|
||||||
|
const [activeTab, setActiveTab] = useState('agents')
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'agents', label: 'Agents', icon: Monitor },
|
||||||
|
{ id: 'apikeys', label: 'API-Keys', icon: Key },
|
||||||
|
{ id: 'wau', label: 'WAU', icon: Package },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800/20 border-t border-gray-800">
|
||||||
|
{/* Tab-Leiste */}
|
||||||
|
<div className="flex gap-0 border-b border-gray-800 px-6">
|
||||||
|
{tabs.map(t => {
|
||||||
|
const Icon = t.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setActiveTab(t.id) }}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 text-xs border-b-2 transition-colors ${
|
||||||
|
activeTab === t.id
|
||||||
|
? 'text-orange-400 border-orange-400'
|
||||||
|
: 'text-gray-500 border-transparent hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3 h-3" />
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inhalt */}
|
||||||
|
<div onClick={e => e.stopPropagation()}>
|
||||||
|
{activeTab === 'agents' && <AgentsTab customerId={customerId} />}
|
||||||
|
{activeTab === 'apikeys' && <APIKeysTab customerId={customerId} />}
|
||||||
|
{activeTab === 'wau' && <WauTab customerId={customerId} customerNumber={customerNumber} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agents-Tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function AgentsTab({ customerId }) {
|
||||||
const [keys, setKeys] = useState(null)
|
const [keys, setKeys] = useState(null)
|
||||||
const [agents, setAgents] = useState(null)
|
const [agents, setAgents] = useState(null)
|
||||||
const [copied, setCopied] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getCustomerAPIKeys(customerId).then(setKeys)
|
api.getCustomerAPIKeys(customerId).then(setKeys)
|
||||||
api.getCustomerAgents(customerId).then(setAgents)
|
api.getCustomerAgents(customerId).then(setAgents)
|
||||||
}, [customerId])
|
}, [customerId])
|
||||||
|
|
||||||
|
if (!keys || !agents) {
|
||||||
|
return <div className="px-8 py-3 text-gray-500 text-xs">Laden...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentKey = keys.find(k => k.permissions === 'agent')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-8 py-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Monitor className="w-3.5 h-3.5 text-gray-500" />
|
||||||
|
<span className="text-xs text-gray-500 font-medium">Agents ({agents.length})</span>
|
||||||
|
</div>
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<div className="text-xs text-gray-600">Keine Agents zugeordnet</div>
|
||||||
|
) : (
|
||||||
|
agents.map((a) => {
|
||||||
|
const wrongKey = agentKey && a.last_api_key && a.last_api_key !== agentKey.key
|
||||||
|
const unknownKey = agentKey && !a.last_api_key
|
||||||
|
return (
|
||||||
|
<div key={a.id} className={`flex items-center gap-2.5 rounded px-1.5 py-0.5 -mx-1.5 ${wrongKey ? 'bg-red-900/20' : ''}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
||||||
|
a.status === 'online' ? 'bg-green-400' :
|
||||||
|
a.status === 'stale' ? 'bg-yellow-400' : 'bg-gray-600'
|
||||||
|
}`} />
|
||||||
|
<span className={`text-xs flex-1 min-w-0 truncate ${wrongKey ? 'text-red-300' : 'text-gray-300'}`}>{a.name}</span>
|
||||||
|
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">{a.agent_version || '—'}</span>
|
||||||
|
{wrongKey && <span title={`Falscher Key: ${a.last_api_key?.substring(0,8)}...`}><AlertTriangle className="w-3 h-3 text-red-400 flex-shrink-0" /></span>}
|
||||||
|
{unknownKey && <span title="Noch kein Heartbeat mit Key-Info"><AlertTriangle className="w-3 h-3 text-gray-600 flex-shrink-0" /></span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API-Keys-Tab ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function APIKeysTab({ customerId }) {
|
||||||
|
const [keys, setKeys] = useState(null)
|
||||||
|
const [copied, setCopied] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getCustomerAPIKeys(customerId).then(setKeys)
|
||||||
|
}, [customerId])
|
||||||
|
|
||||||
const copyKey = (key, id) => {
|
const copyKey = (key, id) => {
|
||||||
copyToClipboard(key)
|
copyToClipboard(key)
|
||||||
setCopied(id)
|
setCopied(id)
|
||||||
setTimeout(() => setCopied(null), 2000)
|
setTimeout(() => setCopied(null), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = keys === null || agents === null
|
if (!keys) return <div className="px-8 py-3 text-gray-500 text-xs">Laden...</div>
|
||||||
if (loading) {
|
|
||||||
return <div className="px-8 py-3 text-gray-500 text-xs bg-gray-800/20 border-t border-gray-800">Laden...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agent-Key fuer diesen Kunden ermitteln (zum Abgleich welcher Agent ihn nutzt)
|
|
||||||
const agentKey = keys.find(k => k.permissions === 'agent')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-800/20 border-t border-gray-800 px-8 py-3 grid grid-cols-1 md:grid-cols-2 gap-x-10 gap-y-3">
|
<div className="px-8 py-3 space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{/* Agents */}
|
<Key className="w-3.5 h-3.5 text-gray-500" />
|
||||||
<div className="space-y-1.5">
|
<span className="text-xs text-gray-500 font-medium">API-Keys</span>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Monitor className="w-3.5 h-3.5 text-gray-500" />
|
|
||||||
<span className="text-xs text-gray-500 font-medium">Agents ({agents.length})</span>
|
|
||||||
</div>
|
|
||||||
{agents.length === 0 ? (
|
|
||||||
<div className="text-xs text-gray-600">Keine Agents zugeordnet</div>
|
|
||||||
) : (
|
|
||||||
agents.map((a) => {
|
|
||||||
const wrongKey = agentKey && a.last_api_key && a.last_api_key !== agentKey.key
|
|
||||||
const unknownKey = agentKey && !a.last_api_key
|
|
||||||
return (
|
|
||||||
<div key={a.id} className={`flex items-center gap-2.5 rounded px-1.5 py-0.5 -mx-1.5 ${wrongKey ? 'bg-red-900/20' : ''}`}>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
|
||||||
a.status === 'online' ? 'bg-green-400' :
|
|
||||||
a.status === 'stale' ? 'bg-yellow-400' : 'bg-gray-600'
|
|
||||||
}`} />
|
|
||||||
<span className={`text-xs flex-1 min-w-0 truncate ${wrongKey ? 'text-red-300' : 'text-gray-300'}`}>
|
|
||||||
{a.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">{a.agent_version || '—'}</span>
|
|
||||||
{wrongKey && (
|
|
||||||
<span title={`Falscher Key: ${a.last_api_key?.substring(0,8)}...`}>
|
|
||||||
<AlertTriangle className="w-3 h-3 text-red-400 flex-shrink-0" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{unknownKey && (
|
|
||||||
<span title="Noch kein Heartbeat mit Key-Info">
|
|
||||||
<AlertTriangle className="w-3 h-3 text-gray-600 flex-shrink-0" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{keys.length === 0 ? (
|
||||||
{/* API-Keys */}
|
<div className="text-xs text-gray-600">Keine Keys</div>
|
||||||
<div className="space-y-1.5">
|
) : (
|
||||||
<div className="flex items-center gap-2 mb-2">
|
keys.map((k) => {
|
||||||
<Key className="w-3.5 h-3.5 text-gray-500" />
|
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
|
||||||
<span className="text-xs text-gray-500 font-medium">API-Keys</span>
|
return (
|
||||||
</div>
|
<div key={k.id} className="flex items-center gap-2.5">
|
||||||
{keys.length === 0 ? (
|
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none w-16 text-center flex-shrink-0 ${perm.color}`}>
|
||||||
<div className="text-xs text-gray-600">Keine Keys</div>
|
{perm.label}
|
||||||
) : (
|
</span>
|
||||||
keys.map((k) => {
|
<code className="text-xs text-gray-500 font-mono flex-1 min-w-0 truncate">
|
||||||
const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read
|
{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}
|
||||||
return (
|
</code>
|
||||||
<div key={k.id} className="flex items-center gap-2.5">
|
<button
|
||||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded border leading-none w-16 text-center flex-shrink-0 ${perm.color}`}>
|
onClick={() => copyKey(k.key, k.id)}
|
||||||
{perm.label}
|
className="text-gray-600 hover:text-orange-400 transition-colors flex-shrink-0"
|
||||||
</span>
|
title="Key kopieren"
|
||||||
<code className="text-xs text-gray-500 font-mono flex-1 min-w-0 truncate">
|
>
|
||||||
{k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)}
|
{copied === k.id ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
</code>
|
</button>
|
||||||
<button
|
</div>
|
||||||
onClick={(e) => { e.stopPropagation(); copyKey(k.key, k.id) }}
|
)
|
||||||
className="text-gray-600 hover:text-orange-400 transition-colors flex-shrink-0"
|
})
|
||||||
title="Key kopieren"
|
)}
|
||||||
>
|
</div>
|
||||||
{copied === k.id
|
)
|
||||||
? <Check className="w-3.5 h-3.5 text-green-400" />
|
}
|
||||||
: <Copy className="w-3.5 h-3.5" />
|
|
||||||
}
|
// ── WAU-Tab ───────────────────────────────────────────────────────────────────
|
||||||
</button>
|
|
||||||
</div>
|
// Heuristik: Anzeigename → winget-ID-Vorschlag
|
||||||
)
|
function guessWingetId(name) {
|
||||||
})
|
// Versionsnummer am Ende entfernen: "Mozilla Firefox 134.0.1" → "Mozilla Firefox"
|
||||||
)}
|
const clean = name.replace(/\s+\d[\d.]*(\s+\(.*\))?$/, '').trim()
|
||||||
</div>
|
// "Publisher Name" → "Publisher.Name" (erste zwei Wörter)
|
||||||
|
const parts = clean.split(/\s+/)
|
||||||
|
if (parts.length >= 2) return `${parts[0]}.${parts.slice(1).join('')}`
|
||||||
|
return clean
|
||||||
|
}
|
||||||
|
|
||||||
|
function WauTab({ customerId, customerNumber }) {
|
||||||
|
const [rules, setRules] = useState(null)
|
||||||
|
const [inventory, setInventory] = useState(null)
|
||||||
|
const [invSearch, setInvSearch] = useState('')
|
||||||
|
const [loadingInv, setLoadingInv] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [externalUrl, setExternalUrl] = useState('')
|
||||||
|
// Inline-Assign: { index, ruleType, wingetId, displayName }
|
||||||
|
const [pending, setPending] = useState(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
// Manuelles Formular (Fallback)
|
||||||
|
const [showManual, setShowManual] = useState(false)
|
||||||
|
const [manualForm, setManualForm] = useState({ wingetId: '', displayName: '', ruleType: 'allow' })
|
||||||
|
|
||||||
|
const reload = () => api.getWAURules(customerId).then(setRules)
|
||||||
|
|
||||||
|
const loadInventory = () => {
|
||||||
|
setLoadingInv(true)
|
||||||
|
api.getWAUInventory(customerId).then(setInventory).finally(() => setLoadingInv(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload()
|
||||||
|
loadInventory()
|
||||||
|
api.getSettings().then(s => {
|
||||||
|
const url = s?.data?.backend_url_external || s?.backend_url_external
|
||||||
|
if (url) setExternalUrl(url.replace(/\/$/, ''))
|
||||||
|
})
|
||||||
|
}, [customerId])
|
||||||
|
|
||||||
|
const handleDelete = async (ruleId) => {
|
||||||
|
await api.deleteWAURule(customerId, ruleId)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline-Assign starten
|
||||||
|
const startAssign = (entry, idx, ruleType) => {
|
||||||
|
setPending({ index: idx, ruleType, wingetId: guessWingetId(entry.name), displayName: entry.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline-Assign bestätigen
|
||||||
|
const confirmAssign = async () => {
|
||||||
|
if (!pending?.wingetId.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.addWAURule(customerId, pending.wingetId.trim(), pending.displayName.trim(), pending.ruleType)
|
||||||
|
setPending(null)
|
||||||
|
reload()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manuell hinzufügen
|
||||||
|
const handleManualAdd = async () => {
|
||||||
|
if (!manualForm.wingetId.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.addWAURule(customerId, manualForm.wingetId.trim(), manualForm.displayName.trim(), manualForm.ruleType)
|
||||||
|
setManualForm({ wingetId: '', displayName: '', ruleType: 'allow' })
|
||||||
|
setShowManual(false)
|
||||||
|
reload()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowRules = (rules || []).filter(r => r.rule_type === 'allow')
|
||||||
|
const blockRules = (rules || []).filter(r => r.rule_type === 'block')
|
||||||
|
|
||||||
|
const filteredInv = (inventory || []).filter(e =>
|
||||||
|
!invSearch ||
|
||||||
|
e.name.toLowerCase().includes(invSearch.toLowerCase()) ||
|
||||||
|
(e.publisher || '').toLowerCase().includes(invSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseUrl = externalUrl || window.location.origin
|
||||||
|
const wauCmd = externalUrl
|
||||||
|
? `winget install Romanitho.Winget-AutoUpdate --silent --accept-package-agreements --accept-source-agreements --override "/qn NOTIFICATIONLEVEL=2 RUNONSTARTUP=1 UPDATESINTERVAL=Daily LISTPATH=${baseUrl}/api/v1/customers/${customerId}/wau/included BLOCKEDAPPSLISTPATH=${baseUrl}/api/v1/customers/${customerId}/wau/excluded"`
|
||||||
|
: '⚠ Externe Backend-URL nicht konfiguriert — bitte unter Einstellungen → Backend-URL (extern) eintragen'
|
||||||
|
|
||||||
|
const copyCmd = () => {
|
||||||
|
copyToClipboard(wauCmd)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
|
||||||
|
{/* Installationsbefehl */}
|
||||||
|
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs text-gray-400 font-medium">WAU-Installationsbefehl</span>
|
||||||
|
<button onClick={copyCmd} className="flex items-center gap-1 text-[10px] text-gray-500 hover:text-orange-400 transition-colors">
|
||||||
|
{copied ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
|
||||||
|
{copied ? 'Kopiert' : 'Kopieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<code className="text-[10px] text-gray-400 font-mono break-all leading-relaxed">{wauCmd}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
|
||||||
|
{/* Linke Spalte: Regeln */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<RuleList title="Allowlist" icon={ShieldCheck} color="text-green-400" rules={allowRules} onDelete={handleDelete} />
|
||||||
|
<RuleList title="Blocklist" icon={ShieldX} color="text-red-400" rules={blockRules} onDelete={handleDelete} />
|
||||||
|
|
||||||
|
{/* Manuell hinzufügen (Fallback) */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManual(v => !v)}
|
||||||
|
className="text-[10px] text-gray-600 hover:text-gray-400 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Manuell per winget-ID hinzufügen
|
||||||
|
</button>
|
||||||
|
{showManual && (
|
||||||
|
<div className="mt-2 bg-gray-900 rounded-lg border border-gray-700 p-3 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={manualForm.ruleType}
|
||||||
|
onChange={e => setManualForm(f => ({ ...f, ruleType: e.target.value }))}
|
||||||
|
className="bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500 w-24 shrink-0"
|
||||||
|
>
|
||||||
|
<option value="allow">Allowlist</option>
|
||||||
|
<option value="block">Blocklist</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
value={manualForm.wingetId}
|
||||||
|
onChange={e => setManualForm(f => ({ ...f, wingetId: e.target.value }))}
|
||||||
|
placeholder="Mozilla.Firefox"
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-white font-mono focus:outline-none focus:border-orange-500 min-w-0"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
value={manualForm.displayName}
|
||||||
|
onChange={e => setManualForm(f => ({ ...f, displayName: e.target.value }))}
|
||||||
|
placeholder="Anzeigename (optional)"
|
||||||
|
className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-xs text-white focus:outline-none focus:border-orange-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleManualAdd}
|
||||||
|
disabled={!manualForm.wingetId.trim() || saving}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-700 rounded text-xs text-white transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rechte Spalte: Software-Inventar */}
|
||||||
|
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden flex flex-col max-h-[520px]">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-700 shrink-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Package className="w-3.5 h-3.5 text-gray-500" />
|
||||||
|
<span className="text-xs text-gray-400 font-medium">
|
||||||
|
Software-Inventar ({inventory ? filteredInv.length : '…'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={loadInventory} disabled={loadingInv} className="text-gray-600 hover:text-gray-400">
|
||||||
|
<RefreshCw className={`w-3 h-3 ${loadingInv ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-2 py-1.5 border-b border-gray-800 shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1.5 w-3 h-3 text-gray-600" />
|
||||||
|
<input
|
||||||
|
value={invSearch}
|
||||||
|
onChange={e => setInvSearch(e.target.value)}
|
||||||
|
placeholder="Suchen..."
|
||||||
|
className="w-full pl-6 pr-2 py-1 bg-gray-800 border border-gray-700 rounded text-xs text-white focus:outline-none focus:border-orange-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{loadingInv ? (
|
||||||
|
<div className="px-3 py-4 text-xs text-gray-500">Lade Inventar...</div>
|
||||||
|
) : !inventory || inventory.length === 0 ? (
|
||||||
|
<div className="px-3 py-4 text-xs text-gray-600">
|
||||||
|
Kein Inventar — Agent noch nicht aktualisiert oder kein Heartbeat.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<tbody>
|
||||||
|
{filteredInv.map((e, i) => {
|
||||||
|
const existingRule = (rules || []).find(r => r.display_name === e.name)
|
||||||
|
const isPending = pending?.index === i
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Haupt-Zeile */}
|
||||||
|
<tr
|
||||||
|
key={`row-${i}`}
|
||||||
|
className={`border-b border-gray-800/50 group transition-colors ${isPending ? 'bg-gray-800/60' : 'hover:bg-gray-800/40'}`}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-1.5 w-full">
|
||||||
|
<div className="text-gray-300 truncate" title={e.name}>{e.name}</div>
|
||||||
|
{e.publisher && <div className="text-gray-600 text-[10px] truncate">{e.publisher}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1.5 text-gray-600 text-right shrink-0 whitespace-nowrap">
|
||||||
|
<span className="text-[10px]">{e.device_count}x</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 shrink-0 whitespace-nowrap">
|
||||||
|
{existingRule ? (
|
||||||
|
// Schon zugeordnet — Badge + Entfernen
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||||
|
existingRule.rule_type === 'allow'
|
||||||
|
? 'bg-green-900/40 text-green-400'
|
||||||
|
: 'bg-red-900/40 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{existingRule.rule_type === 'allow' ? 'Allow' : 'Block'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(existingRule.id)}
|
||||||
|
className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
title="Regel entfernen"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : isPending ? (
|
||||||
|
// Inline-Assign offen → Abbrechen
|
||||||
|
<button onClick={() => setPending(null)} className="text-gray-500 hover:text-gray-300">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
// Allow / Block Buttons on hover
|
||||||
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-all">
|
||||||
|
<button
|
||||||
|
onClick={() => startAssign(e, i, 'allow')}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded bg-green-900/30 text-green-400 hover:bg-green-800/50 font-medium"
|
||||||
|
title="Zur Allowlist hinzufügen"
|
||||||
|
>
|
||||||
|
Allow
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => startAssign(e, i, 'block')}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/30 text-red-400 hover:bg-red-800/50 font-medium"
|
||||||
|
title="Zur Blocklist hinzufügen"
|
||||||
|
>
|
||||||
|
Block
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Inline-Assign Zeile */}
|
||||||
|
{isPending && (
|
||||||
|
<tr key={`pending-${i}`} className="border-b border-orange-500/30 bg-gray-800/80">
|
||||||
|
<td colSpan={3} className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] font-bold shrink-0 px-1.5 py-0.5 rounded ${
|
||||||
|
pending.ruleType === 'allow'
|
||||||
|
? 'bg-green-900/40 text-green-400'
|
||||||
|
: 'bg-red-900/40 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{pending.ruleType === 'allow' ? 'Allow' : 'Block'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
value={pending.wingetId}
|
||||||
|
onChange={e => setPending(p => ({ ...p, wingetId: e.target.value }))}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') confirmAssign(); if (e.key === 'Escape') setPending(null) }}
|
||||||
|
placeholder="winget-ID bestätigen"
|
||||||
|
className="flex-1 bg-gray-700 border border-orange-500/50 rounded px-2 py-1 text-xs text-white font-mono focus:outline-none focus:border-orange-400 min-w-0"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={confirmAssign}
|
||||||
|
disabled={!pending.wingetId.trim() || saving}
|
||||||
|
className="shrink-0 p-1 rounded bg-orange-600 hover:bg-orange-500 disabled:bg-gray-700 text-white transition-colors"
|
||||||
|
title="Speichern"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-500 mt-1 pl-[60px]">
|
||||||
|
Winget-ID anpassen falls nötig, dann bestätigen (Enter)
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RuleList ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function RuleList({ title, icon: Icon, color, rules, onDelete }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-gray-700">
|
||||||
|
<Icon className={`w-3.5 h-3.5 ${color}`} />
|
||||||
|
<span className="text-xs text-gray-400 font-medium">{title}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-gray-600">{rules.length} Einträge</span>
|
||||||
|
</div>
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<div className="px-3 py-3 text-xs text-gray-600">Keine Einträge</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-800">
|
||||||
|
{rules.map(r => (
|
||||||
|
<div key={r.id} className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-800/40 group">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-white truncate">{r.display_name || r.winget_id}</div>
|
||||||
|
<div className="text-[10px] text-gray-600 font-mono">{r.winget_id}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(r.id)}
|
||||||
|
className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user