diff --git a/agent-windows/collector/system.go b/agent-windows/collector/system.go index 2f7f028..170897f 100644 --- a/agent-windows/collector/system.go +++ b/agent-windows/collector/system.go @@ -6,6 +6,7 @@ import ( "log" "os" "os/exec" + "sort" "strconv" "strings" "time" @@ -21,16 +22,17 @@ type SystemData struct { } type WindowsData struct { - Hostname string `json:"hostname"` - OSVersion string `json:"os_version"` - OSBuild string `json:"os_build"` - UptimeSecs int64 `json:"uptime_seconds"` - CPU CPUInfo `json:"cpu"` - Memory MemoryInfo `json:"memory"` - Disks []DiskInfo `json:"disks"` - Services []SvcInfo `json:"services,omitempty"` - Domain string `json:"domain,omitempty"` - LastUpdate time.Time `json:"last_update"` + Hostname string `json:"hostname"` + OSVersion string `json:"os_version"` + OSBuild string `json:"os_build"` + UptimeSecs int64 `json:"uptime_seconds"` + CPU CPUInfo `json:"cpu"` + Memory MemoryInfo `json:"memory"` + Disks []DiskInfo `json:"disks"` + Services []SvcInfo `json:"services,omitempty"` + InstalledSoftware []SoftwareInfo `json:"installed_software,omitempty"` + Domain string `json:"domain,omitempty"` + LastUpdate time.Time `json:"last_update"` } type CPUInfo struct { @@ -62,6 +64,13 @@ type SvcInfo struct { 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 type memoryStatusEx struct { dwLength uint32 @@ -111,6 +120,9 @@ func Collect() (*SystemData, error) { // Domain wd.Domain = getDomain() + // Installierte Software (Registry) + wd.InstalledSoftware = getInstalledSoftware() + return &SystemData{Windows: wd}, nil } @@ -359,3 +371,73 @@ func min(a, b int) int { } 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 +} diff --git a/agent-windows/main.go b/agent-windows/main.go index b3b16b2..4eca756 100644 --- a/agent-windows/main.go +++ b/agent-windows/main.go @@ -27,7 +27,7 @@ import ( "github.com/cynfo/rmm-agent-windows/ws" ) -var Version = "1.0.0" +var Version = "1.1.0" const ( ServiceName = "RMMAgent" diff --git a/backend/api/handlers.go b/backend/api/handlers.go index 5647779..2ad0f9e 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -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("GET /api/v1/customers/{id}/apikeys", h.listCustomerAPIKeys) + // WAU-Regeln + h.setupWAURoutes(mux) + // System Settings mux.HandleFunc("GET /api/v1/settings", h.getSettings) mux.HandleFunc("PUT /api/v1/settings", h.updateSettings) diff --git a/backend/api/wau.go b/backend/api/wau.go new file mode 100644 index 0000000..8c53039 --- /dev/null +++ b/backend/api/wau.go @@ -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")) + } +} diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 03b10c2..1a92279 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -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(`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) d.setupRetention() diff --git a/backend/db/wau.go b/backend/db/wau.go new file mode 100644 index 0000000..b78880b --- /dev/null +++ b/backend/db/wau.go @@ -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() +} diff --git a/backend/models/wau.go b/backend/models/wau.go new file mode 100644 index 0000000..5055bec --- /dev/null +++ b/backend/models/wau.go @@ -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"` +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index d06bbc6..4f11df1 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -254,6 +254,27 @@ class ApiClient { 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) { return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId }) } diff --git a/frontend/src/components/WindowsPanel.jsx b/frontend/src/components/WindowsPanel.jsx index f76678d..92cd949 100644 --- a/frontend/src/components/WindowsPanel.jsx +++ b/frontend/src/components/WindowsPanel.jsx @@ -7,7 +7,7 @@ import ScriptModal from './ScriptModal' import { X, Cpu, MemoryStick, HardDrive, Monitor, Server, Settings, Download, Trash2, RefreshCw, Package, Search, Play, - ShieldCheck, Bot, Cable, CalendarClock, + ShieldCheck, Bot, Cable, CalendarClock, Zap, CheckCircle2, AlertCircle, Clock, } from 'lucide-react' // ── Navigation ──────────────────────────────────────────────────────────────── @@ -23,6 +23,7 @@ const subTabs = { { id: 'installed', label: 'Installiert', icon: Package }, { id: 'winget', label: 'winget Updates', icon: RefreshCw }, { id: 'wupdates', label: 'Windows Updates', icon: ShieldCheck }, + { id: 'wau', label: 'WAU', icon: Zap }, ], system: [ { id: 'services', label: 'Dienste', icon: Settings }, @@ -136,9 +137,10 @@ export default function WindowsPanel({ agentId, customers, onClose, onReload }) const renderContent = () => { if (mainTab === 'uebersicht') return const t = activeSubTab - if (t === 'installed') return + if (t === 'installed') return if (t === 'winget') return if (t === 'wupdates') return + if (t === 'wau') return if (t === 'services') return if (t === 'tunnel') return if (t === 'tasks') return @@ -309,56 +311,74 @@ function OverviewTab({ win, agent, status }) { ) } -// ── Software / winget ───────────────────────────────────────────────────────── +// ── Software / installiert ──────────────────────────────────────────────────── -function WingetListTab({ agentId }) { - const [output, setOutput] = useState(null) - const [loading, setLoading] = useState(false) +function InstalledSoftwareTab({ win }) { const [search, setSearch] = useState('') + const software = win?.installed_software || [] - const load = async () => { - setLoading(true) - try { - const res = await api.execCommand(agentId, 'winget list --accept-source-agreements', 30) - setOutput(res?.output || res?.data?.output || '') - } catch (e) { - setOutput(`Fehler: ${e.message}`) - } finally { - setLoading(false) - } + const filtered = search + ? software.filter(s => + s.name.toLowerCase().includes(search.toLowerCase()) || + (s.publisher || '').toLowerCase().includes(search.toLowerCase()) + ) + : software + + if (software.length === 0) { + return ( +
+ Keine Software-Daten verfügbar — Agent läuft möglicherweise noch auf einer alten Version. +
+ ) } - 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 (
-
+
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" />
- + {filtered.length} / {software.length} +
+ +
+ + + + + + + + + + + {filtered.map((s, i) => ( + + + + + + + ))} + +
NameVersionHerstellerInstalliert
{s.name}{s.version || '—'}{s.publisher || '—'}{formatInstallDate(s.install_date)}
+ {filtered.length === 0 && ( +
Keine Treffer
+ )}
- {loading ? ( -
Lade Liste...
- ) : ( -
-          {filtered.join('\n') || 'Keine Eintraege'}
-        
- )}
) } +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 }) { const [output, setOutput] = useState(null) 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 ( +
+ + {/* Status-Card */} +
+
+
+ + Winget-AutoUpdate +
+ +
+ + {loading ? ( +
Prüfe Status...
+ ) : status === null ? ( +
Status konnte nicht abgerufen werden
+ ) : ( +
+ {/* Installiert / nicht installiert */} +
+ {status.Installed ? ( + + ) : ( + + )} +
+
+ {status.Installed ? 'WAU installiert' : 'WAU nicht installiert'} +
+ {status.Version && ( +
Version {status.Version}
+ )} +
+
+ + {/* Task-Status + letzter Lauf */} + {status.Installed && ( +
+
+
Geplante Aufgabe
+
+ {status.TaskExists ? status.TaskState || 'Vorhanden' : 'Fehlt'} +
+
+
+
Letzter Lauf
+
+ + {status.LastRun || '—'} +
+
+
+ )} + + {/* Aktionen */} +
+ {status.Installed ? ( + <> + + + + ) : ( +
+ {externalUrl && customerId ? ( + + ) : ( +
+ {!externalUrl + ? 'Externe Backend-URL nicht konfiguriert (Einstellungen)' + : 'Kein Kunde zugeordnet — Agent zuerst einem Kunden zuweisen'} +
+ )} +
+ )} +
+ + {/* Kunden-Listen Hinweis */} + {status.Installed && customerId && ( +
+ Allow/Blocklisten werden verwaltet unter: Kunden → Kunde auswählen → WAU-Tab +
+ )} +
+ )} +
+ + {/* Allowlist Desired State */} + {customerId && ( +
+
+
+ + Gewünschten Zustand herstellen +
+ {allowRules.length} Pakete in Allowlist +
+ + {allowRules.length === 0 ? ( +
Keine Allowlist konfiguriert — in der Kundenverwaltung unter WAU hinterlegen.
+ ) : ( + <> +
+ {allowRules.map(r => ( + + {r.winget_id} + + ))} +
+
+ + Bereits installierte Pakete werden übersprungen +
+ + )} +
+ )} + + {/* Log-Ausgabe */} + {log && ( +
+
Log-Ausgabe
+
+            {log}
+          
+
+ )} +
+ ) +} + // ── Hilfskomponenten ────────────────────────────────────────────────────────── function StatCard({ icon: Icon, label, value, sub, color, pct }) { diff --git a/frontend/src/pages/Customers.jsx b/frontend/src/pages/Customers.jsx index fc3ee23..633e1f8 100644 --- a/frontend/src/pages/Customers.jsx +++ b/frontend/src/pages/Customers.jsx @@ -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 } 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 = { 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 && ( - + )} @@ -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 ( +
+ {/* Tab-Leiste */} +
+ {tabs.map(t => { + const Icon = t.icon + return ( + + ) + })} +
+ + {/* Inhalt */} +
e.stopPropagation()}> + {activeTab === 'agents' && } + {activeTab === 'apikeys' && } + {activeTab === 'wau' && } +
+
+ ) +} + +// ── Agents-Tab ──────────────────────────────────────────────────────────────── + +function AgentsTab({ customerId }) { const [keys, setKeys] = useState(null) const [agents, setAgents] = useState(null) - const [copied, setCopied] = useState(null) useEffect(() => { api.getCustomerAPIKeys(customerId).then(setKeys) api.getCustomerAgents(customerId).then(setAgents) }, [customerId]) + if (!keys || !agents) { + return
Laden...
+ } + + const agentKey = keys.find(k => k.permissions === 'agent') + + return ( +
+
+ + Agents ({agents.length}) +
+ {agents.length === 0 ? ( +
Keine Agents zugeordnet
+ ) : ( + agents.map((a) => { + const wrongKey = agentKey && a.last_api_key && a.last_api_key !== agentKey.key + const unknownKey = agentKey && !a.last_api_key + return ( +
+ + {a.name} + {a.agent_version || '—'} + {wrongKey && } + {unknownKey && } +
+ ) + }) + )} +
+ ) +} + +// ── 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) => { copyToClipboard(key) setCopied(id) setTimeout(() => setCopied(null), 2000) } - const loading = keys === null || agents === null - if (loading) { - return
Laden...
- } - - // Agent-Key fuer diesen Kunden ermitteln (zum Abgleich welcher Agent ihn nutzt) - const agentKey = keys.find(k => k.permissions === 'agent') + if (!keys) return
Laden...
return ( -
- - {/* Agents */} -
-
- - Agents ({agents.length}) -
- {agents.length === 0 ? ( -
Keine Agents zugeordnet
- ) : ( - agents.map((a) => { - const wrongKey = agentKey && a.last_api_key && a.last_api_key !== agentKey.key - const unknownKey = agentKey && !a.last_api_key - return ( -
- - - {a.name} - - {a.agent_version || '—'} - {wrongKey && ( - - - - )} - {unknownKey && ( - - - - )} -
- ) - }) - )} +
+
+ + API-Keys
- - {/* API-Keys */} -
-
- - API-Keys -
- {keys.length === 0 ? ( -
Keine Keys
- ) : ( - keys.map((k) => { - const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read - return ( -
- - {perm.label} - - - {k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)} - - -
- ) - }) - )} -
- + {keys.length === 0 ? ( +
Keine Keys
+ ) : ( + keys.map((k) => { + const perm = PERM_LABELS[k.permissions] || PERM_LABELS.read + return ( +
+ + {perm.label} + + + {k.key.substring(0, 8)}...{k.key.substring(k.key.length - 4)} + + +
+ ) + }) + )} +
+ ) +} + +// ── WAU-Tab ─────────────────────────────────────────────────────────────────── + +// 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() + // "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 ( +
+ + {/* Installationsbefehl */} +
+
+ WAU-Installationsbefehl + +
+ {wauCmd} +
+ +
+ + {/* Linke Spalte: Regeln */} +
+ + + + {/* Manuell hinzufügen (Fallback) */} +
+ + {showManual && ( +
+
+ + 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 + /> +
+
+ 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" + /> + +
+
+ )} +
+
+ + {/* Rechte Spalte: Software-Inventar */} +
+
+
+ + + Software-Inventar ({inventory ? filteredInv.length : '…'}) + +
+ +
+ +
+
+ + 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" + /> +
+
+ +
+ {loadingInv ? ( +
Lade Inventar...
+ ) : !inventory || inventory.length === 0 ? ( +
+ Kein Inventar — Agent noch nicht aktualisiert oder kein Heartbeat. +
+ ) : ( + + + {filteredInv.map((e, i) => { + const existingRule = (rules || []).find(r => r.display_name === e.name) + const isPending = pending?.index === i + + return ( + <> + {/* Haupt-Zeile */} + + + + + + + {/* Inline-Assign Zeile */} + {isPending && ( + + + + )} + + ) + })} + +
+
{e.name}
+ {e.publisher &&
{e.publisher}
} +
+ {e.device_count}x + + {existingRule ? ( + // Schon zugeordnet — Badge + Entfernen +
+ + {existingRule.rule_type === 'allow' ? 'Allow' : 'Block'} + + +
+ ) : isPending ? ( + // Inline-Assign offen → Abbrechen + + ) : ( + // Allow / Block Buttons on hover +
+ + +
+ )} +
+
+ + {pending.ruleType === 'allow' ? 'Allow' : 'Block'} + + 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 + /> + +
+
+ Winget-ID anpassen falls nötig, dann bestätigen (Enter) +
+
+ )} +
+
+
+
+ ) +} + +// ── RuleList ────────────────────────────────────────────────────────────────── + +function RuleList({ title, icon: Icon, color, rules, onDelete }) { + return ( +
+
+ + {title} + {rules.length} Einträge +
+ {rules.length === 0 ? ( +
Keine Einträge
+ ) : ( +
+ {rules.map(r => ( +
+
+
{r.display_name || r.winget_id}
+
{r.winget_id}
+
+ +
+ ))} +
+ )}
) }