WAU Compliance: agent collector, backend endpoints, frontend badge + panel

- Agent: WAU registry config check (include/exclude URLs), pending updates count
- Backend: /wau/compliance per customer, /wau/compliance/summary all customers
- Frontend: compliance badge in customer list, compliance panel in WAU tab
- config.js: no longer gitignored (secrets-free, reads from env/window.__RMM_CONFIG__)
- vite.config.js: reads backend URL from .env instead of hardcoded value
- .env.example added for clean setup
This commit is contained in:
cynfo3000
2026-03-10 17:38:58 +01:00
parent bf51ed8569
commit 54ddc0b902
12 changed files with 677 additions and 19 deletions
+6
View File
@@ -0,0 +1,6 @@
# RMM Frontend Konfiguration
# Datei kopieren: cp .env.example .env (wird nicht eingecheckt)
VITE_BACKEND_HOST=your-backend.example.com
VITE_BACKEND_PORT=8443
VITE_API_KEY=your-api-key-here
+8
View File
@@ -275,6 +275,14 @@ class ApiClient {
return this.get(`/api/v1/customers/${customerId}/wau/inventory`)
}
getWAUCompliance(customerId) {
return this.get(`/api/v1/customers/${customerId}/wau/compliance`)
}
getWAUComplianceSummary() {
return this.get('/api/v1/wau/compliance/summary')
}
createAPIKey(name, permissions = 'read', customerId = null) {
return this.post('/api/v1/apikeys', { name, permissions, customer_id: customerId })
}
+8
View File
@@ -0,0 +1,8 @@
// Frontend-Konfiguration
// Priorität: 1. window.__RMM_CONFIG__ (Docker Runtime), 2. Vite ENV (lokaler Build)
const rt = window.__RMM_CONFIG__ || {}
export const BACKEND_HOST = rt.backendHost || import.meta.env.VITE_BACKEND_HOST || 'localhost'
export const BACKEND_PORT = rt.backendPort || import.meta.env.VITE_BACKEND_PORT || '8443'
export const BACKEND_URL = `https://${BACKEND_HOST}:${BACKEND_PORT}`
export const API_KEY = rt.apiKey || import.meta.env.VITE_API_KEY || ''
+227 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import api from '../api/client'
import { copyToClipboard } from '../utils/clipboard'
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle, ShieldCheck, ShieldX, Package, Search, RefreshCw } from 'lucide-react'
import { Plus, Trash2, Edit2, X, Check, ChevronDown, ChevronRight, Copy, Key, Monitor, AlertTriangle, ShieldCheck, ShieldX, ShieldAlert, Package, Search, RefreshCw, CircleCheck, CircleX, CircleAlert, Info } from 'lucide-react'
const PERM_LABELS = {
agent: { label: 'Agent', color: 'text-blue-400 bg-blue-900/30 border-blue-700/50' },
@@ -10,6 +10,45 @@ const PERM_LABELS = {
admin: { label: 'Admin', color: 'text-orange-400 bg-orange-900/30 border-orange-700/50' },
}
// ── WAU Compliance Badge ──────────────────────────────────────────────────────
function WAUComplianceBadge({ summary }) {
if (!summary) return null
if (summary.total_agents === 0) return null
const { status, compliant_agents, total_agents, wau_missing, wau_misconfigured, packages_missing, updates_pending } = summary
const configs = {
ok: { icon: CircleCheck, cls: 'text-green-400', bg: 'bg-green-900/20 border-green-700/40', label: `WAU OK (${compliant_agents}/${total_agents})` },
warning: { icon: CircleAlert, cls: 'text-yellow-400', bg: 'bg-yellow-900/20 border-yellow-700/40', label: buildWarningLabel({ wau_misconfigured, updates_pending, compliant_agents, total_agents }) },
critical: { icon: CircleX, cls: 'text-red-400', bg: 'bg-red-900/20 border-red-700/40', label: buildCriticalLabel({ wau_missing, packages_missing, compliant_agents, total_agents }) },
unknown: { icon: Info, cls: 'text-gray-500', bg: 'bg-gray-800/40 border-gray-700/40', label: 'Keine Windows-Agents' },
}
const cfg = configs[status] || configs.unknown
const Icon = cfg.icon
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[10px] font-medium ${cfg.cls} ${cfg.bg}`}>
<Icon className="w-3 h-3" />
{cfg.label}
</span>
)
}
function buildWarningLabel({ wau_misconfigured, updates_pending, compliant_agents, total_agents }) {
const parts = []
if (wau_misconfigured > 0) parts.push(`${wau_misconfigured} fehlkonfiguriert`)
if (updates_pending > 0) parts.push(`${updates_pending} Updates`)
return parts.length ? parts.join(', ') : `${compliant_agents}/${total_agents} OK`
}
function buildCriticalLabel({ wau_missing, packages_missing, compliant_agents, total_agents }) {
const parts = []
if (wau_missing > 0) parts.push(`${wau_missing} ohne WAU`)
if (packages_missing > 0) parts.push(`${packages_missing} Pakete fehlen`)
return parts.length ? parts.join(', ') : `${compliant_agents}/${total_agents} OK`
}
export default function Customers() {
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
@@ -17,12 +56,21 @@ export default function Customers() {
const [editId, setEditId] = useState(null)
const [form, setForm] = useState({ number: '', name: '' })
const [expandedId, setExpandedId] = useState(null)
const [complianceSummaries, setComplianceSummaries] = useState({}) // customerID → summary
const reload = () => {
api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false))
}
useEffect(() => { reload() }, [])
const loadCompliance = () => {
api.getWAUComplianceSummary().then(data => {
const map = {}
;(data || []).forEach(s => { map[s.customer_id] = s })
setComplianceSummaries(map)
}).catch(() => {})
}
useEffect(() => { reload(); loadCompliance() }, [])
const handleAdd = async () => {
if (!form.number || !form.name) return
@@ -68,6 +116,7 @@ export default function Customers() {
<th className="px-4 py-2 font-medium w-6"></th>
<th className="px-4 py-2 font-medium">Nummer</th>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium hidden lg:table-cell">WAU</th>
<th className="px-4 py-2 font-medium hidden md:table-cell">Erstellt</th>
<th className="px-4 py-2 font-medium w-24"></th>
</tr>
@@ -156,6 +205,9 @@ export default function Customers() {
</td>
<td className="px-4 py-2.5 text-orange-400 font-mono">{c.number}</td>
<td className="px-4 py-2.5 text-white">{c.name}</td>
<td className="px-4 py-2.5 hidden lg:table-cell">
<WAUComplianceBadge summary={complianceSummaries[c.id]} />
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">
{new Date(c.created_at).toLocaleDateString('de-DE')}
</td>
@@ -179,7 +231,7 @@ export default function Customers() {
)}
{expandedId === c.id && (
<tr key={`expand-${c.id}`}>
<td colSpan={5} className="px-0 py-0">
<td colSpan={6} className="px-0 py-0">
<CustomerDetailPanel customerId={c.id} customerNumber={c.number} />
</td>
</tr>
@@ -339,6 +391,165 @@ function APIKeysTab({ customerId }) {
)
}
// ── WAU Compliance Panel ──────────────────────────────────────────────────────
function WAUCompliancePanel({ compliance, loading, onRefresh }) {
const [expanded, setExpanded] = useState(false)
if (loading) return (
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3 text-xs text-gray-500 flex items-center gap-2">
<RefreshCw className="w-3 h-3 animate-spin" /> Compliance wird geprueft...
</div>
)
const agents = compliance?.agents || []
if (agents.length === 0) return null
const compliantCount = agents.filter(a => a.compliant).length
const allOk = compliantCount === agents.length
const overallStatus = agents.some(a => !a.wau_installed || a.packages?.some(p => !p.installed))
? 'critical'
: agents.some(a => !a.wau_config_ok || a.pending_updates > 0)
? 'warning'
: 'ok'
const statusColors = {
ok: 'border-green-700/40 bg-green-900/10',
warning: 'border-yellow-700/40 bg-yellow-900/10',
critical: 'border-red-700/40 bg-red-900/10',
}
const headerColors = {
ok: 'text-green-400',
warning: 'text-yellow-400',
critical: 'text-red-400',
}
const StatusIcon = overallStatus === 'ok' ? CircleCheck : overallStatus === 'warning' ? CircleAlert : CircleX
return (
<div className={`rounded-lg border p-3 ${statusColors[overallStatus]}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusIcon className={`w-4 h-4 ${headerColors[overallStatus]}`} />
<span className={`text-xs font-medium ${headerColors[overallStatus]}`}>
WAU Compliance: {compliantCount}/{agents.length} Agents konform
</span>
</div>
<div className="flex items-center gap-2">
<button onClick={onRefresh} className="text-gray-600 hover:text-gray-400 transition-colors">
<RefreshCw className="w-3 h-3" />
</button>
<button
onClick={() => setExpanded(v => !v)}
className="text-xs text-gray-500 hover:text-gray-300 flex items-center gap-1"
>
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
Details
</button>
</div>
</div>
{expanded && (
<div className="mt-3 space-y-2">
{agents.map(agent => (
<AgentComplianceRow key={agent.agent_id} agent={agent} expectedIncludeUrl={compliance?.include_url} />
))}
</div>
)}
</div>
)
}
function AgentComplianceRow({ agent, expectedIncludeUrl }) {
const [open, setOpen] = useState(false)
const missingPkgs = (agent.packages || []).filter(p => !p.installed)
const hasIssues = !agent.wau_installed || !agent.wau_config_ok || missingPkgs.length > 0 || agent.pending_updates > 0
const rowColor = agent.compliant ? 'text-green-400' : hasIssues ? 'text-red-400' : 'text-yellow-400'
const RowIcon = agent.compliant ? CircleCheck : CircleX
return (
<div className="bg-gray-900/60 rounded border border-gray-700/50">
<button
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-800/40 transition-colors"
onClick={() => setOpen(v => !v)}
>
<RowIcon className={`w-3.5 h-3.5 flex-shrink-0 ${rowColor}`} />
<span className="text-xs text-white font-mono flex-1">{agent.agent_name}</span>
<div className="flex gap-1 flex-wrap justify-end">
{!agent.wau_installed && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">WAU fehlt</span>
)}
{agent.wau_installed && !agent.wau_config_ok && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 border border-yellow-700/40 text-yellow-400">Falsche URLs</span>
)}
{missingPkgs.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/40 border border-red-700/40 text-red-400">{missingPkgs.length} Paket(e) fehlen</span>
)}
{agent.pending_updates > 0 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-yellow-900/40 border border-yellow-700/40 text-yellow-400">{agent.pending_updates} Updates</span>
)}
{agent.compliant && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-900/40 border border-green-700/40 text-green-400">OK</span>
)}
</div>
{open ? <ChevronDown className="w-3 h-3 text-gray-600 flex-shrink-0" /> : <ChevronRight className="w-3 h-3 text-gray-600 flex-shrink-0" />}
</button>
{open && (
<div className="px-4 pb-3 pt-1 space-y-2 border-t border-gray-700/40">
{/* WAU Status */}
<div className="text-[10px] text-gray-400 space-y-1">
<div className="flex items-center gap-1.5">
{agent.wau_installed
? <CircleCheck className="w-3 h-3 text-green-400" />
: <CircleX className="w-3 h-3 text-red-400" />
}
<span>WAU {agent.wau_installed ? `installiert` : 'nicht installiert'}</span>
</div>
{agent.wau_installed && (
<div className="flex items-center gap-1.5">
{agent.wau_config_ok
? <CircleCheck className="w-3 h-3 text-green-400" />
: <CircleX className="w-3 h-3 text-red-400" />
}
<span>
Konfiguration {agent.wau_config_ok ? 'korrekt' : 'falsch'}
{!agent.wau_config_ok && agent.include_url && (
<span className="text-gray-600 ml-1">({agent.include_url || 'keine URL'})</span>
)}
</span>
</div>
)}
{agent.pending_updates > 0 && (
<div className="flex items-center gap-1.5">
<CircleAlert className="w-3 h-3 text-yellow-400" />
<span>{agent.pending_updates} ausstehende Updates</span>
</div>
)}
</div>
{/* Paket-Status */}
{(agent.packages || []).length > 0 && (
<div className="space-y-0.5">
<div className="text-[10px] text-gray-600 font-medium uppercase tracking-wide mb-1">Pflichtpakete</div>
{(agent.packages || []).map(pkg => (
<div key={pkg.winget_id} className="flex items-center gap-1.5 text-[10px]">
{pkg.installed
? <CircleCheck className="w-3 h-3 text-green-400 flex-shrink-0" />
: <CircleX className="w-3 h-3 text-red-400 flex-shrink-0" />
}
<span className={pkg.installed ? 'text-gray-400' : 'text-red-300'}>{pkg.display_name}</span>
<span className="text-gray-700 font-mono">{pkg.winget_id}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
// ── WAU-Tab ───────────────────────────────────────────────────────────────────
// Heuristik: Anzeigename → winget-ID-Vorschlag
@@ -364,6 +575,10 @@ function WauTab({ customerId, customerNumber }) {
// Manuelles Formular (Fallback)
const [showManual, setShowManual] = useState(false)
const [manualForm, setManualForm] = useState({ wingetId: '', displayName: '', ruleType: 'allow' })
// Compliance
const [compliance, setCompliance] = useState(null)
const [loadingComp, setLoadingComp] = useState(false)
const [showCompliance, setShowCompliance] = useState(false)
const reload = () => api.getWAURules(customerId).then(setRules)
@@ -372,9 +587,15 @@ function WauTab({ customerId, customerNumber }) {
api.getWAUInventory(customerId).then(setInventory).finally(() => setLoadingInv(false))
}
const loadCompliance = () => {
setLoadingComp(true)
api.getWAUCompliance(customerId).then(setCompliance).finally(() => setLoadingComp(false))
}
useEffect(() => {
reload()
loadInventory()
loadCompliance()
api.getSettings().then(s => {
const url = s?.data?.backend_url_external || s?.backend_url_external
if (url) setExternalUrl(url.replace(/\/$/, ''))
@@ -441,6 +662,9 @@ function WauTab({ customerId, customerNumber }) {
return (
<div className="px-6 py-4 space-y-4">
{/* WAU Compliance Status */}
<WAUCompliancePanel compliance={compliance} loading={loadingComp} onRefresh={loadCompliance} />
{/* Installationsbefehl */}
<div className="bg-gray-900 rounded-lg border border-gray-700 p-3">
<div className="flex items-center justify-between mb-2">
+17 -10
View File
@@ -1,16 +1,23 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'https://your-backend:8443', // TODO: set your backend URL
changeOrigin: true,
secure: false,
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const backendHost = env.VITE_BACKEND_HOST || 'localhost'
const backendPort = env.VITE_BACKEND_PORT || '8443'
const backendUrl = `https://${backendHost}:${backendPort}`
return {
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: backendUrl,
changeOrigin: true,
secure: false,
},
},
},
},
}
})