diff --git a/.gitignore b/.gitignore
index c5dd419..c47515d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,5 @@ beispielbilder/
certs/
opnsense-plugin/stage/
opnsense-plugin/*.pkg
+frontend/node_modules/
+frontend/dist/
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..18bc70e
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,16 @@
+# React + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..4fa125d
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,29 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ ecmaFeatures: { jsx: true },
+ sourceType: 'module',
+ },
+ },
+ rules: {
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+ },
+ },
+])
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..c20fbd3
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ {/* Sidebar */}
+
+
+ {/* Overlay */}
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+ {/* Main */}
+
+ {/* Top bar (mobile) */}
+
+
+
+ RMM
+
+
+ {/* Content */}
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..f55f4f7
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import './index.css'
+import App from './App'
+
+createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/frontend/src/pages/AgentDetail.jsx b/frontend/src/pages/AgentDetail.jsx
new file mode 100644
index 0000000..b69cd99
--- /dev/null
+++ b/frontend/src/pages/AgentDetail.jsx
@@ -0,0 +1,268 @@
+import { useEffect, useState } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import api from '../api/client'
+import StatusBadge from '../components/StatusBadge'
+import {
+ Cpu,
+ HardDrive,
+ MemoryStick,
+ Network,
+ Clock,
+ Shield,
+ ArrowLeft,
+ Terminal,
+ Download,
+} from 'lucide-react'
+
+export default function AgentDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ const reload = () => {
+ api.getAgent(id).then(setData).finally(() => setLoading(false))
+ }
+
+ useEffect(() => {
+ reload()
+ const interval = setInterval(reload, 30000)
+ return () => clearInterval(interval)
+ }, [id])
+
+ if (loading) return
Laden...
+ if (!data) return
Agent nicht gefunden
+
+ const { agent, system_data: sys, status } = data
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
{agent.name}
+
+
+
+ {agent.ip} — {agent.opnsense_version || 'Version unbekannt'}
+
+
+
+
api.triggerBackup(id)} />
+ {}} />
+
+
+
+ {!sys ? (
+
Noch keine Systemdaten vorhanden
+ ) : (
+ <>
+ {/* Quick Stats */}
+
+
+
+
+
+
+
+ {/* Network Interfaces */}
+ {sys.network_interfaces && sys.network_interfaces.length > 0 && (
+
+
+ {sys.network_interfaces
+ .filter((i) => i.addresses && i.addresses.length > 0)
+ .map((iface) => (
+
+
+
{iface.name}
+
+ {iface.status}
+
+
+ {iface.addresses?.filter((a) => !a.includes(':')).join(', ')}
+
+
+
+
RX: {formatBytes(iface.rx_bytes)}
+
TX: {formatBytes(iface.tx_bytes)}
+
+
+ ))}
+
+
+ )}
+
+ {/* Services */}
+ {sys.services && sys.services.length > 0 && (
+
+
+ {sys.services.map((svc) => (
+
+ {svc.name}
+
+ ))}
+
+
+ )}
+
+ {/* Gateways */}
+ {sys.gateways && sys.gateways.length > 0 && (
+
+
+
+
+ | Name |
+ Gateway |
+ Status |
+ RTT |
+ Loss |
+
+
+
+ {sys.gateways.map((gw) => (
+
+ | {gw.name} |
+ {gw.gateway} |
+
+
+ {gw.status === 'none' ? 'OK' : gw.status}
+
+ |
+ {gw.delay || '—'} |
+ {gw.loss || '—'} |
+
+ ))}
+
+
+
+ )}
+
+ {/* Disks */}
+ {sys.disks && sys.disks.length > 0 && (
+
+
+
+
+ | Dataset |
+ Mountpoint |
+ Belegt |
+ Gesamt |
+ % |
+
+
+
+ {sys.disks.map((d) => {
+ const pct = d.total_bytes > 0 ? (d.used_bytes / d.total_bytes * 100) : 0
+ return (
+
+ | {d.filesystem} |
+ {d.mount_point} |
+ {formatBytes(d.used_bytes)} |
+ {formatBytes(d.total_bytes)} |
+
+
+
+ 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-green-500'}`}
+ style={{ width: `${pct}%` }}
+ />
+
+ {pct.toFixed(0)}%
+
+ |
+
+ )
+ })}
+
+
+
+ )}
+ >
+ )}
+
+ )
+}
+
+function Section({ title, icon: Icon, children }) {
+ return (
+
+
+ {Icon && }
+ {title}
+
+ {children}
+
+ )
+}
+
+function ActionButton({ icon: Icon, label, onClick }) {
+ return (
+
+ )
+}
+
+function QuickStat({ icon: Icon, label, value, sub }) {
+ return (
+
+
+
+ {label}
+
+
{value}
+ {sub &&
{sub}
}
+
+ )
+}
+
+function formatBytes(bytes) {
+ if (!bytes || bytes === 0) return '0 B'
+ const k = 1024
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+ return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
+}
+
+function formatUptime(seconds) {
+ const d = Math.floor(seconds / 86400)
+ const h = Math.floor((seconds % 86400) / 3600)
+ if (d > 0) return `${d}d ${h}h`
+ const m = Math.floor((seconds % 3600) / 60)
+ return `${h}h ${m}m`
+}
diff --git a/frontend/src/pages/Agents.jsx b/frontend/src/pages/Agents.jsx
new file mode 100644
index 0000000..a9bf080
--- /dev/null
+++ b/frontend/src/pages/Agents.jsx
@@ -0,0 +1,118 @@
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../api/client'
+import StatusBadge from '../components/StatusBadge'
+import { Search } from 'lucide-react'
+
+export default function Agents() {
+ const [agents, setAgents] = useState([])
+ const [customers, setCustomers] = useState([])
+ const [search, setSearch] = useState('')
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ Promise.all([api.getAgents(), api.getCustomers()])
+ .then(([a, c]) => {
+ setAgents(a || [])
+ setCustomers(c || [])
+ })
+ .finally(() => setLoading(false))
+ }, [])
+
+ const customerMap = {}
+ customers.forEach((c) => (customerMap[c.id] = c))
+
+ const filtered = agents.filter((a) => {
+ const q = search.toLowerCase()
+ const cust = a.customer_id ? customerMap[a.customer_id] : null
+ return (
+ a.name.toLowerCase().includes(q) ||
+ a.ip.toLowerCase().includes(q) ||
+ a.hostname.toLowerCase().includes(q) ||
+ (cust && (cust.name.toLowerCase().includes(q) || cust.number.toLowerCase().includes(q)))
+ )
+ })
+
+ return (
+
+
+
Firewalls
+ {agents.length} gesamt
+
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-orange-500"
+ />
+
+
+ {loading ? (
+
Laden...
+ ) : (
+
+
+
+
+ | Status |
+ Name |
+ IP |
+ Version |
+ Kunde |
+ Letzter Heartbeat |
+
+
+
+ {filtered.map((agent) => {
+ const cust = agent.customer_id ? customerMap[agent.customer_id] : null
+ return (
+
+ |
+
+ |
+
+
+ {agent.name}
+
+ |
+ {agent.ip} |
+
+ {agent.opnsense_version || '—'}
+ |
+
+ {cust ? (
+
+ {cust.number} {cust.name}
+
+ ) : (
+ —
+ )}
+ |
+
+ {agent.last_heartbeat
+ ? new Date(agent.last_heartbeat).toLocaleString('de-DE')
+ : '—'}
+ |
+
+ )
+ })}
+
+
+ {filtered.length === 0 && (
+
+ {search ? 'Keine Treffer' : 'Keine Firewalls registriert'}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/Customers.jsx b/frontend/src/pages/Customers.jsx
new file mode 100644
index 0000000..556503a
--- /dev/null
+++ b/frontend/src/pages/Customers.jsx
@@ -0,0 +1,160 @@
+import { useEffect, useState } from 'react'
+import api from '../api/client'
+import { Plus, Trash2, Edit2, X, Check } from 'lucide-react'
+
+export default function Customers() {
+ const [customers, setCustomers] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [showAdd, setShowAdd] = useState(false)
+ const [editId, setEditId] = useState(null)
+ const [form, setForm] = useState({ number: '', name: '' })
+
+ const reload = () => {
+ api.getCustomers().then((c) => setCustomers(c || [])).finally(() => setLoading(false))
+ }
+
+ useEffect(() => { reload() }, [])
+
+ const handleAdd = async () => {
+ if (!form.number || !form.name) return
+ await api.createCustomer(form.number, form.name)
+ setForm({ number: '', name: '' })
+ setShowAdd(false)
+ reload()
+ }
+
+ const handleUpdate = async (id) => {
+ await api.updateCustomer(id, form.number, form.name)
+ setEditId(null)
+ reload()
+ }
+
+ const handleDelete = async (id) => {
+ if (!confirm('Kunde wirklich loeschen?')) return
+ await api.deleteCustomer(id)
+ reload()
+ }
+
+ return (
+
+
+
Kunden
+
+
+
+
+
+ {!loading && customers.length === 0 && !showAdd && (
+
Keine Kunden angelegt
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
new file mode 100644
index 0000000..d50ac60
--- /dev/null
+++ b/frontend/src/pages/Dashboard.jsx
@@ -0,0 +1,133 @@
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import api from '../api/client'
+import StatusBadge from '../components/StatusBadge'
+import { Server, Users, Activity, AlertTriangle } from 'lucide-react'
+
+export default function Dashboard() {
+ const [agents, setAgents] = useState([])
+ const [customers, setCustomers] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ Promise.all([api.getAgents(), api.getCustomers()])
+ .then(([a, c]) => {
+ setAgents(a || [])
+ setCustomers(c || [])
+ })
+ .finally(() => setLoading(false))
+ }, [])
+
+ if (loading) {
+ return
Laden...
+ }
+
+ const online = agents.filter((a) => a.status === 'online').length
+ const offline = agents.filter((a) => a.status === 'offline').length
+ const stale = agents.filter((a) => a.status === 'stale').length
+ const unassigned = agents.filter((a) => !a.customer_id).length
+
+ // Agents nach Kunde gruppieren
+ const customerMap = {}
+ customers.forEach((c) => {
+ customerMap[c.id] = { ...c, agents: [] }
+ })
+ agents.forEach((a) => {
+ if (a.customer_id && customerMap[a.customer_id]) {
+ customerMap[a.customer_id].agents.push(a)
+ }
+ })
+
+ return (
+
+
Dashboard
+
+ {/* Stats */}
+
+
+
+
+
+
+
+ {/* Agent-Liste nach Kunde */}
+
+ {Object.values(customerMap)
+ .filter((c) => c.agents.length > 0)
+ .sort((a, b) => a.number.localeCompare(b.number))
+ .map((customer) => (
+
+
+
+ {customer.number} — {customer.name}
+
+
+ {customer.agents.length} {customer.agents.length === 1 ? 'Firewall' : 'Firewalls'}
+
+
+
+ {customer.agents.map((agent) => (
+
+ ))}
+
+
+ ))}
+
+ {/* Nicht zugewiesene Agents */}
+ {unassigned > 0 && (
+
+
+ Nicht zugewiesen
+
+
+ {agents
+ .filter((a) => !a.customer_id)
+ .map((agent) => (
+
+ ))}
+
+
+ )}
+
+
+ )
+}
+
+function StatCard({ icon: Icon, label, value, color }) {
+ return (
+
+ )
+}
+
+function AgentRow({ agent }) {
+ const lastHB = agent.last_heartbeat
+ ? new Date(agent.last_heartbeat).toLocaleString('de-DE')
+ : '—'
+
+ return (
+
+
+
+
+
{agent.name}
+
{agent.ip}
+
+
+
+
{agent.opnsense_version || '—'}
+
{lastHB}
+
+
+ )
+}
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
new file mode 100644
index 0000000..28217ee
--- /dev/null
+++ b/frontend/src/pages/Login.jsx
@@ -0,0 +1,76 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuthStore } from '../stores/auth'
+import { Shield } from 'lucide-react'
+
+export default function Login() {
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+ const login = useAuthStore((s) => s.login)
+ const navigate = useNavigate()
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ setError('')
+ setLoading(true)
+ try {
+ await login(username, password)
+ navigate('/')
+ } catch (err) {
+ setError(err.message || 'Login fehlgeschlagen')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+
RMM
+
Remote Monitoring & Management
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx
new file mode 100644
index 0000000..6f26dc4
--- /dev/null
+++ b/frontend/src/pages/SettingsPage.jsx
@@ -0,0 +1,106 @@
+import { useEffect, useState } from 'react'
+import api from '../api/client'
+import { Plus, Trash2 } from 'lucide-react'
+
+export default function SettingsPage() {
+ const [users, setUsers] = useState([])
+ const [showAdd, setShowAdd] = useState(false)
+ const [form, setForm] = useState({ username: '', password: '', display_name: '' })
+
+ const reload = () => api.getUsers().then((u) => setUsers(u || []))
+ useEffect(() => { reload() }, [])
+
+ const handleAdd = async () => {
+ if (!form.username || !form.password) return
+ await api.createUser(form)
+ setForm({ username: '', password: '', display_name: '' })
+ setShowAdd(false)
+ reload()
+ }
+
+ const handleDelete = async (id) => {
+ if (!confirm('Benutzer wirklich loeschen?')) return
+ await api.deleteUser(id)
+ reload()
+ }
+
+ return (
+
+
Einstellungen
+
+ {/* Users */}
+
+
+
Benutzer
+
+
+
+ {showAdd && (
+
+
+
+ setForm({ ...form, username: e.target.value })}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
+ />
+
+
+
+ setForm({ ...form, password: e.target.value })}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
+ />
+
+
+
+ setForm({ ...form, display_name: e.target.value })}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-sm focus:outline-none focus:border-orange-500"
+ />
+
+
+
+
+ )}
+
+
+ {users.map((u) => (
+
+
+ {u.username}
+ {u.display_name && (
+ ({u.display_name})
+ )}
+ {u.role}
+
+
+
+ {u.last_login ? `Letzter Login: ${new Date(u.last_login).toLocaleString('de-DE')}` : 'Nie eingeloggt'}
+
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js
new file mode 100644
index 0000000..6694750
--- /dev/null
+++ b/frontend/src/stores/auth.js
@@ -0,0 +1,33 @@
+import { create } from 'zustand'
+import api from '../api/client'
+
+export const useAuthStore = create((set) => ({
+ user: null,
+ isAuthenticated: !!api.getToken(),
+ loading: true,
+
+ login: async (username, password) => {
+ const data = await api.login(username, password)
+ set({ user: data.user, isAuthenticated: true })
+ return data
+ },
+
+ logout: () => {
+ api.logout()
+ set({ user: null, isAuthenticated: false })
+ },
+
+ checkAuth: async () => {
+ if (!api.getToken()) {
+ set({ loading: false, isAuthenticated: false })
+ return
+ }
+ try {
+ const data = await api.me()
+ set({ user: data.user || data, isAuthenticated: true, loading: false })
+ } catch {
+ api.logout()
+ set({ user: null, isAuthenticated: false, loading: false })
+ }
+ },
+}))
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..040914c
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'https://192.168.85.13:8443',
+ changeOrigin: true,
+ secure: false,
+ },
+ },
+ },
+})